Compare commits
55 커밋
7fb98ebb08
...
7949b96866
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
|
|
7949b96866 | ||
|
|
6bea387ee2 | ||
| 44a7d0030a | |||
| fbc2173027 | |||
| 63cf614365 | |||
| 86e534b6dc | |||
| 621d8e3516 | |||
| c7c7537dbb | |||
| 33155e0f87 | |||
| e096010ea9 | |||
| 20890fe8a9 | |||
| e8b5a4e093 | |||
| 734ebeeaab | |||
|
|
7110d76276 | ||
| 34ed6b6291 | |||
| b24a6f4c54 | |||
| 130f563ab2 | |||
| 075c6cd9bc | |||
| c4f11423aa | |||
| da077bf884 | |||
| a3b2787ba0 | |||
| 891db2a894 | |||
| 4e0bb23dab | |||
| 1bfc06c6c5 | |||
| 740b8acf1d | |||
| d693c6865f | |||
| a40daf2263 | |||
| 6864f6dab5 | |||
| 421f5f8b52 | |||
| a8ba29fd4c | |||
| 59f4753c12 | |||
| fb2dcce1f5 | |||
| 827dab27a0 | |||
|
|
efc8f18bb9 | ||
|
|
dc82574635 | ||
| 9ddae7a973 | |||
| bf41763925 | |||
| 61ac3b42c0 | |||
| 0c94c631c4 | |||
| ebed0c5657 | |||
| 2119e77501 | |||
| d40281520b | |||
| dfe0ac8efe | |||
| 5c75ba8712 | |||
| b672e9b531 | |||
| 54d3a281c6 | |||
| dc321d2f5a | |||
| bc45fb55cf | |||
| 416fa2211b | |||
| 3743027ce7 | |||
| 4300191000 | |||
| 22a473f254 | |||
| 3946ff6a25 | |||
| b601edd741 | |||
| 88eb6b121a |
@ -5,29 +5,29 @@
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)",
|
||||
"Bash(node *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git rev-parse *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git tag *)",
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)"
|
||||
"Bash(node *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
@ -83,5 +83,7 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"deny": [],
|
||||
"allow": []
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-06",
|
||||
"applied_date": "2026-03-13",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
}
|
||||
}
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@ -55,6 +55,26 @@ wing_source_*.tar.gz
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# prediction/ Python 엔진 (로컬 실행 결과물)
|
||||
prediction/**/__pycache__/
|
||||
prediction/**/*.pyc
|
||||
# prediction/ opendrift 결과물 (로컬 실행 결과물)
|
||||
prediction/opendrift/result/
|
||||
prediction/opendrift/logs/
|
||||
prediction/opendrift/uvicorn.pid
|
||||
prediction/opendrift/.env
|
||||
# prediction/ 이미지분석 결과물 (로컬 실행 결과물)
|
||||
prediction/image/stitch/
|
||||
prediction/image/mx15hdi/Detect/Mask_result/
|
||||
prediction/image/mx15hdi/Detect/result/
|
||||
prediction/image/mx15hdi/Georeference/Mask_Tif/
|
||||
prediction/image/mx15hdi/Georeference/Tif/
|
||||
prediction/image/mx15hdi/Metadata/CSV/
|
||||
prediction/image/mx15hdi/Metadata/Image/Original_Images/
|
||||
prediction/image/mx15hdi/Polygon/Shp/
|
||||
# prediction/ 이미지분석 대용량 바이너리 (모델 가중치)
|
||||
prediction/image/**/*.pth
|
||||
|
||||
# HNS manual images (large binary)
|
||||
frontend/public/hns-manual/pages/
|
||||
frontend/public/hns-manual/images/
|
||||
@ -63,6 +83,7 @@ frontend/public/hns-manual/images/
|
||||
!.claude/
|
||||
.claude/settings.local.json
|
||||
.claude/CLAUDE.local.md
|
||||
*.local
|
||||
|
||||
# Team workflow (managed by /sync-team-workflow)
|
||||
.claude/rules/
|
||||
|
||||
124
backend/package-lock.json
generated
124
backend/package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/multer": "^2.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@ -17,6 +18,7 @@
|
||||
"google-auth-library": "^10.6.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.1.1",
|
||||
"pg": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -515,7 +517,6 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@ -526,7 +527,6 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@ -556,9 +556,7 @@
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@ -569,7 +567,6 @@
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@ -592,7 +589,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
@ -613,11 +609,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
|
||||
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@ -639,21 +643,18 @@
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@ -663,7 +664,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@ -716,6 +716,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@ -837,6 +843,23 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@ -893,6 +916,21 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@ -1841,6 +1879,25 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@ -1992,7 +2049,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.0",
|
||||
"pg-pool": "^3.12.0",
|
||||
@ -2180,6 +2236,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@ -2426,6 +2496,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
@ -2564,6 +2651,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@ -2582,7 +2675,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
@ -2594,6 +2686,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/multer": "^2.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@ -18,6 +19,7 @@
|
||||
"google-auth-library": "^10.6.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.1.1",
|
||||
"pg": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import {
|
||||
listMedia,
|
||||
createMedia,
|
||||
getMediaBySn,
|
||||
fetchOriginalImage,
|
||||
listCctv,
|
||||
listSatRequests,
|
||||
createSatRequest,
|
||||
@ -10,6 +13,7 @@ import {
|
||||
isValidSatStatus,
|
||||
requestOilInference,
|
||||
checkInferenceHealth,
|
||||
stitchImages,
|
||||
listDroneStreams,
|
||||
startDroneStream,
|
||||
stopDroneStream,
|
||||
@ -19,6 +23,7 @@ import { isValidNumber } from '../middleware/security.js';
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||
|
||||
// ============================================================
|
||||
// AERIAL_MEDIA 라우트
|
||||
@ -68,6 +73,40 @@ router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
|
||||
router.get('/media/:sn/download', 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;
|
||||
}
|
||||
|
||||
// fileId 추출: FILE_NM의 앞 36자가 UUID 형식인지 검증 (이미지 분석 생성 레코드만 다운로드 가능)
|
||||
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);
|
||||
const downloadName = media.orgnlNm ?? media.fileNm;
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(downloadName)}`);
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
console.error('[aerial] 이미지 다운로드 오류:', err);
|
||||
res.status(502).json({ error: '이미지 다운로드 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// CCTV_CAMERA 라우트
|
||||
// ============================================================
|
||||
@ -527,6 +566,39 @@ router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requireP
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// STITCH (이미지 합성) 라우트
|
||||
// ============================================================
|
||||
|
||||
// POST /api/aerial/stitch — 여러 이미지를 합성하여 JPEG 반환
|
||||
router.post(
|
||||
'/stitch',
|
||||
requireAuth,
|
||||
requirePermission('aerial', 'READ'),
|
||||
stitchUpload.array('files', 6),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files || files.length < 2) {
|
||||
res.status(400).json({ error: '이미지를 최소 2장 이상 선택해주세요.' });
|
||||
return;
|
||||
}
|
||||
const fileId = `stitch_${Date.now()}`;
|
||||
const buffer = await stitchImages(files, fileId);
|
||||
res.type('image/jpeg').send(buffer);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('abort') || message.includes('timeout')) {
|
||||
console.error('[aerial] 스티칭 서버 타임아웃:', message);
|
||||
res.status(504).json({ error: '이미지 합성 서버 응답 시간 초과' });
|
||||
return;
|
||||
}
|
||||
console.error('[aerial] 이미지 합성 오류:', err);
|
||||
res.status(503).json({ error: '이미지 합성 서버 연결 불가' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/aerial/oil-detect/health — 추론 서버 상태 확인
|
||||
router.get('/oil-detect/health', requireAuth, async (_req, res) => {
|
||||
const health = await checkInferenceHealth();
|
||||
|
||||
@ -53,6 +53,26 @@ function rowToMedia(r: Record<string, unknown>): AerialMediaItem {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMediaBySn(sn: number): Promise<AerialMediaItem | null> {
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT AERIAL_MEDIA_SN, ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH,
|
||||
LON, LAT, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD,
|
||||
TAKNG_DTM, FILE_SZ, RESOLUTION, REG_DTM
|
||||
FROM wing.AERIAL_MEDIA WHERE AERIAL_MEDIA_SN = $1 AND USE_YN = 'Y'`,
|
||||
[sn]
|
||||
);
|
||||
return rows.length > 0 ? rowToMedia(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchOriginalImage(camTy: string, fileId: string): Promise<Buffer> {
|
||||
const res = await fetch(`${IMAGE_API_URL}/get-original-image/${camTy}/${fileId}`, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`이미지 서버 응답: ${res.status}`);
|
||||
const base64 = await res.json() as string;
|
||||
return Buffer.from(base64, 'base64');
|
||||
}
|
||||
|
||||
export async function listMedia(input: ListMediaInput): Promise<AerialMediaItem[]> {
|
||||
const conditions: string[] = ["USE_YN = 'Y'"];
|
||||
const params: (string | number)[] = [];
|
||||
@ -113,8 +133,8 @@ export async function createMedia(input: {
|
||||
TAKNG_DTM, FILE_SZ, RESOLUTION
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6,
|
||||
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::float, $6::float), 4326) END,
|
||||
$5::float8, $6::float8,
|
||||
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5, $6), 4326) END,
|
||||
$7, $8, $9, $10,
|
||||
$11, $12, $13
|
||||
) RETURNING AERIAL_MEDIA_SN`,
|
||||
@ -348,6 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
||||
// OIL INFERENCE (GPU 서버 프록시)
|
||||
// ============================================================
|
||||
|
||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
|
||||
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
||||
const INFERENCE_TIMEOUT_MS = 10_000;
|
||||
|
||||
@ -366,6 +387,28 @@ export interface OilInferenceResult {
|
||||
regions: OilInferenceRegion[];
|
||||
}
|
||||
|
||||
/** 여러 이미지를 이미지 분석 서버의 /stitch 엔드포인트로 전송해 합성 JPEG를 반환한다. */
|
||||
export async function stitchImages(
|
||||
files: Express.Multer.File[],
|
||||
fileId: string
|
||||
): Promise<Buffer> {
|
||||
const form = new FormData();
|
||||
form.append('fileId', fileId);
|
||||
for (const f of files) {
|
||||
form.append('files', new Blob([f.buffer], { type: f.mimetype }), f.originalname);
|
||||
}
|
||||
const response = await fetch(`${IMAGE_API_URL}/stitch`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`stitch server responded ${response.status}: ${detail}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
/** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */
|
||||
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
|
||||
const controller = new AbortController();
|
||||
|
||||
@ -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'
|
||||
|
||||
/**
|
||||
* 응답 헤더에서 서버 정보 제거
|
||||
|
||||
174
backend/src/prediction/imageAnalyzeService.ts
Normal file
174
backend/src/prediction/imageAnalyzeService.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import crypto from 'crypto';
|
||||
import { wingPool } from '../db/wingDb.js';
|
||||
import { createMedia } from '../aerial/aerialService.js';
|
||||
|
||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
|
||||
|
||||
// 유류 클래스 → UI 유종명 매핑
|
||||
const CLASS_ID_TO_OIL_TYPE: Record<string, string> = {
|
||||
'검정': '벙커C유',
|
||||
'갈색': '벙커C유',
|
||||
'무지개': '경유',
|
||||
'은색': '등유',
|
||||
};
|
||||
|
||||
// 유종명 → DB 코드 매핑
|
||||
const OIL_DB_CODE_MAP: Record<string, string> = {
|
||||
'벙커C유': 'BUNKER_C',
|
||||
'원유': 'CRUDE_OIL',
|
||||
'경유': 'DIESEL',
|
||||
'등유': 'GASOLINE',
|
||||
};
|
||||
|
||||
interface OilPolygon {
|
||||
classId: string;
|
||||
area: number;
|
||||
volume: number;
|
||||
note: string;
|
||||
thickness: number;
|
||||
wkt: string;
|
||||
}
|
||||
|
||||
interface ImageServerResponse {
|
||||
meta: string;
|
||||
data: OilPolygon[];
|
||||
}
|
||||
|
||||
export interface ImageAnalyzeResult {
|
||||
acdntSn: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
oilType: string;
|
||||
area: number;
|
||||
volume: number;
|
||||
fileId: string;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* mx15hdi CSV 컬럼 순서:
|
||||
* Filename, Tlat_d, Tlat_m, Tlat_s, Tlon_d, Tlon_m, Tlon_s,
|
||||
* Alat_d, Alat_m, Alat_s, Alon_d, Alon_m, Alon_s,
|
||||
* Az, El, Alt, Date1, Date2, Date3, Time1, Time2, Time3
|
||||
*/
|
||||
function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: string } {
|
||||
const parts = metaStr.split(',');
|
||||
const tlat_d = parseFloat(parts[1]);
|
||||
const tlat_m = parseFloat(parts[2]);
|
||||
const tlat_s = parseFloat(parts[3]);
|
||||
const tlon_d = parseFloat(parts[4]);
|
||||
const tlon_m = parseFloat(parts[5]);
|
||||
const tlon_s = parseFloat(parts[6]);
|
||||
|
||||
const lat = tlat_d + tlat_m / 60 + tlat_s / 3600;
|
||||
const lon = tlon_d + tlon_m / 60 + tlon_s / 3600;
|
||||
|
||||
// Date: Date1(DD), Date2(MM), Date3(YYYY) / Time: Time1(HH), Time2(mm), Time3(ss)
|
||||
const dd = (parts[16] ?? '01').padStart(2, '0');
|
||||
const mm = (parts[17] ?? '01').padStart(2, '0');
|
||||
const yyyy = parts[18] ?? new Date().getFullYear().toString();
|
||||
const time1 = (parts[19] ?? '00').padStart(2, '0');
|
||||
const time2 = (parts[20] ?? '00').padStart(2, '0');
|
||||
const occurredAt = `${yyyy}-${mm}-${dd}T${time1}:${time2}:00+09:00`;
|
||||
|
||||
return { lat, lon, occurredAt };
|
||||
}
|
||||
|
||||
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise<ImageAnalyzeResult> {
|
||||
const fileId = crypto.randomUUID();
|
||||
|
||||
// camTy는 현재 "mx15hdi"로 하드코딩한다.
|
||||
// TODO: 추후 이미지 EXIF에서 카메라 모델명을 읽어 camTy를 자동 판별하는 로직을
|
||||
// 이미지 분석 서버(api.py)에 추가할 예정이다. (check_camera_info 함수 활용)
|
||||
const camTy = 'mx15hdi';
|
||||
|
||||
// 이미지 분석 서버 호출
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([imageBuffer]);
|
||||
formData.append('camTy', camTy);
|
||||
formData.append('fileId', fileId);
|
||||
formData.append('image', blob, originalName);
|
||||
|
||||
let serverResponse: ImageServerResponse;
|
||||
try {
|
||||
const res = await fetch(`${IMAGE_API_URL}/run-script/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
if (res.status === 400 && text.includes('GPS')) {
|
||||
throw Object.assign(new Error('GPS_NOT_FOUND'), { code: 'GPS_NOT_FOUND' });
|
||||
}
|
||||
throw new Error(`이미지 분석 서버 오류: ${res.status} - ${text}`);
|
||||
}
|
||||
|
||||
serverResponse = await res.json() as ImageServerResponse;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'GPS_NOT_FOUND') throw err;
|
||||
if (err instanceof Error && err.name === 'TimeoutError') {
|
||||
throw Object.assign(new Error('TIMEOUT'), { code: 'TIMEOUT' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 응답 파싱
|
||||
const { lat, lon, occurredAt } = parseMeta(serverResponse.meta);
|
||||
const firstOil = serverResponse.data[0];
|
||||
const oilType = firstOil ? (CLASS_ID_TO_OIL_TYPE[firstOil.classId] ?? '벙커C유') : '벙커C유';
|
||||
const area = firstOil?.area ?? 0;
|
||||
const volume = firstOil?.volume ?? 0;
|
||||
|
||||
// ACDNT INSERT
|
||||
const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
|
||||
const acdntRes = await wingPool.query(
|
||||
`INSERT INTO wing.ACDNT
|
||||
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
|
||||
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, occurredAt, lat, lon]
|
||||
);
|
||||
const acdntSn: number = acdntRes.rows[0].acdnt_sn;
|
||||
|
||||
// SPIL_DATA INSERT (img_rslt_data에 분석 원본 저장)
|
||||
await wingPool.query(
|
||||
`INSERT INTO wing.SPIL_DATA
|
||||
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
|
||||
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`,
|
||||
[
|
||||
acdntSn,
|
||||
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',
|
||||
volume,
|
||||
JSON.stringify(serverResponse),
|
||||
]
|
||||
);
|
||||
|
||||
// AERIAL_MEDIA INSERT (영상사진관리 목록에서 조회 가능하도록 저장)
|
||||
const fileSizeMb = (imageBuffer.length / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
await createMedia({
|
||||
fileNm: `${fileId}_${originalName}`,
|
||||
orgnlNm: originalName,
|
||||
acdntSn,
|
||||
lon,
|
||||
lat,
|
||||
locDc: `${lon.toFixed(4)} + ${lat.toFixed(4)}`,
|
||||
equipTpCd: 'drone',
|
||||
equipNm: camTy,
|
||||
mediaTpCd: '사진',
|
||||
takngDtm: occurredAt,
|
||||
fileSz: fileSizeMb,
|
||||
});
|
||||
|
||||
return { acdntSn, lat, lon, oilType, area, volume, fileId, occurredAt };
|
||||
}
|
||||
@ -1,11 +1,15 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import {
|
||||
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
||||
createBacktrack, saveBoomLine, listBoomLines,
|
||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||
} from './predictionService.js';
|
||||
import { analyzeImageFile } from './imageAnalyzeService.js';
|
||||
import { isValidNumber } from '../middleware/security.js';
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/prediction/analyses — 분석 목록
|
||||
@ -40,6 +44,26 @@ router.get('/analyses/:acdntSn', requireAuth, requirePermission('prediction', 'R
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prediction/analyses/:acdntSn/trajectory — 최신 OpenDrift 결과 조회
|
||||
router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||
return;
|
||||
}
|
||||
const result = await getAnalysisTrajectory(acdntSn);
|
||||
if (!result) {
|
||||
res.json({ trajectory: null, summary: null });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[prediction] trajectory 조회 오류:', err);
|
||||
res.status(500).json({ error: 'trajectory 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
||||
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||
try {
|
||||
@ -124,4 +148,36 @@ router.post('/boom', requireAuth, requirePermission('prediction', 'CREATE'), asy
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/prediction/image-analyze — 이미지 업로드 분석
|
||||
router.post(
|
||||
'/image-analyze',
|
||||
requireAuth,
|
||||
requirePermission('prediction', 'CREATE'),
|
||||
upload.single('image'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: '이미지 파일이 필요합니다' });
|
||||
return;
|
||||
}
|
||||
const result = await analyzeImageFile(req.file.buffer, req.file.originalname);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'GPS_NOT_FOUND') {
|
||||
res.status(422).json({ error: 'GPS_NOT_FOUND', message: 'GPS 정보가 없는 이미지입니다' });
|
||||
return;
|
||||
}
|
||||
if (code === 'TIMEOUT') {
|
||||
res.status(504).json({ error: 'TIMEOUT', message: '이미지 분석 서버 응답 시간 초과' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.error('[prediction] 이미지 분석 오류:', err);
|
||||
res.status(500).json({ error: '이미지 분석 실패' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -18,6 +18,7 @@ interface PredictionAnalysis {
|
||||
backtrackStatus: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
acdntSttsCd: string;
|
||||
}
|
||||
|
||||
interface PredictionDetail {
|
||||
@ -129,6 +130,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
||||
SELECT
|
||||
A.ACDNT_SN,
|
||||
A.ACDNT_NM,
|
||||
A.ACDNT_STTS_CD,
|
||||
A.OCCRN_DTM,
|
||||
A.LAT,
|
||||
A.LNG,
|
||||
@ -186,6 +188,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
||||
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
||||
analyst: String(row['analyst_nm'] ?? ''),
|
||||
officeName: String(row['office_nm'] ?? ''),
|
||||
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -404,6 +407,166 @@ export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLine
|
||||
return { boomLineSn: Number((rows[0] as Record<string, unknown>)['boom_line_sn']) };
|
||||
}
|
||||
|
||||
interface TrajectoryParticle {
|
||||
lat: number;
|
||||
lon: number;
|
||||
stranded?: 0 | 1;
|
||||
}
|
||||
|
||||
interface TrajectoryWindPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
wind_speed: number;
|
||||
wind_direction: number;
|
||||
}
|
||||
|
||||
interface TrajectoryHydrGrid {
|
||||
lonInterval: number[];
|
||||
boundLonLat: { top: number; bottom: number; left: number; right: number };
|
||||
rows: number;
|
||||
cols: number;
|
||||
latInterval: number[];
|
||||
}
|
||||
|
||||
interface TrajectoryTimeStep {
|
||||
particles: TrajectoryParticle[];
|
||||
remaining_volume_m3: number;
|
||||
weathered_volume_m3: number;
|
||||
pollution_area_km2: number;
|
||||
beached_volume_m3: number;
|
||||
pollution_coast_length_m: number;
|
||||
center_lat?: number;
|
||||
center_lon?: number;
|
||||
wind_data?: TrajectoryWindPoint[];
|
||||
hydr_data?: [number[][], number[][]];
|
||||
hydr_grid?: TrajectoryHydrGrid;
|
||||
}
|
||||
|
||||
// ALGO_CD → 프론트엔드 모델명 매핑
|
||||
const ALGO_CD_TO_MODEL: Record<string, string> = {
|
||||
'OPENDRIFT': 'OpenDrift',
|
||||
'POSEIDON': 'POSEIDON',
|
||||
};
|
||||
|
||||
interface SingleModelTrajectoryResult {
|
||||
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
|
||||
summary: {
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
pollutionArea: number;
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
};
|
||||
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
|
||||
windData: TrajectoryWindPoint[][];
|
||||
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
|
||||
}
|
||||
|
||||
interface TrajectoryResult {
|
||||
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
|
||||
summary: {
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
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']>;
|
||||
}
|
||||
|
||||
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult {
|
||||
const trajectory = rawResult.flatMap((step, stepIdx) =>
|
||||
step.particles.map((p, i) => ({
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
time: stepIdx,
|
||||
particle: i,
|
||||
stranded: p.stranded,
|
||||
model,
|
||||
}))
|
||||
);
|
||||
const lastStep = rawResult[rawResult.length - 1];
|
||||
const summary = {
|
||||
remainingVolume: lastStep.remaining_volume_m3,
|
||||
weatheredVolume: lastStep.weathered_volume_m3,
|
||||
pollutionArea: lastStep.pollution_area_km2,
|
||||
beachedVolume: lastStep.beached_volume_m3,
|
||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||
};
|
||||
const centerPoints = rawResult
|
||||
.map((step, stepIdx) =>
|
||||
step.center_lat != null && step.center_lon != null
|
||||
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model }
|
||||
: null
|
||||
)
|
||||
.filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null);
|
||||
const windData = rawResult.map((step) => step.wind_data ?? []);
|
||||
const hydrData = rawResult.map((step) =>
|
||||
step.hydr_data && step.hydr_grid
|
||||
? { value: step.hydr_data, grid: step.hydr_grid }
|
||||
: null
|
||||
);
|
||||
return { trajectory, summary, centerPoints, windData, hydrData };
|
||||
}
|
||||
|
||||
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
|
||||
// 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회
|
||||
const sql = `
|
||||
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 { rows } = await wingPool.query(sql, [acdntSn]);
|
||||
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']> = {};
|
||||
|
||||
// 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;
|
||||
|
||||
if (row === baseRow) {
|
||||
baseResult = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!baseResult) return null;
|
||||
|
||||
return {
|
||||
trajectory: mergedTrajectory,
|
||||
summary: baseResult.summary,
|
||||
centerPoints: allCenterPoints,
|
||||
windDataByModel,
|
||||
hydrDataByModel,
|
||||
summaryByModel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
||||
const sql = `
|
||||
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
||||
|
||||
@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
|
||||
// ============================================================
|
||||
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
||||
try {
|
||||
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections } = req.body;
|
||||
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg } = req.body;
|
||||
const result = await createReport({
|
||||
tmplSn,
|
||||
ctgrSn,
|
||||
@ -101,6 +101,7 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
|
||||
jrsdCd,
|
||||
sttsCd,
|
||||
authorId: req.user!.sub,
|
||||
mapCaptureImg,
|
||||
sections,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
@ -124,8 +125,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
|
||||
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
||||
return;
|
||||
}
|
||||
const { title, jrsdCd, sttsCd, acdntSn, sections } = req.body;
|
||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections }, req.user!.sub);
|
||||
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body;
|
||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
|
||||
@ -62,6 +62,7 @@ interface ReportListItem {
|
||||
authorName: string;
|
||||
regDtm: string;
|
||||
mdfcnDtm: string | null;
|
||||
hasMapCapture: boolean;
|
||||
}
|
||||
|
||||
interface SectionData {
|
||||
@ -74,6 +75,7 @@ interface SectionData {
|
||||
interface ReportDetail extends ReportListItem {
|
||||
acdntSn: number | null;
|
||||
sections: SectionData[];
|
||||
mapCaptureImg: string | null;
|
||||
}
|
||||
|
||||
interface ListReportsInput {
|
||||
@ -100,6 +102,7 @@ interface CreateReportInput {
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
authorId: string;
|
||||
mapCaptureImg?: string;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}
|
||||
|
||||
@ -108,6 +111,7 @@ interface UpdateReportInput {
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
acdntSn?: number | null;
|
||||
mapCaptureImg?: string | null;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}
|
||||
|
||||
@ -256,7 +260,8 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
||||
c.CTGR_CD, c.CTGR_NM,
|
||||
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||
r.REG_DTM, r.MDFCN_DTM
|
||||
r.REG_DTM, r.MDFCN_DTM,
|
||||
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||
FROM REPORT r
|
||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
||||
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
||||
@ -281,6 +286,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
||||
authorName: r.author_name || '',
|
||||
regDtm: r.reg_dtm,
|
||||
mdfcnDtm: r.mdfcn_dtm,
|
||||
hasMapCapture: r.has_map_capture,
|
||||
})),
|
||||
totalCount,
|
||||
page,
|
||||
@ -294,7 +300,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
||||
c.CTGR_CD, c.CTGR_NM,
|
||||
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||
r.REG_DTM, r.MDFCN_DTM
|
||||
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG,
|
||||
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||
FROM REPORT r
|
||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
||||
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
||||
@ -331,6 +338,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
||||
authorName: r.author_name || '',
|
||||
regDtm: r.reg_dtm,
|
||||
mdfcnDtm: r.mdfcn_dtm,
|
||||
mapCaptureImg: r.map_capture_img,
|
||||
hasMapCapture: r.has_map_capture,
|
||||
sections: sectRes.rows.map((s) => ({
|
||||
sectCd: s.sect_cd,
|
||||
includeYn: s.include_yn,
|
||||
@ -350,8 +359,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
||||
await client.query('BEGIN');
|
||||
|
||||
const res = await client.query(
|
||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING REPORT_SN`,
|
||||
[
|
||||
input.tmplSn || null,
|
||||
@ -361,6 +370,7 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
||||
input.jrsdCd || null,
|
||||
input.sttsCd || 'DRAFT',
|
||||
input.authorId,
|
||||
input.mapCaptureImg || null,
|
||||
]
|
||||
);
|
||||
const reportSn = res.rows[0].report_sn;
|
||||
@ -432,6 +442,10 @@ export async function updateReport(
|
||||
sets.push(`ACDNT_SN = $${idx++}`);
|
||||
params.push(input.acdntSn);
|
||||
}
|
||||
if (input.mapCaptureImg !== undefined) {
|
||||
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
|
||||
params.push(input.mapCaptureImg);
|
||||
}
|
||||
|
||||
params.push(reportSn);
|
||||
await client.query(
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -158,7 +158,8 @@ app.use('/api/audit', auditRouter)
|
||||
// API 라우트 — 업무
|
||||
app.use('/api/board', boardRouter)
|
||||
app.use('/api/layers', layersRouter)
|
||||
app.use('/api/simulation', simulationLimiter, simulationRouter)
|
||||
app.use('/api/simulation/run', simulationLimiter) // 시뮬레이션 실행만 엄격 제한 (status 폴링 제외)
|
||||
app.use('/api/simulation', simulationRouter)
|
||||
app.use('/api/hns', hnsRouter)
|
||||
app.use('/api/reports', reportsRouter)
|
||||
app.use('/api/assets', assetsRouter)
|
||||
|
||||
@ -93,6 +93,7 @@ const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [
|
||||
{ id: 'board', label: '게시판', icon: '📌', enabled: true, order: 8 },
|
||||
{ id: 'weather', label: '기상정보', icon: '⛅', enabled: true, order: 9 },
|
||||
{ id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 },
|
||||
{ id: 'monitor', label: '실시간 상황관리', icon: '🛰', enabled: true, order: 11 },
|
||||
]
|
||||
|
||||
const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id)
|
||||
@ -103,18 +104,23 @@ export async function getMenuConfig(): Promise<MenuConfigItem[]> {
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(val) as MenuConfigItem[]
|
||||
const defaultMap = new Map(DEFAULT_MENU_CONFIG.map(m => [m.id, m]))
|
||||
const dbMap = new Map(
|
||||
parsed
|
||||
.filter(item => VALID_MENU_IDS.includes(item.id))
|
||||
.map(item => [item.id, item])
|
||||
)
|
||||
|
||||
return parsed
|
||||
.filter(item => VALID_MENU_IDS.includes(item.id))
|
||||
.map(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 {
|
||||
id: item.id,
|
||||
label: item.label || defaults.label,
|
||||
icon: item.icon || defaults.icon,
|
||||
enabled: item.enabled,
|
||||
order: item.order,
|
||||
id: dbItem.id,
|
||||
label: dbItem.label || defaultItem.label,
|
||||
icon: dbItem.icon || defaultItem.icon,
|
||||
enabled: dbItem.enabled,
|
||||
order: dbItem.order,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
@ -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, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'),
|
||||
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
|
||||
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
|
||||
(1, '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 제외
|
||||
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, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', '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 허용
|
||||
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, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
|
||||
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
|
||||
(3, 'admin', 'READ', 'N');
|
||||
(3, 'admin', 'READ', 'N'),
|
||||
(3, 'monitor', 'READ', 'Y');
|
||||
|
||||
-- USER (ROLE_SN=4): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
||||
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, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
|
||||
(4, 'weather', 'READ', 'Y'),
|
||||
(4, 'admin', 'READ', 'N');
|
||||
(4, 'admin', 'READ', 'N'),
|
||||
(4, 'monitor', 'READ', 'Y');
|
||||
|
||||
-- VIEWER (ROLE_SN=5): 제한적 탭의 READ만 허용
|
||||
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, 'board', 'READ', 'Y'),
|
||||
(5, 'weather', 'READ', 'Y'),
|
||||
(5, 'admin', 'READ', 'N');
|
||||
(5, 'admin', 'READ', 'N'),
|
||||
(5, 'monitor', 'READ', 'Y');
|
||||
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@ -299,6 +299,7 @@ CREATE TABLE SPIL_DATA (
|
||||
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리
|
||||
FCST_HR INTEGER, -- 예측시간
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
IMG_RSLT_DATA JSONB, -- 이미지 분석 결과 (2024-06 추가)
|
||||
CONSTRAINT PK_SPIL_DATA PRIMARY KEY (SPIL_DATA_SN),
|
||||
CONSTRAINT FK_SPIL_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE
|
||||
);
|
||||
@ -320,7 +321,8 @@ COMMENT ON COLUMN SPIL_DATA.REG_DTM IS '등록일시';
|
||||
-- ============================================================
|
||||
CREATE TABLE PRED_EXEC (
|
||||
PRED_EXEC_SN SERIAL NOT NULL, -- 예측실행순번
|
||||
SPIL_DATA_SN INTEGER NOT NULL, -- 유출정보순번
|
||||
SPIL_DATA_SN INTEGER, -- 유출정보순번 (NULL 허용 — 사고 미연결 단독 실행 대응)
|
||||
ACDNT_SN INTEGER NOT NULL, -- 사고순번 (사고 참조, 유출정보 미연결 시에도 사고는 필수)
|
||||
ALGO_CD VARCHAR(20) NOT NULL, -- 알고리즘코드
|
||||
EXEC_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- 실행상태코드
|
||||
BGNG_DTM TIMESTAMPTZ, -- 시작일시
|
||||
@ -328,6 +330,7 @@ CREATE TABLE PRED_EXEC (
|
||||
REQD_SEC INTEGER, -- 소요시간초
|
||||
RSLT_DATA JSONB, -- 결과데이터
|
||||
ERR_MSG TEXT, -- 오류메시지
|
||||
EXEC_NM VARCHAR(100), -- 실행명
|
||||
CONSTRAINT PK_PRED_EXEC PRIMARY KEY (PRED_EXEC_SN),
|
||||
CONSTRAINT FK_PRED_SPIL FOREIGN KEY (SPIL_DATA_SN) REFERENCES SPIL_DATA(SPIL_DATA_SN) ON DELETE CASCADE,
|
||||
CONSTRAINT CK_PRED_STTS CHECK (EXEC_STTS_CD IN ('PENDING','RUNNING','COMPLETED','FAILED'))
|
||||
@ -335,14 +338,16 @@ CREATE TABLE PRED_EXEC (
|
||||
|
||||
COMMENT ON TABLE PRED_EXEC IS '예측실행';
|
||||
COMMENT ON COLUMN PRED_EXEC.PRED_EXEC_SN IS '예측실행순번';
|
||||
COMMENT ON COLUMN PRED_EXEC.SPIL_DATA_SN IS '유출정보순번 (유출정보 참조)';
|
||||
COMMENT ON COLUMN PRED_EXEC.ALGO_CD IS '알고리즘코드 (ALGO: GNOME, OSCAR 등)';
|
||||
COMMENT ON COLUMN PRED_EXEC.SPIL_DATA_SN IS '유출정보순번 (FK → SPIL_DATA, NULL 허용)';
|
||||
COMMENT ON COLUMN PRED_EXEC.ACDNT_SN IS '사고순번 (사고 참조)';
|
||||
COMMENT ON COLUMN PRED_EXEC.ALGO_CD IS '알고리즘코드 (ALGO: GNOME, OSCAR, OPENDRIFT 등)';
|
||||
COMMENT ON COLUMN PRED_EXEC.EXEC_STTS_CD IS '실행상태코드 (PENDING:대기, RUNNING:실행중, COMPLETED:완료, FAILED:실패)';
|
||||
COMMENT ON COLUMN PRED_EXEC.BGNG_DTM IS '시작일시';
|
||||
COMMENT ON COLUMN PRED_EXEC.CMPL_DTM IS '완료일시';
|
||||
COMMENT ON COLUMN PRED_EXEC.REQD_SEC IS '소요시간초 (실행 소요 시간, 초 단위)';
|
||||
COMMENT ON COLUMN PRED_EXEC.RSLT_DATA IS '결과데이터 (JSON 형식 예측 결과)';
|
||||
COMMENT ON COLUMN PRED_EXEC.ERR_MSG IS '오류메시지';
|
||||
COMMENT ON COLUMN PRED_EXEC.EXEC_NM IS '실행명 (EXPC_{timestamp} 형식, OpenDrift 연동용)';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS REPORT (
|
||||
USE_YN CHAR(1) DEFAULT 'Y',
|
||||
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
||||
MDFCN_DTM TIMESTAMPTZ,
|
||||
MAP_CAPTURE_IMG TEXT
|
||||
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
|
||||
);
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA (
|
||||
SPIL_TP_CD VARCHAR(20),
|
||||
FCST_HR INTEGER,
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
IMG_RSLT_DATA JSONB,
|
||||
CONSTRAINT PK_SPIL_DATA PRIMARY KEY (SPIL_DATA_SN),
|
||||
CONSTRAINT FK_SPIL_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE
|
||||
);
|
||||
@ -54,20 +55,23 @@ CREATE INDEX IF NOT EXISTS IDX_SPIL_ACDNT ON SPIL_DATA(ACDNT_SN);
|
||||
-- 3. 예측실행 (PRED_EXEC)
|
||||
CREATE TABLE IF NOT EXISTS PRED_EXEC (
|
||||
PRED_EXEC_SN SERIAL NOT NULL,
|
||||
ACDNT_SN INTEGER NOT NULL,
|
||||
SPIL_DATA_SN INTEGER,
|
||||
ACDNT_SN INTEGER NOT NULL,
|
||||
ALGO_CD VARCHAR(20) NOT NULL,
|
||||
EXEC_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
BGNG_DTM TIMESTAMPTZ,
|
||||
CMPL_DTM TIMESTAMPTZ,
|
||||
REQD_SEC INTEGER,
|
||||
RSLT_DATA JSONB,
|
||||
ERR_MSG TEXT,
|
||||
ERR_MSG TEXT,
|
||||
EXEC_NM VARCHAR(100),
|
||||
CONSTRAINT PK_PRED_EXEC PRIMARY KEY (PRED_EXEC_SN),
|
||||
CONSTRAINT FK_PRED_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE,
|
||||
CONSTRAINT CK_PRED_STTS CHECK (EXEC_STTS_CD IN ('PENDING','RUNNING','COMPLETED','FAILED'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_PRED_ACDNT ON PRED_EXEC(ACDNT_SN);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uix_pred_exec_nm ON PRED_EXEC (EXEC_NM) WHERE EXEC_NM IS NOT NULL;
|
||||
|
||||
-- 4. 사고별 기상정보 스냅샷 (ACDNT_WEATHER)
|
||||
CREATE TABLE IF NOT EXISTS ACDNT_WEATHER (
|
||||
|
||||
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;
|
||||
@ -4,7 +4,7 @@
|
||||
연동할 수 있도록 정리한 문서이다.
|
||||
공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것.
|
||||
|
||||
> **최종 갱신**: 2026-03-01 (CSS 리팩토링 + MapLibre GL + deck.gl 전환 반영)
|
||||
> **최종 갱신**: 2026-03-11 (KHOA API 교체 + Vite CORS 프록시 추가)
|
||||
|
||||
---
|
||||
|
||||
@ -1312,6 +1312,25 @@ app.use(helmet({
|
||||
}));
|
||||
```
|
||||
|
||||
### Vite 개발 서버 프록시
|
||||
|
||||
외부 API 이미지의 CORS 문제를 해결하기 위해 `vite.config.ts`에 프록시를 설정한다:
|
||||
|
||||
```typescript
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/daily_ocean': {
|
||||
target: 'https://www.khoa.go.kr',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
적용되는 보안 헤더:
|
||||
- `X-Content-Type-Options: nosniff` (MIME 스니핑 방지)
|
||||
- `X-Frame-Options: DENY` (클릭재킹 방지)
|
||||
|
||||
@ -657,6 +657,7 @@ Settings -> Actions -> Secrets -> Add Secret
|
||||
|
||||
- API 호출이 CORS 에러를 발생시키면 백엔드 `FRONTEND_URL` 환경변수를 확인한다.
|
||||
- 개발 환경에서는 `localhost:5173`, `localhost:5174`, `localhost:3000`이 자동 허용된다.
|
||||
- KHOA 해양 이미지(`/daily_ocean`)는 Vite 프록시 경유: `vite.config.ts` → `proxy` 설정 확인
|
||||
|
||||
**타입 에러:**
|
||||
|
||||
|
||||
191
docs/PREDICTION-GUIDE.md
Normal file
191
docs/PREDICTION-GUIDE.md
Normal file
@ -0,0 +1,191 @@
|
||||
# 확산 예측 기능 가이드
|
||||
|
||||
> 대상: 확산 예측(OpenDrift) 기능 개발 및 유지보수 담당자
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처 개요
|
||||
|
||||
**폴링 방식** — HTTP 연결 불안정 문제 해결을 위해 비동기 폴링 구조를 채택했다.
|
||||
|
||||
```
|
||||
[프론트] 실행 버튼
|
||||
→ POST /api/simulation/run 즉시 { execSn, status:'RUNNING' } 반환
|
||||
→ "분석 중..." UI 표시
|
||||
→ 3초마다 GET /api/simulation/status/:execSn 폴링
|
||||
|
||||
[Express 백엔드]
|
||||
→ PRED_EXEC INSERT (PENDING)
|
||||
→ POST Python /run-model 즉시 { job_id } 수신
|
||||
→ 응답 즉시 반환 (프론트 블록 없음)
|
||||
→ 백그라운드: 3초마다 Python GET /status/:job_id 폴링
|
||||
→ DONE 시 PRED_EXEC UPDATE (결과 JSONB 저장)
|
||||
|
||||
[Python FastAPI :5003]
|
||||
→ 동시 처리 초과 시 503 즉시 반환
|
||||
→ 여유 시 job_id 반환 + 백그라운드 OpenDrift 시뮬레이션 실행
|
||||
→ NC 결과 → JSON 변환 → 상태 DONE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. DB 스키마 (PRED_EXEC)
|
||||
|
||||
```sql
|
||||
PRED_EXEC_SN SERIAL PRIMARY KEY
|
||||
ACDNT_SN INTEGER NOT NULL -- 사고 FK
|
||||
SPIL_DATA_SN INTEGER -- 유출정보 FK (NULL 허용)
|
||||
EXEC_NM VARCHAR(100) UNIQUE -- EXPC_{timestamp} 형식
|
||||
ALGO_CD VARCHAR(20) NOT NULL -- 'OPENDRIFT'
|
||||
EXEC_STTS_CD VARCHAR(20) DEFAULT 'PENDING'
|
||||
-- PENDING | RUNNING | COMPLETED | FAILED
|
||||
BGNG_DTM TIMESTAMPTZ
|
||||
CMPL_DTM TIMESTAMPTZ
|
||||
REQD_SEC INTEGER
|
||||
RSLT_DATA JSONB -- 시뮬레이션 결과 전체
|
||||
ERR_MSG TEXT
|
||||
```
|
||||
|
||||
인덱스: `IDX_PRED_STTS` (EXEC_STTS_CD), `uix_pred_exec_nm` (EXEC_NM, partial)
|
||||
|
||||
---
|
||||
|
||||
## 3. Python FastAPI 엔드포인트 (포트 5003)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/get-received-date` | 최신 예보 수신 가능 날짜 |
|
||||
| GET | `/get-uv/{datetime}/{category}` | 바람/해류 U/V 벡터 (`wind`\|`hydr`) |
|
||||
| POST | `/check-nc` | NetCDF 파일 존재 여부 확인 |
|
||||
| POST | `/run-model` | 시뮬레이션 제출 → 즉시 `job_id` 반환 |
|
||||
| GET | `/status/{job_id}` | 시뮬레이션 진행 상태 조회 |
|
||||
|
||||
### POST /run-model 입력 파라미터
|
||||
|
||||
```json
|
||||
{
|
||||
"startTime": "2025-01-15 12:00:00", // KST (내부 UTC 변환)
|
||||
"runTime": 72, // 예측 시간 (시간)
|
||||
"matTy": "CRUDE OIL", // OpenDrift 유류명
|
||||
"matVol": 100.0, // 시간당 유출량 (m³/hr)
|
||||
"lon": 126.1,
|
||||
"lat": 36.6,
|
||||
"spillTime": 12, // 유출 지속 시간 (0=순간)
|
||||
"name": "EXPC_1710000000000"
|
||||
}
|
||||
```
|
||||
|
||||
### 유류 코드 매핑 (DB → OpenDrift)
|
||||
|
||||
| DB SPIL_MAT_CD | OpenDrift 이름 |
|
||||
|---------------|---------------|
|
||||
| CRUD | CRUDE OIL |
|
||||
| DSEL | DIESEL |
|
||||
| BNKR | BUNKER |
|
||||
| HEFO | IFO 180 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Express 백엔드 주요 엔드포인트
|
||||
|
||||
파일: [backend/src/routes/simulation.ts](../backend/src/routes/simulation.ts)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/simulation/run` | 시뮬레이션 제출 → `execSn` 즉시 반환 |
|
||||
| GET | `/api/simulation/status/:execSn` | 프론트 폴링용 상태 조회 |
|
||||
|
||||
파일: [backend/src/prediction/predictionService.ts](../backend/src/prediction/predictionService.ts)
|
||||
|
||||
- `fetchPredictionList()` — PRED_EXEC 목록 조회
|
||||
- `fetchTrajectoryResult()` — 저장된 결과 조회 (`RSLT_DATA` JSONB 파싱)
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 주요 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| [frontend/src/tabs/prediction/components/OilSpillView.tsx](../frontend/src/tabs/prediction/components/OilSpillView.tsx) | 예측 탭 메인 뷰, 시뮬레이션 실행·폴링 상태 관리 |
|
||||
| [frontend/src/tabs/prediction/hooks/](../frontend/src/tabs/prediction/hooks/) | `useSimulationStatus` 폴링 훅 |
|
||||
| [frontend/src/tabs/prediction/services/predictionApi.ts](../frontend/src/tabs/prediction/services/predictionApi.ts) | API 요청 함수 + 타입 정의 |
|
||||
| [frontend/src/tabs/prediction/components/RightPanel.tsx](../frontend/src/tabs/prediction/components/RightPanel.tsx) | 풍화량·잔류량·오염면적 표시 (마지막 스텝 실제 값) |
|
||||
| [frontend/src/common/components/map/HydrParticleOverlay.tsx](../frontend/src/common/components/map/HydrParticleOverlay.tsx) | 해류 파티클 Canvas 오버레이 |
|
||||
|
||||
### 핵심 타입 (predictionApi.ts)
|
||||
|
||||
```typescript
|
||||
interface HydrGrid {
|
||||
lonInterval: number[];
|
||||
latInterval: number[];
|
||||
boundLonLat: { top: number; bottom: number; left: number; right: number };
|
||||
rows: number; cols: number;
|
||||
}
|
||||
interface HydrDataStep {
|
||||
value: [number[][], number[][]]; // [u_2d, v_2d]
|
||||
grid: HydrGrid;
|
||||
}
|
||||
```
|
||||
|
||||
### 폴링 훅 패턴
|
||||
|
||||
```typescript
|
||||
useQuery({
|
||||
queryKey: ['simulationStatus', execSn],
|
||||
queryFn: () => api.get(`/api/simulation/status/${execSn}`),
|
||||
enabled: execSn !== null,
|
||||
refetchInterval: (data) =>
|
||||
data?.status === 'DONE' || data?.status === 'ERROR' ? false : 3000,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Python 코드 위치 (prediction/)
|
||||
|
||||
```
|
||||
prediction/opendrift/
|
||||
├── api.py FastAPI 진입점 (수정 필요: 폴링 지원 + CORS)
|
||||
├── config.py 경로 설정 (수정 필요: 환경변수화)
|
||||
├── createJsonResult.py NC → JSON 변환 (핵심 후처리)
|
||||
├── coastline/ TN_SHORLINE.shp (한국 해안선)
|
||||
├── startup.sh / shutdown.sh
|
||||
├── .env.example 환경변수 샘플
|
||||
└── environment-opendrift.yml conda 환경 재현용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 환경변수
|
||||
|
||||
### backend/.env
|
||||
|
||||
```bash
|
||||
PYTHON_API_URL=http://localhost:5003
|
||||
```
|
||||
|
||||
### prediction/opendrift/.env
|
||||
|
||||
```bash
|
||||
MPR_STORAGE_ROOT=/data/storage # NetCDF 기상·해양 데이터 루트
|
||||
MPR_RESULT_ROOT=./result # 시뮬레이션 결과 저장 경로
|
||||
MAX_CONCURRENT_JOBS=4 # 동시 처리 최대 수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 위험 요소
|
||||
|
||||
| 위험 | 내용 |
|
||||
|------|------|
|
||||
| NetCDF 파일 부재 | `MPR_STORAGE_ROOT` 경로에 KMA GDAPS·MOHID NC 파일 필요. 없으면 시뮬레이션 불가 |
|
||||
| conda 환경 | `opendrift` conda 환경 설치 필요 (`environment-opendrift.yml`) |
|
||||
| Workers 포화 | 동시 4개 초과 시 503 반환 → `MAX_CONCURRENT_JOBS` 조정 |
|
||||
| 결과 용량 | 12시간 결과 ≈ 1500KB/건. 90일 주기 `RSLT_DATA = NULL` 정리 권장 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 문서
|
||||
|
||||
- [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) — Express API 개발 패턴
|
||||
- [COMMON-GUIDE.md](./COMMON-GUIDE.md) — 인증·상태관리 공통 로직
|
||||
@ -4,8 +4,97 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-18]
|
||||
|
||||
### 추가
|
||||
- 관리자: 방제장비 현황 패널 (CleanupEquipPanel) — 관할청·유형별 필터, 자산 수량 조회
|
||||
- 관리자: 자산 현행화 업로드 패널 (AssetUploadPanel) — 엑셀/CSV 드래그 드롭 업로드
|
||||
|
||||
### 변경
|
||||
- trajectory API 모델별 windData/hydrData 분리 반환
|
||||
- 예측 서비스(predictionService) 개선
|
||||
- 보고서: 유출유 확산 지도 패널 및 보고서 생성기 개선
|
||||
- 관리자: 권한/메뉴 구성 업데이트, AdminView 패널 등록
|
||||
- prediction/image 이미지 분석 서버 분리 (디렉토리 제거)
|
||||
|
||||
### 기타
|
||||
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결)
|
||||
- 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 개선
|
||||
|
||||
### 수정
|
||||
- geo.ts 중복 함수 제거 및 null 좌표 참조 오류 수정
|
||||
|
||||
### 변경
|
||||
- 확산 예측 요약 폰트/레이아웃을 오염 종합 상황과 통일
|
||||
- 오염분석 UI 개선 — HTML 디자인 참고 반영
|
||||
- 범례 UI 개선 — HTML 참고 디자인 반영
|
||||
- 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일
|
||||
|
||||
### 기타
|
||||
- 프론트엔드 포트 변경(5174) + CORS 허용
|
||||
|
||||
## [2026-03-13]
|
||||
|
||||
### 추가
|
||||
- 오염분석 다각형/원 분석 기능 구현
|
||||
- 시뮬레이션 에러 모달 추가
|
||||
- 해류 캔버스 파티클 레이어 추가
|
||||
|
||||
### 수정
|
||||
- useSubMenu useEffect import 누락 수정
|
||||
|
||||
### 변경
|
||||
- 보고서 해안부착 현황 개선
|
||||
|
||||
### 기타
|
||||
- 팀 워크플로우 동기화 (v1.6.1)
|
||||
|
||||
## [2026-03-11]
|
||||
|
||||
### 추가
|
||||
- KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환
|
||||
- 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선
|
||||
- 기상 정보 기상 레이어 업데이트
|
||||
- CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지)
|
||||
- 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
|
||||
- CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
|
||||
- 유류오염보장계약 시드 데이터 추가 (1391건)
|
||||
- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조)
|
||||
- flyTo 완료 후 자동 재생 기능
|
||||
- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경)
|
||||
- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합
|
||||
- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml)
|
||||
|
||||
### 수정
|
||||
- /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정
|
||||
|
||||
### 변경
|
||||
- 이미지 분석/보고서/항공 UI 개선
|
||||
- CCTV/관리자 고도화
|
||||
|
||||
### 문서
|
||||
- 프로젝트 문서 최신화 (KHOA API, Vite 프록시)
|
||||
|
||||
### 기타
|
||||
- CLAUDE_BOT_TOKEN 갱신
|
||||
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결, 적용일 갱신)
|
||||
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
|
||||
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)
|
||||
|
||||
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -25,6 +25,7 @@
|
||||
"@vis.gl/react-maplibre": "^8.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"exifr": "^7.1.3",
|
||||
"hls.js": "^1.6.15",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
@ -3848,6 +3849,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exifr": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
|
||||
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
"@vis.gl/react-maplibre": "^8.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"exifr": "^7.1.3",
|
||||
"hls.js": "^1.6.15",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
|
||||
@ -97,6 +97,8 @@ function App() {
|
||||
return <AdminView />
|
||||
case 'rescue':
|
||||
return <RescueView />
|
||||
case 'monitor':
|
||||
return null
|
||||
default:
|
||||
return <div className="flex items-center justify-center h-full text-text-3">준비 중입니다...</div>
|
||||
}
|
||||
|
||||
@ -39,9 +39,13 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
{/* Left Section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => tabs[0] && onTabChange(tabs[0].id as MainTab)}
|
||||
className="flex items-center hover:opacity-80 transition-opacity cursor-pointer"
|
||||
title="홈으로 이동"
|
||||
>
|
||||
<img src="/wing_logo_white.svg" alt="WING 해양환경 위기대응" className="h-3.5" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-border-light" />
|
||||
@ -50,28 +54,51 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
<div className="flex gap-0.5">
|
||||
{tabs.map((tab) => {
|
||||
const isIncident = tab.id === 'incidents'
|
||||
const isMonitor = tab.id === 'monitor'
|
||||
const handleClick = () => {
|
||||
if (isMonitor) {
|
||||
window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'https://kcg.gc-si.dev', '_blank')
|
||||
} else {
|
||||
onTabChange(tab.id as MainTab)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id as MainTab)}
|
||||
onClick={handleClick}
|
||||
title={tab.label}
|
||||
className={`
|
||||
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
|
||||
font-korean tracking-[0.2px]
|
||||
${isIncident ? 'font-extrabold border-l border-l-[rgba(99,102,241,0.2)] ml-1' : 'font-semibold'}
|
||||
${isMonitor ? 'border-l border-l-[rgba(239,68,68,0.25)] ml-1 flex items-center gap-1.5' : ''}
|
||||
${
|
||||
activeTab === tab.id
|
||||
? isIncident
|
||||
? 'text-[#a5b4fc] bg-[rgba(99,102,241,0.18)] shadow-[0_0_8px_rgba(99,102,241,0.3)]'
|
||||
: 'text-[#22d3ee] bg-[rgba(6,182,212,0.15)] shadow-[0_0_8px_rgba(6,182,212,0.3)]'
|
||||
: isIncident
|
||||
? 'text-[#818cf8] hover:text-[#a5b4fc] hover:bg-[rgba(99,102,241,0.1)]'
|
||||
: 'text-[#c8d6e5] hover:text-white hover:bg-[rgba(255,255,255,0.08)]'
|
||||
isMonitor
|
||||
? 'text-[#f87171] hover:text-[#fca5a5] hover:bg-[rgba(239,68,68,0.1)]'
|
||||
: activeTab === tab.id
|
||||
? isIncident
|
||||
? 'text-[#a5b4fc] bg-[rgba(99,102,241,0.18)] shadow-[0_0_8px_rgba(99,102,241,0.3)]'
|
||||
: 'text-[#22d3ee] bg-[rgba(6,182,212,0.15)] shadow-[0_0_8px_rgba(6,182,212,0.3)]'
|
||||
: isIncident
|
||||
? 'text-[#818cf8] hover:text-[#a5b4fc] hover:bg-[rgba(99,102,241,0.1)]'
|
||||
: 'text-[#c8d6e5] hover:text-white hover:bg-[rgba(255,255,255,0.08)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
|
||||
<span className="hidden xl:inline">{tab.label}</span>
|
||||
{isMonitor ? (
|
||||
<>
|
||||
<span className="hidden xl:flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#f87171] animate-pulse inline-block" />
|
||||
{tab.label}
|
||||
</span>
|
||||
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
|
||||
<span className="hidden xl:inline">{tab.label}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
159
frontend/src/common/components/map/HydrParticleOverlay.tsx
Normal file
159
frontend/src/common/components/map/HydrParticleOverlay.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from '@vis.gl/react-maplibre';
|
||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
||||
|
||||
interface HydrParticleOverlayProps {
|
||||
hydrStep: HydrDataStep | null;
|
||||
lightMode?: boolean;
|
||||
}
|
||||
|
||||
const PARTICLE_COUNT = 3000;
|
||||
const MAX_AGE = 300;
|
||||
const SPEED_SCALE = 0.1;
|
||||
const DT = 600;
|
||||
const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수
|
||||
const NUM_ALPHA_BANDS = 4; // stroke 배치 단위
|
||||
|
||||
interface TrailPoint { x: number; y: number; }
|
||||
interface Particle {
|
||||
lon: number;
|
||||
lat: number;
|
||||
trail: TrailPoint[];
|
||||
age: number;
|
||||
}
|
||||
|
||||
export default function HydrParticleOverlay({ hydrStep, lightMode = false }: HydrParticleOverlayProps) {
|
||||
const { current: map } = useMap();
|
||||
const animRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !hydrStep) return;
|
||||
|
||||
const container = map.getContainer();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:5;';
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
container.appendChild(canvas);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
const { value: [u2d, v2d], grid } = hydrStep;
|
||||
const { boundLonLat, lonInterval, latInterval } = grid;
|
||||
|
||||
const lons: number[] = [boundLonLat.left];
|
||||
for (const d of lonInterval) lons.push(lons[lons.length - 1] + d);
|
||||
const lats: number[] = [boundLonLat.bottom];
|
||||
for (const d of latInterval) lats.push(lats[lats.length - 1] + d);
|
||||
|
||||
function getUV(lon: number, lat: number): [number, number] {
|
||||
let col = -1, row = -1;
|
||||
for (let i = 0; i < lons.length - 1; i++) {
|
||||
if (lon >= lons[i] && lon < lons[i + 1]) { col = i; break; }
|
||||
}
|
||||
for (let i = 0; i < lats.length - 1; i++) {
|
||||
if (lat >= lats[i] && lat < lats[i + 1]) { row = i; break; }
|
||||
}
|
||||
if (col < 0 || row < 0) return [0, 0];
|
||||
const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]);
|
||||
const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]);
|
||||
const u00 = u2d[row]?.[col] ?? 0, u01 = u2d[row]?.[col + 1] ?? u00;
|
||||
const u10 = u2d[row + 1]?.[col] ?? u00, u11 = u2d[row + 1]?.[col + 1] ?? u00;
|
||||
const v00 = v2d[row]?.[col] ?? 0, v01 = v2d[row]?.[col + 1] ?? v00;
|
||||
const v10 = v2d[row + 1]?.[col] ?? v00, v11 = v2d[row + 1]?.[col + 1] ?? v00;
|
||||
const u = u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy;
|
||||
const v = v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy;
|
||||
return [u, v];
|
||||
}
|
||||
|
||||
const bbox = boundLonLat;
|
||||
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({
|
||||
lon: bbox.left + Math.random() * (bbox.right - bbox.left),
|
||||
lat: bbox.bottom + Math.random() * (bbox.top - bbox.bottom),
|
||||
trail: [],
|
||||
age: Math.floor(Math.random() * MAX_AGE),
|
||||
}));
|
||||
|
||||
function resetParticle(p: Particle) {
|
||||
p.lon = bbox.left + Math.random() * (bbox.right - bbox.left);
|
||||
p.lat = bbox.bottom + Math.random() * (bbox.top - bbox.bottom);
|
||||
p.trail = [];
|
||||
p.age = 0;
|
||||
}
|
||||
|
||||
// 지도 이동/줌 시 화면 좌표가 틀어지므로 trail 초기화
|
||||
const onMove = () => { for (const p of particles) p.trail = []; };
|
||||
map.on('move', onMove);
|
||||
|
||||
function animate() {
|
||||
// 매 프레임 완전 초기화 → 잔상 없음
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// alpha band별 세그먼트 버퍼 (드로우 콜 최소화)
|
||||
const bands: [number, number, number, number][][] =
|
||||
Array.from({ length: NUM_ALPHA_BANDS }, () => []);
|
||||
|
||||
for (const p of particles) {
|
||||
const [u, v] = getUV(p.lon, p.lat);
|
||||
const speed = Math.sqrt(u * u + v * v);
|
||||
if (speed < 0.001) { resetParticle(p); continue; }
|
||||
|
||||
const cosLat = Math.cos(p.lat * Math.PI / 180);
|
||||
p.lon += u * SPEED_SCALE * DT / (cosLat * 111320);
|
||||
p.lat += v * SPEED_SCALE * DT / 111320;
|
||||
p.age++;
|
||||
|
||||
if (
|
||||
p.lon < bbox.left || p.lon > bbox.right ||
|
||||
p.lat < bbox.bottom || p.lat > bbox.top ||
|
||||
p.age > MAX_AGE
|
||||
) { resetParticle(p); continue; }
|
||||
|
||||
const curr = map.project([p.lon, p.lat]);
|
||||
if (!curr) continue;
|
||||
|
||||
p.trail.push({ x: curr.x, y: curr.y });
|
||||
if (p.trail.length > TRAIL_LENGTH) p.trail.shift();
|
||||
if (p.trail.length < 2) continue;
|
||||
|
||||
for (let i = 1; i < p.trail.length; i++) {
|
||||
const t = i / p.trail.length; // 0=oldest, 1=newest
|
||||
const band = Math.min(NUM_ALPHA_BANDS - 1, Math.floor(t * NUM_ALPHA_BANDS));
|
||||
const a = p.trail[i - 1], b = p.trail[i];
|
||||
bands[band].push([a.x, a.y, b.x, b.y]);
|
||||
}
|
||||
}
|
||||
|
||||
// alpha band별 일괄 렌더링
|
||||
ctx.lineWidth = 0.8;
|
||||
for (let b = 0; b < NUM_ALPHA_BANDS; b++) {
|
||||
const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255];
|
||||
ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`;
|
||||
ctx.beginPath();
|
||||
for (const [x1, y1, x2, y2] of bands[b]) {
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
|
||||
const onResize = () => {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
};
|
||||
map.on('resize', onResize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current!);
|
||||
map.off('resize', onResize);
|
||||
map.off('move', onMove);
|
||||
canvas.remove();
|
||||
};
|
||||
}, [map, hydrStep, lightMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -1,13 +1,15 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers'
|
||||
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { layerDatabase } from '@common/services/layerService'
|
||||
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'
|
||||
import HydrParticleOverlay from './HydrParticleOverlay'
|
||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
import { createBacktrackLayers } from './BacktrackReplayOverlay'
|
||||
@ -17,8 +19,8 @@ import { useMapStore } from '@common/store/mapStore'
|
||||
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
|
||||
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''
|
||||
|
||||
// 남해안 중심 좌표 (여수 앞바다)
|
||||
const DEFAULT_CENTER: [number, number] = [34.5, 127.8]
|
||||
// 인천 송도 국제도시
|
||||
const DEFAULT_CENTER: [number, number] = [37.39, 126.64]
|
||||
const DEFAULT_ZOOM = 10
|
||||
|
||||
// CartoDB Dark Matter 스타일
|
||||
@ -319,7 +321,7 @@ interface MapViewProps {
|
||||
incidentCoord?: { lon: number; lat: number }
|
||||
isSelectingLocation?: boolean
|
||||
onMapClick?: (lon: number, lat: number) => void
|
||||
oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>
|
||||
oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: string; stranded?: 0 | 1 }>
|
||||
selectedModels?: Set<PredictionModel>
|
||||
dispersionResult?: DispersionResult | null
|
||||
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>
|
||||
@ -337,9 +339,29 @@ interface MapViewProps {
|
||||
incidentCoord: { lat: number; lon: number }
|
||||
}
|
||||
sensitiveResources?: SensitiveResource[]
|
||||
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
||||
flyToTarget?: { lng: number; lat: number; zoom?: number } | null
|
||||
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
|
||||
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
|
||||
windData?: Array<Array<{ lat: number; lon: number; wind_speed: number; wind_direction: number }>>
|
||||
hydrData?: (HydrDataStep | null)[]
|
||||
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
|
||||
externalCurrentTime?: number
|
||||
mapCaptureRef?: React.MutableRefObject<(() => Promise<string | null>) | null>
|
||||
onIncidentFlyEnd?: () => void
|
||||
flyToIncident?: { lon: number; lat: number }
|
||||
showCurrent?: boolean
|
||||
showWind?: boolean
|
||||
showBeached?: boolean
|
||||
showTimeLabel?: boolean
|
||||
simulationStartTime?: string
|
||||
drawAnalysisMode?: 'polygon' | 'circle' | null
|
||||
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
|
||||
analysisCircleCenter?: { lat: number; lon: number } | null
|
||||
analysisCircleRadiusM?: number
|
||||
/** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */
|
||||
lightMode?: boolean
|
||||
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
|
||||
showOverlays?: boolean
|
||||
}
|
||||
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||
@ -350,6 +372,63 @@ function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// flyTo 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
|
||||
function FlyToController({ flyToTarget }: { flyToTarget?: { lng: number; lat: number; zoom?: number } | null }) {
|
||||
const { current: map } = useMap()
|
||||
useEffect(() => {
|
||||
if (!map || !flyToTarget) return
|
||||
map.flyTo({
|
||||
center: [flyToTarget.lng, flyToTarget.lat],
|
||||
zoom: flyToTarget.zoom ?? 10,
|
||||
duration: 1200,
|
||||
})
|
||||
}, [flyToTarget, map])
|
||||
return null
|
||||
}
|
||||
|
||||
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
|
||||
function FitBoundsController({ fitBoundsTarget }: { fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null }) {
|
||||
const { current: map } = useMap()
|
||||
useEffect(() => {
|
||||
if (!map || !fitBoundsTarget) return
|
||||
map.fitBounds(
|
||||
[[fitBoundsTarget.west, fitBoundsTarget.south], [fitBoundsTarget.east, fitBoundsTarget.north]],
|
||||
{ padding: 80, duration: 1200, maxZoom: 12 }
|
||||
)
|
||||
}, [fitBoundsTarget, map])
|
||||
return null
|
||||
}
|
||||
|
||||
// Map 중앙 좌표 + 줌 추적 컴포넌트 (Map 내부에서 useMap() 사용)
|
||||
function MapCenterTracker({
|
||||
onCenterChange,
|
||||
}: {
|
||||
onCenterChange: (lat: number, lng: number, zoom: number) => void;
|
||||
}) {
|
||||
const { current: map } = useMap()
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
|
||||
const update = () => {
|
||||
const center = map.getCenter()
|
||||
const zoom = map.getZoom()
|
||||
onCenterChange(center.lat, center.lng, zoom)
|
||||
}
|
||||
|
||||
update()
|
||||
map.on('move', update)
|
||||
map.on('zoom', update)
|
||||
|
||||
return () => {
|
||||
map.off('move', update)
|
||||
map.off('zoom', update)
|
||||
}
|
||||
}, [map, onCenterChange])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 3D 모드 pitch/bearing 제어 컴포넌트 (Map 내부에서 useMap() 사용)
|
||||
function MapPitchController({ threeD }: { threeD: boolean }) {
|
||||
const { current: map } = useMap()
|
||||
@ -365,14 +444,18 @@ function MapPitchController({ threeD }: { threeD: boolean }) {
|
||||
}
|
||||
|
||||
// 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트)
|
||||
function MapFlyToIncident({ lon, lat }: { lon?: number; lat?: number }) {
|
||||
function MapFlyToIncident({ coord, onFlyEnd }: { coord?: { lon: number; lat: number }; onFlyEnd?: () => void }) {
|
||||
const { current: map } = useMap()
|
||||
const onFlyEndRef = useRef(onFlyEnd)
|
||||
useEffect(() => { onFlyEndRef.current = onFlyEnd }, [onFlyEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || lon == null || lat == null) return
|
||||
if (!map || !coord) return
|
||||
|
||||
const { lon, lat } = coord
|
||||
const doFly = () => {
|
||||
map.flyTo({ center: [lon, lat], zoom: 12, duration: 1200 })
|
||||
map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 })
|
||||
map.once('moveend', () => onFlyEndRef.current?.())
|
||||
}
|
||||
|
||||
if (map.loaded()) {
|
||||
@ -380,20 +463,39 @@ function MapFlyToIncident({ lon, lat }: { lon?: number; lat?: number }) {
|
||||
} else {
|
||||
map.once('load', doFly)
|
||||
}
|
||||
}, [lon, lat, map])
|
||||
}, [coord, map]) // 객체 참조 추적: 같은 좌표라도 새 객체면 effect 재실행
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 지도 캡처 지원 (preserveDrawingBuffer 필요)
|
||||
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => string | null) | null> }) {
|
||||
// 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지)
|
||||
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => Promise<string | null>) | null> }) {
|
||||
const { current: map } = useMap();
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
captureRef.current = () => {
|
||||
try { return map.getCanvas().toDataURL('image/png'); }
|
||||
catch { return null; }
|
||||
};
|
||||
captureRef.current = () =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
map.once('render', () => {
|
||||
try {
|
||||
// WebGL 캔버스는 alpha=0 투명 배경이므로 불투명 배경과 합성 후 추출
|
||||
// 최대 1200px로 리사이즈 + JPEG 압축으로 전송 크기 절감
|
||||
const src = map.getCanvas();
|
||||
const maxW = 1200;
|
||||
const scale = src.width > maxW ? maxW / src.width : 1;
|
||||
const composite = document.createElement('canvas');
|
||||
composite.width = Math.round(src.width * scale);
|
||||
composite.height = Math.round(src.height * scale);
|
||||
const ctx = composite.getContext('2d')!;
|
||||
ctx.fillStyle = '#0f1117';
|
||||
ctx.fillRect(0, 0, composite.width, composite.height);
|
||||
ctx.drawImage(src, 0, 0, composite.width, composite.height);
|
||||
resolve(composite.toDataURL('image/jpeg', 0.82));
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
map.triggerRepaint();
|
||||
});
|
||||
}, [map, captureRef]);
|
||||
return null;
|
||||
}
|
||||
@ -423,15 +525,42 @@ export function MapView({
|
||||
layerBrightness = 50,
|
||||
backtrackReplay,
|
||||
sensitiveResources = [],
|
||||
flyToTarget,
|
||||
fitBoundsTarget,
|
||||
centerPoints = [],
|
||||
windData = [],
|
||||
hydrData = [],
|
||||
externalCurrentTime,
|
||||
mapCaptureRef,
|
||||
onIncidentFlyEnd,
|
||||
flyToIncident,
|
||||
showCurrent = true,
|
||||
showWind = true,
|
||||
showBeached = false,
|
||||
showTimeLabel = false,
|
||||
simulationStartTime,
|
||||
drawAnalysisMode = null,
|
||||
analysisPolygonPoints = [],
|
||||
analysisCircleCenter,
|
||||
analysisCircleRadiusM = 0,
|
||||
lightMode = false,
|
||||
showOverlays = true,
|
||||
}: MapViewProps) {
|
||||
const { mapToggles } = useMapStore()
|
||||
const isControlled = externalCurrentTime !== undefined
|
||||
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [mapCenter, setMapCenter] = useState<[number, number]>(DEFAULT_CENTER)
|
||||
const [mapZoom, setMapZoom] = useState<number>(DEFAULT_ZOOM)
|
||||
const [internalCurrentTime, setInternalCurrentTime] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
|
||||
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime
|
||||
|
||||
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
setMapCenter([lat, lng])
|
||||
setMapZoom(zoom)
|
||||
}, [])
|
||||
|
||||
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
@ -442,33 +571,34 @@ export function MapView({
|
||||
setPopupInfo(null)
|
||||
}, [onMapClick])
|
||||
|
||||
// 애니메이션 재생 로직
|
||||
// 애니메이션 재생 로직 (외부 제어 모드에서는 비활성)
|
||||
useEffect(() => {
|
||||
if (!isPlaying || oilTrajectory.length === 0) return
|
||||
if (isControlled || !isPlaying || oilTrajectory.length === 0) return
|
||||
|
||||
const maxTime = Math.max(...oilTrajectory.map(p => p.time))
|
||||
if (currentTime >= maxTime) {
|
||||
if (internalCurrentTime >= maxTime) {
|
||||
setIsPlaying(false)
|
||||
return
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(prev => {
|
||||
setInternalCurrentTime(prev => {
|
||||
const next = prev + (1 * playbackSpeed)
|
||||
return next > maxTime ? maxTime : next
|
||||
})
|
||||
}, 200)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isPlaying, currentTime, playbackSpeed, oilTrajectory])
|
||||
}, [isControlled, isPlaying, internalCurrentTime, playbackSpeed, oilTrajectory])
|
||||
|
||||
// 시뮬레이션 시작 시 자동으로 애니메이션 재생
|
||||
// 시뮬레이션 시작 시 자동으로 애니메이션 재생 (외부 제어 모드에서는 비활성)
|
||||
useEffect(() => {
|
||||
if (isControlled) return
|
||||
if (oilTrajectory.length > 0) {
|
||||
setCurrentTime(0)
|
||||
setInternalCurrentTime(0)
|
||||
setIsPlaying(true)
|
||||
}
|
||||
}, [oilTrajectory.length])
|
||||
}, [isControlled, oilTrajectory.length])
|
||||
|
||||
// WMS 레이어 목록
|
||||
const wmsLayers = useMemo(() => {
|
||||
@ -493,6 +623,9 @@ export function MapView({
|
||||
|
||||
// --- 유류 확산 입자 (ScatterplotLayer) ---
|
||||
const visibleParticles = oilTrajectory.filter(p => p.time <= currentTime)
|
||||
const activeStep = visibleParticles.length > 0
|
||||
? Math.max(...visibleParticles.map(p => p.time))
|
||||
: -1
|
||||
if (visibleParticles.length > 0) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
@ -501,8 +634,15 @@ export function MapView({
|
||||
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
|
||||
getRadius: 3,
|
||||
getFillColor: (d: (typeof visibleParticles)[0]) => {
|
||||
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
|
||||
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
|
||||
const modelKey = (d.model || Array.from(selectedModels)[0] || 'OpenDrift') as PredictionModel
|
||||
// 1순위: stranded 입자 → showBeached=true 시 모델 색, false 시 회색
|
||||
if (d.stranded === 1) return showBeached
|
||||
? hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
|
||||
: [130, 130, 130, 70] as [number, number, number, number]
|
||||
// 2순위: 현재 활성 스텝 → 모델 기본 색상
|
||||
if (d.time === activeStep) return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
|
||||
// 3순위: 과거 스텝 → 회색 + 투명
|
||||
return [130, 130, 130, 70] as [number, number, number, number]
|
||||
},
|
||||
radiusMinPixels: 2.5,
|
||||
radiusMaxPixels: 5,
|
||||
@ -517,6 +657,7 @@ export function MapView({
|
||||
content: (
|
||||
<div className="text-xs">
|
||||
<strong>{modelKey} 입자 #{(d.particle ?? 0) + 1}</strong>
|
||||
{d.stranded === 1 && <span className="text-red-400"> (육지 부착)</span>}
|
||||
<br />
|
||||
시간: +{d.time}h
|
||||
<br />
|
||||
@ -527,7 +668,32 @@ export function MapView({
|
||||
}
|
||||
},
|
||||
updateTriggers: {
|
||||
getFillColor: [selectedModels],
|
||||
getFillColor: [selectedModels, currentTime, showBeached],
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// --- 육지부착 hollow ring (stranded 모양 구분) ---
|
||||
const strandedParticles = showBeached ? visibleParticles.filter(p => p.stranded === 1) : []
|
||||
if (strandedParticles.length > 0) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'oil-stranded-ring',
|
||||
data: strandedParticles,
|
||||
getPosition: (d: (typeof strandedParticles)[0]) => [d.lon, d.lat],
|
||||
stroked: true,
|
||||
filled: false,
|
||||
getLineColor: (d: (typeof strandedParticles)[0]) => {
|
||||
const modelKey = (d.model || Array.from(selectedModels)[0] || 'OpenDrift') as PredictionModel
|
||||
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 255)
|
||||
},
|
||||
lineWidthMinPixels: 2,
|
||||
getRadius: 4,
|
||||
radiusMinPixels: 5,
|
||||
radiusMaxPixels: 8,
|
||||
updateTriggers: {
|
||||
getLineColor: [selectedModels],
|
||||
},
|
||||
})
|
||||
)
|
||||
@ -629,6 +795,91 @@ export function MapView({
|
||||
)
|
||||
}
|
||||
|
||||
// --- 오염분석 다각형 그리기 ---
|
||||
if (analysisPolygonPoints.length > 0) {
|
||||
if (analysisPolygonPoints.length >= 3) {
|
||||
result.push(
|
||||
new PolygonLayer({
|
||||
id: 'analysis-polygon-fill',
|
||||
data: [{ polygon: analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]) }],
|
||||
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
|
||||
getFillColor: [168, 85, 247, 40],
|
||||
getLineColor: [168, 85, 247, 220],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
lineWidthMinPixels: 2,
|
||||
})
|
||||
)
|
||||
}
|
||||
result.push(
|
||||
new PathLayer({
|
||||
id: 'analysis-polygon-outline',
|
||||
data: [{
|
||||
path: [
|
||||
...analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]),
|
||||
...(analysisPolygonPoints.length >= 3 ? [[analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [number, number]] : []),
|
||||
],
|
||||
}],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [168, 85, 247, 220],
|
||||
getWidth: 2,
|
||||
getDashArray: [8, 4],
|
||||
dashJustified: true,
|
||||
widthMinPixels: 2,
|
||||
})
|
||||
)
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'analysis-polygon-points',
|
||||
data: analysisPolygonPoints.map(p => ({ position: [p.lon, p.lat] as [number, number] })),
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: 5,
|
||||
getFillColor: [168, 85, 247, 255],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 5,
|
||||
radiusMaxPixels: 8,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// --- 오염분석 원 그리기 ---
|
||||
if (analysisCircleCenter) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'analysis-circle-center',
|
||||
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: 6,
|
||||
getFillColor: [168, 85, 247, 255],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 6,
|
||||
radiusMaxPixels: 9,
|
||||
})
|
||||
)
|
||||
if (analysisCircleRadiusM > 0) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'analysis-circle-area',
|
||||
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: analysisCircleRadiusM,
|
||||
radiusUnits: 'meters',
|
||||
getFillColor: [168, 85, 247, 35],
|
||||
getLineColor: [168, 85, 247, 200],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
lineWidthMinPixels: 2,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
|
||||
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
||||
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
|
||||
@ -839,7 +1090,7 @@ export function MapView({
|
||||
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
|
||||
getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`,
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 200],
|
||||
getColor: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [number, number, number, number],
|
||||
getPixelOffset: [0, -20],
|
||||
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
@ -852,37 +1103,113 @@ export function MapView({
|
||||
)
|
||||
}
|
||||
|
||||
// --- 해류 화살표 (TextLayer) ---
|
||||
if (incidentCoord) {
|
||||
const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = []
|
||||
const gridSize = 5
|
||||
const spacing = 0.04 // 약 4km 간격
|
||||
const mainBearing = 200 // SSW 방향 (도)
|
||||
// --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
|
||||
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
|
||||
if (visibleCenters.length > 0) {
|
||||
// 모델별 그룹핑 (Record 사용 — Map 컴포넌트와 이름 충돌 회피)
|
||||
const modelGroups: Record<string, typeof visibleCenters> = {}
|
||||
visibleCenters.forEach(p => {
|
||||
const key = p.model || 'OpenDrift'
|
||||
if (!modelGroups[key]) modelGroups[key] = []
|
||||
modelGroups[key].push(p)
|
||||
})
|
||||
|
||||
for (let row = -gridSize; row <= gridSize; row++) {
|
||||
for (let col = -gridSize; col <= gridSize; col++) {
|
||||
const lat = incidentCoord.lat + row * spacing
|
||||
const lon = incidentCoord.lon + col * spacing / Math.cos(incidentCoord.lat * Math.PI / 180)
|
||||
// 사고 지점에서 멀어질수록 해류 방향 약간 변화
|
||||
const distFactor = Math.sqrt(row * row + col * col) / gridSize
|
||||
const localBearing = mainBearing + (col * 3) + (row * 2)
|
||||
const speed = 0.3 + (1 - distFactor) * 0.2
|
||||
currentArrows.push({ lon, lat, bearing: localBearing, speed })
|
||||
Object.entries(modelGroups).forEach(([model, points]) => {
|
||||
const modelColor = hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 210)
|
||||
if (points.length >= 2) {
|
||||
result.push(
|
||||
new PathLayer({
|
||||
id: `center-path-${model}`,
|
||||
data: [{ path: points.map((p: { lon: number; lat: number }) => [p.lon, p.lat] as [number, number]) }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: modelColor,
|
||||
getWidth: 2,
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 4,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: `center-points-${model}`,
|
||||
data: points,
|
||||
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
|
||||
getRadius: 5,
|
||||
getFillColor: modelColor,
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 8,
|
||||
pickable: false,
|
||||
})
|
||||
)
|
||||
if (showTimeLabel) {
|
||||
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
result.push(
|
||||
new TextLayer({
|
||||
id: `time-labels-${model}`,
|
||||
data: points,
|
||||
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
|
||||
getText: (d: { time: number }) => {
|
||||
if (baseTime) {
|
||||
const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000);
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
}
|
||||
return `+${d.time}h`;
|
||||
},
|
||||
getSize: 12,
|
||||
getColor: hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 240),
|
||||
getPixelOffset: [0, 16] as [number, number],
|
||||
fontWeight: 'bold',
|
||||
outlineWidth: 2,
|
||||
outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [number, number, number, number],
|
||||
billboard: true,
|
||||
sizeUnits: 'pixels' as const,
|
||||
updateTriggers: {
|
||||
getText: [simulationStartTime, currentTime],
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- 바람 화살표 (TextLayer) ---
|
||||
if (incidentCoord && windData.length > 0 && showWind) {
|
||||
type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number }
|
||||
|
||||
const activeWindStep = windData[currentTime] ?? windData[0] ?? []
|
||||
const currentArrows: ArrowPoint[] = activeWindStep
|
||||
.filter((d) => d.wind_speed != null && d.wind_direction != null)
|
||||
.map((d) => ({
|
||||
lon: d.lon,
|
||||
lat: d.lat,
|
||||
bearing: d.wind_direction,
|
||||
speed: d.wind_speed,
|
||||
}))
|
||||
|
||||
result.push(
|
||||
new TextLayer({
|
||||
id: 'current-arrows',
|
||||
data: currentArrows,
|
||||
getPosition: (d: (typeof currentArrows)[0]) => [d.lon, d.lat],
|
||||
getPosition: (d: ArrowPoint) => [d.lon, d.lat],
|
||||
getText: () => '➤',
|
||||
getAngle: (d: (typeof currentArrows)[0]) => -d.bearing + 90,
|
||||
getAngle: (d: ArrowPoint) => -d.bearing + 90,
|
||||
getSize: 22,
|
||||
getColor: [6, 182, 212, 100],
|
||||
getColor: (d: ArrowPoint): [number, number, number, number] => {
|
||||
const s = d.speed
|
||||
if (s < 3) return [6, 182, 212, 130] // cyan-500: calm
|
||||
if (s < 7) return [34, 197, 94, 150] // green-500: light
|
||||
if (s < 12) return [234, 179, 8, 170] // yellow-500: moderate
|
||||
if (s < 17) return [249, 115, 22, 190] // orange-500: fresh
|
||||
return [239, 68, 68, 210] // red-500: strong
|
||||
},
|
||||
characterSet: 'auto',
|
||||
sizeUnits: 'pixels' as const,
|
||||
billboard: true,
|
||||
updateTriggers: {
|
||||
getColor: [currentTime, windData],
|
||||
getAngle: [currentTime, windData],
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -892,7 +1219,9 @@ export function MapView({
|
||||
oilTrajectory, currentTime, selectedModels,
|
||||
boomLines, isDrawingBoom, drawingPoints,
|
||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||
sensitiveResources,
|
||||
sensitiveResources, centerPoints, windData,
|
||||
showWind, showBeached, showTimeLabel, simulationStartTime,
|
||||
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
|
||||
])
|
||||
|
||||
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
||||
@ -908,17 +1237,23 @@ export function MapView({
|
||||
}}
|
||||
mapStyle={currentMapStyle}
|
||||
className="w-full h-full"
|
||||
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
||||
style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }}
|
||||
onClick={handleMapClick}
|
||||
attributionControl={false}
|
||||
preserveDrawingBuffer={true}
|
||||
>
|
||||
{/* 지도 캡처 셋업 */}
|
||||
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
|
||||
{/* 지도 중앙 좌표 + 줌 추적 */}
|
||||
<MapCenterTracker onCenterChange={handleMapCenterChange} />
|
||||
{/* 3D 모드 pitch 제어 */}
|
||||
<MapPitchController threeD={mapToggles.threeD} />
|
||||
{/* 사고 지점 변경 시 지도 이동 */}
|
||||
<MapFlyToIncident lon={incidentCoord?.lon} lat={incidentCoord?.lat} />
|
||||
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
|
||||
{/* 외부에서 flyTo 트리거 */}
|
||||
<FlyToController flyToTarget={flyToTarget} />
|
||||
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
||||
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
|
||||
|
||||
{/* WMS 레이어 */}
|
||||
{wmsLayers.map(layer => (
|
||||
@ -946,6 +1281,11 @@ export function MapView({
|
||||
{/* deck.gl 오버레이 (인터리브드: 일반 레이어) */}
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
|
||||
{/* 해류 파티클 오버레이 */}
|
||||
{hydrData.length > 0 && showCurrent && (
|
||||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} lightMode={lightMode} />
|
||||
)}
|
||||
|
||||
{/* 사고 위치 마커 (MapLibre Marker) */}
|
||||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !(dispersionHeatmap && dispersionHeatmap.length > 0) && (
|
||||
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
|
||||
@ -983,26 +1323,37 @@ export function MapView({
|
||||
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
||||
</div>
|
||||
)}
|
||||
{drawAnalysisMode === 'polygon' && (
|
||||
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||
다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개)
|
||||
</div>
|
||||
)}
|
||||
{drawAnalysisMode === 'circle' && (
|
||||
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기상청 연계 정보 */}
|
||||
<WeatherInfoPanel position={currentPosition} />
|
||||
{showOverlays && <WeatherInfoPanel position={currentPosition} />}
|
||||
|
||||
{/* 범례 */}
|
||||
<MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />
|
||||
{showOverlays && <MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />}
|
||||
|
||||
{/* 좌표 표시 */}
|
||||
<CoordinateDisplay
|
||||
position={incidentCoord ? [incidentCoord.lat, incidentCoord.lon] : currentPosition}
|
||||
/>
|
||||
{showOverlays && <CoordinateDisplay
|
||||
position={mapCenter}
|
||||
zoom={mapZoom}
|
||||
/>}
|
||||
|
||||
{/* 타임라인 컨트롤 */}
|
||||
{oilTrajectory.length > 0 && (
|
||||
{/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */}
|
||||
{!isControlled && oilTrajectory.length > 0 && (
|
||||
<TimelineControl
|
||||
currentTime={currentTime}
|
||||
maxTime={Math.max(...oilTrajectory.map(p => p.time))}
|
||||
isPlaying={isPlaying}
|
||||
playbackSpeed={playbackSpeed}
|
||||
onTimeChange={setCurrentTime}
|
||||
onTimeChange={setInternalCurrentTime}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
onSpeedChange={setPlaybackSpeed}
|
||||
/>
|
||||
@ -1025,23 +1376,23 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number
|
||||
const { current: map } = useMap()
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="absolute top-[80px] left-[10px] z-10">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => map?.zoomIn()}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.zoomOut()}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-[10px]"
|
||||
>
|
||||
🎯
|
||||
</button>
|
||||
@ -1060,7 +1411,7 @@ interface MapLegendProps {
|
||||
}
|
||||
|
||||
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const [minimized, setMinimized] = useState(true)
|
||||
|
||||
if (dispersionResult && incidentCoord) {
|
||||
return (
|
||||
@ -1188,16 +1539,23 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], select
|
||||
}
|
||||
|
||||
// 좌표 표시
|
||||
function CoordinateDisplay({ position }: { position: [number, number] }) {
|
||||
function CoordinateDisplay({ position, zoom }: { position: [number, number]; zoom: number }) {
|
||||
const [lat, lng] = position
|
||||
const latDirection = lat >= 0 ? 'N' : 'S'
|
||||
const lngDirection = lng >= 0 ? 'E' : 'W'
|
||||
|
||||
// MapLibre 줌 → 축척 변환 (96 DPI 기준)
|
||||
const metersPerPixel = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom))
|
||||
const scaleRatio = Math.round(metersPerPixel * (96 / 0.0254))
|
||||
const scaleLabel = scaleRatio >= 1000000
|
||||
? `1:${(scaleRatio / 1000000).toFixed(1)}M`
|
||||
: `1:${scaleRatio.toLocaleString()}`
|
||||
|
||||
return (
|
||||
<div className="cod">
|
||||
<span>위도 <span className="cov">{Math.abs(lat).toFixed(4)}°{latDirection}</span></span>
|
||||
<span>경도 <span className="cov">{Math.abs(lng).toFixed(4)}°{lngDirection}</span></span>
|
||||
<span>축척 <span className="cov">1:50,000</span></span>
|
||||
<span>축척 <span className="cov">{scaleLabel}</span></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1274,7 +1632,11 @@ function TimelineControl({
|
||||
</div>
|
||||
<div className="tli">
|
||||
{/* eslint-disable-next-line react-hooks/purity */}
|
||||
<div className="tlct">+{currentTime.toFixed(0)}h — {new Date(Date.now() + currentTime * 3600000).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} KST</div>
|
||||
<div className="tlct">+{currentTime.toFixed(0)}h — {(() => {
|
||||
const base = simulationStartTime ? new Date(simulationStartTime) : new Date();
|
||||
const d = new Date(base.getTime() + currentTime * 3600 * 1000);
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`;
|
||||
})()}</div>
|
||||
<div className="tlss">
|
||||
<div className="tls"><span className="tlsl">진행률</span><span className="tlsv">{progressPercent.toFixed(0)}%</span></div>
|
||||
<div className="tls"><span className="tlsl">속도</span><span className="tlsv">{playbackSpeed}×</span></div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import type { MainTab } from '../types/navigation'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
@ -38,11 +38,12 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
||||
],
|
||||
aerial: [
|
||||
{ id: 'media', label: '영상사진관리', icon: '📷' },
|
||||
{ id: 'analysis', label: '유출유면적분석', icon: '🧩' },
|
||||
{ id: 'analysis', label: '영상사진합성', icon: '🧩' },
|
||||
{ id: 'realtime', label: '실시간드론', icon: '🛸' },
|
||||
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
|
||||
{ id: 'satellite', label: '위성요청', icon: '🛰' },
|
||||
{ id: 'satellite', label: '위성영상', icon: '🛰' },
|
||||
{ id: 'cctv', label: 'CCTV 조회', icon: '📹' },
|
||||
{ id: 'spectral', label: 'AI 탐지/분석', icon: '🤖' },
|
||||
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
|
||||
{ id: 'theory', label: '항공탐색 이론', icon: '📐' }
|
||||
],
|
||||
assets: null,
|
||||
@ -91,17 +92,10 @@ function subscribe(listener: () => void) {
|
||||
}
|
||||
|
||||
export function useSubMenu(mainTab: MainTab) {
|
||||
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
|
||||
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab])
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe(() => {
|
||||
setActiveSubTabLocal(subMenuState[mainTab])
|
||||
})
|
||||
return unsubscribe
|
||||
}, [mainTab])
|
||||
|
||||
const setActiveSubTab = (subTab: string) => {
|
||||
setSubTab(mainTab, subTab)
|
||||
}
|
||||
@ -175,4 +169,66 @@ export function consumeHnsReportPayload(): HnsReportPayload | null {
|
||||
return v;
|
||||
}
|
||||
|
||||
// ─── 유출유 예측 보고서 실 데이터 전달 ──────────────────────────
|
||||
export interface OilReportMapParticle {
|
||||
lat: number;
|
||||
lon: number;
|
||||
time: number;
|
||||
particle?: number;
|
||||
stranded?: 0 | 1;
|
||||
}
|
||||
|
||||
export interface OilReportPayload {
|
||||
incident: {
|
||||
name: string;
|
||||
occurTime: string;
|
||||
location: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
pollutant: string;
|
||||
spillAmount: string;
|
||||
shipName: string;
|
||||
};
|
||||
pollution: {
|
||||
spillAmount: string;
|
||||
weathered: string;
|
||||
seaRemain: string;
|
||||
pollutionArea: string;
|
||||
coastAttach: string;
|
||||
coastLength: string;
|
||||
oilType: string;
|
||||
};
|
||||
weather: {
|
||||
windDir: string;
|
||||
windSpeed: string;
|
||||
waveHeight: string;
|
||||
temp: string;
|
||||
} | null;
|
||||
spread: {
|
||||
kosps: string;
|
||||
openDrift: string;
|
||||
poseidon: string;
|
||||
};
|
||||
coastal: {
|
||||
firstTime: string | null;
|
||||
};
|
||||
hasSimulation: boolean;
|
||||
mapData: {
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
trajectory: OilReportMapParticle[];
|
||||
currentStep: number;
|
||||
centerPoints: { lat: number; lon: number; time: number }[];
|
||||
simulationStartTime: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
let _oilReportPayload: OilReportPayload | null = null;
|
||||
export function setOilReportPayload(d: OilReportPayload | null) { _oilReportPayload = d; }
|
||||
export function consumeOilReportPayload(): OilReportPayload | null {
|
||||
const v = _oilReportPayload;
|
||||
_oilReportPayload = null;
|
||||
return v;
|
||||
}
|
||||
|
||||
export { subMenuState }
|
||||
|
||||
@ -69,6 +69,76 @@
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
/* Date/Time picker custom styling */
|
||||
.prd-date-input::-webkit-calendar-picker-indicator,
|
||||
.prd-time-input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prd-date-input,
|
||||
.prd-time-input {
|
||||
font-size: 10px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit,
|
||||
.prd-time-input::-webkit-datetime-edit {
|
||||
color: var(--t2);
|
||||
font-family: var(--fM);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-fields-wrapper,
|
||||
.prd-time-input::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-year-field,
|
||||
.prd-date-input::-webkit-datetime-edit-month-field,
|
||||
.prd-date-input::-webkit-datetime-edit-day-field,
|
||||
.prd-time-input::-webkit-datetime-edit-hour-field,
|
||||
.prd-time-input::-webkit-datetime-edit-minute-field,
|
||||
.prd-time-input::-webkit-datetime-edit-ampm-field {
|
||||
color: var(--t2);
|
||||
background: transparent;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-year-field:focus,
|
||||
.prd-date-input::-webkit-datetime-edit-month-field:focus,
|
||||
.prd-date-input::-webkit-datetime-edit-day-field:focus,
|
||||
.prd-time-input::-webkit-datetime-edit-hour-field:focus,
|
||||
.prd-time-input::-webkit-datetime-edit-minute-field:focus,
|
||||
.prd-time-input::-webkit-datetime-edit-ampm-field:focus {
|
||||
background: rgba(6, 182, 212, 0.12);
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-text,
|
||||
.prd-time-input::-webkit-datetime-edit-text {
|
||||
color: var(--t3);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* Time hour/minute select (dark dropdown) */
|
||||
select.prd-i.prd-time-select {
|
||||
color-scheme: dark;
|
||||
-webkit-appearance: menulist !important;
|
||||
appearance: menulist !important;
|
||||
background: var(--bg3) !important;
|
||||
background-image: none !important;
|
||||
padding-right: 4px;
|
||||
color: var(--t1);
|
||||
border-color: var(--bd);
|
||||
}
|
||||
|
||||
/* Select Dropdown */
|
||||
select.prd-i {
|
||||
cursor: pointer;
|
||||
@ -210,10 +280,11 @@
|
||||
.prd-mc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 13px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 5px 4px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
cursor: pointer;
|
||||
@ -284,22 +355,30 @@
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
|
||||
.prd-map-btn.active {
|
||||
background: rgba(6, 182, 212, 0.25);
|
||||
border-color: rgba(6, 182, 212, 0.6);
|
||||
box-shadow: 0 0 0 1px rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
/* ═══ Coordinate Display ═══ */
|
||||
.cod {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 16px;
|
||||
background: rgba(18, 25, 41, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--bd);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(18, 25, 41, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(30, 42, 66, 0.4);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
padding: 5px 14px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--t2);
|
||||
font-size: 10px;
|
||||
color: #1a1a2e;
|
||||
font-weight: 600;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cov {
|
||||
@ -310,40 +389,41 @@
|
||||
/* ═══ Weather Info Panel ═══ */
|
||||
.wip {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
background: rgba(18, 25, 41, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(18, 25, 41, 0.65);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(30, 42, 66, 0.5);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wii {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wii-icon {
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.wii-value {
|
||||
font-size: 15px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--t1);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wii-label {
|
||||
font-size: 9px;
|
||||
color: var(--t3);
|
||||
font-size: 7px;
|
||||
color: #1a1a2e;
|
||||
font-weight: 700;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin';
|
||||
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'monitor' | 'admin';
|
||||
|
||||
@ -281,6 +281,30 @@ export function generateAIBoomLines(
|
||||
return boomLines
|
||||
}
|
||||
|
||||
/** Ray casting — 점이 다각형 내부인지 판정 */
|
||||
export function pointInPolygon(
|
||||
point: { lat: number; lon: number },
|
||||
polygon: { lat: number; lon: number }[]
|
||||
): boolean {
|
||||
if (polygon.length < 3) return false
|
||||
let inside = false
|
||||
const x = point.lon
|
||||
const y = point.lat
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].lon, yi = polygon[i].lat
|
||||
const xj = polygon[j].lon, yj = polygon[j].lat
|
||||
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
|
||||
if (intersect) inside = !inside
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
|
||||
/** 원 면적 (km²) */
|
||||
export function circleAreaKm2(radiusM: number): number {
|
||||
return Math.PI * (radiusM / 1000) ** 2
|
||||
}
|
||||
|
||||
/** 차단 시뮬레이션 실행 */
|
||||
export function runContainmentAnalysis(
|
||||
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
|
||||
|
||||
27
frontend/src/common/utils/imageAnalysisSignal.ts
Normal file
27
frontend/src/common/utils/imageAnalysisSignal.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { ImageAnalyzeResult } from '@tabs/prediction/services/predictionApi';
|
||||
|
||||
/**
|
||||
* 항공탐색(유출유면적분석) → 유출유 확산예측 탭 간 데이터 전달용 모듈 레벨 시그널.
|
||||
* registerMainTabSwitcher / navigateToTab 패턴과 동일한 방식으로 구현된다.
|
||||
*/
|
||||
|
||||
interface PendingImageAnalysis extends ImageAnalyzeResult {
|
||||
autoRun: boolean;
|
||||
}
|
||||
|
||||
let _pending: PendingImageAnalysis | null = null;
|
||||
|
||||
/** 분석 결과를 시그널에 저장한다. navigateToTab 호출 직전에 사용한다. */
|
||||
export function setPendingImageAnalysis(data: PendingImageAnalysis): void {
|
||||
_pending = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시그널에서 분석 결과를 꺼내고 초기화한다.
|
||||
* OilSpillView 마운트 시 1회 호출한다.
|
||||
*/
|
||||
export function consumePendingImageAnalysis(): PendingImageAnalysis | null {
|
||||
const value = _pending;
|
||||
_pending = null;
|
||||
return value;
|
||||
}
|
||||
@ -8,6 +8,8 @@ import MenusPanel from './MenusPanel';
|
||||
import SettingsPanel from './SettingsPanel';
|
||||
import BoardMgmtPanel from './BoardMgmtPanel';
|
||||
import VesselSignalPanel from './VesselSignalPanel';
|
||||
import CleanupEquipPanel from './CleanupEquipPanel';
|
||||
import AssetUploadPanel from './AssetUploadPanel';
|
||||
|
||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||
@ -19,6 +21,8 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||
board: () => <BoardMgmtPanel initialCategory="DATA" />,
|
||||
qna: () => <BoardMgmtPanel initialCategory="QNA" />,
|
||||
'collect-vessel-signal': () => <VesselSignalPanel />,
|
||||
'cleanup-equip': () => <CleanupEquipPanel />,
|
||||
'asset-upload': () => <AssetUploadPanel />,
|
||||
};
|
||||
|
||||
export function AdminView() {
|
||||
|
||||
257
frontend/src/tabs/admin/components/AssetUploadPanel.tsx
Normal file
257
frontend/src/tabs/admin/components/AssetUploadPanel.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { fetchUploadLogs } from '@tabs/assets/services/assetsApi';
|
||||
import type { UploadLogItem } from '@tabs/assets/services/assetsApi';
|
||||
|
||||
const ASSET_CATEGORIES = ['전체', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '오일붐', '흡착재', '기타'];
|
||||
const JURISDICTIONS = ['전체', '남해청', '서해청', '중부청', '동해청', '제주청'];
|
||||
|
||||
const PERM_ITEMS = [
|
||||
{ icon: '👑', role: '시스템관리자', desc: '전체 자산 업로드/삭제 가능', bg: 'rgba(245,158,11,0.15)', color: 'text-yellow-400' },
|
||||
{ icon: '🔧', role: '운영관리자', desc: '관할청 내 자산 업로드 가능', bg: 'rgba(6,182,212,0.15)', color: 'text-primary-cyan' },
|
||||
{ icon: '👁', role: '조회자', desc: '현황 조회만 가능', bg: 'rgba(148,163,184,0.15)', color: 'text-text-2' },
|
||||
{ icon: '🚫', role: '게스트', desc: '접근 불가', bg: 'rgba(239,68,68,0.1)', color: 'text-red-400' },
|
||||
];
|
||||
|
||||
function formatDate(dtm: string) {
|
||||
const d = new Date(dtm);
|
||||
if (isNaN(d.getTime())) return dtm;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function AssetUploadPanel() {
|
||||
const [assetCategory, setAssetCategory] = useState('전체');
|
||||
const [jurisdiction, setJurisdiction] = useState('전체');
|
||||
const [uploadMode, setUploadMode] = useState<'add' | 'replace'>('add');
|
||||
const [uploaded, setUploaded] = useState(false);
|
||||
const [uploadHistory, setUploadHistory] = useState<UploadLogItem[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUploadLogs(10)
|
||||
.then(setUploadHistory)
|
||||
.catch(err => console.error('[AssetUploadPanel] 이력 로드 실패:', err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = (file: File | null) => {
|
||||
if (!file) return;
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
if (ext !== 'xlsx' && ext !== 'csv') return;
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0] ?? null;
|
||||
handleFileSelect(file);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!selectedFile) return;
|
||||
setUploaded(true);
|
||||
resetTimerRef.current = setTimeout(() => {
|
||||
setUploaded(false);
|
||||
setSelectedFile(null);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">자산 현행화</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">자산 데이터를 업로드하여 현행화합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="flex gap-6 h-full">
|
||||
{/* 좌측: 파일 업로드 */}
|
||||
<div className="flex-1 max-w-[560px] space-y-4">
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">파일 업로드</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{/* 드롭존 */}
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${
|
||||
dragging
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.05)]'
|
||||
: 'border-border hover:border-primary-cyan/50 bg-bg-2'
|
||||
}`}
|
||||
>
|
||||
<div className="text-3xl mb-2 opacity-40">📁</div>
|
||||
{selectedFile ? (
|
||||
<div className="text-xs font-semibold text-primary-cyan font-korean mb-1">{selectedFile.name}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs font-semibold text-text-2 font-korean mb-1">파일을 드래그하거나 클릭하여 업로드</div>
|
||||
<div className="text-[10px] text-text-3 font-korean mb-3">엑셀(.xlsx), CSV 파일 지원 · 최대 10MB</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-1.5 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0
|
||||
hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
onClick={e => { e.stopPropagation(); fileInputRef.current?.click(); }}
|
||||
>
|
||||
파일 선택
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.csv"
|
||||
className="hidden"
|
||||
onChange={e => handleFileSelect(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 자산 분류 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">자산 분류</label>
|
||||
<select
|
||||
value={assetCategory}
|
||||
onChange={e => setAssetCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md
|
||||
text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
>
|
||||
{ASSET_CATEGORIES.map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 대상 관할 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">대상 관할</label>
|
||||
<select
|
||||
value={jurisdiction}
|
||||
onChange={e => setJurisdiction(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md
|
||||
text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
>
|
||||
{JURISDICTIONS.map(j => (
|
||||
<option key={j} value={j}>{j}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 업로드 방식 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">업로드 방식</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-text-2 font-korean">
|
||||
<input
|
||||
type="radio"
|
||||
checked={uploadMode === 'add'}
|
||||
onChange={() => setUploadMode('add')}
|
||||
className="accent-primary-cyan"
|
||||
/>
|
||||
추가 (기존 + 신규)
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-text-2 font-korean">
|
||||
<input
|
||||
type="radio"
|
||||
checked={uploadMode === 'replace'}
|
||||
onChange={() => setUploadMode('replace')}
|
||||
className="accent-primary-cyan"
|
||||
/>
|
||||
덮어쓰기 (전체 교체)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업로드 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || uploaded}
|
||||
className={`w-full py-2.5 text-xs font-semibold rounded-md transition-all font-korean disabled:opacity-50 ${
|
||||
uploaded
|
||||
? 'bg-[rgba(34,197,94,0.15)] text-status-green border border-status-green/30'
|
||||
: 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
}`}
|
||||
>
|
||||
{uploaded ? '✅ 업로드 완료!' : '📤 업로드 실행'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 */}
|
||||
<div className="w-[400px] space-y-4 flex-shrink-0">
|
||||
{/* 수정 권한 체계 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">수정 권한 체계</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
{PERM_ITEMS.map(p => (
|
||||
<div
|
||||
key={p.role}
|
||||
className="flex items-center gap-3 px-4 py-3 bg-bg-2 border border-border rounded-md"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0"
|
||||
style={{ background: p.bg }}
|
||||
>
|
||||
{p.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
||||
<div className="text-[10px] text-text-3 font-korean mt-0.5">{p.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 업로드 이력 */}
|
||||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">최근 업로드 이력</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
{uploadHistory.length === 0 ? (
|
||||
<div className="text-[11px] text-text-3 font-korean text-center py-4">이력이 없습니다.</div>
|
||||
) : uploadHistory.map(h => (
|
||||
<div
|
||||
key={h.logSn}
|
||||
className="flex justify-between items-center px-4 py-3 bg-bg-2 border border-border rounded-md"
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-text-1 font-korean">{h.fileNm}</div>
|
||||
<div className="text-[10px] text-text-3 mt-0.5 font-korean">
|
||||
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}건
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold
|
||||
bg-[rgba(34,197,94,0.15)] text-status-green flex-shrink-0">
|
||||
완료
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AssetUploadPanel;
|
||||
230
frontend/src/tabs/admin/components/CleanupEquipPanel.tsx
Normal file
230
frontend/src/tabs/admin/components/CleanupEquipPanel.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { fetchOrganizations } from '@tabs/assets/services/assetsApi';
|
||||
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi';
|
||||
import { typeTagCls } from '@tabs/assets/components/assetTypes';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const regionShort = (j: string) =>
|
||||
j.includes('남해') ? '남해청' : j.includes('서해') ? '서해청' :
|
||||
j.includes('중부') ? '중부청' : j.includes('동해') ? '동해청' :
|
||||
j.includes('제주') ? '제주청' : j;
|
||||
|
||||
function CleanupEquipPanel() {
|
||||
const [organizations, setOrganizations] = useState<AssetOrgCompat[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [regionFilter, setRegionFilter] = useState('전체');
|
||||
const [typeFilter, setTypeFilter] = useState('전체');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
fetchOrganizations()
|
||||
.then(setOrganizations)
|
||||
.catch(err => console.error('[CleanupEquipPanel] 데이터 로드 실패:', err))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchOrganizations()
|
||||
.then(data => { if (!cancelled) setOrganizations(data); })
|
||||
.catch(err => console.error('[CleanupEquipPanel] 데이터 로드 실패:', err))
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const set = new Set(organizations.map(o => o.type));
|
||||
return Array.from(set).sort();
|
||||
}, [organizations]);
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
organizations
|
||||
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
|
||||
.filter(o => typeFilter === '전체' || o.type === typeFilter)
|
||||
.filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)),
|
||||
[organizations, regionFilter, typeFilter, searchTerm]
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||
|
||||
const handleFilterChange = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setter(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const pageNumbers = (() => {
|
||||
const range: number[] = [];
|
||||
const start = Math.max(1, safePage - 2);
|
||||
const end = Math.min(totalPages, safePage + 2);
|
||||
for (let i = start; i <= end; i++) range.push(i);
|
||||
return range;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">방제장비 현황</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {filtered.length}개 기관</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={regionFilter}
|
||||
onChange={handleFilterChange(setRegionFilter)}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
>
|
||||
<option value="전체">전체 관할청</option>
|
||||
<option value="남해">남해청</option>
|
||||
<option value="서해">서해청</option>
|
||||
<option value="중부">중부청</option>
|
||||
<option value="동해">동해청</option>
|
||||
<option value="제주">제주청</option>
|
||||
</select>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={handleFilterChange(setTypeFilter)}
|
||||
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
>
|
||||
<option value="전체">전체 유형</option>
|
||||
{typeOptions.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="기관명, 주소 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
/>
|
||||
<button
|
||||
onClick={load}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-2 border border-border text-text-2 hover:border-primary-cyan hover:text-primary-cyan transition-all font-korean"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">유형</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">관할청</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">기관명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">주소</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">방제선</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">유회수기</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">이송펌프</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">방제차량</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">살포장치</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">총자산</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-6 py-10 text-center text-xs text-text-3 font-korean">
|
||||
조회된 기관이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : paged.map((org, idx) => (
|
||||
<tr key={org.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-mono text-center">
|
||||
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>
|
||||
{org.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-korean">
|
||||
{regionShort(org.jurisdiction)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-1 font-korean font-semibold">
|
||||
{org.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
|
||||
{org.address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||
{org.vessel > 0 ? <span className="text-text-1">{org.vessel}</span> : <span className="text-text-3">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||
{org.skimmer > 0 ? <span className="text-text-1">{org.skimmer}</span> : <span className="text-text-3">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||
{org.pump > 0 ? <span className="text-text-1">{org.pump}</span> : <span className="text-text-3">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||
{org.vehicle > 0 ? <span className="text-text-1">{org.vehicle}</span> : <span className="text-text-3">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||
{org.sprayer > 0 ? <span className="text-text-1">{org.sprayer}</span> : <span className="text-text-3">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
|
||||
{org.totalAssets.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
|
||||
<span className="text-[11px] text-text-3 font-korean">
|
||||
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} / 전체 {filtered.length}개
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={safePage === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
{pageNumbers.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
|
||||
style={p === safePage
|
||||
? { borderColor: 'var(--cyan)', color: 'var(--cyan)', background: 'rgba(6,182,212,0.1)' }
|
||||
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
|
||||
}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CleanupEquipPanel;
|
||||
@ -294,6 +294,7 @@ interface RolePermTabProps {
|
||||
setSelectedRoleSn: (sn: number | null) => void
|
||||
dirty: boolean
|
||||
saving: boolean
|
||||
saveError: string | null
|
||||
handleSave: () => Promise<void>
|
||||
handleToggleExpand: (code: string) => void
|
||||
handleTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void
|
||||
@ -328,6 +329,7 @@ function RolePermTab({
|
||||
setSelectedRoleSn,
|
||||
dirty,
|
||||
saving,
|
||||
saveError,
|
||||
handleSave,
|
||||
handleToggleExpand,
|
||||
handleTogglePerm,
|
||||
@ -378,6 +380,9 @@ function RolePermTab({
|
||||
>
|
||||
{saving ? '저장 중...' : '변경사항 저장'}
|
||||
</button>
|
||||
{saveError && (
|
||||
<span className="text-[11px] text-status-red font-korean">{saveError}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 역할 탭 바 */}
|
||||
@ -861,6 +866,7 @@ function PermissionsPanel() {
|
||||
const [permTree, setPermTree] = useState<PermTreeNode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [newRoleCode, setNewRoleCode] = useState('')
|
||||
@ -962,6 +968,7 @@ function PermissionsPanel() {
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
for (const role of roles) {
|
||||
const perms = rolePerms.get(role.sn)
|
||||
@ -981,6 +988,7 @@ function PermissionsPanel() {
|
||||
setDirty(false)
|
||||
} catch (err) {
|
||||
console.error('권한 저장 실패:', err)
|
||||
setSaveError('권한 저장에 실패했습니다. 다시 시도해주세요.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@ -1096,6 +1104,7 @@ function PermissionsPanel() {
|
||||
setSelectedRoleSn={setSelectedRoleSn}
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
handleSave={handleSave}
|
||||
handleToggleExpand={handleToggleExpand}
|
||||
handleTogglePerm={handleTogglePerm}
|
||||
|
||||
@ -51,6 +51,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [
|
||||
id: 'coast-guard-assets', label: '해경자산',
|
||||
children: [
|
||||
{ id: 'cleanup-equip', label: '방제장비' },
|
||||
{ id: 'asset-upload', label: '자산현행화' },
|
||||
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
|
||||
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
||||
{ id: 'cleanup-resource', label: '방제자원' },
|
||||
|
||||
@ -5,6 +5,7 @@ import { OilAreaAnalysis } from './OilAreaAnalysis'
|
||||
import { RealtimeDrone } from './RealtimeDrone'
|
||||
import { SensorAnalysis } from './SensorAnalysis'
|
||||
import { SatelliteRequest } from './SatelliteRequest'
|
||||
import { WingAI } from './WingAI'
|
||||
import { CctvView } from './CctvView'
|
||||
|
||||
export function AerialView() {
|
||||
@ -16,6 +17,8 @@ export function AerialView() {
|
||||
return <AerialTheoryView />
|
||||
case 'satellite':
|
||||
return <SatelliteRequest />
|
||||
case 'spectral':
|
||||
return <WingAI />
|
||||
case 'cctv':
|
||||
return <CctvView />
|
||||
case 'analysis':
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { fetchAerialMedia } from '../services/aerialApi'
|
||||
import { fetchAerialMedia, downloadAerialMedia } from '../services/aerialApi'
|
||||
import type { AerialMediaItem } from '../services/aerialApi'
|
||||
import { navigateToTab } from '@common/hooks/useSubMenu'
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@ -48,6 +49,9 @@ export function MediaManagement() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState('latest')
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [downloadingId, setDownloadingId] = useState<number | null>(null)
|
||||
const [bulkDownloading, setBulkDownloading] = useState(false)
|
||||
const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(null)
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@ -118,6 +122,38 @@ export function MediaManagement() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkDownload = async () => {
|
||||
if (bulkDownloading || selectedIds.size === 0) return
|
||||
setBulkDownloading(true)
|
||||
let success = 0
|
||||
const total = selectedIds.size
|
||||
for (const sn of selectedIds) {
|
||||
const item = mediaItems.find(f => f.aerialMediaSn === sn)
|
||||
if (!item) continue
|
||||
try {
|
||||
await downloadAerialMedia(sn, item.orgnlNm ?? item.fileNm)
|
||||
success++
|
||||
} catch {
|
||||
// 실패 건 스킵
|
||||
}
|
||||
}
|
||||
setBulkDownloading(false)
|
||||
setDownloadResult({ total, success })
|
||||
}
|
||||
|
||||
const handleDownload = async (e: React.MouseEvent, item: AerialMediaItem) => {
|
||||
e.stopPropagation()
|
||||
if (downloadingId !== null) return
|
||||
setDownloadingId(item.aerialMediaSn)
|
||||
try {
|
||||
await downloadAerialMedia(item.aerialMediaSn, item.orgnlNm ?? item.fileNm)
|
||||
} catch {
|
||||
alert('다운로드 실패: 이미지를 찾을 수 없습니다.')
|
||||
} finally {
|
||||
setDownloadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const droneCount = mediaItems.filter(f => f.equipTpCd === 'drone').length
|
||||
const planeCount = mediaItems.filter(f => f.equipTpCd === 'plane').length
|
||||
const satCount = mediaItems.filter(f => f.equipTpCd === 'satellite').length
|
||||
@ -254,8 +290,12 @@ export function MediaManagement() {
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.fileSz ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
|
||||
<button className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors">
|
||||
📥
|
||||
<button
|
||||
onClick={(e) => handleDownload(e, f)}
|
||||
disabled={downloadingId === f.aerialMediaSn}
|
||||
className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -274,15 +314,47 @@ export function MediaManagement() {
|
||||
<button onClick={toggleAll} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-3 border border-border text-text-2 hover:bg-bg-hover transition-colors font-korean">
|
||||
☑ 전체선택
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean">
|
||||
📥 선택 다운로드
|
||||
<button
|
||||
onClick={handleBulkDownload}
|
||||
disabled={bulkDownloading || selectedIds.size === 0}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean disabled:opacity-50"
|
||||
>
|
||||
{bulkDownloading ? '⏳ 다운로드 중...' : '📥 선택 다운로드'}
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-primary-purple border border-primary-purple/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean">
|
||||
🧩 유출유면적분석으로 →
|
||||
<button
|
||||
onClick={() => navigateToTab('prediction', 'analysis')}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-primary-purple border border-primary-purple/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean"
|
||||
>
|
||||
🔬 유출유확산예측으로 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택 다운로드 결과 팝업 */}
|
||||
{downloadResult && (
|
||||
<div className="fixed inset-0 z-[300] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="bg-bg-1 border border-border rounded-md p-6 w-72 text-center">
|
||||
<div className="text-2xl mb-3">📥</div>
|
||||
<div className="text-sm font-bold font-korean mb-3">다운로드 완료</div>
|
||||
<div className="text-[13px] font-korean text-text-2 mb-1">
|
||||
총 <span className="text-primary-cyan font-bold">{downloadResult.total}</span>건 선택
|
||||
</div>
|
||||
<div className="text-[13px] font-korean text-text-2 mb-4">
|
||||
<span className="text-status-green font-bold">{downloadResult.success}</span>건 다운로드 성공
|
||||
{downloadResult.total - downloadResult.success > 0 && (
|
||||
<> / <span className="text-status-red font-bold">{downloadResult.total - downloadResult.success}</span>건 실패</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDownloadResult(null)}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUpload && (
|
||||
<div className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
||||
|
||||
@ -1,212 +1,425 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import * as exifr from 'exifr';
|
||||
import { stitchImages } from '../services/aerialApi';
|
||||
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
|
||||
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
|
||||
import { navigateToTab } from '@common/hooks/useSubMenu';
|
||||
import { decimalToDMS } from '@common/utils/coordinates';
|
||||
|
||||
// ── Types & Mock Data ──
|
||||
const MAX_IMAGES = 6;
|
||||
|
||||
interface MosaicImage {
|
||||
id: string
|
||||
filename: string
|
||||
status: 'done' | 'processing' | 'waiting'
|
||||
hasOil: boolean
|
||||
interface ImageExif {
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
altitude: number | null;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
dateTime: Date | string | null;
|
||||
exposureTime: number | null;
|
||||
fNumber: number | null;
|
||||
iso: number | null;
|
||||
focalLength: number | null;
|
||||
imageWidth: number | null;
|
||||
imageHeight: number | null;
|
||||
}
|
||||
|
||||
const mosaicImages: MosaicImage[] = [
|
||||
{ id: 'T1', filename: '드론_001.jpg', status: 'done', hasOil: true },
|
||||
{ id: 'T2', filename: '드론_002.jpg', status: 'done', hasOil: true },
|
||||
{ id: 'T3', filename: '드론_003.jpg', status: 'done', hasOil: true },
|
||||
{ id: 'T4', filename: '드론_004.jpg', status: 'done', hasOil: true },
|
||||
{ id: 'T5', filename: '드론_005.jpg', status: 'processing', hasOil: false },
|
||||
{ id: 'T6', filename: '드론_006.jpg', status: 'waiting', hasOil: false },
|
||||
]
|
||||
function formatFileSize(bytes?: number): string | null {
|
||||
if (bytes == null) return null;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
function formatDateTime(dt: Date | string | null): string | null {
|
||||
if (!dt) return null;
|
||||
if (dt instanceof Date) {
|
||||
return dt.toLocaleString('ko-KR', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
return String(dt);
|
||||
}
|
||||
|
||||
interface MetaRowProps {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}
|
||||
|
||||
function MetaRow({ label, value }: MetaRowProps) {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<div className="flex justify-between gap-2 py-0.5 border-b border-border/40 last:border-0 font-korean">
|
||||
<span className="text-text-3 shrink-0">{label}</span>
|
||||
<span className="text-text-1 text-right break-all">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OilAreaAnalysis() {
|
||||
const [activeStep, setActiveStep] = useState(1)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [analyzed, setAnalyzed] = useState(false)
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||
const [imageExifs, setImageExifs] = useState<(ImageExif | undefined)[]>([]);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);
|
||||
const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null);
|
||||
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
|
||||
const [isStitching, setIsStitching] = useState(false);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const processedFilesRef = useRef<Set<File>>(new Set());
|
||||
|
||||
const handleAnalyze = () => {
|
||||
setAnalyzing(true)
|
||||
setTimeout(() => {
|
||||
setAnalyzing(false)
|
||||
setAnalyzed(true)
|
||||
}, 1500)
|
||||
}
|
||||
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previewUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const stepCls = (idx: number) => {
|
||||
if (idx < activeStep) return 'border-status-green text-status-green bg-[rgba(34,197,94,0.05)]'
|
||||
if (idx === activeStep) return 'border-primary-cyan text-primary-cyan bg-[rgba(6,182,212,0.05)]'
|
||||
return 'border-border text-text-3 bg-bg-3'
|
||||
}
|
||||
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF 전체 추출
|
||||
useEffect(() => {
|
||||
selectedFiles.forEach((file, i) => {
|
||||
if (processedFilesRef.current.has(file)) return;
|
||||
processedFilesRef.current.add(file);
|
||||
|
||||
exifr.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false })
|
||||
.then(exif => {
|
||||
const info: ImageExif = {
|
||||
lat: exif?.latitude ?? null,
|
||||
lon: exif?.longitude ?? null,
|
||||
altitude: exif?.GPSAltitude ?? null,
|
||||
make: exif?.Make ?? null,
|
||||
model: exif?.Model ?? null,
|
||||
dateTime: exif?.DateTimeOriginal ?? null,
|
||||
exposureTime: exif?.ExposureTime ?? null,
|
||||
fNumber: exif?.FNumber ?? null,
|
||||
iso: exif?.ISO ?? null,
|
||||
focalLength: exif?.FocalLength ?? null,
|
||||
imageWidth: exif?.ImageWidth ?? exif?.ExifImageWidth ?? null,
|
||||
imageHeight: exif?.ImageHeight ?? exif?.ExifImageHeight ?? null,
|
||||
};
|
||||
setImageExifs(prev => {
|
||||
const updated = [...prev];
|
||||
while (updated.length <= i) updated.push(undefined);
|
||||
updated[i] = info;
|
||||
return updated;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setImageExifs(prev => {
|
||||
const updated = [...prev];
|
||||
while (updated.length <= i) updated.push(undefined);
|
||||
updated[i] = {
|
||||
lat: null, lon: null, altitude: null,
|
||||
make: null, model: null, dateTime: null,
|
||||
exposureTime: null, fNumber: null, iso: null,
|
||||
focalLength: null, imageWidth: null, imageHeight: null,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [selectedFiles]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setError(null);
|
||||
const incoming = Array.from(e.target.files ?? []);
|
||||
if (incoming.length === 0) return;
|
||||
|
||||
setSelectedFiles(prev => {
|
||||
const merged = [...prev, ...incoming].slice(0, MAX_IMAGES);
|
||||
if (prev.length + incoming.length > MAX_IMAGES) {
|
||||
setError(`최대 ${MAX_IMAGES}장까지 선택할 수 있습니다.`);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
// setSelectedFiles updater 밖에서 독립 호출 — updater 내부 side effect는
|
||||
// React Strict Mode의 이중 호출로 인해 URL이 중복 생성되는 버그를 유발함
|
||||
setPreviewUrls(prev => {
|
||||
const available = MAX_IMAGES - prev.length;
|
||||
const toAdd = incoming.slice(0, available);
|
||||
return [...prev, ...toAdd.map(f => URL.createObjectURL(f))];
|
||||
});
|
||||
|
||||
// input 초기화 (동일 파일 재선택 허용)
|
||||
e.target.value = '';
|
||||
}, []);
|
||||
|
||||
const handleRemoveFile = useCallback((idx: number) => {
|
||||
setSelectedFiles(prev => prev.filter((_, i) => i !== idx));
|
||||
setPreviewUrls(prev => {
|
||||
URL.revokeObjectURL(prev[idx]);
|
||||
return prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
setImageExifs(prev => prev.filter((_, i) => i !== idx));
|
||||
setSelectedImageIndex(prev => {
|
||||
if (prev === null) return null;
|
||||
if (prev === idx) return null;
|
||||
if (prev > idx) return prev - 1;
|
||||
return prev;
|
||||
});
|
||||
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
|
||||
setStitchedBlob(null);
|
||||
if (stitchedPreviewUrl) {
|
||||
URL.revokeObjectURL(stitchedPreviewUrl);
|
||||
setStitchedPreviewUrl(null);
|
||||
}
|
||||
setError(null);
|
||||
}, [stitchedPreviewUrl]);
|
||||
|
||||
const handleStitch = async () => {
|
||||
if (selectedFiles.length < 2) {
|
||||
setError('이미지를 2장 이상 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setIsStitching(true);
|
||||
try {
|
||||
const blob = await stitchImages(selectedFiles);
|
||||
if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl);
|
||||
setStitchedBlob(blob);
|
||||
setStitchedPreviewUrl(URL.createObjectURL(blob));
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: (err as { message?: string }).message ?? '이미지 합성에 실패했습니다.';
|
||||
const status = err instanceof Error ? 0 : (err as { status?: number }).status ?? 0;
|
||||
setError(status === 504 ? '이미지 합성 서버 응답 시간이 초과되었습니다.' : msg);
|
||||
} finally {
|
||||
setIsStitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!stitchedBlob) return;
|
||||
setError(null);
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
const stitchedFile = new File([stitchedBlob], `stitch_${Date.now()}.jpg`, { type: 'image/jpeg' });
|
||||
const result = await analyzeImage(stitchedFile);
|
||||
setPendingImageAnalysis({ ...result, autoRun: true });
|
||||
navigateToTab('prediction', 'analysis');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '분석에 실패했습니다.';
|
||||
setError(msg.includes('GPS') ? '이미지에 GPS 정보가 없습니다. GPS 정보가 포함된 이미지를 사용해주세요.' : msg);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canStitch = selectedFiles.length >= 2 && !isStitching && !isAnalyzing;
|
||||
const canAnalyze = stitchedBlob !== null && !isStitching && !isAnalyzing;
|
||||
|
||||
return (
|
||||
<div className="flex gap-5 h-full overflow-hidden">
|
||||
{/* Left Panel */}
|
||||
<div className="w-[340px] min-w-[340px] flex flex-col overflow-y-auto scrollbar-thin">
|
||||
<div className="text-sm font-bold mb-1 font-korean">🧩 유출유면적분석</div>
|
||||
<div className="text-[11px] text-text-3 mb-4 font-korean">단면 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
{['① 사진 선택', '② 정합·합성', '③ 면적 산정'].map((label, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveStep(i)}
|
||||
className={`flex-1 py-2 rounded-sm border text-center text-[10px] font-semibold font-korean cursor-pointer transition-colors ${stepCls(i)}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{/* ── Left Panel ── */}
|
||||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
||||
<div className="text-sm font-bold mb-1 font-korean">🧩 영상사진합성</div>
|
||||
<div className="text-[11px] text-text-3 mb-4 font-korean">
|
||||
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
||||
</div>
|
||||
|
||||
{/* Selected Images */}
|
||||
<div className="text-[11px] font-bold mb-2 font-korean">선택된 사진 (6장)</div>
|
||||
<div className="flex flex-col gap-1 mb-3.5">
|
||||
{['여수항_드론_001.jpg', '여수항_드론_002.jpg', '여수항_드론_003.jpg', '여수항_드론_004.jpg', '여수항_드론_005.jpg', '여수항_드론_006.jpg'].map((name, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 border border-border rounded-sm text-[11px] font-korean">
|
||||
<span>🛸</span>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span className={`text-[9px] font-semibold ${
|
||||
i < 4 ? 'text-status-green' : i === 4 ? 'text-status-orange' : 'text-text-3'
|
||||
}`}>
|
||||
{i < 4 ? '✓ 정합' : i === 4 ? '⏳ 정합중' : '대기'}
|
||||
</span>
|
||||
{/* 이미지 선택 버튼 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={selectedFiles.length >= MAX_IMAGES || isStitching || isAnalyzing}
|
||||
className="w-full py-2 mb-3 border border-dashed border-border rounded-sm text-xs font-korean text-text-2
|
||||
hover:border-primary-cyan hover:text-primary-cyan transition-colors cursor-pointer
|
||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
+ 이미지 선택 ({selectedFiles.length}/{MAX_IMAGES})
|
||||
</button>
|
||||
|
||||
{/* 선택된 이미지 목록 */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<>
|
||||
<div className="text-[11px] font-bold mb-1.5 font-korean">선택된 이미지</div>
|
||||
<div className="flex flex-col gap-1 mb-3">
|
||||
{selectedFiles.map((file, i) => (
|
||||
<div key={`${file.name}-${i}`}>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2 py-1.5 bg-bg-3 border rounded-sm text-[11px] font-korean cursor-pointer transition-colors
|
||||
${selectedImageIndex === i ? 'border-primary-cyan' : 'border-border'}`}
|
||||
onClick={() => setSelectedImageIndex(i)}
|
||||
>
|
||||
<span className="text-primary-cyan">📷</span>
|
||||
<span className="flex-1 truncate text-text-1">{file.name}</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleRemoveFile(i); }}
|
||||
disabled={isStitching || isAnalyzing}
|
||||
className="text-text-3 hover:text-status-red transition-colors cursor-pointer
|
||||
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
|
||||
title="제거"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{selectedImageIndex === i && imageExifs[i] !== undefined && (
|
||||
<div className="mt-1 mb-1 px-2 py-1.5 bg-bg-0 border border-border/60 rounded-sm text-[11px] font-korean">
|
||||
<MetaRow label="파일 크기" value={formatFileSize(file.size)} />
|
||||
<MetaRow
|
||||
label="해상도"
|
||||
value={imageExifs[i]!.imageWidth && imageExifs[i]!.imageHeight
|
||||
? `${imageExifs[i]!.imageWidth} × ${imageExifs[i]!.imageHeight}`
|
||||
: null}
|
||||
/>
|
||||
<MetaRow label="촬영일시" value={formatDateTime(imageExifs[i]!.dateTime)} />
|
||||
<MetaRow
|
||||
label="장비"
|
||||
value={imageExifs[i]!.make || imageExifs[i]!.model
|
||||
? [imageExifs[i]!.make, imageExifs[i]!.model].filter(Boolean).join(' ')
|
||||
: null}
|
||||
/>
|
||||
<MetaRow
|
||||
label="위도"
|
||||
value={imageExifs[i]!.lat !== null ? decimalToDMS(imageExifs[i]!.lat!, true) : null}
|
||||
/>
|
||||
<MetaRow
|
||||
label="경도"
|
||||
value={imageExifs[i]!.lon !== null ? decimalToDMS(imageExifs[i]!.lon!, false) : null}
|
||||
/>
|
||||
<MetaRow
|
||||
label="고도"
|
||||
value={imageExifs[i]!.altitude !== null ? `${imageExifs[i]!.altitude!.toFixed(1)} m` : null}
|
||||
/>
|
||||
<MetaRow
|
||||
label="셔터속도"
|
||||
value={imageExifs[i]!.exposureTime
|
||||
? imageExifs[i]!.exposureTime! < 1
|
||||
? `1/${Math.round(1 / imageExifs[i]!.exposureTime!)}s`
|
||||
: `${imageExifs[i]!.exposureTime}s`
|
||||
: null}
|
||||
/>
|
||||
<MetaRow
|
||||
label="조리개"
|
||||
value={imageExifs[i]!.fNumber ? `f/${imageExifs[i]!.fNumber}` : null}
|
||||
/>
|
||||
<MetaRow
|
||||
label="ISO"
|
||||
value={imageExifs[i]!.iso ? String(imageExifs[i]!.iso) : null}
|
||||
/>
|
||||
<MetaRow
|
||||
label="초점거리"
|
||||
value={imageExifs[i]!.focalLength ? `${imageExifs[i]!.focalLength} mm` : null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Analysis Parameters */}
|
||||
<div className="text-[11px] font-bold mb-2 font-korean">분석 파라미터</div>
|
||||
<div className="flex flex-col gap-1.5 mb-3.5">
|
||||
{[
|
||||
['촬영 고도', '120 m'],
|
||||
['GSD (지상해상도)', '3.2 cm/px'],
|
||||
['오버랩 비율', '80% / 70%'],
|
||||
['좌표계', 'EPSG:5186'],
|
||||
['유종 판별 기준', 'NDVI + NIR'],
|
||||
['유막 두께 추정', 'Bonn Agreement'],
|
||||
].map(([label, value], i) => (
|
||||
<div key={i} className="flex justify-between items-center text-[11px]">
|
||||
<span className="text-text-3 font-korean">{label}</span>
|
||||
<span className="font-mono font-semibold">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="mb-3 px-2.5 py-2 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.3)] rounded-sm text-[11px] text-status-red font-korean">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{/* 이미지 합성 버튼 */}
|
||||
<button
|
||||
onClick={handleStitch}
|
||||
disabled={!canStitch}
|
||||
className="w-full py-2.5 mb-2 rounded-sm text-[12px] font-bold font-korean cursor-pointer transition-colors
|
||||
border border-primary-cyan text-primary-cyan bg-[rgba(6,182,212,0.06)]
|
||||
hover:bg-[rgba(6,182,212,0.15)] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isStitching ? '⏳ 합성 중...' : stitchedBlob ? '✅ 다시 합성' : '🔗 이미지 합성'}
|
||||
</button>
|
||||
|
||||
{/* 분석 시작 버튼 */}
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={analyzing}
|
||||
className={`w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none mb-2 transition-colors ${
|
||||
analyzed
|
||||
? 'bg-[rgba(34,197,94,0.15)] text-status-green border border-status-green'
|
||||
: 'text-white'
|
||||
}`}
|
||||
style={!analyzed ? { background: 'linear-gradient(135deg, var(--cyan), var(--blue))' } : undefined}
|
||||
disabled={!canAnalyze}
|
||||
className="w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none transition-colors
|
||||
disabled:opacity-40 disabled:cursor-not-allowed text-white"
|
||||
style={canAnalyze ? { background: 'linear-gradient(135deg, var(--cyan), var(--blue))' } : { background: 'var(--bg-3)' }}
|
||||
>
|
||||
{analyzing ? '⏳ 분석중...' : analyzed ? '✅ 분석 완료!' : '🧩 면적분석 실행'}
|
||||
</button>
|
||||
<button className="w-full py-2.5 border border-border bg-bg-3 text-text-2 rounded-sm text-xs font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
|
||||
📥 결과 다운로드 (GeoTIFF)
|
||||
{isAnalyzing ? '⏳ 분석 중...' : '🧩 분석 시작'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
{/* ── Right Panel ── */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-bold font-korean">🗺 합성 영상 및 유막 탐지 결과</span>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(239,68,68,0.1)] text-status-red font-semibold font-korean">■ 유막 탐지</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(6,182,212,0.1)] text-primary-cyan font-semibold font-korean">□ 원본 타일</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.1)] text-status-green font-semibold font-korean">정합률 96.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Grid 3×2 */}
|
||||
{/* 3×2 이미지 그리드 */}
|
||||
<div className="text-[11px] font-bold mb-2 font-korean">선택된 이미지 미리보기</div>
|
||||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||
{mosaicImages.map(img => (
|
||||
<div key={img.id} className="bg-bg-3 border border-border rounded-sm overflow-hidden cursor-pointer hover:border-border-light transition-colors">
|
||||
<div
|
||||
className="h-[100px] relative flex items-center justify-center overflow-hidden"
|
||||
style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}
|
||||
>
|
||||
{img.hasOil && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.15)',
|
||||
border: '1px solid rgba(239,68,68,0.35)',
|
||||
clipPath: 'polygon(20% 30%,60% 15%,85% 40%,70% 80%,30% 75%,10% 50%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="text-lg font-bold text-white/[0.08] font-mono">{img.id}</div>
|
||||
<div className={`absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded-md text-[9px] font-bold font-korean ${
|
||||
img.status === 'done' && img.hasOil ? 'bg-[rgba(239,68,68,0.2)] text-status-red' :
|
||||
img.status === 'processing' ? 'bg-[rgba(249,115,22,0.2)] text-status-orange' :
|
||||
'bg-[rgba(100,116,139,0.2)] text-text-3'
|
||||
}`}>
|
||||
{img.status === 'done' && img.hasOil ? '유막' : img.status === 'processing' ? '정합중' : '대기'}
|
||||
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`bg-bg-3 border rounded-sm overflow-hidden flex flex-col transition-colors
|
||||
${previewUrls[i] ? 'cursor-pointer' : ''}
|
||||
${selectedImageIndex === i ? 'border-primary-cyan' : 'border-border'}`}
|
||||
style={{ height: '300px' }}
|
||||
onClick={() => { if (previewUrls[i]) setSelectedImageIndex(i); }}
|
||||
>
|
||||
{previewUrls[i] ? (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<img
|
||||
src={previewUrls[i]}
|
||||
alt={selectedFiles[i]?.name ?? ''}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1 bg-bg-0 border-t border-border shrink-0 flex items-start justify-between gap-1">
|
||||
<div className="text-[10px] text-text-2 truncate font-korean flex-1 min-w-0">
|
||||
{selectedFiles[i]?.name}
|
||||
</div>
|
||||
{imageExifs[i] === undefined ? (
|
||||
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS 읽는 중...</div>
|
||||
) : imageExifs[i]?.lat !== null ? (
|
||||
<div className="text-[10px] text-primary-cyan font-mono leading-tight text-right shrink-0">
|
||||
{decimalToDMS(imageExifs[i]!.lat!, true)}<br />
|
||||
{decimalToDMS(imageExifs[i]!.lon!, false)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS 정보 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-text-3 text-lg font-mono opacity-20">
|
||||
{i + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 flex justify-between items-center text-[10px] font-korean text-text-2">
|
||||
<span>{img.filename}</span>
|
||||
<span className={
|
||||
img.status === 'done' ? 'text-status-green' :
|
||||
img.status === 'processing' ? 'text-status-orange' :
|
||||
'text-text-3'
|
||||
}>
|
||||
{img.status === 'done' ? '✓' : img.status === 'processing' ? '⏳' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Merged Result Preview */}
|
||||
<div className="relative h-[140px] bg-bg-0 border border-border rounded-sm overflow-hidden mb-3">
|
||||
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 40% 50%, rgba(10,25,40,0.7), rgba(8,14,26,0.95))' }}>
|
||||
<div className="absolute border border-dashed rounded flex items-center justify-center text-[10px] font-korean" style={{ top: '15%', left: '10%', width: '65%', height: '70%', borderColor: 'rgba(6,182,212,0.3)', color: 'rgba(6,182,212,0.5)' }}>
|
||||
합성 영역 (3×2 그리드)
|
||||
{/* 합성 결과 */}
|
||||
<div className="text-[11px] font-bold mb-2 font-korean">합성 결과</div>
|
||||
<div
|
||||
className="relative bg-bg-0 border border-border rounded-sm overflow-hidden flex items-center justify-center"
|
||||
style={{ minHeight: '160px', flex: '1 1 0' }}
|
||||
>
|
||||
{stitchedPreviewUrl ? (
|
||||
<img
|
||||
src={stitchedPreviewUrl}
|
||||
alt="합성 결과"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[12px] text-text-3 font-korean text-center px-4">
|
||||
{isStitching
|
||||
? '⏳ 이미지를 합성하고 있습니다...'
|
||||
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
|
||||
</div>
|
||||
<div className="absolute" style={{ top: '22%', left: '18%', width: '35%', height: '40%', background: 'rgba(239,68,68,0.12)', border: '1.5px solid rgba(239,68,68,0.4)', borderRadius: '30% 50% 40% 60%' }} />
|
||||
<div className="absolute" style={{ top: '40%', left: '38%', width: '20%', height: '30%', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '50% 30% 60% 40%' }} />
|
||||
</div>
|
||||
<div className="absolute bottom-1.5 left-2.5 text-[9px] text-text-3 font-mono">34.7312°N, 127.6845°E</div>
|
||||
<div className="absolute bottom-1.5 right-2.5 text-[9px] text-text-3 font-mono">축척 ≈ 1:2,500</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis Results */}
|
||||
<div className="p-4 bg-bg-3 border border-border rounded-md">
|
||||
<div className="text-xs font-bold mb-2.5 font-korean">📊 유출유 분석 결과</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: '0.42 km²', label: '유막 면적', color: 'text-status-red' },
|
||||
{ value: '12.6 kL', label: '추정 유출량', color: 'text-status-orange' },
|
||||
{ value: '1.84 km²', label: '합성 영역 면적', color: 'text-primary-cyan' },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="text-center py-2.5 px-2 bg-bg-0 border border-border rounded-sm">
|
||||
<div className={`text-lg font-bold font-mono ${r.color}`}>{r.value}</div>
|
||||
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{r.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5 mt-2.5 text-[11px]">
|
||||
{[
|
||||
['두꺼운 유막 (>1mm)', '0.08 km²', 'text-status-red'],
|
||||
['얇은 유막 (<1mm)', '0.34 km²', 'text-status-orange'],
|
||||
['무지개 빛깔', '0.12 km²', 'text-status-yellow'],
|
||||
['Bonn 코드', 'Code 3~4', 'text-text-1'],
|
||||
].map(([label, value, color], i) => (
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
|
||||
<span className="text-text-3 font-korean">{label}</span>
|
||||
<span className={`font-semibold font-mono ${color}`}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -115,6 +115,9 @@ export function SatelliteRequest() {
|
||||
const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
})
|
||||
const [mapSelectedItem, setMapSelectedItem] = useState<SatRequest | null>(null)
|
||||
const satImgOpacity = 90
|
||||
const satImgBrightness = 100
|
||||
const satShowOverlay = true
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@ -436,26 +439,31 @@ export function SatelliteRequest() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 선택된 완료 항목: VWorld 위성 영상 오버레이 */}
|
||||
{mapSelectedItem && mapSelectedItem.status === '완료' && VWORLD_API_KEY && (
|
||||
{/* 선택된 완료 항목: VWorld 위성 영상 오버레이 (BlackSky 스타일) */}
|
||||
{mapSelectedItem && mapSelectedItem.status === '완료' && satShowOverlay && VWORLD_API_KEY && (
|
||||
<Source
|
||||
id="sat-vworld-overlay"
|
||||
type="raster"
|
||||
tiles={[`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg`]}
|
||||
tileSize={256}
|
||||
>
|
||||
<Layer id="sat-vworld-layer" type="raster" paint={{ 'raster-opacity': 0.9 }} />
|
||||
<Layer id="sat-vworld-layer" type="raster" paint={{
|
||||
'raster-opacity': satImgOpacity / 100,
|
||||
'raster-brightness-max': Math.min(satImgBrightness / 100 * 1.2, 1),
|
||||
'raster-brightness-min': Math.max((satImgBrightness - 100) / 200, 0),
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* 선택된 항목 마커 + 팝업 */}
|
||||
{/* 선택된 항목 마커 */}
|
||||
{mapSelectedItem && (() => {
|
||||
const coord = parseCoord(mapSelectedItem.zoneCoord)
|
||||
if (!coord) return null
|
||||
return (
|
||||
<Marker longitude={coord.lon} latitude={coord.lat} anchor="bottom">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px]" style={{ background: 'rgba(6,182,212,.8)', border: '2px solid #fff', boxShadow: '0 0 10px rgba(6,182,212,.5)' }}>📷</div>
|
||||
<Marker longitude={coord.lon} latitude={coord.lat} anchor="center">
|
||||
<div className="relative">
|
||||
<div className="w-4 h-4 rounded-full" style={{ background: 'rgba(6,182,212,.6)', border: '2px solid #fff', boxShadow: '0 0 12px rgba(6,182,212,.5)' }} />
|
||||
<div className="absolute inset-0 w-4 h-4 rounded-full animate-ping" style={{ background: 'rgba(6,182,212,.3)' }} />
|
||||
</div>
|
||||
</Marker>
|
||||
)
|
||||
|
||||
1137
frontend/src/tabs/aerial/components/WingAI.tsx
Normal file
1137
frontend/src/tabs/aerial/components/WingAI.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -104,6 +104,33 @@ export async function createSatRequest(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function downloadAerialMedia(sn: number, fileName: string): Promise<void> {
|
||||
const res = await api.get(`/aerial/media/${sn}/download`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(res.data as Blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 이미지 파일을 /aerial/stitch 엔드포인트로 전송해 합성 JPEG Blob을 반환한다.
|
||||
* FastAPI /stitch → pic_gps.py 스티칭 파이프라인 프록시.
|
||||
*/
|
||||
export async function stitchImages(files: File[]): Promise<Blob> {
|
||||
const form = new FormData();
|
||||
files.forEach(f => form.append('files', f));
|
||||
const response = await api.post<Blob>('/aerial/stitch', form, {
|
||||
responseType: 'blob',
|
||||
timeout: 310_000,
|
||||
headers: { 'Content-Type': undefined }, // 기본 application/json 제거 → 브라우저가 multipart/form-data 자동 설정
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// === DRONE STREAM ===
|
||||
export interface DroneStreamItem {
|
||||
id: string;
|
||||
|
||||
202
frontend/src/tabs/incidents/components/DischargeZonePanel.tsx
Normal file
202
frontend/src/tabs/incidents/components/DischargeZonePanel.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* 해양환경관리법 제22조 기반 선박 발생 오염물 배출 규정
|
||||
* 영해기선으로부터의 거리에 따라 배출 가능 여부 결정
|
||||
*
|
||||
* 법률 근거:
|
||||
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
|
||||
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||
*/
|
||||
|
||||
type Status = 'forbidden' | 'allowed' | 'conditional'
|
||||
|
||||
interface DischargeRule {
|
||||
category: string
|
||||
item: string
|
||||
zones: [Status, Status, Status, Status] // [~3NM, 3~12NM, 12~25NM, 25NM+]
|
||||
condition?: string
|
||||
}
|
||||
|
||||
const RULES: DischargeRule[] = [
|
||||
// 폐기물
|
||||
{ category: '폐기물', item: '플라스틱 제품', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||
{ category: '폐기물', item: '포장유해물질·용기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||
{ category: '폐기물', item: '중금속 포함 쓰레기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||
// 화물잔류물
|
||||
{ category: '화물잔류물', item: '부유성 화물잔류물', zones: ['forbidden', 'forbidden', 'forbidden', 'allowed'] },
|
||||
{ category: '화물잔류물', item: '침강성 화물잔류물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
|
||||
{ category: '화물잔류물', item: '화물창 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '해양환경에 해롭지 않은 일반세제 사용시' },
|
||||
// 음식물 찌꺼기
|
||||
{ category: '음식물찌꺼기', item: '미분쇄', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
|
||||
{ category: '음식물찌꺼기', item: '분쇄·연마', zones: ['forbidden', 'conditional', 'allowed', 'allowed'], condition: '크기 25mm 이하시' },
|
||||
// 분뇨
|
||||
{ category: '분뇨', item: '분뇨저장장치', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출' },
|
||||
{ category: '분뇨', item: '분뇨마쇄소독장치', zones: ['forbidden', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능' },
|
||||
{ category: '분뇨', item: '분뇨처리장치', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면 및 육성수면은 불가' },
|
||||
// 중수
|
||||
{ category: '중수', item: '거주구역 중수', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가' },
|
||||
// 수산동식물
|
||||
{ category: '수산동식물', item: '자연기원물질', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면' },
|
||||
]
|
||||
|
||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25해리+']
|
||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e']
|
||||
|
||||
function getZoneIndex(distanceNm: number): number {
|
||||
if (distanceNm < 3) return 0
|
||||
if (distanceNm < 12) return 1
|
||||
if (distanceNm < 25) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: Status }) {
|
||||
if (status === 'forbidden') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(239,68,68,0.15)', color: '#ef4444' }}>배출불가</span>
|
||||
if (status === 'allowed') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(34,197,94,0.15)', color: '#22c55e' }}>배출가능</span>
|
||||
return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(234,179,8,0.15)', color: '#eab308' }}>조건부</span>
|
||||
}
|
||||
|
||||
interface DischargeZonePanelProps {
|
||||
lat: number
|
||||
lon: number
|
||||
distanceNm: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) {
|
||||
const zoneIdx = getZoneIndex(distanceNm)
|
||||
const [expandedCat, setExpandedCat] = useState<string | null>(null)
|
||||
|
||||
const categories = [...new Set(RULES.map(r => r.category))]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-4 right-4 z-[1000] rounded-lg overflow-hidden flex flex-col"
|
||||
style={{
|
||||
width: 320,
|
||||
maxHeight: 'calc(100% - 32px)',
|
||||
background: 'rgba(13,17,23,0.95)',
|
||||
border: '1px solid #30363d',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid #30363d',
|
||||
background: 'linear-gradient(135deg, #1c2333, #161b22)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-[#f0f6fc] font-korean">🚢 오염물 배출 규정</div>
|
||||
<div className="text-[8px] text-[#8b949e] font-korean">해양환경관리법 제22조</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="text-[14px] cursor-pointer text-[#8b949e] hover:text-[#f0f6fc]">✕</span>
|
||||
</div>
|
||||
|
||||
{/* Location Info */}
|
||||
<div className="shrink-0" style={{ padding: '8px 14px', borderBottom: '1px solid #21262d' }}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[9px] text-[#8b949e] font-korean">선택 위치</span>
|
||||
<span className="text-[9px] text-[#c9d1d9] font-mono">{lat.toFixed(4)}°N, {lon.toFixed(4)}°E</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[9px] text-[#8b949e] font-korean">영해기선 거리 (추정)</span>
|
||||
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
||||
{distanceNm.toFixed(1)} NM
|
||||
</span>
|
||||
</div>
|
||||
{/* Zone indicator */}
|
||||
<div className="flex gap-1">
|
||||
{ZONE_LABELS.map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex-1 text-center rounded-sm"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
fontSize: 8,
|
||||
fontWeight: i === zoneIdx ? 700 : 400,
|
||||
color: i === zoneIdx ? '#fff' : '#8b949e',
|
||||
background: i === zoneIdx ? ZONE_COLORS[i] : 'rgba(255,255,255,0.04)',
|
||||
border: i === zoneIdx ? 'none' : '1px solid #21262d',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#30363d transparent' }}>
|
||||
{categories.map(cat => {
|
||||
const catRules = RULES.filter(r => r.category === cat)
|
||||
const isExpanded = expandedCat === cat
|
||||
const allForbidden = catRules.every(r => r.zones[zoneIdx] === 'forbidden')
|
||||
const allAllowed = catRules.every(r => r.zones[zoneIdx] === 'allowed')
|
||||
const summaryColor = allForbidden ? '#ef4444' : allAllowed ? '#22c55e' : '#eab308'
|
||||
|
||||
return (
|
||||
<div key={cat} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setExpandedCat(isExpanded ? null : cat)}
|
||||
style={{ padding: '8px 14px' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }} />
|
||||
<span className="text-[10px] font-bold text-[#c9d1d9] font-korean">{cat}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[8px] font-semibold" style={{ color: summaryColor }}>
|
||||
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
|
||||
</span>
|
||||
<span className="text-[9px] text-[#8b949e]">{isExpanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '0 14px 10px' }}>
|
||||
{catRules.map((rule, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between"
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
marginBottom: 2,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] text-[#c9d1d9] font-korean">{rule.item}</span>
|
||||
<StatusBadge status={rule.zones[zoneIdx]} />
|
||||
</div>
|
||||
))}
|
||||
{catRules.some(r => r.condition && r.zones[zoneIdx] !== 'forbidden') && (
|
||||
<div className="mt-1" style={{ padding: '4px 8px' }}>
|
||||
{catRules.filter(r => r.condition && r.zones[zoneIdx] !== 'forbidden').map((r, i) => (
|
||||
<div key={i} className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
|
||||
💡 {r.item}: {r.condition}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0" style={{ padding: '6px 14px', borderTop: '1px solid #21262d' }}>
|
||||
<div className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
|
||||
※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있을 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Map, Popup, useControl } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, IconLayer } from '@deck.gl/layers'
|
||||
import { ScatterplotLayer, IconLayer, PathLayer } from '@deck.gl/layers'
|
||||
import { PathStyleExtension } from '@deck.gl/extensions'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
|
||||
@ -9,23 +10,25 @@ import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './Inci
|
||||
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
|
||||
import { fetchIncidents } from '../services/incidentsApi'
|
||||
import type { IncidentCompat } from '../services/incidentsApi'
|
||||
import { DischargeZonePanel } from './DischargeZonePanel'
|
||||
import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData'
|
||||
|
||||
// ── CartoDB Dark Matter 베이스맵 ────────────────────────
|
||||
// ── CartoDB Positron 베이스맵 (밝은 테마) ────────────────
|
||||
const BASE_STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
'carto-light': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
||||
layers: [{ id: 'carto-light-layer', type: 'raster', source: 'carto-light' }],
|
||||
}
|
||||
|
||||
// ── DeckGLOverlay ──────────────────────────────────────
|
||||
@ -90,6 +93,10 @@ export function IncidentsView() {
|
||||
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null)
|
||||
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null)
|
||||
|
||||
// Discharge zone mode
|
||||
const [dischargeMode, setDischargeMode] = useState(false)
|
||||
const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number } | null>(null)
|
||||
|
||||
// Analysis view mode
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('overlay')
|
||||
const [analysisActive, setAnalysisActive] = useState(false)
|
||||
@ -223,10 +230,30 @@ export function IncidentsView() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── 배출 구역 경계선 레이어 ──
|
||||
const dischargeZoneLayers = useMemo(() => {
|
||||
if (!dischargeMode) return []
|
||||
const zoneLines = getDischargeZoneLines()
|
||||
return zoneLines.map((line, i) =>
|
||||
new PathLayer({
|
||||
id: `discharge-zone-${i}`,
|
||||
data: [line],
|
||||
getPath: (d: typeof line) => d.path,
|
||||
getColor: (d: typeof line) => d.color,
|
||||
getWidth: 2,
|
||||
widthUnits: 'pixels',
|
||||
getDashArray: [6, 3],
|
||||
dashJustified: true,
|
||||
extensions: [new PathStyleExtension({ dash: true })],
|
||||
pickable: false,
|
||||
})
|
||||
)
|
||||
}, [dischargeMode])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deckLayers: any[] = useMemo(
|
||||
() => [incidentLayer, vesselIconLayer],
|
||||
[incidentLayer, vesselIconLayer],
|
||||
() => [incidentLayer, vesselIconLayer, ...dischargeZoneLayers],
|
||||
[incidentLayer, vesselIconLayer, dischargeZoneLayers],
|
||||
)
|
||||
|
||||
return (
|
||||
@ -320,8 +347,17 @@ export function IncidentsView() {
|
||||
<Map
|
||||
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
|
||||
mapStyle={BASE_STYLE}
|
||||
style={{ width: '100%', height: '100%', background: '#0a0e1a' }}
|
||||
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
|
||||
attributionControl={false}
|
||||
onClick={(e) => {
|
||||
if (dischargeMode && e.lngLat) {
|
||||
const lat = e.lngLat.lat
|
||||
const lon = e.lngLat.lng
|
||||
const distanceNm = estimateDistanceFromCoast(lat, lon)
|
||||
setDischargeInfo({ lat, lon, distanceNm })
|
||||
}
|
||||
}}
|
||||
cursor={dischargeMode ? 'crosshair' : undefined}
|
||||
>
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
|
||||
@ -428,6 +464,57 @@ export function IncidentsView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오염물 배출 규정 토글 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setDischargeMode(!dischargeMode)
|
||||
if (dischargeMode) setDischargeInfo(null)
|
||||
}}
|
||||
className="absolute z-[500] cursor-pointer rounded-md text-[10px] font-bold font-korean"
|
||||
style={{
|
||||
top: 10,
|
||||
right: dischargeMode ? 340 : 180,
|
||||
padding: '6px 10px',
|
||||
background: dischargeMode ? 'rgba(6,182,212,0.15)' : 'rgba(13,17,23,0.88)',
|
||||
border: dischargeMode ? '1px solid rgba(6,182,212,0.4)' : '1px solid #30363d',
|
||||
color: dischargeMode ? '#22d3ee' : '#8b949e',
|
||||
backdropFilter: 'blur(8px)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
🚢 배출규정 {dischargeMode ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
|
||||
{/* 오염물 배출 규정 패널 */}
|
||||
{dischargeMode && dischargeInfo && (
|
||||
<DischargeZonePanel
|
||||
lat={dischargeInfo.lat}
|
||||
lon={dischargeInfo.lon}
|
||||
distanceNm={dischargeInfo.distanceNm}
|
||||
onClose={() => setDischargeInfo(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 배출규정 모드 안내 */}
|
||||
{dischargeMode && !dischargeInfo && (
|
||||
<div
|
||||
className="absolute z-[500] rounded-md text-[11px] font-korean font-semibold"
|
||||
style={{
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
padding: '12px 20px',
|
||||
background: 'rgba(13,17,23,0.9)',
|
||||
border: '1px solid rgba(6,182,212,0.3)',
|
||||
color: '#22d3ee',
|
||||
backdropFilter: 'blur(8px)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
📍 지도를 클릭하여 배출 규정을 확인하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AIS Live Badge */}
|
||||
<div
|
||||
className="absolute top-[10px] right-[10px] z-[500] rounded-md"
|
||||
|
||||
166
frontend/src/tabs/incidents/utils/dischargeZoneData.ts
Normal file
166
frontend/src/tabs/incidents/utils/dischargeZoneData.ts
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 해양환경관리법 제22조 기반 오염물 배출 구역 데이터 및 유틸리티
|
||||
*
|
||||
* 법률 근거:
|
||||
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
|
||||
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||
*/
|
||||
|
||||
// 한국 해안선 — OpenStreetMap Nominatim 기반 실측 좌표
|
||||
// [lat, lon] 형식, 시계방향 (동해→남해→서해→DMZ)
|
||||
const COASTLINE_POINTS: [number, number][] = [
|
||||
// 동해안 (북→남)
|
||||
[38.6177, 128.6560], [38.5504, 128.4092], [38.4032, 128.7767],
|
||||
[38.1904, 128.8902], [38.0681, 128.9977], [37.9726, 129.0715],
|
||||
[37.8794, 129.1721], [37.8179, 129.2397], [37.6258, 129.3669],
|
||||
[37.5053, 129.4577], [37.3617, 129.5700], [37.1579, 129.6538],
|
||||
[37.0087, 129.6706], [36.6618, 129.7210], [36.3944, 129.6827],
|
||||
[36.2052, 129.7641], [35.9397, 129.8124], [35.6272, 129.7121],
|
||||
[35.4732, 129.6908], [35.2843, 129.5924], [35.1410, 129.4656],
|
||||
[35.0829, 129.2125],
|
||||
// 남해안 (부산→여수→목포)
|
||||
[34.8950, 129.0658], [34.2050, 128.3063], [35.0220, 128.0362],
|
||||
[34.9663, 127.8732], [34.9547, 127.7148], [34.8434, 127.6625],
|
||||
[34.7826, 127.7422], [34.6902, 127.6324], [34.8401, 127.5236],
|
||||
[34.8230, 127.4043], [34.6882, 127.4234], [34.6252, 127.4791],
|
||||
[34.5525, 127.4012], [34.4633, 127.3246], [34.5461, 127.1734],
|
||||
[34.6617, 127.2605], [34.7551, 127.2471], [34.6069, 127.0308],
|
||||
[34.4389, 126.8975], [34.4511, 126.8263], [34.4949, 126.7965],
|
||||
[34.5119, 126.7548], [34.4035, 126.6108], [34.3175, 126.5844],
|
||||
[34.3143, 126.5314], [34.3506, 126.5083], [34.4284, 126.5064],
|
||||
[34.4939, 126.4817], [34.5896, 126.3326], [34.6732, 126.2645],
|
||||
// 서해안 (목포→인천)
|
||||
[34.7200, 126.3011], [34.6946, 126.4256], [34.6979, 126.5245],
|
||||
[34.7787, 126.5386], [34.8244, 126.5934], [34.8104, 126.4785],
|
||||
[34.8234, 126.4207], [34.9328, 126.3979], [35.0451, 126.3274],
|
||||
[35.1542, 126.2911], [35.2169, 126.3605], [35.3144, 126.3959],
|
||||
[35.4556, 126.4604], [35.5013, 126.4928], [35.5345, 126.5822],
|
||||
[35.5710, 126.6141], [35.5897, 126.5649], [35.6063, 126.4865],
|
||||
[35.6471, 126.4885], [35.6693, 126.5419], [35.7142, 126.6016],
|
||||
[35.7688, 126.7174], [35.8720, 126.7530], [35.8979, 126.7196],
|
||||
[35.9225, 126.6475], [35.9745, 126.6637], [36.0142, 126.6935],
|
||||
[36.0379, 126.6823], [36.1050, 126.5971], [36.1662, 126.5404],
|
||||
[36.2358, 126.5572], [36.3412, 126.5442], [36.4297, 126.5520],
|
||||
[36.4776, 126.5482], [36.5856, 126.5066], [36.6938, 126.4877],
|
||||
[36.6780, 126.4330], [36.6512, 126.3888], [36.6893, 126.2307],
|
||||
[36.6916, 126.1809], [36.7719, 126.1605], [36.8709, 126.2172],
|
||||
[36.9582, 126.3516], [36.9690, 126.4287], [37.0075, 126.4870],
|
||||
[37.0196, 126.5777], [36.9604, 126.6867], [36.9484, 126.7845],
|
||||
[36.8461, 126.8388], [36.8245, 126.8721], [36.8621, 126.8791],
|
||||
[36.9062, 126.9580], [36.9394, 126.9769], [36.9576, 126.9598],
|
||||
[36.9757, 126.8689], [37.1027, 126.7874], [37.1582, 126.7761],
|
||||
[37.1936, 126.7464], [37.2949, 126.7905], [37.4107, 126.6962],
|
||||
[37.4471, 126.6503], [37.5512, 126.6568], [37.6174, 126.6076],
|
||||
[37.6538, 126.5802], [37.7165, 126.5634], [37.7447, 126.5777],
|
||||
[37.7555, 126.6207], [37.7818, 126.6339], [37.8007, 126.6646],
|
||||
[37.8279, 126.6665], [37.9172, 126.6668], [37.9790, 126.7543],
|
||||
// DMZ (간소화)
|
||||
[38.1066, 126.8789], [38.1756, 126.9400], [38.2405, 127.0097],
|
||||
[38.2839, 127.0903], [38.3045, 127.1695], [38.3133, 127.2940],
|
||||
[38.3244, 127.5469], [38.3353, 127.7299], [38.3469, 127.7858],
|
||||
[38.3066, 127.8207], [38.3250, 127.9001], [38.3150, 128.0083],
|
||||
[38.3107, 128.0314], [38.3189, 128.0887], [38.3317, 128.1269],
|
||||
[38.3481, 128.1606], [38.3748, 128.2054], [38.4032, 128.2347],
|
||||
[38.4797, 128.3064], [38.5339, 128.6952], [38.6177, 128.6560],
|
||||
]
|
||||
|
||||
// 제주도 — OpenStreetMap 기반 (26 points)
|
||||
const JEJU_POINTS: [number, number][] = [
|
||||
[33.5168, 126.0128], [33.5067, 126.0073], [33.1190, 126.0102],
|
||||
[33.0938, 126.0176], [33.0748, 126.0305], [33.0556, 126.0355],
|
||||
[33.0280, 126.0492], [33.0159, 126.4783], [33.0115, 126.5186],
|
||||
[33.0143, 126.5572], [33.0231, 126.5970], [33.0182, 126.6432],
|
||||
[33.0201, 126.7129], [33.0458, 126.7847], [33.0662, 126.8169],
|
||||
[33.0979, 126.8512], [33.1192, 126.9292], [33.1445, 126.9783],
|
||||
[33.1683, 127.0129], [33.1974, 127.0430], [33.2226, 127.0634],
|
||||
[33.2436, 127.0723], [33.4646, 127.2106], [33.5440, 126.0355],
|
||||
[33.5808, 126.0814], [33.5168, 126.0128],
|
||||
]
|
||||
|
||||
const ALL_COASTLINE = [...COASTLINE_POINTS, ...JEJU_POINTS]
|
||||
|
||||
/** 두 좌표 간 대략적 해리(NM) 계산 (Haversine) */
|
||||
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 3440.065
|
||||
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 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
/** 클릭 지점에서 가장 가까운 해안선까지의 거리 (NM) */
|
||||
export function estimateDistanceFromCoast(lat: number, lon: number): number {
|
||||
let minDist = Infinity
|
||||
for (const [cLat, cLon] of ALL_COASTLINE) {
|
||||
const dist = haversineNm(lat, lon, cLat, cLon)
|
||||
if (dist < minDist) minDist = dist
|
||||
}
|
||||
return minDist
|
||||
}
|
||||
|
||||
/**
|
||||
* 해안선을 주어진 해리(NM) 만큼 바깥(바다쪽)으로 오프셋한 경계선 생성
|
||||
*/
|
||||
function offsetCoastline(points: [number, number][], distanceNm: number, outwardSign: number = 1): [number, number][] {
|
||||
const degPerNm = 1 / 60
|
||||
const result: [number, number][] = []
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const prev = points[(i - 1 + points.length) % points.length]
|
||||
const curr = points[i]
|
||||
const next = points[(i + 1) % points.length]
|
||||
|
||||
const cosLat = Math.cos(curr[0] * Math.PI / 180)
|
||||
const dx0 = (curr[1] - prev[1]) * cosLat
|
||||
const dy0 = curr[0] - prev[0]
|
||||
const dx1 = (next[1] - curr[1]) * cosLat
|
||||
const dy1 = next[0] - curr[0]
|
||||
|
||||
let nx = -(dy0 + dy1) / 2
|
||||
let ny = (dx0 + dx1) / 2
|
||||
const len = Math.sqrt(nx * nx + ny * ny) || 1
|
||||
nx /= len
|
||||
ny /= len
|
||||
|
||||
const latOff = outwardSign * nx * distanceNm * degPerNm
|
||||
const lonOff = outwardSign * ny * distanceNm * degPerNm / cosLat
|
||||
|
||||
result.push([curr[0] + latOff, curr[1] + lonOff])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export interface ZoneLine {
|
||||
path: [number, number][]
|
||||
color: [number, number, number, number]
|
||||
label: string
|
||||
distanceNm: number
|
||||
}
|
||||
|
||||
export function getDischargeZoneLines(): ZoneLine[] {
|
||||
const zones = [
|
||||
{ nm: 3, color: [239, 68, 68, 180] as [number, number, number, number], label: '3해리' },
|
||||
{ nm: 12, color: [249, 115, 22, 160] as [number, number, number, number], label: '12해리' },
|
||||
{ nm: 25, color: [234, 179, 8, 140] as [number, number, number, number], label: '25해리' },
|
||||
]
|
||||
|
||||
const lines: ZoneLine[] = []
|
||||
for (const zone of zones) {
|
||||
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1)
|
||||
lines.push({
|
||||
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: zone.label,
|
||||
distanceNm: zone.nm,
|
||||
})
|
||||
const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1)
|
||||
lines.push({
|
||||
path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: `${zone.label} (제주)`,
|
||||
distanceNm: zone.nm,
|
||||
})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@ -10,13 +10,19 @@ export function LeftPanel({
|
||||
selectedAnalysis,
|
||||
enabledLayers,
|
||||
onToggleLayer,
|
||||
accidentTime,
|
||||
onAccidentTimeChange,
|
||||
incidentCoord,
|
||||
onCoordChange,
|
||||
isSelectingLocation,
|
||||
onMapSelectClick,
|
||||
onRunSimulation,
|
||||
isRunningSimulation,
|
||||
selectedModels,
|
||||
onModelsChange,
|
||||
visibleModels,
|
||||
onVisibleModelsChange,
|
||||
hasResults,
|
||||
predictionTime,
|
||||
onPredictionTimeChange,
|
||||
spillType,
|
||||
@ -25,6 +31,10 @@ export function LeftPanel({
|
||||
onOilTypeChange,
|
||||
spillAmount,
|
||||
onSpillAmountChange,
|
||||
incidentName,
|
||||
onIncidentNameChange,
|
||||
spillUnit,
|
||||
onSpillUnitChange,
|
||||
boomLines,
|
||||
onBoomLinesChange,
|
||||
oilTrajectory,
|
||||
@ -40,12 +50,13 @@ export function LeftPanel({
|
||||
onLayerOpacityChange,
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
onImageAnalysisResult,
|
||||
}: LeftPanelProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
||||
predictionInput: true,
|
||||
incident: false,
|
||||
impactResources: false,
|
||||
infoLayer: true,
|
||||
infoLayer: false,
|
||||
oilBoom: false,
|
||||
})
|
||||
|
||||
@ -64,13 +75,19 @@ export function LeftPanel({
|
||||
<PredictionInputSection
|
||||
expanded={expandedSections.predictionInput}
|
||||
onToggle={() => toggleSection('predictionInput')}
|
||||
accidentTime={accidentTime}
|
||||
onAccidentTimeChange={onAccidentTimeChange}
|
||||
incidentCoord={incidentCoord}
|
||||
onCoordChange={onCoordChange}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapSelectClick={onMapSelectClick}
|
||||
onRunSimulation={onRunSimulation}
|
||||
isRunningSimulation={isRunningSimulation}
|
||||
selectedModels={selectedModels}
|
||||
onModelsChange={onModelsChange}
|
||||
visibleModels={visibleModels}
|
||||
onVisibleModelsChange={onVisibleModelsChange}
|
||||
hasResults={hasResults}
|
||||
predictionTime={predictionTime}
|
||||
onPredictionTimeChange={onPredictionTimeChange}
|
||||
spillType={spillType}
|
||||
@ -79,6 +96,11 @@ export function LeftPanel({
|
||||
onOilTypeChange={onOilTypeChange}
|
||||
spillAmount={spillAmount}
|
||||
onSpillAmountChange={onSpillAmountChange}
|
||||
incidentName={incidentName}
|
||||
onIncidentNameChange={onIncidentNameChange}
|
||||
spillUnit={spillUnit}
|
||||
onSpillUnitChange={onSpillUnitChange}
|
||||
onImageAnalysisResult={onImageAnalysisResult}
|
||||
/>
|
||||
|
||||
{/* Incident Section */}
|
||||
@ -96,45 +118,73 @@ export function LeftPanel({
|
||||
</div>
|
||||
|
||||
{expandedSections.incident && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Status Badge */}
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
|
||||
진행중
|
||||
</div>
|
||||
selectedAnalysis ? (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Status Badge */}
|
||||
{(() => {
|
||||
const statusMap: Record<string, { label: string; style: string; dot: string }> = {
|
||||
ACTIVE: {
|
||||
label: '진행중',
|
||||
style: 'bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]',
|
||||
dot: 'bg-status-red animate-pulse',
|
||||
},
|
||||
INVESTIGATING: {
|
||||
label: '조사중',
|
||||
style: 'bg-[rgba(249,115,22,0.15)] text-status-orange border border-[rgba(249,115,22,0.3)]',
|
||||
dot: 'bg-status-orange animate-pulse',
|
||||
},
|
||||
CLOSED: {
|
||||
label: '종료',
|
||||
style: 'bg-[rgba(100,116,139,0.15)] text-text-3 border border-[rgba(100,116,139,0.3)]',
|
||||
dot: 'bg-text-3',
|
||||
},
|
||||
}
|
||||
const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE']
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold ${s.style}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${s.dot}`} />
|
||||
{s.label}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고코드</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고명</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.name || '씨프린스호'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis?.occurredAt || '2025-02-10 06:30'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.oilType || 'BUNKER_C'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유출량</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">담당자</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.analyst || '남해청, 방재과'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">위치</span>
|
||||
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis?.location || '여수 돌산 남방 5NM'}</span>
|
||||
{/* Info Grid */}
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고코드</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.acdntSn}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고명</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.acdntNm || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.oilType || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유출량</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">담당자</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.analyst || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">위치</span>
|
||||
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis.location || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
<p className="text-[11px] text-text-3 font-korean text-center py-2">선택된 사고정보가 없습니다.</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -178,7 +228,7 @@ export function LeftPanel({
|
||||
boomLines={boomLines}
|
||||
onBoomLinesChange={onBoomLinesChange}
|
||||
oilTrajectory={oilTrajectory}
|
||||
incidentCoord={incidentCoord}
|
||||
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
|
||||
algorithmSettings={algorithmSettings}
|
||||
onAlgorithmSettingsChange={onAlgorithmSettingsChange}
|
||||
isDrawingBoom={isDrawingBoom}
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,19 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { decimalToDMS } from '@common/utils/coordinates'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { ComboBox } from '@common/components/ui/ComboBox'
|
||||
import { ALL_MODELS } from './OilSpillView'
|
||||
import type { PredictionModel } from './OilSpillView'
|
||||
import { analyzeImage } from '../services/predictionApi'
|
||||
import type { ImageAnalyzeResult } from '../services/predictionApi'
|
||||
|
||||
interface PredictionInputSectionProps {
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
incidentCoord: { lon: number; lat: number }
|
||||
accidentTime: string
|
||||
onAccidentTimeChange: (time: string) => void
|
||||
incidentCoord: { lon: number; lat: number } | null
|
||||
onCoordChange: (coord: { lon: number; lat: number }) => void
|
||||
isSelectingLocation: boolean
|
||||
onMapSelectClick: () => void
|
||||
onRunSimulation: () => void
|
||||
isRunningSimulation: boolean
|
||||
selectedModels: Set<PredictionModel>
|
||||
onModelsChange: (models: Set<PredictionModel>) => void
|
||||
visibleModels?: Set<PredictionModel>
|
||||
onVisibleModelsChange?: (models: Set<PredictionModel>) => void
|
||||
hasResults?: boolean
|
||||
predictionTime: number
|
||||
onPredictionTimeChange: (time: number) => void
|
||||
spillType: string
|
||||
@ -22,18 +28,28 @@ interface PredictionInputSectionProps {
|
||||
onOilTypeChange: (type: string) => void
|
||||
spillAmount: number
|
||||
onSpillAmountChange: (amount: number) => void
|
||||
incidentName: string
|
||||
onIncidentNameChange: (name: string) => void
|
||||
spillUnit: string
|
||||
onSpillUnitChange: (unit: string) => void
|
||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
|
||||
}
|
||||
|
||||
const PredictionInputSection = ({
|
||||
expanded,
|
||||
onToggle,
|
||||
accidentTime,
|
||||
onAccidentTimeChange,
|
||||
incidentCoord,
|
||||
onCoordChange,
|
||||
isSelectingLocation,
|
||||
onMapSelectClick,
|
||||
onRunSimulation,
|
||||
isRunningSimulation,
|
||||
selectedModels,
|
||||
onModelsChange,
|
||||
onVisibleModelsChange,
|
||||
hasResults,
|
||||
predictionTime,
|
||||
onPredictionTimeChange,
|
||||
spillType,
|
||||
@ -42,26 +58,57 @@ const PredictionInputSection = ({
|
||||
onOilTypeChange,
|
||||
spillAmount,
|
||||
onSpillAmountChange,
|
||||
incidentName,
|
||||
onIncidentNameChange,
|
||||
spillUnit,
|
||||
onSpillUnitChange,
|
||||
onImageAnalysisResult,
|
||||
}: PredictionInputSectionProps) => {
|
||||
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct')
|
||||
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string>('')
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [analyzeError, setAnalyzeError] = useState<string | null>(null)
|
||||
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setUploadedFileName(file.name)
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
setUploadedImage(event.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] ?? null
|
||||
setUploadedFile(file)
|
||||
setAnalyzeError(null)
|
||||
setAnalyzeResult(null)
|
||||
}
|
||||
|
||||
const removeUploadedImage = () => {
|
||||
setUploadedImage(null)
|
||||
setUploadedFileName('')
|
||||
const handleRemoveFile = () => {
|
||||
setUploadedFile(null)
|
||||
setAnalyzeError(null)
|
||||
setAnalyzeResult(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!uploadedFile) return
|
||||
setIsAnalyzing(true)
|
||||
setAnalyzeError(null)
|
||||
try {
|
||||
const result = await analyzeImage(uploadedFile)
|
||||
setAnalyzeResult(result)
|
||||
onImageAnalysisResult?.(result)
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const res = (err as { response?: { data?: { error?: string } } }).response
|
||||
if (res?.data?.error === 'GPS_NOT_FOUND') {
|
||||
setAnalyzeError('GPS 정보가 없는 이미지입니다')
|
||||
return
|
||||
}
|
||||
if (res?.data?.error === 'TIMEOUT') {
|
||||
setAnalyzeError('분석 서버 응답 없음 (시간 초과)')
|
||||
return
|
||||
}
|
||||
}
|
||||
setAnalyzeError('이미지 분석 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -88,7 +135,7 @@ const PredictionInputSection = ({
|
||||
name="prdType"
|
||||
checked={inputMode === 'direct'}
|
||||
onChange={() => setInputMode('direct')}
|
||||
className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]"
|
||||
className="accent-[var(--cyan)] m-0 w-[11px] h-[11px]"
|
||||
/>
|
||||
직접 입력
|
||||
</label>
|
||||
@ -98,7 +145,7 @@ const PredictionInputSection = ({
|
||||
name="prdType"
|
||||
checked={inputMode === 'upload'}
|
||||
onChange={() => setInputMode('upload')}
|
||||
className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]"
|
||||
className="accent-[var(--cyan)] m-0 w-[11px] h-[11px]"
|
||||
/>
|
||||
이미지 업로드
|
||||
</label>
|
||||
@ -107,43 +154,23 @@ const PredictionInputSection = ({
|
||||
{/* Direct Input Mode */}
|
||||
{inputMode === 'direct' && (
|
||||
<>
|
||||
<input className="prd-i" placeholder="사고명 직접 입력" />
|
||||
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" />
|
||||
<input
|
||||
className="prd-i"
|
||||
placeholder="사고명 직접 입력"
|
||||
value={incidentName}
|
||||
onChange={(e) => onIncidentNameChange(e.target.value)}
|
||||
/>
|
||||
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Image Upload Mode */}
|
||||
{inputMode === 'upload' && (
|
||||
<>
|
||||
<input className="prd-i" placeholder="여수 유조선 충돌" />
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: '', label: '여수 유조선 충돌 (INC-0042)' },
|
||||
{ value: 'INC-0042', label: '여수 유조선 충돌 (INC-0042)' }
|
||||
]}
|
||||
placeholder="사고 선택"
|
||||
/>
|
||||
|
||||
{/* Upload Success Message */}
|
||||
{uploadedImage && (
|
||||
<div className="flex items-center gap-[6px] text-[10px] font-semibold text-[#22c55e] rounded"
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: 'rgba(34,197,94,0.1)',
|
||||
border: '1px solid rgba(34,197,94,0.3)',
|
||||
borderRadius: 'var(--rS)',
|
||||
}}>
|
||||
<span className="text-[12px]">✓</span>
|
||||
내 이미지가 업로드됨
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Upload Area */}
|
||||
{!uploadedImage ? (
|
||||
<label className="flex items-center justify-center text-[11px] text-text-3 cursor-pointer"
|
||||
{/* 파일 선택 영역 */}
|
||||
{!uploadedFile ? (
|
||||
<label
|
||||
className="flex items-center justify-center text-[11px] text-text-3 cursor-pointer"
|
||||
style={{
|
||||
padding: '20px',
|
||||
background: 'var(--bg0)',
|
||||
@ -158,103 +185,114 @@ const PredictionInputSection = ({
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--bd)'
|
||||
e.currentTarget.style.background = 'var(--bg0)'
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
📁 이미지 파일을 선택하세요
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div className="flex items-center justify-between font-mono text-[10px] bg-bg-0 border border-border"
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 'var(--rS)',
|
||||
}}>
|
||||
<span className="text-text-2">📄 {uploadedFileName || 'example_plot_0.gif'}</span>
|
||||
<div
|
||||
className="flex items-center justify-between font-mono text-[10px] bg-bg-0 border border-border"
|
||||
style={{ padding: '8px 10px', borderRadius: 'var(--rS)' }}
|
||||
>
|
||||
<span className="text-text-2">📄 {uploadedFile.name}</span>
|
||||
<button
|
||||
onClick={removeUploadedImage}
|
||||
onClick={handleRemoveFile}
|
||||
className="text-[10px] text-text-3 bg-transparent border-none cursor-pointer"
|
||||
style={{ padding: '2px 6px', transition: '0.15s' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--red)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--t3)'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--red)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--t3)' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdowns */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: '', label: '유출회사' },
|
||||
{ value: 'company1', label: '회사A' },
|
||||
{ value: 'company2', label: '회사B' }
|
||||
]}
|
||||
placeholder="유출회사"
|
||||
/>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: '', label: '예상시각' },
|
||||
{ value: '09:00', label: '09:00' },
|
||||
{ value: '12:00', label: '12:00' }
|
||||
]}
|
||||
placeholder="예상시각"
|
||||
/>
|
||||
</div>
|
||||
{/* 분석 실행 버튼 */}
|
||||
<button
|
||||
className="prd-btn pri"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={handleAnalyze}
|
||||
disabled={!uploadedFile || isAnalyzing}
|
||||
>
|
||||
{isAnalyzing ? '⏳ 분석 중...' : '🔍 이미지 분석 실행'}
|
||||
</button>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{analyzeError && (
|
||||
<div
|
||||
className="text-[10px] font-semibold"
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: 'rgba(239,68,68,0.1)',
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: 'var(--rS)',
|
||||
color: 'var(--red)',
|
||||
}}
|
||||
>
|
||||
⚠ {analyzeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분석 완료 메시지 */}
|
||||
{analyzeResult && (
|
||||
<div
|
||||
className="text-[10px] font-semibold"
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
background: 'rgba(34,197,94,0.1)',
|
||||
border: '1px solid rgba(34,197,94,0.3)',
|
||||
borderRadius: 'var(--rS)',
|
||||
color: '#22c55e',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
✓ 분석 완료<br />
|
||||
<span className="font-normal text-text-3">
|
||||
위도 {analyzeResult.lat.toFixed(4)} / 경도 {analyzeResult.lon.toFixed(4)}<br />
|
||||
유종: {analyzeResult.oilType} / 면적: {analyzeResult.area.toFixed(1)} m²
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Coordinates + Map Button */}
|
||||
{/* 사고 발생 시각 */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-text-3 font-korean">사고 발생 시각 (KST)</label>
|
||||
<DateTimeInput
|
||||
value={accidentTime}
|
||||
onChange={onAccidentTimeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Coordinates (DMS) + Map Button */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
||||
<input
|
||||
className="prd-i"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lat ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
||||
onCoordChange({ ...incidentCoord, lat: isNaN(value) ? 0 : value })
|
||||
}}
|
||||
placeholder="위도°"
|
||||
<div className="grid grid-cols-[1fr_auto] gap-x-1 gap-y-1">
|
||||
<DmsCoordInput
|
||||
label="위도"
|
||||
isLatitude={true}
|
||||
decimal={incidentCoord?.lat ?? 0}
|
||||
onChange={(val) => onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: val })}
|
||||
/>
|
||||
<input
|
||||
className="prd-i"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lon ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
||||
onCoordChange({ ...incidentCoord, lon: isNaN(value) ? 0 : value })
|
||||
}}
|
||||
placeholder="경도°"
|
||||
<button
|
||||
className={`prd-map-btn${isSelectingLocation ? ' active' : ''}`}
|
||||
onClick={onMapSelectClick}
|
||||
style={{ gridRow: '1 / 3', gridColumn: 2, whiteSpace: 'nowrap', height: '100%', minWidth: 48, padding: '0 10px' }}
|
||||
>📍<br/>지도</button>
|
||||
<DmsCoordInput
|
||||
label="경도"
|
||||
isLatitude={false}
|
||||
decimal={incidentCoord?.lon ?? 0}
|
||||
onChange={(val) => onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: val })}
|
||||
/>
|
||||
<button className="prd-map-btn" onClick={onMapSelectClick}>📍 지도</button>
|
||||
</div>
|
||||
{/* 도분초 표시 */}
|
||||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
|
||||
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: 'var(--rS)',
|
||||
}}>
|
||||
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Oil Type + Oil Kind */}
|
||||
@ -297,8 +335,8 @@ const PredictionInputSection = ({
|
||||
/>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value="kL"
|
||||
onChange={() => {}}
|
||||
value={spillUnit}
|
||||
onChange={onSpillUnitChange}
|
||||
options={[
|
||||
{ value: 'kL', label: 'kL' },
|
||||
{ value: 'ton', label: 'Ton' },
|
||||
@ -319,24 +357,12 @@ const PredictionInputSection = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image Analysis Note (Upload Mode Only) */}
|
||||
{inputMode === 'upload' && uploadedImage && (
|
||||
<div className="text-[9px] text-text-3 leading-[1.4]"
|
||||
style={{
|
||||
padding: '8px',
|
||||
background: 'rgba(59,130,246,0.08)',
|
||||
border: '1px solid rgba(59,130,246,0.2)',
|
||||
borderRadius: 'var(--rS)',
|
||||
}}>
|
||||
📊 이미지 내 확산경로를 분석하였습니다. 각 방제요소 가이드 참고하세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border my-0.5" />
|
||||
|
||||
{/* Model Selection (다중 선택) */}
|
||||
<div className="flex flex-wrap gap-[3px]">
|
||||
{/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
|
||||
<div className="grid grid-cols-3 gap-[3px]">
|
||||
{([
|
||||
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false },
|
||||
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true },
|
||||
@ -344,25 +370,25 @@ const PredictionInputSection = ({
|
||||
] as const).map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer`}
|
||||
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer text-center`}
|
||||
onClick={() => {
|
||||
if (!m.ready) {
|
||||
alert(`${m.id} 모델은 현재 준비중입니다.`)
|
||||
return
|
||||
}
|
||||
const next = new Set(selectedModels)
|
||||
if (next.has(m.id)) {
|
||||
next.delete(m.id)
|
||||
} else {
|
||||
next.add(m.id)
|
||||
}
|
||||
if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) }
|
||||
onModelsChange(next)
|
||||
if (hasResults && onVisibleModelsChange) {
|
||||
onVisibleModelsChange(new Set(next))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="prd-md" style={{ background: m.color }} />
|
||||
{m.id}
|
||||
</div>
|
||||
))}
|
||||
{/* 임시 비활성화 — OpenDrift만 구동 가능 (앙상블은 모든 모델 연동 후 활성화 예정)
|
||||
<div
|
||||
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
|
||||
onClick={() => {
|
||||
@ -372,8 +398,16 @@ const PredictionInputSection = ({
|
||||
<span className="prd-md" style={{ background: 'var(--purple)' }} />
|
||||
앙상블
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* 모델 미선택 경고 */}
|
||||
{selectedModels.size === 0 && (
|
||||
<p className="text-[10px] text-status-red font-korean">
|
||||
⚠ 예측 모델을 하나 이상 선택하세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Run Button */}
|
||||
<button
|
||||
className="prd-btn pri mt-0.5"
|
||||
@ -389,4 +423,294 @@ const PredictionInputSection = ({
|
||||
)
|
||||
}
|
||||
|
||||
// ── 커스텀 날짜/시간 선택 컴포넌트 ─────────────────────
|
||||
function DateTimeInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [showCal, setShowCal] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const datePart = value ? value.split('T')[0] : ''
|
||||
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'
|
||||
const [hh, mm] = timePart.split(':').map(Number)
|
||||
|
||||
const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
|
||||
const [viewYear, setViewYear] = useState(parsed.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed.getMonth())
|
||||
|
||||
const selY = datePart ? parsed.getFullYear() : -1
|
||||
const selM = datePart ? parsed.getMonth() : -1
|
||||
const selD = datePart ? parsed.getDate() : -1
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setShowCal(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
|
||||
const firstDay = new Date(viewYear, viewMonth, 1).getDay()
|
||||
const days: (number | null)[] = []
|
||||
for (let i = 0; i < firstDay; i++) days.push(null)
|
||||
for (let i = 1; i <= daysInMonth; i++) days.push(i)
|
||||
|
||||
const pickDate = (day: number) => {
|
||||
const m = String(viewMonth + 1).padStart(2, '0')
|
||||
const d = String(day).padStart(2, '0')
|
||||
onChange(`${viewYear}-${m}-${d}T${timePart}`)
|
||||
setShowCal(false)
|
||||
}
|
||||
|
||||
const updateTime = (newHH: number, newMM: number) => {
|
||||
const date = datePart || new Date().toISOString().split('T')[0]
|
||||
onChange(`${date}T${String(newHH).padStart(2, '0')}:${String(newMM).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11) }
|
||||
else setViewMonth(viewMonth - 1)
|
||||
}
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) { setViewYear(viewYear + 1); setViewMonth(0) }
|
||||
else setViewMonth(viewMonth + 1)
|
||||
}
|
||||
|
||||
const displayDate = datePart
|
||||
? `${selY}.${String(selM + 1).padStart(2, '0')}.${String(selD).padStart(2, '0')}`
|
||||
: '날짜 선택'
|
||||
|
||||
const today = new Date()
|
||||
const todayY = today.getFullYear()
|
||||
const todayM = today.getMonth()
|
||||
const todayD = today.getDate()
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex items-center gap-1 relative">
|
||||
{/* 날짜 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCal(!showCal)}
|
||||
className="prd-i flex-1 flex items-center justify-between cursor-pointer"
|
||||
style={{ padding: '5px 8px', fontSize: 10 }}
|
||||
>
|
||||
<span className="font-mono" style={{ color: datePart ? 'var(--t1)' : 'var(--t3)' }}>{displayDate}</span>
|
||||
<span className="text-[9px] opacity-60">📅</span>
|
||||
</button>
|
||||
|
||||
{/* 시 */}
|
||||
<TimeDropdown value={hh} max={24} onChange={(v) => updateTime(v, mm)} />
|
||||
<span className="text-[8px] text-text-3 font-bold">:</span>
|
||||
{/* 분 */}
|
||||
<TimeDropdown value={mm} max={60} onChange={(v) => updateTime(hh, v)} />
|
||||
|
||||
{/* 캘린더 팝업 */}
|
||||
{showCal && (
|
||||
<div
|
||||
className="absolute z-[9999] rounded-md overflow-hidden"
|
||||
style={{
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: 4,
|
||||
width: 200,
|
||||
background: 'var(--bg3)',
|
||||
border: '1px solid var(--bd)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between" style={{ padding: '6px 8px', borderBottom: '1px solid var(--bd)' }}>
|
||||
<button type="button" onClick={prevMonth} className="text-[10px] text-text-3 cursor-pointer px-1 hover:text-text-1">◀</button>
|
||||
<span className="text-[10px] font-bold text-text-1 font-korean">{viewYear}년 {viewMonth + 1}월</span>
|
||||
<button type="button" onClick={nextMonth} className="text-[10px] text-text-3 cursor-pointer px-1 hover:text-text-1">▶</button>
|
||||
</div>
|
||||
{/* 요일 */}
|
||||
<div className="grid grid-cols-7 text-center" style={{ padding: '3px 4px 0' }}>
|
||||
{['일', '월', '화', '수', '목', '금', '토'].map((d) => (
|
||||
<span key={d} className="text-[8px] text-text-3 font-korean" style={{ padding: '2px 0' }}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
{/* 날짜 */}
|
||||
<div className="grid grid-cols-7 text-center" style={{ padding: '2px 4px 6px' }}>
|
||||
{days.map((day, i) => {
|
||||
if (day === null) return <span key={`e-${i}`} />
|
||||
const isSelected = viewYear === selY && viewMonth === selM && day === selD
|
||||
const isToday = viewYear === todayY && viewMonth === todayM && day === todayD
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => pickDate(day)}
|
||||
className="cursor-pointer rounded-sm"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
fontSize: 9,
|
||||
fontFamily: 'var(--fM)',
|
||||
fontWeight: isSelected ? 700 : 400,
|
||||
color: isSelected ? '#fff' : isToday ? 'var(--cyan)' : 'var(--t2)',
|
||||
background: isSelected ? 'var(--cyan)' : 'transparent',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* 오늘 버튼 */}
|
||||
<div style={{ padding: '0 8px 6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewYear(todayY)
|
||||
setViewMonth(todayM)
|
||||
pickDate(todayD)
|
||||
}}
|
||||
className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
background: 'rgba(6,182,212,0.08)',
|
||||
border: '1px solid rgba(6,182,212,0.2)',
|
||||
color: 'var(--cyan)',
|
||||
}}
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 커스텀 시간 드롭다운 (다크 테마) ───────────────────
|
||||
function TimeDropdown({ value, max, onChange }: { value: number; max: number; onChange: (v: number) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && listRef.current) {
|
||||
const activeEl = listRef.current.querySelector('[data-active="true"]')
|
||||
if (activeEl) activeEl.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={dropRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="prd-i text-center font-mono cursor-pointer"
|
||||
style={{ width: 38, padding: '5px 2px', fontSize: 9 }}
|
||||
>
|
||||
{String(value).padStart(2, '0')}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-[9999] overflow-y-auto rounded-md"
|
||||
style={{
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginTop: 2,
|
||||
width: 42,
|
||||
maxHeight: 160,
|
||||
background: 'var(--bg3)',
|
||||
border: '1px solid var(--bd)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--bd) transparent',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
data-active={i === value}
|
||||
onClick={() => { onChange(i); setOpen(false) }}
|
||||
className="w-full text-center font-mono cursor-pointer"
|
||||
style={{
|
||||
padding: '4px 0',
|
||||
fontSize: 9,
|
||||
color: i === value ? 'var(--cyan)' : 'var(--t2)',
|
||||
background: i === value ? 'rgba(6,182,212,0.15)' : 'transparent',
|
||||
fontWeight: i === value ? 700 : 400,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{String(i).padStart(2, '0')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 도분초 좌표 입력 컴포넌트 ──────────────────────────
|
||||
function DmsCoordInput({
|
||||
label,
|
||||
isLatitude,
|
||||
decimal,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
isLatitude: boolean
|
||||
decimal: number
|
||||
onChange: (val: number) => void
|
||||
}) {
|
||||
const abs = Math.abs(decimal)
|
||||
const d = Math.floor(abs)
|
||||
const mDec = (abs - d) * 60
|
||||
const m = Math.floor(mDec)
|
||||
const s = parseFloat(((mDec - m) * 60).toFixed(2))
|
||||
const dir = isLatitude ? (decimal >= 0 ? 'N' : 'S') : (decimal >= 0 ? 'E' : 'W')
|
||||
|
||||
const update = (deg: number, min: number, sec: number, direction: string) => {
|
||||
let val = deg + min / 60 + sec / 3600
|
||||
if (direction === 'S' || direction === 'W') val = -val
|
||||
onChange(val)
|
||||
}
|
||||
|
||||
const fieldStyle = { padding: '5px 2px', fontSize: 10, minWidth: 0 }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[8px] text-text-3 font-korean">{label}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<select
|
||||
className="prd-i text-center"
|
||||
value={dir}
|
||||
onChange={(e) => update(d, m, s, e.target.value)}
|
||||
style={{ width: 32, padding: '5px 1px', fontSize: 10, appearance: 'none', WebkitAppearance: 'none', backgroundImage: 'none' }}
|
||||
>
|
||||
{isLatitude ? (
|
||||
<><option value="N">N</option><option value="S">S</option></>
|
||||
) : (
|
||||
<><option value="E">E</option><option value="W">W</option></>
|
||||
)}
|
||||
</select>
|
||||
<input className="prd-i text-center flex-1" type="number" min={0} max={isLatitude ? 90 : 180}
|
||||
value={d} onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)} style={fieldStyle} />
|
||||
<span className="text-[9px] text-text-3">°</span>
|
||||
<input className="prd-i text-center flex-1" type="number" min={0} max={59}
|
||||
value={m} onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)} style={fieldStyle} />
|
||||
<span className="text-[9px] text-text-3">'</span>
|
||||
<input className="prd-i text-center flex-1" type="number" min={0} max={59.99} step={0.01}
|
||||
value={s} onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)} style={fieldStyle} />
|
||||
<span className="text-[9px] text-text-3">"</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PredictionInputSection
|
||||
|
||||
@ -1,27 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import type { PredictionDetail } from '../services/predictionApi'
|
||||
import { analyzeSpillPolygon } from '@common/utils/geo'
|
||||
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
||||
import type { DisplayControls } from './OilSpillView'
|
||||
|
||||
interface AnalysisResult {
|
||||
area: number
|
||||
particleCount: number
|
||||
particlePercent: number
|
||||
sensitiveCount: number
|
||||
}
|
||||
|
||||
|
||||
interface RightPanelProps {
|
||||
onOpenBacktrack?: () => void
|
||||
onOpenRecalc?: () => void
|
||||
onOpenReport?: () => void
|
||||
detail?: PredictionDetail | null
|
||||
oilTrajectory?: Array<{ lat: number; lon: number; time: number }>
|
||||
summary?: SimulationSummary | null
|
||||
displayControls?: DisplayControls
|
||||
onDisplayControlsChange?: (controls: DisplayControls) => void
|
||||
windHydrModel?: string
|
||||
windHydrModelOptions?: string[]
|
||||
onWindHydrModelChange?: (model: string) => void
|
||||
analysisTab?: 'polygon' | 'circle'
|
||||
onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void
|
||||
drawAnalysisMode?: 'polygon' | null
|
||||
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
|
||||
circleRadiusNm?: number
|
||||
onCircleRadiusChange?: (nm: number) => void
|
||||
analysisResult?: AnalysisResult | null
|
||||
incidentCoord?: { lat: number; lon: number } | null
|
||||
onStartPolygonDraw?: () => void
|
||||
onRunPolygonAnalysis?: () => void
|
||||
onRunCircleAnalysis?: () => void
|
||||
onCancelAnalysis?: () => void
|
||||
onClearAnalysis?: () => void
|
||||
}
|
||||
|
||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, oilTrajectory = [] }: RightPanelProps) {
|
||||
export function RightPanel({
|
||||
onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary,
|
||||
displayControls, onDisplayControlsChange,
|
||||
windHydrModel, windHydrModelOptions = [], onWindHydrModelChange,
|
||||
analysisTab = 'polygon', onSwitchAnalysisTab,
|
||||
drawAnalysisMode, analysisPolygonPoints = [],
|
||||
circleRadiusNm = 5, onCircleRadiusChange,
|
||||
analysisResult,
|
||||
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
|
||||
onCancelAnalysis, onClearAnalysis,
|
||||
}: RightPanelProps) {
|
||||
const vessel = detail?.vessels?.[0]
|
||||
const vessel2 = detail?.vessels?.[1]
|
||||
const spill = detail?.spill
|
||||
const insurance = vessel?.insuranceData as Array<{ type: string; insurer: string; value: string; currency: string }> | null
|
||||
const [shipExpanded, setShipExpanded] = useState(false)
|
||||
const [insuranceExpanded, setInsuranceExpanded] = useState(false)
|
||||
const [polygonResult, setPolygonResult] = useState<{ areaKm2: number; perimeterKm: number; particleCount: number; hullPoints: number } | null>(null)
|
||||
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
||||
const [circleRadiusNm, setCircleRadiusNm] = useState('5')
|
||||
const [circleResult, setCircleResult] = useState<{ areaKm2: number; areaNm2: number; circumferenceKm: number; radiusNm: number } | null>(null)
|
||||
const NM_PRESETS = [1, 3, 5, 10, 15, 20, 30, 50]
|
||||
|
||||
return (
|
||||
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
|
||||
@ -38,167 +69,152 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
|
||||
{/* 표시 정보 제어 */}
|
||||
<Section title="표시 정보 제어">
|
||||
<div className="grid grid-cols-2 gap-x-2.5 gap-y-1">
|
||||
<CheckboxLabel checked>유향/유속</CheckboxLabel>
|
||||
<CheckboxLabel checked>풍향/풍속</CheckboxLabel>
|
||||
<CheckboxLabel>해안부착</CheckboxLabel>
|
||||
<CheckboxLabel>민감자원</CheckboxLabel>
|
||||
<CheckboxLabel>시간 표시</CheckboxLabel>
|
||||
<CheckboxLabel>날짜시간</CheckboxLabel>
|
||||
<ControlledCheckbox
|
||||
checked={displayControls?.showCurrent ?? true}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showCurrent: v })}
|
||||
>유향/유속</ControlledCheckbox>
|
||||
<ControlledCheckbox
|
||||
checked={displayControls?.showWind ?? true}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showWind: v })}
|
||||
>풍향/풍속</ControlledCheckbox>
|
||||
<ControlledCheckbox
|
||||
checked={displayControls?.showBeached ?? false}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
|
||||
>해안부착</ControlledCheckbox>
|
||||
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
|
||||
민감자원
|
||||
</ControlledCheckbox>
|
||||
<ControlledCheckbox
|
||||
checked={displayControls?.showTimeLabel ?? false}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })}
|
||||
>시간 표시</ControlledCheckbox>
|
||||
</div>
|
||||
{windHydrModelOptions.length > 1 && (
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap">데이터 모델</span>
|
||||
<select
|
||||
value={windHydrModel}
|
||||
onChange={e => onWindHydrModelChange?.(e.target.value)}
|
||||
className="flex-1 text-[9px] bg-bg-3 border border-border rounded px-1 py-0.5 text-text-2 font-korean"
|
||||
>
|
||||
{windHydrModelOptions.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* 오염분석 */}
|
||||
<Section title="오염분석">
|
||||
{/* 탭 버튼 */}
|
||||
<div className="flex gap-1.5 mb-2.5">
|
||||
<button
|
||||
onClick={() => setAnalysisTab('polygon')}
|
||||
className="flex-1 py-1.5 px-2 rounded text-[10px] font-semibold font-korean cursor-pointer transition-colors"
|
||||
style={analysisTab === 'polygon'
|
||||
? { background: 'rgba(6,182,212,0.12)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg0)', border: '1px solid var(--bd)', color: 'var(--t3)' }
|
||||
}
|
||||
>다각형 분석</button>
|
||||
<button
|
||||
onClick={() => setAnalysisTab('circle')}
|
||||
className="flex-1 py-1.5 px-2 rounded text-[10px] font-semibold font-korean cursor-pointer transition-colors"
|
||||
style={analysisTab === 'circle'
|
||||
? { background: 'rgba(6,182,212,0.12)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg0)', border: '1px solid var(--bd)', color: 'var(--t3)' }
|
||||
}
|
||||
>원 분석</button>
|
||||
{/* 탭 전환 */}
|
||||
<div className="flex gap-[3px] mb-2">
|
||||
{(['polygon', 'circle'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { onSwitchAnalysisTab?.(tab); onClearAnalysis?.() }}
|
||||
className={`flex-1 py-1.5 px-1 rounded text-[9px] font-semibold font-korean border transition-colors ${
|
||||
analysisTab === tab
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)] text-primary-cyan'
|
||||
: 'border-border bg-bg-3 text-text-3 hover:text-text-2'
|
||||
}`}
|
||||
>
|
||||
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 다각형 분석 탭 ── */}
|
||||
{/* 다각형 패널 */}
|
||||
{analysisTab === 'polygon' && (
|
||||
<>
|
||||
<p className="text-[9px] text-text-3 font-korean leading-relaxed mb-2">
|
||||
<div>
|
||||
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||
지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (oilTrajectory.length < 3) {
|
||||
alert('확산 예측을 먼저 실행하세요.')
|
||||
return
|
||||
}
|
||||
const result = analyzeSpillPolygon(oilTrajectory)
|
||||
setPolygonResult({ areaKm2: result.areaKm2, perimeterKm: result.perimeterKm, particleCount: result.particleCount, hullPoints: result.hull.length })
|
||||
}}
|
||||
className="w-full py-2 px-3 rounded text-[10px] font-bold font-korean cursor-pointer"
|
||||
style={{ background: 'linear-gradient(to right, #a855f7, var(--cyan))', color: '#fff' }}
|
||||
>
|
||||
📐 다각형 분석수행
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 원 분석 탭 ── */}
|
||||
{analysisTab === 'circle' && (
|
||||
<>
|
||||
<p className="text-[9px] text-text-3 font-korean leading-relaxed mb-2">
|
||||
반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.
|
||||
</p>
|
||||
|
||||
{/* 반경 선택 (NM) */}
|
||||
<div className="text-[9px] font-bold text-text-2 font-korean mb-1.5">반경 선택 (NM)</div>
|
||||
<div className="grid grid-cols-6 gap-1 mb-2">
|
||||
{NM_PRESETS.map(nm => (
|
||||
<button
|
||||
key={nm}
|
||||
onClick={() => setCircleRadiusNm(String(nm))}
|
||||
className="py-1.5 rounded text-[10px] font-bold font-mono cursor-pointer transition-colors"
|
||||
style={parseFloat(circleRadiusNm) === nm
|
||||
? { background: 'rgba(6,182,212,0.2)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg0)', border: '1px solid var(--bd)', color: 'var(--t3)' }
|
||||
}
|
||||
>{nm}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 직접 입력 + 분석 실행 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] text-text-3 font-korean shrink-0">직접 입력</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
value={circleRadiusNm}
|
||||
onChange={e => setCircleRadiusNm(e.target.value)}
|
||||
className="w-[60px] px-2 py-1.5 bg-bg-3 border border-border rounded text-[10px] font-mono text-text-1 text-center outline-none focus:border-[var(--cyan)] transition-colors"
|
||||
/>
|
||||
<span className="text-[9px] text-text-3 font-korean shrink-0">NM</span>
|
||||
{!drawAnalysisMode && !analysisResult && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const nm = parseFloat(circleRadiusNm)
|
||||
if (isNaN(nm) || nm <= 0) {
|
||||
alert('반경을 올바르게 입력하세요.')
|
||||
return
|
||||
}
|
||||
const km = nm * 1.852
|
||||
const areaKm2 = Math.PI * km * km
|
||||
const areaNm2 = Math.PI * nm * nm
|
||||
const circumferenceKm = 2 * Math.PI * km
|
||||
setCircleResult({ areaKm2, areaNm2, circumferenceKm, radiusNm: nm })
|
||||
}}
|
||||
disabled={!circleRadiusNm || parseFloat(circleRadiusNm) <= 0}
|
||||
className="ml-auto px-3 py-1.5 rounded text-[10px] font-bold font-korean cursor-pointer shrink-0 transition-colors"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.15)',
|
||||
border: '1px solid var(--cyan)',
|
||||
color: 'var(--cyan)',
|
||||
}}
|
||||
>분석 실행</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 원 분석 결과 */}
|
||||
{analysisTab === 'circle' && circleResult && (
|
||||
<div className="flex flex-col gap-1.5 mt-2.5">
|
||||
<div className="text-[9px] font-bold text-primary-cyan font-korean">⭕ 원 분석 결과 (반경 {circleResult.radiusNm} NM)</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
|
||||
<div className="text-sm font-extrabold font-mono text-primary-cyan">{circleResult.areaNm2.toFixed(1)}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">면적 (NM²)</div>
|
||||
onClick={onStartPolygonDraw}
|
||||
className="w-full py-2 rounded text-[10px] font-bold font-korean text-white mb-0 transition-opacity hover:opacity-90"
|
||||
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||
>
|
||||
📐 다각형 분석수행
|
||||
</button>
|
||||
)}
|
||||
{drawAnalysisMode === 'polygon' && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[9px] text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
|
||||
지도를 클릭하여 꼭짓점을 추가하세요<br />
|
||||
<span className="text-text-3">현재 {analysisPolygonPoints.length}개 선택됨</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={onRunPolygonAnalysis}
|
||||
disabled={analysisPolygonPoints.length < 3}
|
||||
className="flex-1 py-1.5 rounded text-[10px] font-bold font-korean text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
||||
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||
>
|
||||
분석 실행
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelAnalysis}
|
||||
className="py-1.5 px-2 rounded text-[10px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
|
||||
<div className="text-sm font-extrabold font-mono text-status-orange">{circleResult.areaKm2.toFixed(1)}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">면적 (km²)</div>
|
||||
</div>
|
||||
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
|
||||
<div className="text-sm font-extrabold font-mono text-text-1">{circleResult.circumferenceKm.toFixed(1)}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">원 둘레 (km)</div>
|
||||
</div>
|
||||
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
|
||||
<div className="text-sm font-extrabold font-mono text-text-1">{(circleResult.radiusNm * 1.852).toFixed(1)}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">반경 (km)</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult && !drawAnalysisMode && (
|
||||
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} onRerun={onStartPolygonDraw} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 다각형 분석 결과 */}
|
||||
{analysisTab === 'polygon' && polygonResult && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-[9px] font-bold text-purple-400 font-korean mb-0.5">📐 Convex Hull 다각형 분석 결과</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
|
||||
<div className="text-sm font-extrabold font-mono text-purple-400">{polygonResult.areaKm2.toFixed(2)}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">오염 면적 (km²)</div>
|
||||
</div>
|
||||
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
|
||||
<div className="text-sm font-extrabold font-mono text-primary-cyan">{polygonResult.perimeterKm.toFixed(1)}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">외곽 둘레 (km)</div>
|
||||
</div>
|
||||
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
|
||||
<div className="text-sm font-extrabold font-mono text-status-orange">{polygonResult.particleCount.toLocaleString()}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">분석 입자 수</div>
|
||||
</div>
|
||||
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
|
||||
<div className="text-sm font-extrabold font-mono text-text-1">{polygonResult.hullPoints}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean">외곽 꼭짓점</div>
|
||||
</div>
|
||||
{/* 원 분석 패널 */}
|
||||
{analysisTab === 'circle' && (
|
||||
<div>
|
||||
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||
반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.
|
||||
</p>
|
||||
<div className="text-[9px] font-semibold text-text-2 font-korean mb-1.5">반경 선택 (NM)</div>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => (
|
||||
<button
|
||||
key={nm}
|
||||
onClick={() => onCircleRadiusChange?.(nm)}
|
||||
className={`w-8 h-7 rounded text-[10px] font-semibold font-mono border transition-all ${
|
||||
circleRadiusNm === nm
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.1)] text-primary-cyan'
|
||||
: 'border-border bg-bg-0 text-text-3 hover:text-text-2'
|
||||
}`}
|
||||
>
|
||||
{nm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mb-2.5">
|
||||
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap">직접 입력</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={circleRadiusNm}
|
||||
onChange={(e) => onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)}
|
||||
className="w-14 text-center py-1 px-1 bg-bg-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan"
|
||||
style={{ colorScheme: 'dark' }}
|
||||
/>
|
||||
<span className="text-[9px] text-text-3 font-korean">NM</span>
|
||||
<button
|
||||
onClick={onRunCircleAnalysis}
|
||||
className="ml-auto py-1 px-3 rounded text-[9px] font-bold font-korean text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}
|
||||
>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
{analysisResult && (
|
||||
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} radiusNm={circleRadiusNm} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
@ -207,11 +223,11 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
|
||||
<Section title="오염 종합 상황" badge="위험" badgeColor="red">
|
||||
<div className="grid grid-cols-2 gap-0.5 text-[9px]">
|
||||
<StatBox label="유출량" value={spill?.volume != null ? spill.volume.toFixed(2) : '—'} unit={spill?.unit || 'kl'} color="var(--t1)" />
|
||||
<StatBox label="풍화량" value="0.43" unit="kl" color="var(--orange)" />
|
||||
<StatBox label="해상잔존" value="9.57" unit="kl" color="var(--blue)" />
|
||||
<StatBox label="연안부착" value="0.00" unit="kl" color="var(--red)" />
|
||||
<StatBox label="풍화량" value={summary ? summary.weatheredVolume.toFixed(2) : '—'} unit="m³" color="var(--orange)" />
|
||||
<StatBox label="해상잔존" value={summary ? summary.remainingVolume.toFixed(2) : '—'} unit="m³" color="var(--blue)" />
|
||||
<StatBox label="연안부착" value={summary ? summary.beachedVolume.toFixed(2) : '—'} unit="m³" color="var(--red)" />
|
||||
<div className="col-span-2">
|
||||
<StatBox label="오염해역면적" value="8.56" unit="㎢" color="var(--cyan)" />
|
||||
<StatBox label="오염해역면적" value={summary ? summary.pollutionArea.toFixed(2) : '—'} unit="km²" color="var(--cyan)" />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
@ -383,17 +399,33 @@ function Section({
|
||||
)
|
||||
}
|
||||
|
||||
function CheckboxLabel({ checked, children }: { checked?: boolean; children: string }) {
|
||||
function ControlledCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
children: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-text-2 font-korean cursor-pointer">
|
||||
<label
|
||||
className={`flex items-center gap-1.5 text-[10px] font-korean cursor-pointer ${
|
||||
disabled ? 'text-text-3 cursor-not-allowed opacity-40' : 'text-text-2'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={checked}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="w-[13px] h-[13px] accent-[var(--cyan)]"
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({
|
||||
@ -580,3 +612,78 @@ function InsuranceCard({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PollResult({
|
||||
result,
|
||||
summary,
|
||||
onClear,
|
||||
onRerun,
|
||||
radiusNm,
|
||||
}: {
|
||||
result: AnalysisResult
|
||||
summary?: SimulationSummary | null
|
||||
onClear?: () => void
|
||||
onRerun?: () => void
|
||||
radiusNm?: number
|
||||
}) {
|
||||
const pollutedArea = (result.area * result.particlePercent / 100).toFixed(2)
|
||||
return (
|
||||
<div className="mt-1 p-2.5 bg-bg-0 border border-[rgba(168,85,247,0.2)] rounded-md" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, var(--purple), var(--cyan))' }} />
|
||||
{radiusNm && (
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-[10px] font-semibold text-text-1 font-korean">분석 결과</span>
|
||||
<span className="text-[9px] font-semibold text-primary-cyan font-mono">반경 {radiusNm} NM</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-1 mb-2">
|
||||
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--red)' }}>{result.area.toFixed(2)}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean mt-0.5">분석면적(km²)</div>
|
||||
</div>
|
||||
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--orange)' }}>{result.particlePercent}%</div>
|
||||
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염비율</div>
|
||||
</div>
|
||||
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--cyan)' }}>{pollutedArea}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염면적(km²)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-[9px] font-korean">
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">해상잔존량</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
|
||||
</div>
|
||||
)}
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">연안부착량</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">민감자원 포함</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--orange)' }}>{result.sensitiveCount}개소</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
{onRerun && (
|
||||
<button
|
||||
onClick={onRerun}
|
||||
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-[rgba(168,85,247,0.3)] text-purple-400 hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
||||
>
|
||||
재분석
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
110
frontend/src/tabs/prediction/components/SimulationErrorModal.tsx
Normal file
110
frontend/src/tabs/prediction/components/SimulationErrorModal.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
interface SimulationErrorModalProps {
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(10, 14, 26, 0.75)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 360,
|
||||
background: 'var(--bg1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||
borderRadius: 'var(--rM)',
|
||||
padding: '28px 24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* 아이콘 + 제목 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(239, 68, 68, 0.12)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
|
||||
fill="rgb(239, 68, 68)"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'var(--t1)', fontSize: 14, fontWeight: 600 }}>
|
||||
확산 예측 실패
|
||||
</div>
|
||||
<div style={{ color: 'var(--t3)', fontSize: 12, marginTop: 2 }}>
|
||||
시뮬레이션 실행 중 오류가 발생했습니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.06)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: 'var(--rS)',
|
||||
padding: '10px 14px',
|
||||
color: 'rgb(252, 165, 165)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
|
||||
{/* 확인 버튼 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
marginTop: 4,
|
||||
padding: '8px 0',
|
||||
background: 'rgba(239, 68, 68, 0.15)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||
borderRadius: 'var(--rS)',
|
||||
color: 'rgb(252, 165, 165)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.15)';
|
||||
}}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimulationErrorModal;
|
||||
@ -0,0 +1,123 @@
|
||||
interface SimulationLoadingOverlayProps {
|
||||
status: 'PENDING' | 'RUNNING';
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
const SimulationLoadingOverlay = ({ status, progress }: SimulationLoadingOverlayProps) => {
|
||||
const displayProgress = progress ?? 0;
|
||||
const statusText = status === 'PENDING' ? '모델 초기화 중...' : '입자 추적 계산 중...';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(10, 14, 26, 0.75)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 320,
|
||||
background: 'var(--bg1)',
|
||||
border: '1px solid var(--bd)',
|
||||
borderRadius: 'var(--rM)',
|
||||
padding: '28px 24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* 아이콘 + 제목 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(6, 182, 212, 0.12)',
|
||||
border: '1px solid rgba(6, 182, 212, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14l-4-4 1.41-1.41L11 13.17l6.59-6.59L19 8l-8 8z"
|
||||
fill="var(--cyan)"
|
||||
opacity="0.8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'var(--t1)', fontSize: 14, fontWeight: 600 }}>
|
||||
확산 예측 분석 중
|
||||
</div>
|
||||
<div style={{ color: 'var(--t3)', fontSize: 12, marginTop: 2 }}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 바 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${displayProgress}%`,
|
||||
background: 'linear-gradient(90deg, var(--cyan), var(--blue))',
|
||||
borderRadius: 999,
|
||||
transition: 'width 0.6s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--t3)', fontSize: 11 }}>
|
||||
{status === 'PENDING' ? '대기 중' : '분석 진행 중'}
|
||||
</span>
|
||||
<span style={{ color: 'var(--cyan)', fontSize: 12, fontWeight: 600 }}>
|
||||
{status === 'PENDING' ? '—' : `${displayProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--t3)',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
borderTop: '1px solid var(--bdL)',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
OpenDrift 모델로 유류 확산을 시뮬레이션하고 있습니다.
|
||||
<br />
|
||||
완료되면 자동으로 결과가 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimulationLoadingOverlay;
|
||||
@ -1,18 +1,25 @@
|
||||
import type { PredictionModel } from './OilSpillView'
|
||||
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
||||
import type { Analysis } from './AnalysisListTable'
|
||||
import type { ImageAnalyzeResult } from '../services/predictionApi'
|
||||
|
||||
export interface LeftPanelProps {
|
||||
selectedAnalysis?: Analysis | null
|
||||
enabledLayers: Set<string>
|
||||
onToggleLayer: (layerId: string, enabled: boolean) => void
|
||||
incidentCoord: { lon: number; lat: number }
|
||||
accidentTime: string
|
||||
onAccidentTimeChange: (time: string) => void
|
||||
incidentCoord: { lon: number; lat: number } | null
|
||||
onCoordChange: (coord: { lon: number; lat: number }) => void
|
||||
isSelectingLocation: boolean
|
||||
onMapSelectClick: () => void
|
||||
onRunSimulation: () => void
|
||||
isRunningSimulation: boolean
|
||||
selectedModels: Set<PredictionModel>
|
||||
onModelsChange: (models: Set<PredictionModel>) => void
|
||||
visibleModels?: Set<PredictionModel>
|
||||
onVisibleModelsChange?: (models: Set<PredictionModel>) => void
|
||||
hasResults?: boolean
|
||||
predictionTime: number
|
||||
onPredictionTimeChange: (time: number) => void
|
||||
spillType: string
|
||||
@ -21,6 +28,10 @@ export interface LeftPanelProps {
|
||||
onOilTypeChange: (type: string) => void
|
||||
spillAmount: number
|
||||
onSpillAmountChange: (amount: number) => void
|
||||
incidentName: string
|
||||
onIncidentNameChange: (name: string) => void
|
||||
spillUnit: string
|
||||
onSpillUnitChange: (unit: string) => void
|
||||
// 오일펜스 배치 관련
|
||||
boomLines: BoomLine[]
|
||||
onBoomLinesChange: (lines: BoomLine[]) => void
|
||||
@ -38,6 +49,8 @@ export interface LeftPanelProps {
|
||||
onLayerOpacityChange: (val: number) => void
|
||||
layerBrightness: number
|
||||
onLayerBrightnessChange: (val: number) => void
|
||||
// 이미지 분석 결과 콜백
|
||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
|
||||
}
|
||||
|
||||
export interface ExpandedSections {
|
||||
|
||||
@ -18,6 +18,7 @@ export interface PredictionAnalysis {
|
||||
backtrackStatus: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
acdntSttsCd: string;
|
||||
}
|
||||
|
||||
export interface PredictionDetail {
|
||||
@ -115,3 +116,128 @@ export const createBacktrack = async (input: {
|
||||
const response = await api.post<{ backtrackSn: number }>('/prediction/backtrack', input);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 확산 예측 시뮬레이션 (OpenDrift 연동)
|
||||
// ============================================================
|
||||
|
||||
export interface SimulationRunResponse {
|
||||
success: boolean;
|
||||
execSn: number; // 하위 호환 유지 (첫 번째 모델의 execSn)
|
||||
execSns: Array<{ model: string; execSn: number }>;
|
||||
acdntSn: number | null;
|
||||
status: 'RUNNING';
|
||||
}
|
||||
|
||||
export interface WindPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
wind_speed: number;
|
||||
wind_direction: number;
|
||||
}
|
||||
|
||||
export interface HydrGrid {
|
||||
lonInterval: number[];
|
||||
boundLonLat: { top: number; bottom: number; left: number; right: number };
|
||||
rows: number;
|
||||
cols: number;
|
||||
latInterval: number[];
|
||||
}
|
||||
|
||||
export interface HydrDataStep {
|
||||
value: [number[][], number[][]]; // [u_2d, v_2d]
|
||||
grid: HydrGrid;
|
||||
}
|
||||
|
||||
export interface CenterPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
time: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface OilParticle {
|
||||
lat: number;
|
||||
lon: number;
|
||||
time: number;
|
||||
particle?: number;
|
||||
stranded?: 0 | 1;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SimulationSummary {
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
pollutionArea: number;
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
}
|
||||
|
||||
export interface SimulationStatusResponse {
|
||||
status: 'PENDING' | 'RUNNING' | 'DONE' | 'ERROR';
|
||||
progress?: number;
|
||||
trajectory?: OilParticle[];
|
||||
summary?: SimulationSummary;
|
||||
centerPoints?: CenterPoint[];
|
||||
windData?: WindPoint[][];
|
||||
hydrData?: (HydrDataStep | null)[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RunModelSyncResult {
|
||||
model: string;
|
||||
execSn: number;
|
||||
status: 'DONE' | 'ERROR';
|
||||
trajectory?: OilParticle[];
|
||||
summary?: SimulationSummary;
|
||||
centerPoints?: CenterPoint[];
|
||||
windData?: WindPoint[][];
|
||||
hydrData?: (HydrDataStep | null)[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RunModelSyncResponse {
|
||||
success: boolean;
|
||||
acdntSn: number | null;
|
||||
execSns: Array<{ model: string; execSn: number }>;
|
||||
results: RunModelSyncResult[];
|
||||
}
|
||||
|
||||
export interface TrajectoryResponse {
|
||||
trajectory: OilParticle[] | null;
|
||||
summary: SimulationSummary | null;
|
||||
centerPoints?: CenterPoint[];
|
||||
windDataByModel?: Record<string, WindPoint[][]>;
|
||||
hydrDataByModel?: Record<string, (HydrDataStep | null)[]>;
|
||||
summaryByModel?: Record<string, SimulationSummary>;
|
||||
}
|
||||
|
||||
export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<TrajectoryResponse> => {
|
||||
const response = await api.get<TrajectoryResponse>(`/prediction/analyses/${acdntSn}/trajectory`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 이미지 업로드 분석
|
||||
// ============================================================
|
||||
|
||||
export interface ImageAnalyzeResult {
|
||||
acdntSn: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
oilType: string;
|
||||
area: number;
|
||||
volume: number;
|
||||
fileId: string;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export const analyzeImage = async (file: File): Promise<ImageAnalyzeResult> => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await api.post<ImageAnalyzeResult>('/prediction/image-analyze', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 330_000,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@ -38,6 +38,8 @@ export interface OilSpillReportData {
|
||||
etcEquipment: string
|
||||
recovery: { shipName: string; period: string }[]
|
||||
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
|
||||
capturedMapImage?: string;
|
||||
hasMapCapture?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
|
||||
107
frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx
Normal file
107
frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { MapView } from '@common/components/map/MapView';
|
||||
import type { OilReportPayload } from '@common/hooks/useSubMenu';
|
||||
|
||||
interface OilSpreadMapPanelProps {
|
||||
mapData: OilReportPayload['mapData'];
|
||||
capturedImage: string | null;
|
||||
onCapture: (dataUrl: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSpreadMapPanelProps) => {
|
||||
const captureRef = useRef<(() => Promise<string | null>) | null>(null);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
const handleCapture = async () => {
|
||||
if (!captureRef.current) return;
|
||||
setIsCapturing(true);
|
||||
const dataUrl = await captureRef.current();
|
||||
setIsCapturing(false);
|
||||
if (dataUrl) {
|
||||
onCapture(dataUrl);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mapData) {
|
||||
return (
|
||||
<div className="w-full h-[280px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
||||
확산 예측 데이터가 없습니다. 예측 탭에서 시뮬레이션을 실행 후 보고서를 생성하세요.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */}
|
||||
<div className="relative w-full rounded-lg border border-border overflow-hidden" style={{ height: '620px' }}>
|
||||
<MapView
|
||||
center={mapData.center}
|
||||
zoom={mapData.zoom}
|
||||
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
|
||||
oilTrajectory={mapData.trajectory}
|
||||
externalCurrentTime={mapData.currentStep}
|
||||
centerPoints={mapData.centerPoints}
|
||||
showBeached={true}
|
||||
showTimeLabel={true}
|
||||
simulationStartTime={mapData.simulationStartTime || undefined}
|
||||
mapCaptureRef={captureRef}
|
||||
showOverlays={false}
|
||||
lightMode
|
||||
/>
|
||||
|
||||
{/* 캡처 이미지 오버레이 — 우측 상단 */}
|
||||
{capturedImage && (
|
||||
<div className="absolute top-3 right-3 z-10" style={{ width: '220px' }}>
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
<img src={capturedImage} alt="확산예측 지도 캡처" className="w-full block" />
|
||||
<div
|
||||
className="flex items-center justify-between px-2.5 py-1.5"
|
||||
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
|
||||
>
|
||||
<span className="text-[10px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
|
||||
📷 캡처 완료
|
||||
</span>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-[10px] font-korean hover:text-text-1 transition-colors"
|
||||
style={{ color: 'rgba(148,163,184,0.8)' }}
|
||||
>
|
||||
다시 선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 안내 + 캡처 버튼 */}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-[10px] text-text-3 font-korean">
|
||||
{capturedImage
|
||||
? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.'
|
||||
: '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCapture}
|
||||
disabled={isCapturing || !!capturedImage}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
|
||||
style={{
|
||||
background: capturedImage ? 'rgba(6,182,212,0.06)' : 'rgba(6,182,212,0.12)',
|
||||
border: '1px solid rgba(6,182,212,0.4)',
|
||||
color: capturedImage ? 'rgba(6,182,212,0.5)' : '#06b6d4',
|
||||
opacity: isCapturing ? 0.6 : 1,
|
||||
cursor: capturedImage ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{capturedImage ? '✓ 캡처됨' : '📷 이 범위로 캡처'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OilSpreadMapPanel;
|
||||
@ -2,13 +2,11 @@ import { useState, useEffect } from 'react';
|
||||
import {
|
||||
createEmptyReport,
|
||||
} from './OilSpillReportTemplate';
|
||||
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload } from '@common/hooks/useSubMenu';
|
||||
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
|
||||
import OilSpreadMapPanel from './OilSpreadMapPanel';
|
||||
import { saveReport } from '../services/reportsApi';
|
||||
import {
|
||||
CATEGORIES,
|
||||
sampleOilData,
|
||||
sampleHnsData,
|
||||
sampleRescueData,
|
||||
type ReportCategory,
|
||||
type ReportSection,
|
||||
} from './reportTypes';
|
||||
@ -32,6 +30,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
|
||||
// HNS 실 데이터 (없으면 sampleHnsData fallback)
|
||||
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
|
||||
// OIL 실 데이터 (없으면 sampleOilData fallback)
|
||||
const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(null)
|
||||
// 확산예측 지도 캡처 이미지
|
||||
const [oilMapCaptured, setOilMapCaptured] = useState<string | null>(null)
|
||||
|
||||
// 외부에서 카테고리 힌트가 변경되면 반영
|
||||
useEffect(() => {
|
||||
@ -44,6 +46,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
// HNS 데이터 소비
|
||||
const payload = consumeHnsReportPayload()
|
||||
if (payload) setHnsPayload(payload)
|
||||
// OIL 예측 데이터 소비
|
||||
const oilData = consumeOilReportPayload()
|
||||
if (oilData) setOilPayload(oilData)
|
||||
}, [])
|
||||
|
||||
const cat = CATEGORIES[activeCat]
|
||||
@ -65,8 +70,22 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
report.status = '완료'
|
||||
report.author = '시스템 자동생성'
|
||||
if (activeCat === 0) {
|
||||
report.incident.pollutant = sampleOilData.pollution.oilType
|
||||
report.incident.spillAmount = sampleOilData.pollution.spillAmount
|
||||
if (oilPayload) {
|
||||
report.incident.name = oilPayload.incident.name;
|
||||
report.incident.occurTime = oilPayload.incident.occurTime;
|
||||
report.incident.location = oilPayload.incident.location;
|
||||
report.incident.lat = String(oilPayload.incident.lat ?? '');
|
||||
report.incident.lon = String(oilPayload.incident.lon ?? '');
|
||||
report.incident.shipName = oilPayload.incident.shipName;
|
||||
report.incident.pollutant = oilPayload.pollution.oilType;
|
||||
report.incident.spillAmount = oilPayload.pollution.spillAmount;
|
||||
} else {
|
||||
report.incident.pollutant = '';
|
||||
report.incident.spillAmount = '';
|
||||
}
|
||||
}
|
||||
if (activeCat === 0 && oilMapCaptured) {
|
||||
report.capturedMapImage = oilMapCaptured;
|
||||
}
|
||||
try {
|
||||
await saveReport(report)
|
||||
@ -80,7 +99,52 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
const handleDownload = () => {
|
||||
const secColor = cat.color === 'var(--cyan)' ? '#06b6d4' : cat.color === 'var(--orange)' ? '#f97316' : '#ef4444';
|
||||
const sectionHTML = activeSections.map(sec => {
|
||||
let content = `<p style="font-size:12px;color:#666;">${sec.desc}</p>`;
|
||||
let content = `<p style="font-size:12px;color:#999;">—</p>`;
|
||||
|
||||
// OIL 섹션에 실 데이터 삽입
|
||||
if (activeCat === 0) {
|
||||
if (sec.id === 'oil-spread') {
|
||||
const mapImg = oilMapCaptured
|
||||
? `<img src="${oilMapCaptured}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:8px;margin-bottom:12px;" />`
|
||||
: '<div style="height:60px;background:#f5f5f5;border:1px solid #ddd;border-radius:8px;display:flex;align-items:center;justify-content:center;color:#999;margin-bottom:12px;font-size:12px;">[확산예측 지도 미캡처]</div>';
|
||||
const spreadRows = oilPayload
|
||||
? [
|
||||
['KOSPS', oilPayload.spread.kosps],
|
||||
['OpenDrift', oilPayload.spread.openDrift],
|
||||
['POSEIDON', oilPayload.spread.poseidon],
|
||||
]
|
||||
: [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']];
|
||||
const tds = spreadRows.map(r =>
|
||||
`<td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>${r[0]}</b><br/>${r[1]}</td>`
|
||||
).join('');
|
||||
content = `${mapImg}<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr>${tds}</tr></table>`;
|
||||
}
|
||||
}
|
||||
if (activeCat === 0 && sec.id === 'oil-coastal') {
|
||||
if (oilPayload) {
|
||||
const coastLength = oilPayload.pollution.coastLength;
|
||||
const hasNoCoastal = !coastLength || coastLength === '—' || coastLength.startsWith('0.00');
|
||||
content = hasNoCoastal
|
||||
? `<p style="font-size:12px;">유출유의 해안 부착이 없습니다.</p>`
|
||||
: `<p style="font-size:12px;">최초 부착시간: <b>${oilPayload.coastal?.firstTime ?? '—'}</b> / 부착 해안길이: <b>${coastLength}</b></p>`;
|
||||
}
|
||||
}
|
||||
if (activeCat === 0 && oilPayload) {
|
||||
if (sec.id === 'oil-pollution') {
|
||||
const rows = [
|
||||
['유출량', oilPayload.pollution.spillAmount, '풍화량', oilPayload.pollution.weathered],
|
||||
['해상잔유량', oilPayload.pollution.seaRemain, '오염해역면적', oilPayload.pollution.pollutionArea],
|
||||
['연안부착량', oilPayload.pollution.coastAttach, '오염해안길이', oilPayload.pollution.coastLength],
|
||||
];
|
||||
const simBanner = !oilPayload.hasSimulation
|
||||
? '<p style="font-size:10px;color:#f97316;margin-bottom:8px;">시뮬레이션이 실행되지 않아 오염량은 입력값 기준으로 표시됩니다.</p>'
|
||||
: '';
|
||||
const trs = rows.map(r =>
|
||||
`<tr><td style="padding:6px 8px;border:1px solid #ddd;color:#888;">${r[0]}</td><td style="padding:6px 8px;border:1px solid #ddd;font-weight:bold;text-align:right;">${r[1]}</td><td style="padding:6px 8px;border:1px solid #ddd;color:#888;">${r[2]}</td><td style="padding:6px 8px;border:1px solid #ddd;font-weight:bold;text-align:right;">${r[3]}</td></tr>`
|
||||
).join('');
|
||||
content = `${simBanner}<table style="width:100%;border-collapse:collapse;font-size:12px;">${trs}</table>`;
|
||||
}
|
||||
}
|
||||
|
||||
// HNS 섹션에 실 데이터 삽입
|
||||
if (activeCat === 1 && hnsPayload) {
|
||||
@ -256,14 +320,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{/* ── 유출유 확산예측 섹션들 ── */}
|
||||
{sec.id === 'oil-spread' && (
|
||||
<>
|
||||
<div className="w-full h-[140px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
||||
[확산예측 지도 - 범위 조절 작업]
|
||||
</div>
|
||||
<OilSpreadMapPanel
|
||||
mapData={oilPayload?.mapData ?? null}
|
||||
capturedImage={oilMapCaptured}
|
||||
onCapture={(dataUrl) => setOilMapCaptured(dataUrl)}
|
||||
onReset={() => setOilMapCaptured(null)}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'KOSPS', value: sampleOilData.spread.kosps, color: '#06b6d4' },
|
||||
{ label: 'OpenDrift', value: sampleOilData.spread.openDrift, color: '#ef4444' },
|
||||
{ label: 'POSEIDON', value: sampleOilData.spread.poseidon, color: '#f97316' },
|
||||
{ label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' },
|
||||
{ label: 'OpenDrift', value: oilPayload?.spread.openDrift || '—', color: '#ef4444' },
|
||||
{ label: 'POSEIDON', value: oilPayload?.spread.poseidon || '—', color: '#f97316' },
|
||||
].map((m, i) => (
|
||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||
<p className="text-[10px] text-text-3 font-korean mb-1">{m.label}</p>
|
||||
@ -274,41 +341,61 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
</>
|
||||
)}
|
||||
{sec.id === 'oil-pollution' && (
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<colgroup><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /></colgroup>
|
||||
<tbody>
|
||||
{[
|
||||
['유출량', sampleOilData.pollution.spillAmount, '풍화량', sampleOilData.pollution.weathered],
|
||||
['해상잔유량', sampleOilData.pollution.seaRemain, '오염해역면적', sampleOilData.pollution.pollutionArea],
|
||||
['연안부착량', sampleOilData.pollution.coastAttach, '오염해안길이', sampleOilData.pollution.coastLength],
|
||||
].map((row, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[0]}</td>
|
||||
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[1]}</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[2]}</td>
|
||||
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[3]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{sec.id === 'oil-sensitive' && (
|
||||
<>
|
||||
<p className="text-[11px] text-text-3 font-korean mb-3">반경 10 NM 기준</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sampleOilData.sensitive.map((item, i) => (
|
||||
<span key={i} className="px-3 py-1.5 text-[11px] font-semibold rounded-md bg-bg-3 border border-border text-text-2 font-korean">{item.label}</span>
|
||||
))}
|
||||
</div>
|
||||
{oilPayload && !oilPayload.hasSimulation && (
|
||||
<div className="mb-3 px-3 py-2 rounded text-[10px] font-korean" style={{ background: 'rgba(249,115,22,0.08)', border: '1px solid rgba(249,115,22,0.3)', color: '#f97316' }}>
|
||||
시뮬레이션이 실행되지 않아 오염량은 입력값 기준으로 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<colgroup><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /></colgroup>
|
||||
<tbody>
|
||||
{[
|
||||
['유출량', oilPayload?.pollution.spillAmount || '—', '풍화량', oilPayload?.pollution.weathered || '—'],
|
||||
['해상잔유량', oilPayload?.pollution.seaRemain || '—', '오염해역면적', oilPayload?.pollution.pollutionArea || '—'],
|
||||
['연안부착량', oilPayload?.pollution.coastAttach || '—', '오염해안길이', oilPayload?.pollution.coastLength || '—'],
|
||||
].map((row, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[0]}</td>
|
||||
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[1]}</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[2]}</td>
|
||||
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[3]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
{sec.id === 'oil-coastal' && (
|
||||
<p className="text-[12px] text-text-2 font-korean">
|
||||
최초 부착시간: <span className="font-semibold text-text-1">{sampleOilData.coastal.firstTime}</span>
|
||||
{' / '}
|
||||
부착 해안길이: <span className="font-semibold text-text-1">{sampleOilData.coastal.coastLength}</span>
|
||||
{sec.id === 'oil-sensitive' && (
|
||||
<p className="text-[12px] text-text-3 font-korean italic">
|
||||
현재 민감자원 데이터가 없습니다.
|
||||
</p>
|
||||
)}
|
||||
{sec.id === 'oil-coastal' && (() => {
|
||||
if (!oilPayload) {
|
||||
return (
|
||||
<p className="text-[12px] text-text-3 font-korean italic">
|
||||
현재 해안 부착 데이터가 없습니다.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const coastLength = oilPayload.pollution.coastLength;
|
||||
const hasNoCoastal = !coastLength || coastLength === '—' || coastLength.startsWith('0.00');
|
||||
if (hasNoCoastal) {
|
||||
return (
|
||||
<p className="text-[12px] text-text-2 font-korean">
|
||||
시뮬레이션 결과 유출유의 <span className="font-semibold text-text-1">해안 부착이 없습니다</span>.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-[12px] text-text-2 font-korean">
|
||||
최초 부착시간: <span className="font-semibold text-text-1">{oilPayload.coastal?.firstTime ?? '—'}</span>
|
||||
{' / '}
|
||||
부착 해안길이: <span className="font-semibold text-text-1">{coastLength}</span>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
{sec.id === 'oil-defense' && (
|
||||
<div className="text-[12px] text-text-3 font-korean">
|
||||
<p className="mb-2">방제자원 배치 계획에 따른 전략을 수립합니다.</p>
|
||||
@ -318,10 +405,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
</div>
|
||||
)}
|
||||
{sec.id === 'oil-tide' && (
|
||||
<p className="text-[12px] text-text-2 font-korean">
|
||||
고조: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide1}</span>
|
||||
{' / '}저조: <span className="font-semibold text-text-1">{sampleOilData.tide.lowTide}</span>
|
||||
{' / '}고조: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide2}</span>
|
||||
<p className="text-[12px] text-text-3 font-korean italic">
|
||||
현재 조석·기상 데이터가 없습니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -342,7 +427,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: hnsPayload?.atm.model || 'ALOHA', value: hnsPayload?.atm.maxDistance || sampleHnsData.atm.aloha, color: '#f97316', desc: '최대 확산거리' },
|
||||
{ label: hnsPayload?.atm.model || 'ALOHA', value: hnsPayload?.atm.maxDistance || '—', color: '#f97316', desc: '최대 확산거리' },
|
||||
{ label: '최대 농도', value: hnsPayload?.maxConcentration || '—', color: '#ef4444', desc: '지상 1.5m 기준' },
|
||||
{ label: 'AEGL-1 면적', value: hnsPayload?.aeglAreas.aegl1 || '—', color: '#06b6d4', desc: '확산 영향 면적' },
|
||||
].map((m, i) => (
|
||||
@ -358,9 +443,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{sec.id === 'hns-hazard' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'AEGL-3 구역', value: hnsPayload?.hazard.aegl3 || sampleHnsData.hazard.erpg3, area: hnsPayload?.aeglAreas.aegl3, color: '#ef4444', desc: '생명 위협' },
|
||||
{ label: 'AEGL-2 구역', value: hnsPayload?.hazard.aegl2 || sampleHnsData.hazard.erpg2, area: hnsPayload?.aeglAreas.aegl2, color: '#f97316', desc: '건강 피해' },
|
||||
{ label: 'AEGL-1 구역', value: hnsPayload?.hazard.aegl1 || sampleHnsData.hazard.evacuation, area: hnsPayload?.aeglAreas.aegl1, color: '#eab308', desc: '불쾌감' },
|
||||
{ label: 'AEGL-3 구역', value: hnsPayload?.hazard.aegl3 || '—', area: hnsPayload?.aeglAreas.aegl3, color: '#ef4444', desc: '생명 위협' },
|
||||
{ label: 'AEGL-2 구역', value: hnsPayload?.hazard.aegl2 || '—', area: hnsPayload?.aeglAreas.aegl2, color: '#f97316', desc: '건강 피해' },
|
||||
{ label: 'AEGL-1 구역', value: hnsPayload?.hazard.aegl1 || '—', area: hnsPayload?.aeglAreas.aegl1, color: '#eab308', desc: '불쾌감' },
|
||||
].map((h, i) => (
|
||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||
<p className="text-[9px] font-bold font-korean mb-1" style={{ color: h.color }}>{h.label}</p>
|
||||
@ -374,10 +459,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{sec.id === 'hns-substance' && (
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
{[
|
||||
{ k: '물질명', v: hnsPayload?.substance.name || sampleHnsData.substance.name },
|
||||
{ k: 'UN번호', v: hnsPayload?.substance.un || sampleHnsData.substance.un },
|
||||
{ k: 'CAS번호', v: hnsPayload?.substance.cas || sampleHnsData.substance.cas },
|
||||
{ k: '위험등급', v: hnsPayload?.substance.class || sampleHnsData.substance.class },
|
||||
{ k: '물질명', v: hnsPayload?.substance.name || '—' },
|
||||
{ k: 'UN번호', v: hnsPayload?.substance.un || '—' },
|
||||
{ k: 'CAS번호', v: hnsPayload?.substance.cas || '—' },
|
||||
{ k: '위험등급', v: hnsPayload?.substance.class || '—' },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="flex justify-between px-3 py-2 bg-bg-1 rounded border border-border">
|
||||
<span className="text-text-3 font-korean">{r.k}</span>
|
||||
@ -386,25 +471,21 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
))}
|
||||
<div className="col-span-2 flex justify-between px-3 py-2 bg-bg-1 rounded border border-[rgba(239,68,68,0.3)]">
|
||||
<span className="text-text-3 font-korean">독성기준</span>
|
||||
<span className="text-[var(--red)] font-semibold font-mono text-[10px]">{hnsPayload?.substance.toxicity || sampleHnsData.substance.toxicity}</span>
|
||||
<span className="text-[var(--red)] font-semibold font-mono text-[10px]">{hnsPayload?.substance.toxicity || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sec.id === 'hns-ppe' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sampleHnsData.ppe.map((item, i) => (
|
||||
<span key={i} className="px-3 py-1.5 text-[11px] font-semibold rounded-md border text-text-2 font-korean" style={{ background: 'rgba(249,115,22,0.06)', borderColor: 'rgba(249,115,22,0.2)' }}>
|
||||
🛡 {item}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-text-3 font-korean text-[11px]">—</span>
|
||||
</div>
|
||||
)}
|
||||
{sec.id === 'hns-facility' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: '인근 학교', value: `${sampleHnsData.facility.schools}개소`, icon: '🏫' },
|
||||
{ label: '의료시설', value: `${sampleHnsData.facility.hospitals}개소`, icon: '🏥' },
|
||||
{ label: '주변 인구', value: sampleHnsData.facility.population, icon: '👥' },
|
||||
{ label: '인근 학교', value: '—', icon: '🏫' },
|
||||
{ label: '의료시설', value: '—', icon: '🏥' },
|
||||
{ label: '주변 인구', value: '—', icon: '👥' },
|
||||
].map((f, i) => (
|
||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||
<div className="text-[18px] mb-1">{f.icon}</div>
|
||||
@ -422,10 +503,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{sec.id === 'hns-weather' && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: '풍향', value: hnsPayload?.weather.windDir || 'NE 42°', icon: '🌬' },
|
||||
{ label: '풍속', value: hnsPayload?.weather.windSpeed || '5.2 m/s', icon: '💨' },
|
||||
{ label: '대기안정도', value: hnsPayload?.weather.stability || 'D (중립)', icon: '🌡' },
|
||||
{ label: '기온', value: hnsPayload?.weather.temperature || '8.5°C', icon: '☀️' },
|
||||
{ label: '풍향', value: hnsPayload?.weather.windDir || '—', icon: '🌬' },
|
||||
{ label: '풍속', value: hnsPayload?.weather.windSpeed || '—', icon: '💨' },
|
||||
{ label: '대기안정도', value: hnsPayload?.weather.stability || '—', icon: '🌡' },
|
||||
{ label: '기온', value: hnsPayload?.weather.temperature || '—', icon: '☀️' },
|
||||
].map((w, i) => (
|
||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
|
||||
<div className="text-[16px] mb-0.5">{w.icon}</div>
|
||||
@ -440,10 +521,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{sec.id === 'rescue-safety' && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'GM (복원력)', value: sampleRescueData.safety.gm, color: '#f97316' },
|
||||
{ label: '경사각 (Heel)', value: sampleRescueData.safety.heel, color: '#ef4444' },
|
||||
{ label: '트림 (Trim)', value: sampleRescueData.safety.trim, color: '#06b6d4' },
|
||||
{ label: '안전 상태', value: sampleRescueData.safety.status, color: '#f97316' },
|
||||
{ label: 'GM (복원력)', value: '—', color: '#f97316' },
|
||||
{ label: '경사각 (Heel)', value: '—', color: '#ef4444' },
|
||||
{ label: '트림 (Trim)', value: '—', color: '#06b6d4' },
|
||||
{ label: '안전 상태', value: '—', color: '#f97316' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
|
||||
<p className="text-[9px] text-text-3 font-korean mb-1">{s.label}</p>
|
||||
@ -454,26 +535,18 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
)}
|
||||
{sec.id === 'rescue-timeline' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ time: '06:28', event: '충돌 발생 — ORIENTAL GLORY ↔ HAI FENG 168', color: '#ef4444' },
|
||||
{ time: '06:30', event: 'No.1P 탱크 파공, 벙커C유 유출 개시', color: '#f97316' },
|
||||
{ time: '06:35', event: 'VHF Ch.16 조난통신, 해경 출동 요청', color: '#eab308' },
|
||||
{ time: '07:15', event: '해경 3009함 현장 도착, 방제 개시', color: '#06b6d4' },
|
||||
].map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-bg-1 rounded border border-border">
|
||||
<span className="font-mono text-[11px] font-bold min-w-[40px]" style={{ color: e.color }}>{e.time}</span>
|
||||
<span className="text-[11px] text-text-2 font-korean">{e.event}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-bg-1 rounded border border-border">
|
||||
<span className="text-[11px] text-text-3 font-korean">—</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sec.id === 'rescue-casualty' && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: '총원', value: sampleRescueData.casualty.total },
|
||||
{ label: '구조완료', value: sampleRescueData.casualty.rescued, color: '#22c55e' },
|
||||
{ label: '실종', value: sampleRescueData.casualty.missing, color: '#ef4444' },
|
||||
{ label: '부상', value: sampleRescueData.casualty.injured, color: '#f97316' },
|
||||
{ label: '총원', value: '—' },
|
||||
{ label: '구조완료', value: '—', color: '#22c55e' },
|
||||
{ label: '실종', value: '—', color: '#ef4444' },
|
||||
{ label: '부상', value: '—', color: '#f97316' },
|
||||
].map((c, i) => (
|
||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||
<p className="text-[9px] text-text-3 font-korean mb-1">{c.label}</p>
|
||||
@ -494,30 +567,18 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sampleRescueData.resources.map((r, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
<td className="px-3 py-2 text-text-2 font-korean">{r.type}</td>
|
||||
<td className="px-3 py-2 text-text-1 font-mono font-semibold">{r.name}</td>
|
||||
<td className="px-3 py-2 text-text-2 text-center font-mono">{r.eta}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean" style={{
|
||||
background: r.status === '투입중' ? 'rgba(34,197,94,0.15)' : r.status === '이동중' ? 'rgba(249,115,22,0.15)' : 'rgba(138,150,168,0.15)',
|
||||
color: r.status === '투입중' ? '#22c55e' : r.status === '이동중' ? '#f97316' : '#8a96a8',
|
||||
}}>
|
||||
{r.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-b border-border">
|
||||
<td colSpan={4} className="px-3 py-3 text-center text-text-3 font-korean text-[11px]">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{sec.id === 'rescue-grounding' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: '좌초 위험도', value: sampleRescueData.grounding.risk, color: '#ef4444' },
|
||||
{ label: '최근 천해', value: sampleRescueData.grounding.nearestShallow, color: '#f97316' },
|
||||
{ label: '현재 수심', value: sampleRescueData.grounding.depth, color: '#06b6d4' },
|
||||
{ label: '좌초 위험도', value: '—', color: '#ef4444' },
|
||||
{ label: '최근 천해', value: '—', color: '#f97316' },
|
||||
{ label: '현재 수심', value: '—', color: '#06b6d4' },
|
||||
].map((g, i) => (
|
||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||
<p className="text-[9px] text-text-3 font-korean mb-1">{g.label}</p>
|
||||
@ -529,10 +590,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{sec.id === 'rescue-weather' && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: '파고', value: '1.5 m', icon: '🌊' },
|
||||
{ label: '풍속', value: '5.2 m/s', icon: '🌬' },
|
||||
{ label: '조류', value: '1.2 kts NE', icon: '🌀' },
|
||||
{ label: '시정', value: '8 km', icon: '👁' },
|
||||
{ label: '파고', value: '—', icon: '🌊' },
|
||||
{ label: '풍속', value: '—', icon: '🌬' },
|
||||
{ label: '조류', value: '—', icon: '🌀' },
|
||||
{ label: '시정', value: '—', icon: '👁' },
|
||||
].map((w, i) => (
|
||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
|
||||
<div className="text-[16px] mb-0.5">{w.icon}</div>
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
generateReportHTML,
|
||||
exportAsPDF,
|
||||
exportAsHWP,
|
||||
buildReportGetVal,
|
||||
typeColors,
|
||||
statusColors,
|
||||
analysisCatColors,
|
||||
@ -130,6 +131,7 @@ export function ReportsView() {
|
||||
<col style={{ width: '7%' }} />
|
||||
<col style={{ width: '6%' }} />
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '6%' }} />
|
||||
<col style={{ width: '4%' }} />
|
||||
</colgroup>
|
||||
@ -145,6 +147,7 @@ export function ReportsView() {
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">관할</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">상태</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">수정</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">지도</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">다운로드</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">삭제</th>
|
||||
</tr>
|
||||
@ -177,6 +180,12 @@ export function ReportsView() {
|
||||
<td className="px-3 py-3 text-[11px] text-text-2 text-center font-korean">{report.jurisdiction}</td>
|
||||
<td className="px-3 py-3 text-center"><span className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean" style={{ background: statusColors[report.status]?.bg, color: statusColors[report.status]?.text }}>{report.status}</span></td>
|
||||
<td className="px-3 py-3 text-center"><button onClick={async () => { try { const detail = await loadReportDetail(parseInt(report.id, 10)); setView({ screen: 'edit', data: detail }) } catch { setView({ screen: 'edit', data: { ...report } }) } }} className="text-[11px] text-primary-cyan hover:underline font-korean">수정</button></td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{(report.hasMapCapture || report.capturedMapImage)
|
||||
? <span title="확산예측 지도 캡처 있음" className="text-[14px]">📷</span>
|
||||
: <span className="text-[11px] text-text-3">—</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center"><button className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded bg-[rgba(239,68,68,0.12)] text-[#ef4444] border border-[rgba(239,68,68,0.25)] hover:bg-[rgba(239,68,68,0.2)] transition-all">PDF</button></td>
|
||||
<td className="px-3 py-3 text-center"><button onClick={() => handleDelete(report.id)} className="w-7 h-7 rounded flex items-center justify-center text-status-red hover:bg-[rgba(239,68,68,0.1)] transition-all text-sm">🗑</button></td>
|
||||
</tr>
|
||||
@ -276,16 +285,7 @@ export function ReportsView() {
|
||||
onClick={() => {
|
||||
const tpl = templateTypes.find(t => t.id === previewReport.reportType)
|
||||
if (tpl) {
|
||||
const getVal = (key: string) => {
|
||||
if (key === 'author') return previewReport.author
|
||||
if (key.startsWith('incident.')) {
|
||||
const f = key.split('.')[1]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (previewReport.incident as any)[f] || ''
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (previewReport as any)[key] || ''
|
||||
}
|
||||
const getVal = buildReportGetVal(previewReport)
|
||||
const html = generateReportHTML(tpl.label, { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }, tpl.sections, getVal)
|
||||
exportAsPDF(html, previewReport.title || tpl.label)
|
||||
}
|
||||
@ -299,16 +299,7 @@ export function ReportsView() {
|
||||
onClick={() => {
|
||||
const tpl = templateTypes.find(t => t.id === previewReport.reportType) as TemplateType | undefined
|
||||
if (tpl) {
|
||||
const getVal = (key: string) => {
|
||||
if (key === 'author') return previewReport.author
|
||||
if (key.startsWith('incident.')) {
|
||||
const f = key.split('.')[1]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (previewReport.incident as any)[f] || ''
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (previewReport as any)[key] || ''
|
||||
}
|
||||
const getVal = buildReportGetVal(previewReport)
|
||||
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
|
||||
const filename = previewReport.title || tpl.label
|
||||
exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename)
|
||||
@ -369,6 +360,14 @@ export function ReportsView() {
|
||||
previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`,
|
||||
].filter(Boolean).join('\n') || '—'}
|
||||
</div>
|
||||
{previewReport.capturedMapImage && (
|
||||
<img
|
||||
src={previewReport.capturedMapImage}
|
||||
alt="확산예측 지도 캡처"
|
||||
className="w-full rounded-lg border border-border mt-3"
|
||||
style={{ maxHeight: '300px', objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. 초동조치 / 대응현황 */}
|
||||
|
||||
@ -66,24 +66,23 @@ export const templateTypes: TemplateType[] = [
|
||||
sections: [
|
||||
{ title: '1. 기본정보', fields: [
|
||||
{ key: 'incident.writeTime', label: '보고일시', type: 'text' },
|
||||
{ key: 'author', label: '작성자', type: 'text' },
|
||||
{ key: 'author', label: '작성자', type: 'text' },
|
||||
]},
|
||||
{ title: '2. 사고개요', fields: [
|
||||
{ key: 'incident.name', label: '사고명', type: 'text' },
|
||||
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
|
||||
{ key: 'incident.location', label: '발생위치', type: 'text' },
|
||||
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
|
||||
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
|
||||
]},
|
||||
{ title: '3. 해양기상 현황', fields: [
|
||||
{ key: 'weatherSummary', label: '', type: 'textarea' },
|
||||
]},
|
||||
{ title: '4. 확산예측 결과', fields: [
|
||||
{ key: 'spreadResult', label: '', type: 'textarea' },
|
||||
]},
|
||||
{ title: '5. 민감자원 영향', fields: [
|
||||
{ key: 'sensitiveImpact', label: '', type: 'textarea' },
|
||||
{ key: 'incident.name', label: '사고명', type: 'text' },
|
||||
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
|
||||
{ key: 'incident.location', label: '발생위치', type: 'text' },
|
||||
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
|
||||
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
|
||||
]},
|
||||
{ title: '3. 조석 현황', fields: [{ key: '__tide', label: '', type: 'textarea' }] },
|
||||
{ title: '4. 해양기상 현황', fields: [{ key: '__weather', label: '', type: 'textarea' }] },
|
||||
{ title: '5. 확산예측 결과', fields: [{ key: '__spread', label: '', type: 'textarea' }] },
|
||||
{ title: '6. 민감자원 현황', fields: [{ key: '__sensitive', label: '', type: 'textarea' }] },
|
||||
{ title: '7. 방제 자원', fields: [{ key: '__vessels', label: '', type: 'textarea' }] },
|
||||
{ title: '8. 회수·처리 현황',fields: [{ key: '__recovery', label: '', type: 'textarea' }] },
|
||||
{ title: '9. 최종 결과', fields: [{ key: '__result', label: '', type: 'textarea' }] },
|
||||
{ title: '10. 분석 의견', fields: [{ key: 'analysis', label: '', type: 'textarea' }] },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -86,3 +86,122 @@ export function inferAnalysisCategory(report: OilSpillReportData): string {
|
||||
if (t.includes('유출유') || t.includes('확산예측') || t.includes('민감자원') || t.includes('유출사고') || t.includes('오염') || t.includes('방제') || rt === '유출유 보고' || rt === '예측보고서') return '유출유 확산예측'
|
||||
return ''
|
||||
}
|
||||
|
||||
// ─── PDF/HWP 섹션 포맷 헬퍼 ─────────────────────────────────
|
||||
const TH = 'padding:6px 8px;border:1px solid #d1d5db;background:#f0f4f8;font-weight:600;font-size:11px;'
|
||||
const TD = 'padding:6px 8px;border:1px solid #d1d5db;font-size:11px;'
|
||||
const TABLE = 'width:100%;border-collapse:collapse;'
|
||||
|
||||
function formatTideTable(tide: OilSpillReportData['tide']): string {
|
||||
if (!tide?.length) return ''
|
||||
const header = `<tr><th style="${TH}">날짜</th><th style="${TH}">조형</th><th style="${TH}">간조1</th><th style="${TH}">만조1</th><th style="${TH}">간조2</th><th style="${TH}">만조2</th></tr>`
|
||||
const rows = tide.map(t =>
|
||||
`<tr><td style="${TD}">${t.date}</td><td style="${TD}">${t.tideType}</td><td style="${TD}">${t.lowTide1}</td><td style="${TD}">${t.highTide1}</td><td style="${TD}">${t.lowTide2}</td><td style="${TD}">${t.highTide2}</td></tr>`
|
||||
).join('')
|
||||
return `<table style="${TABLE}">${header}${rows}</table>`
|
||||
}
|
||||
|
||||
function formatWeatherTable(weather: OilSpillReportData['weather']): string {
|
||||
if (!weather?.length) return ''
|
||||
const header = `<tr><th style="${TH}">시각</th><th style="${TH}">풍향</th><th style="${TH}">풍속</th><th style="${TH}">유향</th><th style="${TH}">유속</th><th style="${TH}">파고</th></tr>`
|
||||
const rows = weather.map(w =>
|
||||
`<tr><td style="${TD}">${w.time}</td><td style="${TD}">${w.windDir}</td><td style="${TD}">${w.windSpeed}</td><td style="${TD}">${w.currentDir}</td><td style="${TD}">${w.currentSpeed}</td><td style="${TD}">${w.waveHeight}</td></tr>`
|
||||
).join('')
|
||||
return `<table style="${TABLE}">${header}${rows}</table>`
|
||||
}
|
||||
|
||||
function formatSpreadTable(spread: OilSpillReportData['spread']): string {
|
||||
if (!spread?.length) return ''
|
||||
const header = `<tr><th style="${TH}">경과시간</th><th style="${TH}">풍화량</th><th style="${TH}">해상잔유량</th><th style="${TH}">연안부착량</th><th style="${TH}">면적</th></tr>`
|
||||
const rows = spread.map(s =>
|
||||
`<tr><td style="${TD}">${s.elapsed}</td><td style="${TD}">${s.weathered}</td><td style="${TD}">${s.seaRemain}</td><td style="${TD}">${s.coastAttach}</td><td style="${TD}">${s.area}</td></tr>`
|
||||
).join('')
|
||||
return `<table style="${TABLE}">${header}${rows}</table>`
|
||||
}
|
||||
|
||||
function formatSensitiveTable(r: OilSpillReportData): string {
|
||||
const parts: string[] = []
|
||||
if (r.aquaculture?.length) {
|
||||
const h = `<tr><th style="${TH}">종류</th><th style="${TH}">면적</th><th style="${TH}">거리</th></tr>`
|
||||
const rows = r.aquaculture.map(a => `<tr><td style="${TD}">${a.type}</td><td style="${TD}">${a.area}</td><td style="${TD}">${a.distance}</td></tr>`).join('')
|
||||
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">양식업</p><table style="${TABLE}">${h}${rows}</table>`)
|
||||
}
|
||||
if (r.beaches?.length) {
|
||||
const h = `<tr><th style="${TH}">해수욕장명</th><th style="${TH}">거리</th></tr>`
|
||||
const rows = r.beaches.map(b => `<tr><td style="${TD}">${b.name}</td><td style="${TD}">${b.distance}</td></tr>`).join('')
|
||||
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">해수욕장</p><table style="${TABLE}">${h}${rows}</table>`)
|
||||
}
|
||||
if (r.markets?.length) {
|
||||
const h = `<tr><th style="${TH}">수산시장명</th><th style="${TH}">거리</th></tr>`
|
||||
const rows = r.markets.map(m => `<tr><td style="${TD}">${m.name}</td><td style="${TD}">${m.distance}</td></tr>`).join('')
|
||||
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">수산시장</p><table style="${TABLE}">${h}${rows}</table>`)
|
||||
}
|
||||
if (r.esi?.length) {
|
||||
const h = `<tr><th style="${TH}">코드</th><th style="${TH}">유형</th><th style="${TH}">길이</th></tr>`
|
||||
const rows = r.esi.map(e => `<tr><td style="${TD}">${e.code}</td><td style="${TD}">${e.type}</td><td style="${TD}">${e.length}</td></tr>`).join('')
|
||||
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">ESI 해안선</p><table style="${TABLE}">${h}${rows}</table>`)
|
||||
}
|
||||
if (r.species?.length) {
|
||||
const h = `<tr><th style="${TH}">분류</th><th style="${TH}">종명</th></tr>`
|
||||
const rows = r.species.map(s => `<tr><td style="${TD}">${s.category}</td><td style="${TD}">${s.species}</td></tr>`).join('')
|
||||
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">보호생물</p><table style="${TABLE}">${h}${rows}</table>`)
|
||||
}
|
||||
if (r.habitat?.length) {
|
||||
const h = `<tr><th style="${TH}">유형</th><th style="${TH}">면적</th></tr>`
|
||||
const rows = r.habitat.map(h2 => `<tr><td style="${TD}">${h2.type}</td><td style="${TD}">${h2.area}</td></tr>`).join('')
|
||||
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">서식지</p><table style="${TABLE}">${h}${rows}</table>`)
|
||||
}
|
||||
if (r.sensitivity?.length) {
|
||||
const h = `<tr><th style="${TH}">민감도</th><th style="${TH}">면적</th></tr>`
|
||||
const rows = r.sensitivity.map(s => `<tr><td style="${TD}">${s.level}</td><td style="${TD}">${s.area}</td></tr>`).join('')
|
||||
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">민감도 등급</p><table style="${TABLE}">${h}${rows}</table>`)
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
function formatVesselsTable(vessels: OilSpillReportData['vessels']): string {
|
||||
if (!vessels?.length) return ''
|
||||
const header = `<tr><th style="${TH}">선명</th><th style="${TH}">기관</th><th style="${TH}">거리</th><th style="${TH}">속력</th><th style="${TH}">톤수</th><th style="${TH}">회수장비</th><th style="${TH}">오일붐</th></tr>`
|
||||
const rows = vessels.map(v =>
|
||||
`<tr><td style="${TD}">${v.name}</td><td style="${TD}">${v.org}</td><td style="${TD}">${v.dist}</td><td style="${TD}">${v.speed}</td><td style="${TD}">${v.ton}</td><td style="${TD}">${v.collectorType} ${v.collectorCap}</td><td style="${TD}">${v.boomType} ${v.boomLength}</td></tr>`
|
||||
).join('')
|
||||
return `<table style="${TABLE}">${header}${rows}</table>`
|
||||
}
|
||||
|
||||
function formatRecoveryTable(recovery: OilSpillReportData['recovery']): string {
|
||||
if (!recovery?.length) return ''
|
||||
const header = `<tr><th style="${TH}">선박명</th><th style="${TH}">회수 기간</th></tr>`
|
||||
const rows = recovery.map(r =>
|
||||
`<tr><td style="${TD}">${r.shipName}</td><td style="${TD}">${r.period}</td></tr>`
|
||||
).join('')
|
||||
return `<table style="${TABLE}">${header}${rows}</table>`
|
||||
}
|
||||
|
||||
function formatResultTable(result: OilSpillReportData['result']): string {
|
||||
if (!result) return ''
|
||||
return `<table style="${TABLE}">
|
||||
<tr><td style="${TH}">유출총량</td><td style="${TD}">${result.spillTotal}</td><td style="${TH}">풍화총량</td><td style="${TD}">${result.weatheredTotal}</td></tr>
|
||||
<tr><td style="${TH}">회수총량</td><td style="${TD}">${result.recoveredTotal}</td><td style="${TH}">해상잔유량</td><td style="${TD}">${result.seaRemainTotal}</td></tr>
|
||||
<tr><td style="${TH}">연안부착량</td><td style="${TD}" colspan="3">${result.coastAttachTotal}</td></tr>
|
||||
</table>`
|
||||
}
|
||||
|
||||
export function buildReportGetVal(report: OilSpillReportData) {
|
||||
return (key: string): string => {
|
||||
if (key === 'author') return report.author ?? ''
|
||||
if (key.startsWith('incident.')) {
|
||||
const f = key.split('.')[1]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (report.incident as any)[f] || ''
|
||||
}
|
||||
if (key === '__tide') return formatTideTable(report.tide)
|
||||
if (key === '__weather') return formatWeatherTable(report.weather)
|
||||
if (key === '__spread') return formatSpreadTable(report.spread)
|
||||
if (key === '__sensitive') return formatSensitiveTable(report)
|
||||
if (key === '__vessels') return formatVesselsTable(report.vessels)
|
||||
if (key === '__recovery') return formatRecoveryTable(report.recovery)
|
||||
if (key === '__result') return formatResultTable(report.result)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (report as any)[key] || ''
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,7 @@ export interface ApiReportListItem {
|
||||
authorName: string;
|
||||
regDtm: string;
|
||||
mdfcnDtm: string | null;
|
||||
hasMapCapture?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiReportSectionData {
|
||||
@ -74,6 +75,7 @@ export interface ApiReportSectionData {
|
||||
export interface ApiReportDetail extends ApiReportListItem {
|
||||
acdntSn: number | null;
|
||||
sections: ApiReportSectionData[];
|
||||
mapCaptureImg?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiReportListResponse {
|
||||
@ -176,6 +178,7 @@ export async function createReportApi(input: {
|
||||
title: string;
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
mapCaptureImg?: string;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}): Promise<{ sn: number }> {
|
||||
const res = await api.post<{ sn: number }>('/reports', input);
|
||||
@ -187,6 +190,7 @@ export async function updateReportApi(sn: number, input: {
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
acdntSn?: number | null;
|
||||
mapCaptureImg?: string | null;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}): Promise<void> {
|
||||
await api.post(`/reports/${sn}/update`, input);
|
||||
@ -239,6 +243,7 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
||||
title: data.title || data.incident.name || '보고서',
|
||||
jrsdCd: data.jurisdiction,
|
||||
sttsCd,
|
||||
mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined,
|
||||
sections,
|
||||
});
|
||||
return existingSn;
|
||||
@ -250,6 +255,7 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
||||
title: data.title || data.incident.name || '보고서',
|
||||
jrsdCd: data.jurisdiction,
|
||||
sttsCd,
|
||||
mapCaptureImg: data.capturedMapImage || undefined,
|
||||
sections,
|
||||
});
|
||||
return result.sn;
|
||||
@ -266,6 +272,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
|
||||
analysisCategory: (item.ctgrCd ? CTGR_CODE_TO_CAT[item.ctgrCd] : '') || '',
|
||||
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
|
||||
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
|
||||
hasMapCapture: item.hasMapCapture,
|
||||
// 목록에서는 섹션 데이터 없음 — 빈 기본값
|
||||
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
|
||||
tide: [], weather: [], spread: [],
|
||||
@ -337,6 +344,10 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
||||
}
|
||||
}
|
||||
|
||||
if (detail.mapCaptureImg) {
|
||||
reportData.capturedMapImage = detail.mapCaptureImg;
|
||||
}
|
||||
|
||||
return reportData;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,324 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useMap } from '@vis.gl/react-maplibre'
|
||||
import type { Map as MapLibreMap } from 'maplibre-gl'
|
||||
|
||||
interface CurrentVectorPoint {
|
||||
lat: number
|
||||
lon: number
|
||||
u: number // 동서 방향 속도 (양수=동, 음수=서) [m/s]
|
||||
v: number // 남북 방향 속도 (양수=북, 음수=남) [m/s]
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number
|
||||
y: number
|
||||
age: number
|
||||
maxAge: number
|
||||
}
|
||||
|
||||
interface OceanCurrentParticleLayerProps {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
// 해류 속도 기반 색상
|
||||
function getCurrentColor(speed: number): string {
|
||||
if (speed < 0.2) return 'rgba(59, 130, 246, 0.8)' // 파랑
|
||||
if (speed < 0.4) return 'rgba(6, 182, 212, 0.8)' // 청록
|
||||
if (speed < 0.6) return 'rgba(34, 197, 94, 0.8)' // 초록
|
||||
return 'rgba(249, 115, 22, 0.8)' // 주황
|
||||
}
|
||||
|
||||
// 한반도 육지 영역 판별 (간략화된 폴리곤)
|
||||
const isOnLand = (lat: number, lon: number): boolean => {
|
||||
const peninsula: [number, number][] = [
|
||||
[38.5, 124.5], [38.5, 128.3],
|
||||
[37.8, 128.8], [37.0, 129.2],
|
||||
[36.0, 129.5], [35.1, 129.2],
|
||||
[34.8, 128.6], [34.5, 127.8],
|
||||
[34.3, 126.5], [34.8, 126.1],
|
||||
[35.5, 126.0], [36.0, 126.3],
|
||||
[36.8, 126.0], [37.5, 126.2],
|
||||
[38.5, 124.5],
|
||||
]
|
||||
|
||||
// 제주도 영역
|
||||
if (lat >= 33.1 && lat <= 33.7 && lon >= 126.1 && lon <= 127.0) return true
|
||||
|
||||
// Ray casting algorithm
|
||||
let inside = false
|
||||
for (let i = 0, j = peninsula.length - 1; i < peninsula.length; j = i++) {
|
||||
const [yi, xi] = peninsula[i]
|
||||
const [yj, xj] = peninsula[j]
|
||||
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
// 한국 해역의 해류 u,v 벡터 데이터 생성 (Mock)
|
||||
const generateOceanCurrentData = (): CurrentVectorPoint[] => {
|
||||
const data: CurrentVectorPoint[] = []
|
||||
|
||||
for (let lat = 33.5; lat <= 38.0; lat += 0.8) {
|
||||
for (let lon = 125.0; lon <= 130.5; lon += 0.8) {
|
||||
if (isOnLand(lat, lon)) continue
|
||||
|
||||
let u = 0
|
||||
let v = 0
|
||||
|
||||
if (lon > 128.5) {
|
||||
// 동해 — 북동진하는 동한난류
|
||||
u = 0.2 + Math.random() * 0.2 // 동쪽 0.2~0.4
|
||||
v = 0.3 + Math.random() * 0.2 // 북쪽 0.3~0.5
|
||||
} else if (lon < 126.5) {
|
||||
// 서해 — 북진
|
||||
u = -0.05 + Math.random() * 0.1 // 동서 -0.05~0.05
|
||||
v = 0.15 + Math.random() * 0.15 // 북쪽 0.15~0.3
|
||||
} else {
|
||||
// 남해 — 동진
|
||||
u = 0.3 + Math.random() * 0.2 // 동쪽 0.3~0.5
|
||||
v = -0.05 + Math.random() * 0.15 // 남북 -0.05~0.1
|
||||
}
|
||||
|
||||
data.push({ lat, lon, u, v })
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 해류 데이터는 한 번만 생성
|
||||
const CURRENT_DATA = generateOceanCurrentData()
|
||||
|
||||
// IDW 보간으로 특정 위치의 u,v 벡터 추정 → speed/direction 반환
|
||||
function interpolateCurrent(
|
||||
lat: number,
|
||||
lon: number,
|
||||
points: CurrentVectorPoint[]
|
||||
): { speed: number; direction: number } {
|
||||
if (points.length === 0) return { speed: 0.3, direction: 90 }
|
||||
|
||||
let totalWeight = 0
|
||||
let weightedU = 0
|
||||
let weightedV = 0
|
||||
|
||||
for (const point of points) {
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2)
|
||||
)
|
||||
const weight = 1 / Math.pow(Math.max(dist, 0.01), 2)
|
||||
totalWeight += weight
|
||||
weightedU += point.u * weight
|
||||
weightedV += point.v * weight
|
||||
}
|
||||
|
||||
const u = weightedU / totalWeight
|
||||
const v = weightedV / totalWeight
|
||||
const speed = Math.sqrt(u * u + v * v)
|
||||
// u=동(+), v=북(+) → 화면 방향: sin=동(+x), -cos=남(+y)
|
||||
const direction = (Math.atan2(u, v) * 180) / Math.PI
|
||||
return { speed, direction: (direction + 360) % 360 }
|
||||
}
|
||||
|
||||
// MapLibre map.unproject()를 통해 픽셀 → 경위도 변환
|
||||
function containerPointToLatLng(
|
||||
map: MapLibreMap,
|
||||
x: number,
|
||||
y: number
|
||||
): { lat: number; lng: number } {
|
||||
const lngLat = map.unproject([x, y])
|
||||
return { lat: lngLat.lat, lng: lngLat.lng }
|
||||
}
|
||||
|
||||
const PARTICLE_COUNT = 400
|
||||
const FADE_ALPHA = 0.93
|
||||
|
||||
/**
|
||||
* OceanCurrentParticleLayer
|
||||
*
|
||||
* Canvas 2D + requestAnimationFrame 패턴으로 해류 흐름 시각화
|
||||
* u,v 벡터 격자 데이터를 IDW 보간하여 파티클 애니메이션 렌더링
|
||||
* 바람 파티클 대비: 적은 입자, 느린 속도, 긴 트레일
|
||||
*/
|
||||
export function OceanCurrentParticleLayer({ visible }: OceanCurrentParticleLayerProps) {
|
||||
const { current: mapRef } = useMap()
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const particlesRef = useRef<Particle[]>([])
|
||||
const animFrameRef = useRef<number>(0)
|
||||
|
||||
const initParticles = useCallback((width: number, height: number) => {
|
||||
particlesRef.current = []
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
particlesRef.current.push({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
age: Math.floor(Math.random() * 150),
|
||||
maxAge: 120 + Math.floor(Math.random() * 60),
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef?.getMap()
|
||||
if (!map) return
|
||||
|
||||
if (!visible) {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.remove()
|
||||
canvasRef.current = null
|
||||
}
|
||||
cancelAnimationFrame(animFrameRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
const container = map.getContainer()
|
||||
|
||||
// Canvas 생성 또는 재사용
|
||||
let canvas = canvasRef.current
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas')
|
||||
canvas.style.position = 'absolute'
|
||||
canvas.style.top = '0'
|
||||
canvas.style.left = '0'
|
||||
canvas.style.pointerEvents = 'none'
|
||||
canvas.style.zIndex = '440'
|
||||
container.appendChild(canvas)
|
||||
canvasRef.current = canvas
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
if (!canvas) return
|
||||
const { clientWidth: w, clientHeight: h } = container
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
}
|
||||
resize()
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
initParticles(canvas.width, canvas.height)
|
||||
|
||||
// 오프스크린 캔버스 (트레일 효과)
|
||||
let offCanvas: HTMLCanvasElement | null = null
|
||||
let offCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
function animate() {
|
||||
if (!ctx || !canvas) return
|
||||
|
||||
// 오프스크린 캔버스 크기 동기화
|
||||
if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) {
|
||||
offCanvas = document.createElement('canvas')
|
||||
offCanvas.width = canvas.width
|
||||
offCanvas.height = canvas.height
|
||||
offCtx = offCanvas.getContext('2d')
|
||||
}
|
||||
|
||||
if (!offCtx) return
|
||||
|
||||
// 트레일 페이드 효과 (느린 페이드 = 부드러운 흐름)
|
||||
offCtx.globalCompositeOperation = 'destination-in'
|
||||
offCtx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`
|
||||
offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height)
|
||||
offCtx.globalCompositeOperation = 'source-over'
|
||||
|
||||
// 현재 지도 bounds 확인
|
||||
const bounds = map!.getBounds()
|
||||
|
||||
for (const particle of particlesRef.current) {
|
||||
particle.age++
|
||||
|
||||
// 수명 초과 시 리셋
|
||||
if (particle.age > particle.maxAge) {
|
||||
particle.x = Math.random() * canvas.width
|
||||
particle.y = Math.random() * canvas.height
|
||||
particle.age = 0
|
||||
particle.maxAge = 120 + Math.floor(Math.random() * 60)
|
||||
continue
|
||||
}
|
||||
|
||||
const { lat, lng } = containerPointToLatLng(map!, particle.x, particle.y)
|
||||
|
||||
// 화면 밖이면 리셋
|
||||
if (!bounds.contains([lng, lat])) {
|
||||
particle.x = Math.random() * canvas.width
|
||||
particle.y = Math.random() * canvas.height
|
||||
particle.age = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// 육지 위이면 리셋
|
||||
if (isOnLand(lat, lng)) {
|
||||
particle.x = Math.random() * canvas.width
|
||||
particle.y = Math.random() * canvas.height
|
||||
particle.age = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const current = interpolateCurrent(lat, lng, CURRENT_DATA)
|
||||
const rad = (current.direction * Math.PI) / 180
|
||||
const pixelSpeed = current.speed * 2.0
|
||||
|
||||
const newX = particle.x + Math.sin(rad) * pixelSpeed
|
||||
const newY = particle.y + -Math.cos(rad) * pixelSpeed
|
||||
|
||||
// 다음 위치가 육지이면 리셋
|
||||
const nextPos = containerPointToLatLng(map!, newX, newY)
|
||||
if (isOnLand(nextPos.lat, nextPos.lng)) {
|
||||
particle.x = Math.random() * canvas.width
|
||||
particle.y = Math.random() * canvas.height
|
||||
particle.age = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const oldX = particle.x
|
||||
const oldY = particle.y
|
||||
particle.x = newX
|
||||
particle.y = newY
|
||||
|
||||
// 파티클 트레일 그리기
|
||||
const alpha = 1 - particle.age / particle.maxAge
|
||||
offCtx.strokeStyle = getCurrentColor(current.speed).replace('0.8', String(alpha * 0.8))
|
||||
offCtx.lineWidth = 0.8
|
||||
offCtx.beginPath()
|
||||
offCtx.moveTo(oldX, oldY)
|
||||
offCtx.lineTo(particle.x, particle.y)
|
||||
offCtx.stroke()
|
||||
}
|
||||
|
||||
// 메인 캔버스에 합성 (배경 오버레이 없이 파티클만)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.drawImage(offCanvas, 0, 0)
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
// 지도 이동/줌 시 리셋
|
||||
const onMoveEnd = () => {
|
||||
resize()
|
||||
if (canvas) initParticles(canvas.width, canvas.height)
|
||||
if (offCanvas && canvas) {
|
||||
offCanvas.width = canvas.width
|
||||
offCanvas.height = canvas.height
|
||||
}
|
||||
}
|
||||
map.on('moveend', onMoveEnd)
|
||||
map.on('zoomend', onMoveEnd)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animFrameRef.current)
|
||||
map.off('moveend', onMoveEnd)
|
||||
map.off('zoomend', onMoveEnd)
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.remove()
|
||||
canvasRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mapRef, visible, initParticles])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Source, Layer } from '@vis.gl/react-maplibre'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useMap } from '@vis.gl/react-maplibre'
|
||||
import type { OceanForecastData } from '../services/khoaApi'
|
||||
|
||||
interface OceanForecastOverlayProps {
|
||||
@ -8,62 +8,118 @@ interface OceanForecastOverlayProps {
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
// 한국 해역 범위 (MapLibre image source용 좌표 배열)
|
||||
// [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se]
|
||||
// [lon, lat] 순서
|
||||
const KOREA_IMAGE_COORDINATES: [[number, number], [number, number], [number, number], [number, number]] = [
|
||||
[124.5, 33.0], // 남서 (제주 남쪽)
|
||||
[124.5, 38.5], // 북서
|
||||
[132.0, 38.5], // 북동 (동해 북쪽)
|
||||
[132.0, 33.0], // 남동
|
||||
]
|
||||
// 한국 해역 범위 [lon, lat]
|
||||
const BOUNDS = {
|
||||
nw: [124.5, 38.5] as [number, number],
|
||||
ne: [132.0, 38.5] as [number, number],
|
||||
se: [132.0, 33.0] as [number, number],
|
||||
sw: [124.5, 33.0] as [number, number],
|
||||
}
|
||||
|
||||
// www.khoa.go.kr 이미지는 CORS 미지원 → Vite 프록시 경유
|
||||
function toProxyUrl(url: string): string {
|
||||
return url.replace('https://www.khoa.go.kr', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* OceanForecastOverlay
|
||||
*
|
||||
* 기존: react-leaflet ImageOverlay + LatLngBounds
|
||||
* 전환: @vis.gl/react-maplibre Source(type=image) + Layer(type=raster)
|
||||
*
|
||||
* MapLibre image source는 Map 컴포넌트 자식으로 직접 렌더링 가능
|
||||
* MapLibre raster layer는 deck.gl 캔버스보다 항상 아래 렌더링되므로,
|
||||
* WindParticleLayer와 동일하게 canvas를 직접 map 컨테이너에 삽입하는 방식 사용.
|
||||
* z-index 500으로 WindParticleLayer(450) 위에 렌더링.
|
||||
*/
|
||||
export function OceanForecastOverlay({
|
||||
forecast,
|
||||
opacity = 0.6,
|
||||
visible = true,
|
||||
}: OceanForecastOverlayProps) {
|
||||
const [loadedUrl, setLoadedUrl] = useState<string | null>(null)
|
||||
const { current: mapRef } = useMap()
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!forecast?.filePath) return
|
||||
let cancelled = false
|
||||
const img = new Image()
|
||||
img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) }
|
||||
img.onerror = () => { if (!cancelled) setLoadedUrl(null) }
|
||||
img.src = forecast.filePath
|
||||
return () => { cancelled = true }
|
||||
}, [forecast?.filePath])
|
||||
const map = mapRef?.getMap()
|
||||
if (!map) return
|
||||
|
||||
const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath
|
||||
const container = map.getContainer()
|
||||
|
||||
if (!visible || !forecast || !imageLoaded) {
|
||||
return null
|
||||
}
|
||||
// canvas 생성 (최초 1회)
|
||||
if (!canvasRef.current) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.style.position = 'absolute'
|
||||
canvas.style.top = '0'
|
||||
canvas.style.left = '0'
|
||||
canvas.style.pointerEvents = 'none'
|
||||
canvas.style.zIndex = '500' // WindParticleLayer(450) 위
|
||||
container.appendChild(canvas)
|
||||
canvasRef.current = canvas
|
||||
}
|
||||
|
||||
return (
|
||||
<Source
|
||||
id="ocean-forecast-image"
|
||||
type="image"
|
||||
url={forecast.filePath}
|
||||
coordinates={KOREA_IMAGE_COORDINATES}
|
||||
>
|
||||
<Layer
|
||||
id="ocean-forecast-raster"
|
||||
type="raster"
|
||||
paint={{
|
||||
'raster-opacity': opacity,
|
||||
'raster-resampling': 'linear',
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)
|
||||
const canvas = canvasRef.current
|
||||
|
||||
if (!visible || !forecast?.imgFilePath) {
|
||||
canvas.style.display = 'none'
|
||||
return
|
||||
}
|
||||
|
||||
canvas.style.display = 'block'
|
||||
const proxyUrl = toProxyUrl(forecast.imgFilePath)
|
||||
|
||||
function draw() {
|
||||
const img = imgRef.current
|
||||
if (!canvas || !img || !img.complete || img.naturalWidth === 0) return
|
||||
|
||||
const { clientWidth: w, clientHeight: h } = container
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// 4개 꼭짓점을 픽셀 좌표로 변환
|
||||
const nw = map!.project(BOUNDS.nw)
|
||||
const ne = map!.project(BOUNDS.ne)
|
||||
const sw = map!.project(BOUNDS.sw)
|
||||
|
||||
const x = Math.min(nw.x, sw.x)
|
||||
const y = nw.y
|
||||
const w2 = ne.x - nw.x
|
||||
const h2 = sw.y - nw.y
|
||||
|
||||
ctx.globalAlpha = opacity
|
||||
ctx.drawImage(img, x, y, w2, h2)
|
||||
}
|
||||
|
||||
// 이미지가 바뀌었으면 새로 로드
|
||||
if (!imgRef.current || imgRef.current.dataset.src !== proxyUrl) {
|
||||
const img = new Image()
|
||||
img.dataset.src = proxyUrl
|
||||
img.onload = draw
|
||||
img.src = proxyUrl
|
||||
imgRef.current = img
|
||||
} else {
|
||||
draw()
|
||||
}
|
||||
|
||||
map.on('move', draw)
|
||||
map.on('zoom', draw)
|
||||
map.on('resize', draw)
|
||||
|
||||
return () => {
|
||||
map.off('move', draw)
|
||||
map.off('zoom', draw)
|
||||
map.off('resize', draw)
|
||||
}
|
||||
}, [mapRef, visible, forecast?.imgFilePath, opacity])
|
||||
|
||||
// 언마운트 시 canvas 제거
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
canvasRef.current?.remove()
|
||||
canvasRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
48
frontend/src/tabs/weather/components/WeatherMapControls.tsx
Normal file
48
frontend/src/tabs/weather/components/WeatherMapControls.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useMap } from '@vis.gl/react-maplibre'
|
||||
|
||||
interface WeatherMapControlsProps {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
}
|
||||
|
||||
export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) {
|
||||
const { current: map } = useMap()
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: '+',
|
||||
tooltip: '확대',
|
||||
onClick: () => map?.zoomIn(),
|
||||
},
|
||||
{
|
||||
label: '−',
|
||||
tooltip: '축소',
|
||||
onClick: () => map?.zoomOut(),
|
||||
},
|
||||
{
|
||||
label: '🎯',
|
||||
tooltip: '한국 해역 초기화',
|
||||
onClick: () => map?.flyTo({ center, zoom, duration: 1000 }),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
{buttons.map(({ label, tooltip, onClick }) => (
|
||||
<div key={tooltip} className="relative group">
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-2 px-2 py-1 text-xs bg-bg-0 text-text-1 border border-border rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-20">
|
||||
{tooltip}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -77,8 +77,6 @@ export function WeatherMapOverlay({
|
||||
stations.map((station) => {
|
||||
const isSelected = selectedStationId === station.id
|
||||
const color = getWindHexColor(station.wind.speed, isSelected)
|
||||
const size = Math.min(40 + station.wind.speed * 2, 80)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`wind-${station.id}`}
|
||||
@ -87,35 +85,34 @@ export function WeatherMapOverlay({
|
||||
anchor="center"
|
||||
onClick={() => onStationClick(station)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
transform: `rotate(${station.wind.direction}deg)`,
|
||||
}}
|
||||
className="flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
|
||||
<div className="flex items-center gap-1 cursor-pointer">
|
||||
<div style={{ transform: `rotate(${station.wind.direction}deg)` }}>
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
|
||||
>
|
||||
{/* 위쪽이 바람 방향을 나타내는 삼각형 */}
|
||||
<polygon
|
||||
points="12,2 4,22 12,16 20,22"
|
||||
fill={color}
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
|
||||
className="text-xs font-bold leading-none"
|
||||
>
|
||||
<path
|
||||
d="M12 2L12 20M12 2L8 6M12 2L16 6M12 20L8 16M12 20L16 16"
|
||||
stroke={color}
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" fill={color} opacity="0.8" />
|
||||
</svg>
|
||||
{station.wind.speed.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 기상 데이터 라벨 — MapLibre Marker */}
|
||||
{/* 기상 데이터 라벨 — 임시 비활성화
|
||||
{enabledLayers.has('labels') &&
|
||||
stations.map((station) => {
|
||||
const isSelected = selectedStationId === station.id
|
||||
@ -139,7 +136,6 @@ export function WeatherMapOverlay({
|
||||
}}
|
||||
className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer"
|
||||
>
|
||||
{/* 관측소명 */}
|
||||
<div
|
||||
style={{
|
||||
color: textColor,
|
||||
@ -150,8 +146,6 @@ export function WeatherMapOverlay({
|
||||
>
|
||||
{station.name}
|
||||
</div>
|
||||
|
||||
{/* 수온 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||
@ -160,22 +154,12 @@ export function WeatherMapOverlay({
|
||||
🌡️
|
||||
</div>
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<span
|
||||
className="text-sm font-bold text-white"
|
||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||
{station.temperature.current.toFixed(1)}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] text-white opacity-90"
|
||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
°C
|
||||
</span>
|
||||
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파고 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||
@ -184,22 +168,12 @@ export function WeatherMapOverlay({
|
||||
🌊
|
||||
</div>
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<span
|
||||
className="text-sm font-bold text-white"
|
||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||
{station.wave.height.toFixed(1)}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] text-white opacity-90"
|
||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
m
|
||||
</span>
|
||||
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 풍속 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||
@ -208,24 +182,17 @@ export function WeatherMapOverlay({
|
||||
💨
|
||||
</div>
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<span
|
||||
className="text-sm font-bold text-white"
|
||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||
{station.wind.speed.toFixed(1)}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] text-white opacity-90"
|
||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
m/s
|
||||
</span>
|
||||
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
*/}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import type { Layer } from '@deck.gl/core'
|
||||
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { WeatherRightPanel } from './WeatherRightPanel'
|
||||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
|
||||
import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||
import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||||
// import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||||
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
||||
import { WindParticleLayer } from './WindParticleLayer'
|
||||
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
|
||||
import { useWeatherData } from '../hooks/useWeatherData'
|
||||
import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||
import { WeatherMapControls } from './WeatherMapControls'
|
||||
|
||||
type TimeOffset = '0' | '3' | '6' | '9'
|
||||
|
||||
@ -117,38 +119,6 @@ function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 줌 컨트롤
|
||||
function WeatherMapControls() {
|
||||
const { current: map } = useMap()
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => map?.zoomIn()}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.zoomOut()}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
map?.flyTo({ center: WEATHER_MAP_CENTER, zoom: WEATHER_MAP_ZOOM, duration: 1000 })
|
||||
}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
|
||||
>
|
||||
🎯
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
||||
*/
|
||||
@ -156,18 +126,20 @@ interface WeatherMapInnerProps {
|
||||
weatherStations: WeatherStation[]
|
||||
enabledLayers: Set<string>
|
||||
selectedStationId: string | null
|
||||
oceanForecastOpacity: number
|
||||
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
|
||||
onStationClick: (station: WeatherStation) => void
|
||||
mapCenter: [number, number]
|
||||
mapZoom: number
|
||||
clickedLocation: { lat: number; lon: number } | null
|
||||
}
|
||||
|
||||
function WeatherMapInner({
|
||||
weatherStations,
|
||||
enabledLayers,
|
||||
selectedStationId,
|
||||
oceanForecastOpacity,
|
||||
selectedForecast,
|
||||
onStationClick,
|
||||
mapCenter,
|
||||
mapZoom,
|
||||
clickedLocation,
|
||||
}: WeatherMapInnerProps) {
|
||||
// deck.gl layers 조합
|
||||
const weatherDeckLayers = useWeatherDeckLayers(
|
||||
@ -176,18 +148,18 @@ function WeatherMapInner({
|
||||
selectedStationId,
|
||||
onStationClick
|
||||
)
|
||||
const oceanCurrentLayers = useOceanCurrentLayers({
|
||||
visible: enabledLayers.has('oceanCurrent'),
|
||||
opacity: 0.7,
|
||||
})
|
||||
// const oceanCurrentLayers = useOceanCurrentLayers({
|
||||
// visible: enabledLayers.has('oceanCurrent'),
|
||||
// opacity: 0.7,
|
||||
// })
|
||||
const waterTempLayers = useWaterTemperatureLayers({
|
||||
visible: enabledLayers.has('waterTemperature'),
|
||||
opacity: 0.5,
|
||||
})
|
||||
|
||||
const deckLayers = useMemo(
|
||||
() => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers],
|
||||
[oceanCurrentLayers, waterTempLayers, weatherDeckLayers]
|
||||
() => [...waterTempLayers, ...weatherDeckLayers],
|
||||
[waterTempLayers, weatherDeckLayers]
|
||||
)
|
||||
|
||||
return (
|
||||
@ -195,11 +167,16 @@ function WeatherMapInner({
|
||||
{/* deck.gl 오버레이 */}
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
|
||||
{/* 해황예보도 — MapLibre image source + raster layer */}
|
||||
{/* 해황예보도 — 임시 비활성화
|
||||
<OceanForecastOverlay
|
||||
forecast={selectedForecast}
|
||||
opacity={oceanForecastOpacity}
|
||||
visible={enabledLayers.has('oceanForecast')}
|
||||
/> */}
|
||||
|
||||
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
|
||||
<OceanCurrentParticleLayer
|
||||
visible={enabledLayers.has('oceanCurrentParticle')}
|
||||
/>
|
||||
|
||||
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
|
||||
@ -216,8 +193,31 @@ function WeatherMapInner({
|
||||
stations={weatherStations}
|
||||
/>
|
||||
|
||||
{/* 클릭 위치 마커 */}
|
||||
{clickedLocation && (
|
||||
<Marker
|
||||
longitude={clickedLocation.lon}
|
||||
latitude={clickedLocation.lat}
|
||||
anchor="bottom"
|
||||
>
|
||||
<div className="flex flex-col items-center pointer-events-none">
|
||||
{/* 펄스 링 */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="absolute w-8 h-8 rounded-full border-2 border-primary-cyan animate-ping opacity-60" />
|
||||
<div className="w-4 h-4 rounded-full bg-primary-cyan border-2 border-white shadow-lg" />
|
||||
</div>
|
||||
{/* 핀 꼬리 */}
|
||||
<div className="w-px h-3 bg-primary-cyan" />
|
||||
{/* 좌표 라벨 */}
|
||||
<div className="px-2 py-1 bg-bg-0/90 border border-primary-cyan rounded text-[10px] text-primary-cyan whitespace-nowrap backdrop-blur-sm">
|
||||
{clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<WeatherMapControls />
|
||||
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -225,21 +225,22 @@ function WeatherMapInner({
|
||||
export function WeatherView() {
|
||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||
|
||||
const {
|
||||
selectedForecast,
|
||||
availableTimes,
|
||||
loading: oceanLoading,
|
||||
error: oceanError,
|
||||
selectForecast,
|
||||
} = useOceanForecast('KOREA')
|
||||
|
||||
// const {
|
||||
// selectedForecast,
|
||||
// availableTimes,
|
||||
// loading: oceanLoading,
|
||||
// error: oceanError,
|
||||
// selectForecast,
|
||||
// } = useOceanForecast('KOREA')
|
||||
|
||||
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
|
||||
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
|
||||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
||||
null
|
||||
)
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind', 'labels']))
|
||||
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
|
||||
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||
|
||||
// 첫 관측소 자동 선택 (파생 값)
|
||||
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
|
||||
@ -343,12 +344,6 @@ export function WeatherView() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="px-6">
|
||||
<button className="px-4 py-2 text-xs font-semibold rounded bg-status-red text-white hover:opacity-90 transition-opacity">
|
||||
🚨 예보전송
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
@ -368,9 +363,10 @@ export function WeatherView() {
|
||||
weatherStations={weatherStations}
|
||||
enabledLayers={enabledLayers}
|
||||
selectedStationId={selectedStation?.id || null}
|
||||
oceanForecastOpacity={oceanForecastOpacity}
|
||||
selectedForecast={selectedForecast}
|
||||
onStationClick={handleStationClick}
|
||||
mapCenter={WEATHER_MAP_CENTER}
|
||||
mapZoom={WEATHER_MAP_ZOOM}
|
||||
clickedLocation={selectedLocation}
|
||||
/>
|
||||
</Map>
|
||||
|
||||
@ -396,6 +392,7 @@ export function WeatherView() {
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌬️ 바람 벡터</span>
|
||||
</label>
|
||||
{/* 기상 데이터 레이어 — 임시 비활성화
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -405,6 +402,7 @@ export function WeatherView() {
|
||||
/>
|
||||
<span className="text-xs text-text-2">📊 기상 데이터</span>
|
||||
</label>
|
||||
*/}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -426,11 +424,11 @@ export function WeatherView() {
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('oceanCurrent')}
|
||||
onChange={() => toggleLayer('oceanCurrent')}
|
||||
checked={enabledLayers.has('oceanCurrentParticle')}
|
||||
onChange={() => toggleLayer('oceanCurrentParticle')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌊 해류 방향</span>
|
||||
<span className="text-xs text-text-2">🌊 해류 흐름</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@ -442,7 +440,7 @@ export function WeatherView() {
|
||||
<span className="text-xs text-text-2">🌡️ 수온 색상도</span>
|
||||
</label>
|
||||
|
||||
{/* 해황예보도 레이어 */}
|
||||
{/* 해황예보도 레이어 — 임시 비활성화
|
||||
<div className="pt-2 mt-2 border-t border-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@ -453,71 +451,19 @@ export function WeatherView() {
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌊 해황예보도</span>
|
||||
</label>
|
||||
|
||||
{enabledLayers.has('oceanForecast') && (
|
||||
<div className="mt-2 ml-6 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-3">투명도:</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={oceanForecastOpacity * 100}
|
||||
onChange={(e) =>
|
||||
setOceanForecastOpacity(Number(e.target.value) / 100)
|
||||
}
|
||||
className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-text-3 w-8">
|
||||
{Math.round(oceanForecastOpacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{availableTimes.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-3">예보 시간:</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{availableTimes.map((time) => (
|
||||
<button
|
||||
key={`${time.day}-${time.hour}`}
|
||||
onClick={() => selectForecast(time.day, time.hour)}
|
||||
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedForecast?.day === time.day &&
|
||||
selectedForecast?.hour === time.hour
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
|
||||
}`}
|
||||
>
|
||||
{time.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oceanLoading && <div className="text-xs text-text-3">로딩 중...</div>}
|
||||
{oceanError && <div className="text-xs text-status-red">오류 발생</div>}
|
||||
{selectedForecast && (
|
||||
<div className="text-xs text-text-3 pt-2 border-t border-border">
|
||||
현재: {selectedForecast.name} •{' '}
|
||||
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '}
|
||||
{selectedForecast.hour}:00
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="absolute bottom-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm z-10">
|
||||
<div className="text-sm font-semibold text-text-1 mb-3">기상 범례</div>
|
||||
<div className="space-y-3 text-xs">
|
||||
{/* 바람 (Windy 스타일) */}
|
||||
<div className="absolute bottom-4 left-4 bg-bg-1/85 border border-border rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px', maxWidth: 180 }}>
|
||||
<div className="text-[9px] font-semibold text-text-1 mb-1.5 font-korean">기상 범례</div>
|
||||
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
|
||||
{/* 바람 */}
|
||||
<div>
|
||||
<div className="font-semibold text-text-2 mb-1">바람 (m/s)</div>
|
||||
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
|
||||
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}>바람 (m/s)</div>
|
||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#50d591' }} />
|
||||
@ -527,36 +473,38 @@ export function WeatherView() {
|
||||
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-text-3 text-[9px]">
|
||||
<span>3</span>
|
||||
<span>5</span>
|
||||
<span>7</span>
|
||||
<span>10</span>
|
||||
<span>13</span>
|
||||
<span>16</span>
|
||||
<span>20+</span>
|
||||
<div className="flex justify-between text-text-3" style={{ fontSize: 7 }}>
|
||||
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 해류 */}
|
||||
<div className="pt-1 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}>해류 (m/s)</div>
|
||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-text-3" style={{ fontSize: 7 }}>
|
||||
<span>0.2</span><span>0.4</span><span>0.6</span><span>0.6+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파고 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-1">파고 (m)</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-text-3">< 1.5: 낮음</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500" />
|
||||
<span className="text-text-3">1.5-2.5: 보통</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="text-text-3">> 2.5: 높음</span>
|
||||
<div className="pt-1 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}>파고 (m)</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-text-3"><1.5 낮음</span>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 ml-1" />
|
||||
<span className="text-text-3">~2.5</span>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 ml-1" />
|
||||
<span className="text-text-3">>2.5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-border text-xs text-text-3">
|
||||
💡 지도를 클릭하여 해당 지점의 기상 예보를 확인하세요
|
||||
<div className="mt-1 pt-1 border-t border-border text-text-3 font-korean" style={{ fontSize: 7 }}>
|
||||
💡 지도 클릭 → 기상 예보 확인
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -70,9 +70,9 @@ export function useOceanForecast(
|
||||
// 사용 가능한 시간대 목록 생성
|
||||
const availableTimes = forecasts
|
||||
.map((f) => ({
|
||||
day: f.day,
|
||||
hour: f.hour,
|
||||
label: `${f.day.slice(4, 6)}/${f.day.slice(6, 8)} ${f.hour}:00`
|
||||
day: f.ofcFrcstYmd,
|
||||
hour: f.ofcFrcstTm,
|
||||
label: `${f.ofcFrcstYmd.slice(4, 6)}/${f.ofcFrcstYmd.slice(6, 8)} ${f.ofcFrcstTm}:00`
|
||||
}))
|
||||
.sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`))
|
||||
|
||||
|
||||
@ -87,6 +87,7 @@ export function useWeatherData(stations: WeatherStation[]) {
|
||||
}
|
||||
|
||||
const obs = await getRecentObservation(obsCode)
|
||||
|
||||
|
||||
if (obs) {
|
||||
const r = (n: number) => Math.round(n * 10) / 10
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
|
||||
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || ''
|
||||
const BASE_URL = 'https://www.khoa.go.kr/api/oceangrid/DataType/search.do'
|
||||
const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService'
|
||||
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService'
|
||||
|
||||
// 지역 유형 (총 20개 지역)
|
||||
@ -22,20 +22,21 @@ export const OCEAN_REGIONS = {
|
||||
} as const
|
||||
|
||||
export interface OceanForecastData {
|
||||
name: string // 지역명
|
||||
type: string // 지역유형
|
||||
day: string // 예보날짜 (YYYYMMDD)
|
||||
hour: string // 예보시간 (HH)
|
||||
fileName: string // 이미지 파일명
|
||||
filePath: string // 해양예측 이미지 URL
|
||||
imgFileNm: string // 이미지 파일명
|
||||
imgFilePath: string // 이미지 파일경로
|
||||
ofcBrnchId: string // 해황예보도 지점코드
|
||||
ofcBrnchNm: string // 해황예보도 지점이름
|
||||
ofcFrcstTm: string // 해황예보도 예보시각
|
||||
ofcFrcstYmd: string // 해황예보도 예보일자
|
||||
}
|
||||
|
||||
export interface OceanForecastResponse {
|
||||
result: {
|
||||
data: OceanForecastData[]
|
||||
meta: {
|
||||
totalCount: number
|
||||
}
|
||||
interface OceanForecastApiResponse {
|
||||
header: { resultCode: string; resultMsg: string }
|
||||
body: {
|
||||
items: { item: OceanForecastData[] }
|
||||
pageNo: number
|
||||
numOfRows: number
|
||||
totalCount: number
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,9 +50,9 @@ export async function getOceanForecast(
|
||||
): Promise<OceanForecastData[]> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
ServiceKey: API_KEY,
|
||||
type: regionType,
|
||||
ResultType: 'json'
|
||||
serviceKey: API_KEY,
|
||||
areaCode: regionType,
|
||||
type: 'json',
|
||||
})
|
||||
|
||||
const response = await fetch(`${BASE_URL}?${params}`)
|
||||
@ -60,20 +61,8 @@ export async function getOceanForecast(
|
||||
throw new Error(`HTTP Error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// API 응답 구조에 따라 데이터 추출
|
||||
if (data?.result?.data) {
|
||||
return data.result.data
|
||||
}
|
||||
|
||||
// 응답이 배열 형태인 경우
|
||||
if (Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
|
||||
console.warn('Unexpected API response structure:', data)
|
||||
return []
|
||||
const data = await response.json() as OceanForecastApiResponse
|
||||
return data?.body?.items?.item ?? []
|
||||
|
||||
} catch (error) {
|
||||
console.error('해황예보도 조회 오류:', error)
|
||||
@ -89,10 +78,9 @@ export async function getOceanForecast(
|
||||
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
|
||||
if (!forecasts || forecasts.length === 0) return null
|
||||
|
||||
// 날짜와 시간을 기준으로 정렬
|
||||
const sorted = [...forecasts].sort((a, b) => {
|
||||
const dateTimeA = `${a.day}${a.hour}`
|
||||
const dateTimeB = `${b.day}${b.hour}`
|
||||
const dateTimeA = `${a.ofcFrcstYmd}${a.ofcFrcstTm}`
|
||||
const dateTimeB = `${b.ofcFrcstYmd}${b.ofcFrcstTm}`
|
||||
return dateTimeB.localeCompare(dateTimeA)
|
||||
})
|
||||
|
||||
@ -112,7 +100,7 @@ export function getForecastByTime(
|
||||
targetHour: string
|
||||
): OceanForecastData | null {
|
||||
return (
|
||||
forecasts.find((f) => f.day === targetDay && f.hour === targetHour) || null
|
||||
forecasts.find((f) => f.ofcFrcstYmd === targetDay && f.ofcFrcstTm === targetHour) || null
|
||||
)
|
||||
}
|
||||
|
||||
@ -157,23 +145,25 @@ export async function getRecentObservation(obsCode: string): Promise<RecentObser
|
||||
})
|
||||
|
||||
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json()
|
||||
const item = data?.result?.data?.[0]
|
||||
const item = data.body ? data.body.items.item[0] : null
|
||||
if (!item) return null
|
||||
|
||||
|
||||
return {
|
||||
water_temp: item.water_temp != null ? parseFloat(item.water_temp) : null,
|
||||
air_temp: item.air_temp != null ? parseFloat(item.air_temp) : null,
|
||||
air_pres: item.air_pres != null ? parseFloat(item.air_pres) : null,
|
||||
wind_dir: item.wind_dir != null ? parseFloat(item.wind_dir) : null,
|
||||
wind_speed: item.wind_speed != null ? parseFloat(item.wind_speed) : null,
|
||||
current_dir: item.current_dir != null ? parseFloat(item.current_dir) : null,
|
||||
current_speed: item.current_speed != null ? parseFloat(item.current_speed) : null,
|
||||
tide_level: item.tide_level != null ? parseFloat(item.tide_level) : null,
|
||||
water_temp: item.wtem != null ? parseFloat(item.wtem) : null,
|
||||
air_temp: item.artmp != null ? parseFloat(item.artmp) : null,
|
||||
air_pres: item.atmpr != null ? parseFloat(item.atmpr) : null,
|
||||
wind_dir: item.wndrct != null ? parseFloat(item.wndrct) : null,
|
||||
wind_speed: item.wspd != null ? parseFloat(item.wspd) : null,
|
||||
current_dir: item.crdir != null ? parseFloat(item.crdir) : null,
|
||||
current_speed: item.crsp != null ? parseFloat(item.crsp) : null,
|
||||
tide_level: item.bscTdlvHgt != null ? parseFloat(item.bscTdlvHgt) : null,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)
|
||||
|
||||
@ -13,6 +13,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/daily_ocean': {
|
||||
target: 'https://www.khoa.go.kr',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user