diff --git a/.claude/settings.json b/.claude/settings.json index 868df2d..3027e9b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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": [] +} \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index d87fe3a..ab9c219 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -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 -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index fecd7b4..9a71415 100755 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/package-lock.json b/backend/package-lock.json index e5e0aed..64418c7 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 1e578cb..50ceb73 100755 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts index 6823ccf..7f892e7 100644 --- a/backend/src/aerial/aerialRouter.ts +++ b/backend/src/aerial/aerialRouter.ts @@ -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(); diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts index 8b9318a..c3ec980 100644 --- a/backend/src/aerial/aerialService.ts +++ b/backend/src/aerial/aerialService.ts @@ -53,6 +53,26 @@ function rowToMedia(r: Record): AerialMediaItem { }; } +export async function getMediaBySn(sn: number): Promise { + 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 { + 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 { 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 { + 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 { const controller = new AbortController(); diff --git a/backend/src/middleware/security.ts b/backend/src/middleware/security.ts index 4f5d53e..3945c1b 100755 --- a/backend/src/middleware/security.ts +++ b/backend/src/middleware/security.ts @@ -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' /** * 응답 헤더에서 서버 정보 제거 diff --git a/backend/src/prediction/imageAnalyzeService.ts b/backend/src/prediction/imageAnalyzeService.ts new file mode 100644 index 0000000..41452e7 --- /dev/null +++ b/backend/src/prediction/imageAnalyzeService.ts @@ -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 = { + '검정': '벙커C유', + '갈색': '벙커C유', + '무지개': '경유', + '은색': '등유', +}; + +// 유종명 → DB 코드 매핑 +const OIL_DB_CODE_MAP: Record = { + '벙커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 { + 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 }; +} diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index c6802e4..d017a19 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -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; diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index e46afd3..115c4c4 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -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)['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 = { + '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; + hydrDataByModel: Record; + summaryByModel: Record; +} + +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 { + // 완료된 모든 모델(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 = {}; + const hydrDataByModel: Record = {}; + const summaryByModel: Record = {}; + + // OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근) + const opendriftRow = (rows as Array>).find((r) => r['algo_cd'] === 'OPENDRIFT'); + const poseidonRow = (rows as Array>).find((r) => r['algo_cd'] === 'POSEIDON'); + const baseRow = opendriftRow ?? poseidonRow ?? null; + + for (const row of rows as Array>) { + 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 { const sql = ` SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD, diff --git a/backend/src/reports/reportsRouter.ts b/backend/src/reports/reportsRouter.ts index 5455dd1..1644497 100644 --- a/backend/src/reports/reportsRouter.ts +++ b/backend/src/reports/reportsRouter.ts @@ -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) { diff --git a/backend/src/reports/reportsService.ts b/backend/src/reports/reportsService.ts index 63437e6..db655d9 100644 --- a/backend/src/reports/reportsService.ts +++ b/backend/src/reports/reportsService.ts @@ -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 '' 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 { 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 { 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( diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index 98bcf84..cd905a5 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -1,227 +1,907 @@ import { Router, Request, Response } from 'express' +import { wingPool } from '../db/wingDb.js' +import { requireAuth } from '../auth/authMiddleware.js' import { isValidLatitude, isValidLongitude, isValidNumber, - isAllowedValue, isValidStringLength, - escapeHtml, } from '../middleware/security.js' const router = Router() -// 허용된 모델 목록 (화이트리스트) -const ALLOWED_MODELS = ['KOSPS', 'POSEIDON', 'OpenDrift', '앙상블'] as const -type AllowedModel = typeof ALLOWED_MODELS[number] +const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003' +const POSEIDON_API_URL = process.env.POSEIDON_API_URL ?? 'http://localhost:5004' +const POLL_INTERVAL_MS = 3000 +const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분 -// 허용된 유종 목록 -const ALLOWED_OIL_TYPES = ['원유', '벙커C유', '경유', '휘발유', '등유', '윤활유', '기타'] as const - -// 허용된 유출 유형 목록 -const ALLOWED_SPILL_TYPES = ['연속유출', '순간유출'] as const - -interface ParticlePoint { - lat: number - lon: number - time: number - particle: number +// 유종 매핑: 한국어 UI 선택값 → OpenDrift 유종 코드 +// 추후 DB/설정 파일로 외부화 예정 (개발 단계 임시 구현) +const OIL_TYPE_MAP: Record = { + '벙커C유': 'GENERIC BUNKER C', + '경유': 'GENERIC DIESEL', + '원유': 'WEST TEXAS INTERMEDIATE (WTI)', + '중유': 'GENERIC HEAVY FUEL OIL', + '등유': 'FUEL OIL NO.1 (KEROSENE)', + '휘발유': 'GENERIC GASOLINE', } -/** - * POST /api/simulation/run - * 오일 확산 시뮬레이션 실행 - * - * 보안 조치: - * - 화이트리스트 기반 모델명 검증 - * - 좌표 범위 검증 (위도 -90~90, 경도 -180~180) - * - 숫자 범위 검증 (duration, spill_amount) - * - 문자열 길이 제한 - */ -router.post('/run', async (req: Request, res: Response) => { - try { - const { model, lat, lon, duration_hours, oil_type, spill_amount, spill_type } = req.body +// 유종 매핑: 한국어 UI → DB 저장 코드 +const OIL_DB_CODE_MAP: Record = { + '벙커C유': 'BUNKER_C', + '경유': 'DIESEL', + '원유': 'CRUDE_OIL', + '중유': 'HEAVY_FUEL_OIL', + '등유': 'KEROSENE', + '휘발유': 'GASOLINE', +} - // 1. 필수 파라미터 존재 검증 - if (model === undefined || lat === undefined || lon === undefined || duration_hours === undefined) { +// 유출 형태 매핑: 한국어 UI → DB 저장 코드 +const SPIL_TYPE_MAP: Record = { + '연속': 'CONTINUOUS', + '비연속': 'DISCONTINUOUS', + '순간 유출': 'INSTANT', +} + +// 단위 매핑: 한국어 UI → DB 저장 코드 +const UNIT_MAP: Record = { + 'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL', +} + +// ============================================================ +// 신규 생성된 ACDNT/SPIL_DATA/PRED_EXEC 롤백 헬퍼 +// Python 호출 실패 시 이번 요청에서 생성된 레코드만 삭제한다. +// ============================================================ +async function rollbackNewRecords( + predExecSn: number | null, + newSpilDataSn: number | null, + newAcdntSn: number | null +): Promise { + try { + if (predExecSn !== null) { + await wingPool.query('DELETE FROM wing.PRED_EXEC WHERE PRED_EXEC_SN=$1', [predExecSn]) + } + if (newSpilDataSn !== null) { + await wingPool.query('DELETE FROM wing.SPIL_DATA WHERE SPIL_DATA_SN=$1', [newSpilDataSn]) + } + if (newAcdntSn !== null) { + await wingPool.query('DELETE FROM wing.ACDNT WHERE ACDNT_SN=$1', [newAcdntSn]) + } + } catch (cleanupErr) { + console.error('[simulation] 롤백 실패:', cleanupErr) + } +} + +// 모델명 → ALGO_CD 매핑 +const MODEL_ALGO_CD_MAP: Record = { + 'OpenDrift': 'OPENDRIFT', + 'POSEIDON': 'POSEIDON', +} + +// 모델명 → API URL 매핑 +const MODEL_API_URL_MAP: Record = { + 'OpenDrift': PYTHON_API_URL, + 'POSEIDON': POSEIDON_API_URL, +} + +// ============================================================ +// POST /api/simulation/run +// 확산 시뮬레이션 실행 (다중 모델 지원: OpenDrift, POSEIDON) +// ============================================================ +/** + * 선택된 모델(OpenDrift, POSEIDON)로 확산 시뮬레이션을 실행한다. + * 각 모델에 대해 PRED_EXEC 레코드를 별도 생성하고 Python API에 병렬 제출한다. + * KOSPS 모델은 PRED_EXEC INSERT(PENDING)만 수행하고 외부 API 연동은 하지 않는다. + * 프론트엔드는 execSns 배열의 각 execSn으로 GET /status/:execSn을 폴링하여 결과를 수신한다. + */ +router.post('/run', requireAuth, async (req: Request, res: Response) => { + try { + const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd, + lat, lon, runTime, matTy, matVol, spillTime, startTime, + models: rawModels } = req.body + + // 실행할 모델 목록 (기본값: OpenDrift) + const requestedModels: string[] = Array.isArray(rawModels) && rawModels.length > 0 + ? (rawModels as string[]) + : ['OpenDrift'] + + // 1. 필수 파라미터 검증 + if (lat === undefined || lon === undefined || runTime === undefined) { return res.status(400).json({ error: '필수 파라미터 누락', - required: ['model', 'lat', 'lon', 'duration_hours'] + required: ['lat', 'lon', 'runTime'], }) } - - // 2. 모델명 화이트리스트 검증 - if (!isAllowedValue(model, [...ALLOWED_MODELS])) { - return res.status(400).json({ - error: '유효하지 않은 모델', - message: `허용된 모델: ${ALLOWED_MODELS.join(', ')}`, - }) - } - - // 3. 위도/경도 범위 검증 if (!isValidLatitude(lat)) { - return res.status(400).json({ - error: '유효하지 않은 위도', - message: '위도는 -90 ~ 90 범위의 숫자여야 합니다.' - }) + return res.status(400).json({ error: '유효하지 않은 위도', message: '위도는 -90~90 범위여야 합니다.' }) } if (!isValidLongitude(lon)) { - return res.status(400).json({ - error: '유효하지 않은 경도', - message: '경도는 -180 ~ 180 범위의 숫자여야 합니다.' - }) + return res.status(400).json({ error: '유효하지 않은 경도', message: '경도는 -180~180 범위여야 합니다.' }) + } + if (!isValidNumber(runTime, 1, 720)) { + return res.status(400).json({ error: '유효하지 않은 예측 시간', message: '예측 시간은 1~720 범위여야 합니다.' }) + } + if (matVol !== undefined && !isValidNumber(matVol, 0, 1000000)) { + return res.status(400).json({ error: '유효하지 않은 유출량' }) + } + if (matTy !== undefined && (typeof matTy !== 'string' || !isValidStringLength(matTy, 50))) { + return res.status(400).json({ error: '유효하지 않은 유종' }) + } + // acdntSn 없는 경우 acdntNm 필수 + if (!rawAcdntSn && (!acdntNm || typeof acdntNm !== 'string' || !acdntNm.trim())) { + return res.status(400).json({ error: '사고를 선택하거나 사고명을 입력해야 합니다.' }) + } + if (acdntNm && (typeof acdntNm !== 'string' || !isValidStringLength(acdntNm, 200))) { + return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' }) } - // 4. 예측 시간 범위 검증 (1~720시간 = 최대 30일) - if (!isValidNumber(duration_hours, 1, 720)) { - return res.status(400).json({ - error: '유효하지 않은 예측 시간', - message: '예측 시간은 1~720 범위의 숫자여야 합니다.' - }) - } - - // 5. 선택적 파라미터 검증 - if (oil_type !== undefined) { - if (typeof oil_type !== 'string' || !isValidStringLength(oil_type, 50)) { - return res.status(400).json({ error: '유효하지 않은 유종' }) - } - } - - if (spill_amount !== undefined) { - if (!isValidNumber(spill_amount, 0, 1000000)) { - return res.status(400).json({ - error: '유효하지 않은 유출량', - message: '유출량은 0~1,000,000 범위의 숫자여야 합니다.' + // 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지) + // OpenDrift 모델이 포함된 경우에만 check-nc 수행 + if (requestedModels.includes('OpenDrift')) { + try { + const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lon, startTime }), + signal: AbortSignal.timeout(5000), }) + if (!checkRes.ok) { + return res.status(409).json({ + error: '해당 좌표의 해양 기상 데이터가 없습니다.', + message: 'NC 파일이 준비되지 않았습니다.', + }) + } + } catch { + // Python 서버 미기동 — 5번에서 처리 } } - if (spill_type !== undefined) { - if (typeof spill_type !== 'string' || !isValidStringLength(spill_type, 50)) { - return res.status(400).json({ error: '유효하지 않은 유출 유형' }) + // 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성 + let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null + let resolvedSpilDataSn: number | null = null + // 이번 요청에서 신규 생성된 레코드 추적 (Python 실패 시 롤백 대상) + let newlyCreatedAcdntSn: number | null = null + let newlyCreatedSpilDataSn: number | null = null + + if (!resolvedAcdntSn && acdntNm) { + try { + const occrn = startTime ?? new Date().toISOString() + const acdntRes = await wingPool.query( + `INSERT INTO wing.ACDNT + (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM) + VALUES ( + 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-' || + LPAD( + (SELECT COALESCE(MAX(CAST(SPLIT_PART(ACDNT_CD, '-', 3) AS INTEGER)), 0) + 1 + FROM wing.ACDNT + WHERE ACDNT_CD LIKE 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-%')::TEXT, + 4, '0' + ), + $1, '유류유출', $2, $3, $4, 'ACTIVE', 'Y', NOW() + ) + RETURNING ACDNT_SN`, + [acdntNm.trim(), occrn, lat, lon] + ) + resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number + newlyCreatedAcdntSn = resolvedAcdntSn + + const spilRes = await wingPool.query( + `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING SPIL_DATA_SN`, + [ + resolvedAcdntSn, + OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C', + matVol ?? 0, + UNIT_MAP[spillUnit as string] ?? 'KL', + SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS', + runTime, + ] + ) + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number + newlyCreatedSpilDataSn = resolvedSpilDataSn + } catch (dbErr) { + console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr) + return res.status(500).json({ error: '사고 정보 생성 실패' }) } } - // 검증 완료 - 시뮬레이션 실행 - const trajectory = generateDemoTrajectory( - lat, - lon, - duration_hours, - model, - 20 + // 3. 기존 사고의 경우 SPIL_DATA_SN 조회 + if (resolvedAcdntSn && !resolvedSpilDataSn) { + try { + const spilRes = await wingPool.query( + `SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`, + [resolvedAcdntSn] + ) + if (spilRes.rows.length > 0) { + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number + } + } catch (dbErr) { + console.error('[simulation] SPIL_DATA 조회 실패:', dbErr) + } + } + + // matTy 변환: 한국어 유종 → OpenDrift 유종 코드 + // 매핑 대상이 아니면 원본 값 그대로 사용 (영문 직접 입력 대응) + const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined + + // 4. 각 모델별 PRED_EXEC INSERT 및 API 호출 (병렬) + // KOSPS: PRED_EXEC PENDING 생성만 하고 배열에서 제외 (외부 API 미연동) + const execNmBase = `EXPC_${Date.now()}` + const execSns: Array<{ model: string; execSn: number }> = [] + + // KOSPS 처리: PRED_EXEC INSERT(PENDING)만 수행 + if (requestedModels.includes('KOSPS')) { + try { + const kospsExecNm = `${execNmBase}_KOSPS` + const insertRes = await wingPool.query( + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) + VALUES ($1, $2, 'KOSPS', 'PENDING', $3, NOW()) + RETURNING PRED_EXEC_SN`, + [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm] + ) + execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number }) + } catch (dbErr) { + console.error('[simulation] KOSPS PRED_EXEC INSERT 실패:', dbErr) + } + } + + // API 연동 모델 필터링 (KOSPS 제외) + const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined) + + // 각 모델에 대해 PRED_EXEC INSERT → /run-model 호출 + await Promise.all( + apiModels.map(async (model) => { + const algoCd = MODEL_ALGO_CD_MAP[model] + const apiUrl = MODEL_API_URL_MAP[model] + const execNm = `${execNmBase}_${algoCd}` + + // PRED_EXEC INSERT (PENDING) + let predExecSn: number + try { + const insertRes = await wingPool.query( + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) + VALUES ($1, $2, $3, 'PENDING', $4, NOW()) + RETURNING PRED_EXEC_SN`, + [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm] + ) + predExecSn = insertRes.rows[0].pred_exec_sn as number + } catch (dbErr) { + console.error(`[simulation] ${model} PRED_EXEC INSERT 실패:`, dbErr) + return + } + + execSns.push({ model, execSn: predExecSn }) + + // Python /run-model 호출 + let jobId: string + try { + const pythonRes = await fetch(`${apiUrl}/run-model`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + lat, + lon, + startTime, + runTime, + matTy: odMatTy, + matVol, + spillTime, + name: execNm, + }), + signal: AbortSignal.timeout(10000), + }) + + if (pythonRes.status === 503) { + const errData = await pythonRes.json() as { error?: string } + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errData.error || '분석 서버 포화', predExecSn] + ) + await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) + return + } + + if (!pythonRes.ok) { + throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`) + } + + const pythonData = await pythonRes.json() as { job_id: string } + jobId = pythonData.job_id + } catch { + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`, + [predExecSn] + ) + // 이 모델의 PRED_EXEC만 롤백 (다른 모델은 계속 진행) + await rollbackNewRecords(predExecSn, null, null) + return + } + + // RUNNING 업데이트 + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`, + [predExecSn] + ) + + // 백그라운드 폴링 시작 + pollAndSaveModel(jobId, predExecSn, apiUrl, algoCd).catch((err: unknown) => + console.error(`[simulation] ${model} pollAndSaveModel 오류:`, err) + ) + }) ) + // ACDNT/SPIL_DATA가 신규 생성됐으나 모든 모델이 실패한 경우 롤백 + const hasRunning = execSns.some(({ model }) => model !== 'KOSPS') + if (!hasRunning && newlyCreatedAcdntSn !== null) { + await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) + return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' }) + } + + // 즉시 응답 (하위 호환을 위해 execSn도 포함) + res.json({ + success: true, + execSns, + execSn: execSns[0]?.execSn ?? 0, + acdntSn: resolvedAcdntSn, + status: 'RUNNING', + }) + } catch { + res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// GET /api/simulation/status/:execSn +// 시뮬레이션 실행 상태 및 결과 조회 +// ============================================================ +/** + * PRED_EXEC 테이블에서 실행 상태를 조회한다. + * DB 상태(COMPLETED/FAILED)를 프론트 상태(DONE/ERROR)로 매핑하여 반환한다. + */ +router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) => { + const execSn = parseInt(req.params.execSn as string, 10) + if (isNaN(execSn) || execSn <= 0) { + return res.status(400).json({ error: '유효하지 않은 execSn' }) + } + + try { + const result = await wingPool.query( + `SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, pe.ALGO_CD, sd.FCST_HR, + ( + SELECT AVG(hist.REQD_SEC::FLOAT / hsd.FCST_HR) + FROM wing.PRED_EXEC hist + JOIN wing.SPIL_DATA hsd ON hist.SPIL_DATA_SN = hsd.SPIL_DATA_SN + WHERE hist.ALGO_CD = pe.ALGO_CD + AND hist.EXEC_STTS_CD = 'COMPLETED' + AND hist.REQD_SEC IS NOT NULL AND hist.REQD_SEC > 0 + AND hsd.FCST_HR IS NOT NULL AND hsd.FCST_HR > 0 + ) AS avg_sec_per_hr + FROM wing.PRED_EXEC pe + LEFT JOIN wing.SPIL_DATA sd ON pe.SPIL_DATA_SN = sd.SPIL_DATA_SN + WHERE pe.PRED_EXEC_SN=$1`, + [execSn] + ) + if (result.rows.length === 0) { + return res.status(404).json({ error: '분석 기록을 찾을 수 없습니다.' }) + } + + const row = result.rows[0] + const dbStatus: string = row.exec_stts_cd as string + // DB 상태 → API 상태 매핑 + const statusMap: Record = { + PENDING: 'PENDING', + RUNNING: 'RUNNING', + COMPLETED: 'DONE', + FAILED: 'ERROR', + } + const status = statusMap[dbStatus] ?? dbStatus + + if (status === 'DONE' && row.rslt_data) { + const algoCd = String(row.algo_cd ?? '') + const modelName = ALGO_CD_TO_MODEL_NAME[algoCd] ?? algoCd + const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[], modelName) + return res.json({ status, trajectory, summary, centerPoints, windData, hydrData }) + } + + if (status === 'ERROR') { + return res.json({ status, error: (row.err_msg as string) || '분석 중 오류가 발생했습니다.' }) + } + + // PENDING/RUNNING: 경과 시간 기반 진행률 계산 + // 과거 실행의 초/예측시간 비율(avg_sec_per_hr) × 현재 fcst_hr로 추정, 이력 없으면 5초/hr 폴백 + let progress: number | undefined; + if (status === 'RUNNING' && row.bgng_dtm) { + const fcstHr = Number(row.fcst_hr) || 24; + const avgSecPerHr = row.avg_sec_per_hr ? Number(row.avg_sec_per_hr) : 5; + const estimatedSec = avgSecPerHr * fcstHr; + const elapsedSec = (Date.now() - new Date(row.bgng_dtm as string).getTime()) / 1000; + progress = Math.min(95, Math.floor((elapsedSec / estimatedSec) * 100)); + } + + res.json({ status, ...(progress !== undefined && { progress }) }) + } catch { + res.status(500).json({ error: '상태 조회 실패' }) + } +}) + +// ============================================================ +// POST /api/simulation/run-model (동기 방식) +// 예측 완료 후 결과를 직접 반환한다. +// ============================================================ +/** + * 선택된 모델로 확산 시뮬레이션을 실행하고 완료될 때까지 대기한 후 결과를 반환한다. + * 다중 모델은 병렬로 실행되며, 일부 모델 실패 시 성공한 모델 결과는 포함된다. + */ +router.post('/run-model', requireAuth, async (req: Request, res: Response) => { + try { + const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd, + lat, lon, runTime, matTy, matVol, spillTime, startTime, + models: rawModels } = req.body + + let requestedModels: string[] = Array.isArray(rawModels) && rawModels.length > 0 + ? (rawModels as string[]) + : ['OpenDrift'] + + // 1. 필수 파라미터 검증 + if (lat === undefined || lon === undefined || runTime === undefined) { + return res.status(400).json({ error: '필수 파라미터 누락', required: ['lat', 'lon', 'runTime'] }) + } + if (!isValidLatitude(lat)) { + return res.status(400).json({ error: '유효하지 않은 위도', message: '위도는 -90~90 범위여야 합니다.' }) + } + if (!isValidLongitude(lon)) { + return res.status(400).json({ error: '유효하지 않은 경도', message: '경도는 -180~180 범위여야 합니다.' }) + } + if (!isValidNumber(runTime, 1, 720)) { + return res.status(400).json({ error: '유효하지 않은 예측 시간', message: '예측 시간은 1~720 범위여야 합니다.' }) + } + if (matVol !== undefined && !isValidNumber(matVol, 0, 1000000)) { + return res.status(400).json({ error: '유효하지 않은 유출량' }) + } + if (matTy !== undefined && (typeof matTy !== 'string' || !isValidStringLength(matTy, 50))) { + return res.status(400).json({ error: '유효하지 않은 유종' }) + } + if (!rawAcdntSn && (!acdntNm || typeof acdntNm !== 'string' || !acdntNm.trim())) { + return res.status(400).json({ error: '사고를 선택하거나 사고명을 입력해야 합니다.' }) + } + if (acdntNm && (typeof acdntNm !== 'string' || !isValidStringLength(acdntNm, 200))) { + return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' }) + } + + // 2. NC 파일 존재 여부 확인 + if (requestedModels.includes('OpenDrift')) { + try { + const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lon, startTime }), + signal: AbortSignal.timeout(5000), + }) + if (!checkRes.ok) { + // NC 파일 없으면 OpenDrift만 제외, 나머지 모델(POSEIDON 등)은 계속 진행 + requestedModels = requestedModels.filter(m => m !== 'OpenDrift') + if (requestedModels.length === 0) { + return res.status(409).json({ + error: '해당 좌표의 해양 기상 데이터가 없습니다.', + message: 'NC 파일이 준비되지 않았습니다.', + }) + } + } + } catch { + // Python 서버 미기동 — 이후 단계에서 처리 + } + } + + // 3. ACDNT/SPIL_DATA 생성 또는 조회 + let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null + let resolvedSpilDataSn: number | null = null + let newlyCreatedAcdntSn: number | null = null + let newlyCreatedSpilDataSn: number | null = null + + if (!resolvedAcdntSn && acdntNm) { + try { + const occrn = startTime ?? new Date().toISOString() + const acdntRes = await wingPool.query( + `INSERT INTO wing.ACDNT + (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM) + VALUES ( + 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-' || + LPAD( + (SELECT COALESCE(MAX(CAST(SPLIT_PART(ACDNT_CD, '-', 3) AS INTEGER)), 0) + 1 + FROM wing.ACDNT + WHERE ACDNT_CD LIKE 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-%')::TEXT, + 4, '0' + ), + $1, '유류유출', $2, $3, $4, 'ACTIVE', 'Y', NOW() + ) + RETURNING ACDNT_SN`, + [acdntNm.trim(), occrn, lat, lon] + ) + resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number + newlyCreatedAcdntSn = resolvedAcdntSn + + const spilRes = await wingPool.query( + `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING SPIL_DATA_SN`, + [ + resolvedAcdntSn, + OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C', + matVol ?? 0, + UNIT_MAP[spillUnit as string] ?? 'KL', + SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS', + runTime, + ] + ) + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number + newlyCreatedSpilDataSn = resolvedSpilDataSn + } catch (dbErr) { + console.error('[simulation/run-model] ACDNT/SPIL_DATA INSERT 실패:', dbErr) + return res.status(500).json({ error: '사고 정보 생성 실패' }) + } + } + + if (resolvedAcdntSn && !resolvedSpilDataSn) { + try { + const spilRes = await wingPool.query( + `SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`, + [resolvedAcdntSn] + ) + if (spilRes.rows.length > 0) { + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number + } + } catch (dbErr) { + console.error('[simulation/run-model] SPIL_DATA 조회 실패:', dbErr) + } + } + + const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined + const execNmBase = `EXPC_${Date.now()}` + + // KOSPS: PRED_EXEC INSERT(PENDING)만 수행 + const execSns: Array<{ model: string; execSn: number }> = [] + if (requestedModels.includes('KOSPS')) { + try { + const kospsExecNm = `${execNmBase}_KOSPS` + const insertRes = await wingPool.query( + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) + VALUES ($1, $2, 'KOSPS', 'PENDING', $3, NOW()) + RETURNING PRED_EXEC_SN`, + [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm] + ) + execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number }) + } catch (dbErr) { + console.error('[simulation/run-model] KOSPS PRED_EXEC INSERT 실패:', dbErr) + } + } + + // 4. API 연동 모델 시작 및 완료 대기 (병렬) + const apiModels = requestedModels.filter((m) => m !== 'KOSPS' && MODEL_ALGO_CD_MAP[m] !== undefined) + + interface SyncModelResult { + model: string + execSn: number + status: 'DONE' | 'ERROR' + trajectory?: ReturnType['trajectory'] + summary?: ReturnType['summary'] + centerPoints?: ReturnType['centerPoints'] + windData?: ReturnType['windData'] + hydrData?: ReturnType['hydrData'] + error?: string + } + + const modelResults = await Promise.all( + apiModels.map(async (model): Promise => { + const algoCd = MODEL_ALGO_CD_MAP[model] + const apiUrl = MODEL_API_URL_MAP[model] + const execNm = `${execNmBase}_${algoCd}` + + // PRED_EXEC INSERT + let predExecSn: number + try { + const insertRes = await wingPool.query( + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) + VALUES ($1, $2, $3, 'PENDING', $4, NOW()) + RETURNING PRED_EXEC_SN`, + [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm] + ) + predExecSn = insertRes.rows[0].pred_exec_sn as number + } catch (dbErr) { + console.error(`[simulation/run-model] ${model} PRED_EXEC INSERT 실패:`, dbErr) + return { model, execSn: 0, status: 'ERROR', error: 'DB 오류' } + } + + execSns.push({ model, execSn: predExecSn }) + + // Python /run-model 호출 + let jobId: string | undefined + try { + const pythonRes = await fetch(`${apiUrl}/run-model`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lon, startTime, runTime, matTy: odMatTy, matVol, spillTime, name: execNm }), + signal: AbortSignal.timeout(POLL_TIMEOUT_MS), + }) + + if (pythonRes.status === 503) { + const errData = await pythonRes.json() as { error?: string } + const errMsg = errData.error || '분석 서버 포화' + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errMsg, predExecSn] + ) + return { model, execSn: predExecSn, status: 'ERROR', error: errMsg } + } + + if (!pythonRes.ok) { + throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`) + } + + const pythonData = await pythonRes.json() as { + success?: boolean; + result?: PythonTimeStep[]; + job_id?: string; + error?: string; + message?: string; + error_code?: number; + } + + // 동기 성공 응답 (OpenDrift & POSEIDON 공통) + if (Array.isArray(pythonData.result)) { + await wingPool.query( + `UPDATE wing.PRED_EXEC + SET EXEC_STTS_CD='COMPLETED', RSLT_DATA=$1, + CMPL_DTM=NOW(), REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER + WHERE PRED_EXEC_SN=$2`, + [JSON.stringify(pythonData.result), predExecSn] + ) + const { trajectory, summary, centerPoints, windData, hydrData } = + transformResult(pythonData.result, model) + return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData } + } + + // 비동기 응답 (하위 호환) + if (pythonData.job_id) { + jobId = pythonData.job_id + } else { + // 오류 응답 (success: false, HTTP 200) + const errMsg = pythonData.error || pythonData.message || '분석 오류' + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errMsg, predExecSn] + ) + return { model, execSn: predExecSn, status: 'ERROR', error: errMsg } + } + } catch (fetchErr) { + const errMsg = 'Python 분석 서버에 연결할 수 없습니다.' + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errMsg, predExecSn] + ) + return { model, execSn: predExecSn, status: 'ERROR', error: errMsg } + } + + // RUNNING 업데이트 (비동기 폴링 경로) + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`, + [predExecSn] + ) + + // 결과 동기 대기 + try { + const rawResult = await runModelSync(jobId!, predExecSn, apiUrl) + const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(rawResult, model) + return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData } + } catch (syncErr) { + return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message } + } + }) + ) + + // 모든 모델이 실패하고 신규 생성한 ACDNT가 있으면 롤백 + const hasSuccess = modelResults.some((r) => r.status === 'DONE') + if (!hasSuccess && newlyCreatedAcdntSn !== null) { + for (const r of modelResults) { + if (r.execSn) await rollbackNewRecords(r.execSn, null, null) + } + await rollbackNewRecords(null, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) + return res.status(503).json({ error: '분석 서버에 연결할 수 없습니다.' }) + } + res.json({ success: true, - model: escapeHtml(String(model)), - parameters: { - lat, - lon, - duration_hours, - oil_type: oil_type ? escapeHtml(String(oil_type)) : undefined, - spill_amount, - spill_type: spill_type ? escapeHtml(String(spill_type)) : undefined, - }, - trajectory, - metadata: { - particle_count: 20, - time_steps: duration_hours + 1, - generated_at: new Date().toISOString() - } + acdntSn: resolvedAcdntSn, + execSns: [...execSns, ...modelResults.map(({ model, execSn }) => ({ model, execSn }))], + results: modelResults, }) } catch { - // 내부 오류 메시지 노출 방지 - res.status(500).json({ - error: '시뮬레이션 실행 실패', - message: '서버 내부 오류가 발생했습니다.' - }) + res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' }) } }) -/** - * 데모 궤적 데이터 생성 - */ -function generateDemoTrajectory( - startLat: number, - startLon: number, - hours: number, - model: string, - particleCount: number -): ParticlePoint[] { - const trajectory: ParticlePoint[] = [] +// ============================================================ +// 백그라운드 폴링 +// ============================================================ +async function pollAndSaveModel(jobId: string, execSn: number, apiUrl: string, algoCode: string): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS - const modelFactors: Record = { - 'KOSPS': 0.004, - 'POSEIDON': 0.006, - 'OpenDrift': 0.005, - '앙상블': 0.0055 - } - const spreadFactor = modelFactors[model] || 0.005 + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) - const windSpeed = 5.5 - const windDirection = 135 - const currentSpeed = 0.55 - const currentDirection = 120 - const waveHeight = 2.2 - - const windRadians = (windDirection * Math.PI) / 180 - const currentRadians = (currentDirection * Math.PI) / 180 - - const windWeight = 0.03 - const currentWeight = 0.07 - - const mainDriftLat = - Math.sin(windRadians) * windSpeed * windWeight + - Math.sin(currentRadians) * currentSpeed * currentWeight - - const mainDriftLon = - Math.cos(windRadians) * windSpeed * windWeight + - Math.cos(currentRadians) * currentSpeed * currentWeight - - const dispersal = waveHeight * 0.001 - - for (let p = 0; p < particleCount; p++) { - const initialSpread = 0.001 - const randomAngle = Math.random() * Math.PI * 2 - let particleLat = startLat + Math.sin(randomAngle) * initialSpread * Math.random() - let particleLon = startLon + Math.cos(randomAngle) * initialSpread * Math.random() - - for (let h = 0; h <= hours; h++) { - const mainMovementLat = mainDriftLat * h * 0.01 - const mainMovementLon = mainDriftLon * h * 0.01 - - const turbulence = Math.sin(h * 0.3 + p * 0.5) * dispersal * h - const turbulenceAngle = (h * 0.2 + p * 0.7) * Math.PI - - trajectory.push({ - lat: particleLat + mainMovementLat + Math.sin(turbulenceAngle) * turbulence, - lon: particleLon + mainMovementLon + Math.cos(turbulenceAngle) * turbulence, - time: h, - particle: p + try { + const pollRes = await fetch(`${apiUrl}/status/${jobId}`, { + signal: AbortSignal.timeout(5000), }) + if (!pollRes.ok) continue + + const data = await pollRes.json() as PythonStatusResponse + + if (data.status === 'DONE' && data.result) { + await wingPool.query( + `UPDATE wing.PRED_EXEC + SET EXEC_STTS_CD='COMPLETED', + RSLT_DATA=$1, + CMPL_DTM=NOW(), + REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER + WHERE PRED_EXEC_SN=$2`, + [JSON.stringify(data.result), execSn] + ) + return + } + + if (data.status === 'ERROR') { + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [data.error ?? '분석 오류', execSn] + ) + return + } + } catch { + // 개별 폴링 오류는 무시하고 재시도 } } - return trajectory + // 타임아웃 처리 + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='분석 시간 초과 (30분)', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`, + [execSn] + ) } -/** - * GET /api/simulation/status/:jobId - * 시뮬레이션 작업 상태 확인 - */ -router.get('/status/:jobId', async (req: Request, res: Response) => { - const jobId = req.params.jobId as string +// ============================================================ +// 동기 폴링: Python 결과 대기 후 반환 +// ============================================================ +async function runModelSync(jobId: string, execSn: number, apiUrl: string): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS - // jobId 형식 검증 (영숫자, 하이픈만 허용) - if (!jobId || !/^[a-zA-Z0-9-]+$/.test(jobId) || jobId.length > 50) { - return res.status(400).json({ error: '유효하지 않은 작업 ID' }) + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) + + let data: PythonStatusResponse + try { + const pollRes = await fetch(`${apiUrl}/status/${jobId}`, { + signal: AbortSignal.timeout(5000), + }) + if (!pollRes.ok) continue + data = await pollRes.json() as PythonStatusResponse + } catch { + // 네트워크 오류 — 재시도 + continue + } + + if (data.status === 'DONE' && data.result) { + await wingPool.query( + `UPDATE wing.PRED_EXEC + SET EXEC_STTS_CD='COMPLETED', + RSLT_DATA=$1, + CMPL_DTM=NOW(), + REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER + WHERE PRED_EXEC_SN=$2`, + [JSON.stringify(data.result), execSn] + ) + return data.result + } + + if (data.status === 'ERROR') { + const errMsg = data.error ?? '분석 오류' + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, + [errMsg, execSn] + ) + throw new Error(errMsg) + } } - res.json({ - jobId: escapeHtml(jobId), - status: 'completed', - progress: 100, - message: 'Simulation completed' - }) -}) + await wingPool.query( + `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='분석 시간 초과 (30분)', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`, + [execSn] + ) + throw new Error('분석 시간 초과 (30분)') +} + +// ============================================================ +// 타입 및 결과 변환 +// ============================================================ +interface PythonParticle { + lat: number + lon: number + stranded?: 0 | 1 +} + +interface WindPoint { + lat: number + lon: number + wind_speed: number + wind_direction: number +} + +interface HydrGrid { + lonInterval: number[] + boundLonLat: { top: number; bottom: number; left: number; right: number } + rows: number + cols: number + latInterval: number[] +} + +interface PythonTimeStep { + particles: PythonParticle[] + 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?: WindPoint[] + hydr_data?: [number[][], number[][]] + hydr_grid?: HydrGrid +} + +interface PythonStatusResponse { + status: 'RUNNING' | 'DONE' | 'ERROR' + result?: PythonTimeStep[] + error?: string +} + +// ALGO_CD → 프론트엔드 모델명 매핑 +const ALGO_CD_TO_MODEL_NAME: Record = { + 'OPENDRIFT': 'OpenDrift', + 'POSEIDON': 'POSEIDON', +} + +function transformResult(rawResult: PythonTimeStep[], model: string) { + const trajectory = rawResult.flatMap((step, stepIdx) => + step.particles.map((p, i) => ({ + lat: p.lat, + lon: p.lon, + time: stepIdx, + particle: i, + stranded: p.stranded, + })) + ) + 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 default router diff --git a/backend/src/server.ts b/backend/src/server.ts index 72bcc1b..d406340 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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) diff --git a/backend/src/settings/settingsService.ts b/backend/src/settings/settingsService.ts index acb601b..ec33ad7 100644 --- a/backend/src/settings/settingsService.ts +++ b/backend/src/settings/settingsService.ts @@ -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 { 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) diff --git a/database/auth_init.sql b/database/auth_init.sql index eca52a0..be0384d 100644 --- a/database/auth_init.sql +++ b/database/auth_init.sql @@ -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'); -- ============================================================ diff --git a/database/init.sql b/database/init.sql index 5803553..a23122d 100755 --- a/database/init.sql +++ b/database/init.sql @@ -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 연동용)'; -- ============================================================ diff --git a/database/migration/007_reports.sql b/database/migration/007_reports.sql index 76cfb5a..fe0a09d 100644 --- a/database/migration/007_reports.sql +++ b/database/migration/007_reports.sql @@ -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')) ); diff --git a/database/migration/009_incidents.sql b/database/migration/009_incidents.sql index 85a36de..166c595 100644 --- a/database/migration/009_incidents.sql +++ b/database/migration/009_incidents.sql @@ -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 ( diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index cc84ae5..656f795 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -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` (클릭재킹 방지) diff --git a/docs/DEVELOPMENT-GUIDE.md b/docs/DEVELOPMENT-GUIDE.md index 7029ed8..429d75e 100644 --- a/docs/DEVELOPMENT-GUIDE.md +++ b/docs/DEVELOPMENT-GUIDE.md @@ -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` 설정 확인 **타입 에러:** diff --git a/docs/PREDICTION-GUIDE.md b/docs/PREDICTION-GUIDE.md new file mode 100644 index 0000000..9b90b98 --- /dev/null +++ b/docs/PREDICTION-GUIDE.md @@ -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) — 인증·상태관리 공통 로직 diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index bdfc795..72c5d5a 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.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 추가) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e2e1d6..d2534ec 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 325dc86..14c3f0d 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f148c4..eb46896 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -97,6 +97,8 @@ function App() { return case 'rescue': return + case 'monitor': + return null default: return
준비 중입니다...
} diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index c0c7690..b9fdccc 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -39,9 +39,13 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { {/* Left Section */}
{/* Logo */} -
+
+ {/* Divider */}
@@ -50,28 +54,51 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{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 ( ) })} diff --git a/frontend/src/common/components/map/HydrParticleOverlay.tsx b/frontend/src/common/components/map/HydrParticleOverlay.tsx new file mode 100644 index 0000000..c6454f6 --- /dev/null +++ b/frontend/src/common/components/map/HydrParticleOverlay.tsx @@ -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(); + + 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; +} diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 46b0d98..fb3bd80 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -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 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> + hydrData?: (HydrDataStep | null)[] + // 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용) + externalCurrentTime?: number + mapCaptureRef?: React.MutableRefObject<(() => Promise) | 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) | 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((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(DEFAULT_ZOOM) + const [internalCurrentTime, setInternalCurrentTime] = useState(0) const [isPlaying, setIsPlaying] = useState(false) const [playbackSpeed, setPlaybackSpeed] = useState(1) const [popupInfo, setPopupInfo] = useState(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: (
{modelKey} 입자 #{(d.particle ?? 0) + 1} + {d.stranded === 1 && (육지 부착)}
시간: +{d.time}h
@@ -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 = {} + 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 && } + {/* 지도 중앙 좌표 + 줌 추적 */} + {/* 3D 모드 pitch 제어 */} {/* 사고 지점 변경 시 지도 이동 */} - + + {/* 외부에서 flyTo 트리거 */} + + {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} + {/* WMS 레이어 */} {wmsLayers.map(layer => ( @@ -946,6 +1281,11 @@ export function MapView({ {/* deck.gl 오버레이 (인터리브드: 일반 레이어) */} + {/* 해류 파티클 오버레이 */} + {hydrData.length > 0 && showCurrent && ( + + )} + {/* 사고 위치 마커 (MapLibre Marker) */} {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !(dispersionHeatmap && dispersionHeatmap.length > 0) && ( @@ -983,26 +1323,37 @@ export function MapView({ 오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
)} + {drawAnalysisMode === 'polygon' && ( +
+ 다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개) +
+ )} + {drawAnalysisMode === 'circle' && ( +
+ {!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'} +
+ )} {/* 기상청 연계 정보 */} - + {showOverlays && } {/* 범례 */} - + {showOverlays && } {/* 좌표 표시 */} - + {showOverlays && } - {/* 타임라인 컨트롤 */} - {oilTrajectory.length > 0 && ( + {/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */} + {!isControlled && oilTrajectory.length > 0 && ( p.time))} isPlaying={isPlaying} playbackSpeed={playbackSpeed} - onTimeChange={setCurrentTime} + onTimeChange={setInternalCurrentTime} onPlayPause={() => setIsPlaying(!isPlaying)} onSpeedChange={setPlaybackSpeed} /> @@ -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 (
위도 {Math.abs(lat).toFixed(4)}°{latDirection} 경도 {Math.abs(lng).toFixed(4)}°{lngDirection} - 축척 1:50,000 + 축척 {scaleLabel}
) } @@ -1274,7 +1632,11 @@ function TimelineControl({
{/* eslint-disable-next-line react-hooks/purity */} -
+{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
+
+{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`; + })()}
진행률{progressPercent.toFixed(0)}%
속도{playbackSpeed}×
diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index f84b672..af37161 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -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,7 +38,7 @@ const subMenuConfigs: Record = { ], aerial: [ { id: 'media', label: '영상사진관리', icon: '📷' }, - { id: 'analysis', label: '유출유면적분석', icon: '🧩' }, + { id: 'analysis', label: '영상사진합성', icon: '🧩' }, { id: 'realtime', label: '실시간드론', icon: '🛸' }, { id: 'satellite', label: '위성영상', icon: '🛰' }, { id: 'cctv', label: 'CCTV 조회', icon: '📹' }, @@ -92,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) } @@ -176,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 } diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 04c3458..373fe49 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -284,6 +284,12 @@ 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; diff --git a/frontend/src/common/types/navigation.ts b/frontend/src/common/types/navigation.ts index a3c3c57..fd9f6f8 100644 --- a/frontend/src/common/types/navigation.ts +++ b/frontend/src/common/types/navigation.ts @@ -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'; diff --git a/frontend/src/common/utils/geo.ts b/frontend/src/common/utils/geo.ts index 9c1c9b1..d955236 100755 --- a/frontend/src/common/utils/geo.ts +++ b/frontend/src/common/utils/geo.ts @@ -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 }>, diff --git a/frontend/src/common/utils/imageAnalysisSignal.ts b/frontend/src/common/utils/imageAnalysisSignal.ts new file mode 100644 index 0000000..84569f4 --- /dev/null +++ b/frontend/src/common/utils/imageAnalysisSignal.ts @@ -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; +} diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 1182643..3fe69c8 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -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 JSX.Element> = { @@ -19,6 +21,8 @@ const PANEL_MAP: Record JSX.Element> = { board: () => , qna: () => , 'collect-vessel-signal': () => , + 'cleanup-equip': () => , + 'asset-upload': () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/AssetUploadPanel.tsx b/frontend/src/tabs/admin/components/AssetUploadPanel.tsx new file mode 100644 index 0000000..59672f8 --- /dev/null +++ b/frontend/src/tabs/admin/components/AssetUploadPanel.tsx @@ -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([]); + const [dragging, setDragging] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const fileInputRef = useRef(null); + const resetTimerRef = useRef | 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 ( +
+ {/* 헤더 */} +
+

자산 현행화

+

자산 데이터를 업로드하여 현행화합니다

+
+ + {/* 본문 */} +
+
+ {/* 좌측: 파일 업로드 */} +
+
+
+

파일 업로드

+
+
+ {/* 드롭존 */} +
{ 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' + }`} + > +
📁
+ {selectedFile ? ( +
{selectedFile.name}
+ ) : ( + <> +
파일을 드래그하거나 클릭하여 업로드
+
엑셀(.xlsx), CSV 파일 지원 · 최대 10MB
+ + + )} + handleFileSelect(e.target.files?.[0] ?? null)} + /> +
+ + {/* 자산 분류 */} +
+ + +
+ + {/* 대상 관할 */} +
+ + +
+ + {/* 업로드 방식 */} +
+ +
+ + +
+
+ + {/* 업로드 버튼 */} + +
+
+
+ + {/* 우측 */} +
+ {/* 수정 권한 체계 */} +
+
+

수정 권한 체계

+
+
+ {PERM_ITEMS.map(p => ( +
+
+ {p.icon} +
+
+
{p.role}
+
{p.desc}
+
+
+ ))} +
+
+ + {/* 최근 업로드 이력 */} +
+
+

최근 업로드 이력

+
+
+ {uploadHistory.length === 0 ? ( +
이력이 없습니다.
+ ) : uploadHistory.map(h => ( +
+
+
{h.fileNm}
+
+ {formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}건 +
+
+ + 완료 + +
+ ))} +
+
+
+
+
+
+ ); +} + +export default AssetUploadPanel; diff --git a/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx b/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx new file mode 100644 index 0000000..4708041 --- /dev/null +++ b/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx @@ -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([]); + 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) => { + 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 ( +
+ {/* 헤더 */} +
+
+

방제장비 현황

+

총 {filtered.length}개 기관

+
+
+ + + { 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" + /> + +
+
+ + {/* 테이블 */} +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : ( + + + + + + + + + + + + + + + + + + {paged.length === 0 ? ( + + + + ) : paged.map((org, idx) => ( + + + + + + + + + + + + + + ))} + +
번호유형관할청기관명주소방제선유회수기이송펌프방제차량살포장치총자산
+ 조회된 기관이 없습니다. +
+ {(safePage - 1) * PAGE_SIZE + idx + 1} + + + {org.type} + + + {regionShort(org.jurisdiction)} + + {org.name} + + {org.address} + + {org.vessel > 0 ? {org.vessel} : } + + {org.skimmer > 0 ? {org.skimmer} : } + + {org.pump > 0 ? {org.pump} : } + + {org.vehicle > 0 ? {org.vehicle} : } + + {org.sprayer > 0 ? {org.sprayer} : } + + {org.totalAssets.toLocaleString()} +
+ )} +
+ + {/* 페이지네이션 */} + {!loading && filtered.length > 0 && ( +
+ + {(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} / 전체 {filtered.length}개 + +
+ + {pageNumbers.map(p => ( + + ))} + +
+
+ )} +
+ ); +} + +export default CleanupEquipPanel; diff --git a/frontend/src/tabs/admin/components/PermissionsPanel.tsx b/frontend/src/tabs/admin/components/PermissionsPanel.tsx index 4124a33..d6b6734 100644 --- a/frontend/src/tabs/admin/components/PermissionsPanel.tsx +++ b/frontend/src/tabs/admin/components/PermissionsPanel.tsx @@ -294,6 +294,7 @@ interface RolePermTabProps { setSelectedRoleSn: (sn: number | null) => void dirty: boolean saving: boolean + saveError: string | null handleSave: () => Promise 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 ? '저장 중...' : '변경사항 저장'} + {saveError && ( + {saveError} + )}
{/* 역할 탭 바 */} @@ -861,6 +866,7 @@ function PermissionsPanel() { const [permTree, setPermTree] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(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} diff --git a/frontend/src/tabs/admin/components/adminMenuConfig.ts b/frontend/src/tabs/admin/components/adminMenuConfig.ts index 892447c..0e27e9f 100644 --- a/frontend/src/tabs/admin/components/adminMenuConfig.ts +++ b/frontend/src/tabs/admin/components/adminMenuConfig.ts @@ -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: '방제자원' }, diff --git a/frontend/src/tabs/aerial/components/MediaManagement.tsx b/frontend/src/tabs/aerial/components/MediaManagement.tsx index e5d9940..90a41f9 100644 --- a/frontend/src/tabs/aerial/components/MediaManagement.tsx +++ b/frontend/src/tabs/aerial/components/MediaManagement.tsx @@ -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(null) + const [bulkDownloading, setBulkDownloading] = useState(false) + const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(null) const modalRef = useRef(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() { {f.fileSz ?? '—'} {f.resolution ?? '—'} e.stopPropagation()}> - @@ -274,15 +314,47 @@ export function MediaManagement() { - -
+ {/* 선택 다운로드 결과 팝업 */} + {downloadResult && ( +
+
+
📥
+
다운로드 완료
+
+ 총 {downloadResult.total}건 선택 +
+
+ {downloadResult.success}건 다운로드 성공 + {downloadResult.total - downloadResult.success > 0 && ( + <> / {downloadResult.total - downloadResult.success}건 실패 + )} +
+ +
+
+ )} + {/* Upload Modal */} {showUpload && (
diff --git a/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx b/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx index ea8333c..1f84c52 100644 --- a/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx +++ b/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx @@ -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 ( +
+ {label} + {value} +
+ ); +} export function OilAreaAnalysis() { - const [activeStep, setActiveStep] = useState(1) - const [analyzing, setAnalyzing] = useState(false) - const [analyzed, setAnalyzed] = useState(false) + const [selectedFiles, setSelectedFiles] = useState([]); + const [previewUrls, setPreviewUrls] = useState([]); + const [imageExifs, setImageExifs] = useState<(ImageExif | undefined)[]>([]); + const [selectedImageIndex, setSelectedImageIndex] = useState(null); + const [stitchedBlob, setStitchedBlob] = useState(null); + const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState(null); + const [isStitching, setIsStitching] = useState(false); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const processedFilesRef = useRef>(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) => { + 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 (
- {/* Left Panel */} -
-
🧩 유출유면적분석
-
단면 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
- - {/* Step Indicator */} -
- {['① 사진 선택', '② 정합·합성', '③ 면적 산정'].map((label, i) => ( - - ))} + {/* ── Left Panel ── */} +
+
🧩 영상사진합성
+
+ 드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
- {/* Selected Images */} -
선택된 사진 (6장)
-
- {['여수항_드론_001.jpg', '여수항_드론_002.jpg', '여수항_드론_003.jpg', '여수항_드론_004.jpg', '여수항_드론_005.jpg', '여수항_드론_006.jpg'].map((name, i) => ( -
- 🛸 - {name} - - {i < 4 ? '✓ 정합' : i === 4 ? '⏳ 정합중' : '대기'} - + {/* 이미지 선택 버튼 */} + + + + {/* 선택된 이미지 목록 */} + {selectedFiles.length > 0 && ( + <> +
선택된 이미지
+
+ {selectedFiles.map((file, i) => ( +
+
setSelectedImageIndex(i)} + > + 📷 + {file.name} + +
+ {selectedImageIndex === i && imageExifs[i] !== undefined && ( +
+ + + + + + + + + + + +
+ )} +
+ ))}
- ))} -
+ + )} - {/* Analysis Parameters */} -
분석 파라미터
-
- {[ - ['촬영 고도', '120 m'], - ['GSD (지상해상도)', '3.2 cm/px'], - ['오버랩 비율', '80% / 70%'], - ['좌표계', 'EPSG:5186'], - ['유종 판별 기준', 'NDVI + NIR'], - ['유막 두께 추정', 'Bonn Agreement'], - ].map(([label, value], i) => ( -
- {label} - {value} -
- ))} -
+ {/* 에러 메시지 */} + {error && ( +
+ {error} +
+ )} - {/* Action Buttons */} + {/* 이미지 합성 버튼 */} + + + {/* 분석 시작 버튼 */} -
- {/* Right Panel */} + {/* ── Right Panel ── */}
- {/* Header */} -
- 🗺 합성 영상 및 유막 탐지 결과 -
- ■ 유막 탐지 - □ 원본 타일 - 정합률 96.2% -
-
- - {/* Image Grid 3×2 */} + {/* 3×2 이미지 그리드 */} +
선택된 이미지 미리보기
- {mosaicImages.map(img => ( -
-
- {img.hasOil && ( -
- )} -
{img.id}
-
- {img.status === 'done' && img.hasOil ? '유막' : img.status === 'processing' ? '정합중' : '대기'} + {Array.from({ length: MAX_IMAGES }).map((_, i) => ( +
{ if (previewUrls[i]) setSelectedImageIndex(i); }} + > + {previewUrls[i] ? ( + <> +
+ {selectedFiles[i]?.name +
+
+
+ {selectedFiles[i]?.name} +
+ {imageExifs[i] === undefined ? ( +
GPS 읽는 중...
+ ) : imageExifs[i]?.lat !== null ? ( +
+ {decimalToDMS(imageExifs[i]!.lat!, true)}
+ {decimalToDMS(imageExifs[i]!.lon!, false)} +
+ ) : ( +
GPS 정보 없음
+ )} +
+ + ) : ( +
+ {i + 1}
-
-
- {img.filename} - - {img.status === 'done' ? '✓' : img.status === 'processing' ? '⏳' : '—'} - -
+ )}
))}
- {/* Merged Result Preview */} -
-
-
- 합성 영역 (3×2 그리드) + {/* 합성 결과 */} +
합성 결과
+
+ {stitchedPreviewUrl ? ( + 합성 결과 + ) : ( +
+ {isStitching + ? '⏳ 이미지를 합성하고 있습니다...' + : '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
-
-
-
-
34.7312°N, 127.6845°E
-
축척 ≈ 1:2,500
-
- - {/* Analysis Results */} -
-
📊 유출유 분석 결과
-
- {[ - { 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) => ( -
-
{r.value}
-
{r.label}
-
- ))} -
-
- {[ - ['두꺼운 유막 (>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) => ( -
- {label} - {value} -
- ))} -
+ )}
- ) + ); } diff --git a/frontend/src/tabs/aerial/services/aerialApi.ts b/frontend/src/tabs/aerial/services/aerialApi.ts index 5681925..d1eadb3 100644 --- a/frontend/src/tabs/aerial/services/aerialApi.ts +++ b/frontend/src/tabs/aerial/services/aerialApi.ts @@ -104,6 +104,33 @@ export async function createSatRequest( return response.data; } +export async function downloadAerialMedia(sn: number, fileName: string): Promise { + 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 { + const form = new FormData(); + files.forEach(f => form.append('files', f)); + const response = await api.post('/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; diff --git a/frontend/src/tabs/prediction/components/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index 4811dd9..887b39b 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -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({ predictionInput: true, incident: false, impactResources: false, - infoLayer: true, + infoLayer: false, oilBoom: false, }) @@ -64,13 +75,19 @@ export function LeftPanel({ 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({
{expandedSections.incident && ( -
- {/* Status Badge */} -
- - 진행중 -
+ selectedAnalysis ? ( +
+ {/* Status Badge */} + {(() => { + const statusMap: Record = { + 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 ( +
+ + {s.label} +
+ ) + })()} - {/* Info Grid */} -
-
- 사고코드 - {selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'} -
-
- 사고명 - {selectedAnalysis?.name || '씨프린스호'} -
-
- 사고일시 - {selectedAnalysis?.occurredAt || '2025-02-10 06:30'} -
-
- 유종 - {selectedAnalysis?.oilType || 'BUNKER_C'} -
-
- 유출량 - {selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'} -
-
- 담당자 - {selectedAnalysis?.analyst || '남해청, 방재과'} -
-
- 위치 - {selectedAnalysis?.location || '여수 돌산 남방 5NM'} + {/* Info Grid */} +
+
+ 사고코드 + {selectedAnalysis.acdntSn} +
+
+ 사고명 + {selectedAnalysis.acdntNm || '—'} +
+
+ 사고일시 + {selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'} +
+
+ 유종 + {selectedAnalysis.oilType || '—'} +
+
+ 유출량 + {selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'} +
+
+ 담당자 + {selectedAnalysis.analyst || '—'} +
+
+ 위치 + {selectedAnalysis.location || '—'} +
-
+ ) : ( +
+

선택된 사고정보가 없습니다.

+
+ ) )}
@@ -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} diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index 2ecf8e2..a06e5b8 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { LeftPanel } from './LeftPanel' import { RightPanel } from './RightPanel' import { MapView } from '@common/components/map/MapView' @@ -8,14 +8,17 @@ import { BoomDeploymentTheoryView } from './BoomDeploymentTheoryView' import { BacktrackModal } from './BacktrackModal' import { RecalcModal } from './RecalcModal' import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar' -import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu' +import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu' import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' -import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail } from '../services/predictionApi' -import type { PredictionDetail } from '../services/predictionApi' +import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' +import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi' +import SimulationLoadingOverlay from './SimulationLoadingOverlay' +import SimulationErrorModal from './SimulationErrorModal' import { api } from '@common/services/api' -import { generateAIBoomLines } from '@common/utils/geo' +import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo' +import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' @@ -32,6 +35,13 @@ export interface SensitiveResource { arrivalTimeH: number } +export interface DisplayControls { + showCurrent: boolean; // 유향/유속 + showWind: boolean; // 풍향/풍속 + showBeached: boolean; // 해안부착 + showTimeLabel: boolean; // 시간 표시 +} + const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [ { id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 }, { id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 }, @@ -101,15 +111,29 @@ export const ALL_MODELS: PredictionModel[] = ['KOSPS', 'POSEIDON', 'OpenDrift'] export function OilSpillView() { const { activeSubTab, setActiveSubTab } = useSubMenu('prediction') const [enabledLayers, setEnabledLayers] = useState>(new Set()) - const [incidentCoord, setIncidentCoord] = useState({ lon: 127.6845, lat: 34.7312 }) + const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null) + const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined) + const flyToTarget = null + const fitBoundsTarget = null const [isSelectingLocation, setIsSelectingLocation] = useState(false) - const [oilTrajectory, setOilTrajectory] = useState>([]) + const [oilTrajectory, setOilTrajectory] = useState([]) + const [centerPoints, setCenterPoints] = useState([]) + const [windDataByModel, setWindDataByModel] = useState>({}) + const [hydrDataByModel, setHydrDataByModel] = useState>({}) + const [windHydrModel, setWindHydrModel] = useState('OpenDrift') const [isRunningSimulation, setIsRunningSimulation] = useState(false) - const [selectedModels, setSelectedModels] = useState>(new Set(['POSEIDON'])) + const [simulationProgress, setSimulationProgress] = useState(0) + const progressTimerRef = useRef | null>(null) + const [simulationError, setSimulationError] = useState(null) + const [selectedModels, setSelectedModels] = useState>(new Set(['OpenDrift'])) + const [visibleModels, setVisibleModels] = useState>(new Set(['OpenDrift'])) const [predictionTime, setPredictionTime] = useState(48) + const [accidentTime, setAccidentTime] = useState('') const [spillType, setSpillType] = useState('연속') const [oilType, setOilType] = useState('벙커C유') const [spillAmount, setSpillAmount] = useState(100) + const [incidentName, setIncidentName] = useState('') + const [spillUnit, setSpillUnit] = useState('kL') // 민감자원 const [sensitiveResources, setSensitiveResources] = useState([]) @@ -130,9 +154,17 @@ export function OilSpillView() { const [layerOpacity, setLayerOpacity] = useState(50) const [layerBrightness, setLayerBrightness] = useState(50) + // 표시 정보 제어 + const [displayControls, setDisplayControls] = useState({ + showCurrent: true, + showWind: false, + showBeached: false, + showTimeLabel: false, + }) + // 타임라인 플레이어 상태 const [isPlaying, setIsPlaying] = useState(false) - const [timelinePosition, setTimelinePosition] = useState(25) // 0~100% + const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위) const [playSpeed, setPlaySpeed] = useState(1) // 역추적 상태 @@ -152,21 +184,35 @@ export function OilSpillView() { // 역추적 API 데이터 const [backtrackConditions, setBacktrackConditions] = useState({ estimatedSpillTime: '', analysisRange: '±12시간', searchRadius: '10 NM', - spillLocation: { lat: 34.7312, lon: 127.6845 }, totalVessels: 0, + spillLocation: { lat: 37.3883, lon: 126.6435 }, totalVessels: 0, }) const [replayShips, setReplayShips] = useState([]) const [collisionEvent, setCollisionEvent] = useState(null) // 재계산 상태 const [recalcModalOpen, setRecalcModalOpen] = useState(false) + const [simulationSummary, setSimulationSummary] = useState(null) + const [summaryByModel, setSummaryByModel] = useState>({}) + + // 오염분석 상태 + const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon') + const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null) + const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([]) + const [circleRadiusNm, setCircleRadiusNm] = useState(5) + const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null) + + // 원 분석용 derived 값 (state 아님) + const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null + const analysisCircleRadiusM = circleRadiusNm * 1852 // 분석 탭 초기 진입 시 기본 데모 자동 표시 useEffect(() => { if (activeSubTab === 'analysis' && oilTrajectory.length === 0 && !selectedAnalysis) { - const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set(['POSEIDON'])) - const demoTrajectory = generateDemoTrajectory(incidentCoord, models, predictionTime) + const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set(['OpenDrift'])) + const coord = incidentCoord ?? { lat: 37.39, lon: 126.64 } + const demoTrajectory = generateDemoTrajectory(coord, models, predictionTime) setOilTrajectory(demoTrajectory) - const demoBooms = generateAIBoomLines(demoTrajectory, incidentCoord, algorithmSettings) + const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) setBoomLines(demoBooms) setSensitiveResources(DEMO_SENSITIVE_RESOURCES) } @@ -204,7 +250,7 @@ export function OilSpillView() { estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '', analysisRange: bt.anlysRange || '±12시간', searchRadius: bt.srchRadiusNm ? `${bt.srchRadiusNm} NM` : '10 NM', - spillLocation: { lat: bt.lat || incidentCoord.lat, lon: bt.lon || incidentCoord.lon }, + spillLocation: { lat: bt.lat || incidentCoord?.lat || 0, lon: bt.lon || incidentCoord?.lon || 0 }, totalVessels: bt.totalVessels || 0, }) setBacktrackPhase('results') @@ -225,7 +271,7 @@ export function OilSpillView() { setBacktrackModalOpen(true) setBacktrackConditions(prev => ({ ...prev, - spillLocation: incidentCoord, + spillLocation: incidentCoord ?? prev.spillLocation, })) if (selectedAnalysis) { loadBacktrackData(selectedAnalysis.acdntSn) @@ -236,6 +282,7 @@ export function OilSpillView() { } const handleRunBacktrackAnalysis = async () => { + if (!incidentCoord) return setBacktrackPhase('analyzing') try { if (selectedAnalysis) { @@ -290,10 +337,6 @@ export function OilSpillView() { // 역추적 리플레이 애니메이션 useEffect(() => { if (!isReplayPlaying) return - if (replayFrame >= TOTAL_REPLAY_FRAMES) { - setIsReplayPlaying(false) - return - } const interval = setInterval(() => { setReplayFrame(prev => { const next = prev + 1 @@ -305,13 +348,125 @@ export function OilSpillView() { }) }, 50 / replaySpeed) return () => clearInterval(interval) - }, [isReplayPlaying, replayFrame, replaySpeed]) + }, [isReplayPlaying, replaySpeed]) + + // flyTo 완료 후 재생 대기 플래그 + const pendingPlayRef = useRef(false) + + // 항공 이미지 분석 완료 후 자동실행 플래그 + const pendingAutoRunRef = useRef(false) + + // 마운트 시 이미지 분석 시그널 확인 (유출유면적분석 탭에서 이동한 경우) + useEffect(() => { + const pending = consumePendingImageAnalysis() + if (!pending) return + handleImageAnalysisResult({ + acdntSn: pending.acdntSn, + lat: pending.lat, + lon: pending.lon, + oilType: pending.oilType, + area: pending.area, + volume: pending.volume, + fileId: pending.fileId, + occurredAt: pending.occurredAt, + }) + if (pending.autoRun) pendingAutoRunRef.current = true + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // incidentCoord 업데이트 후 시뮬레이션 자동실행 + useEffect(() => { + if (pendingAutoRunRef.current && incidentCoord) { + pendingAutoRunRef.current = false + handleRunSimulation() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [incidentCoord]) + + const handleFlyEnd = useCallback(() => { + setFlyToCoord(undefined) + if (pendingPlayRef.current) { + pendingPlayRef.current = false + setIsPlaying(true) + } + }, []) + + + // trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리) + useEffect(() => { + if (oilTrajectory.length > 0) { + setCurrentStep(0); + } + }, [oilTrajectory.length]); + + useEffect(() => { + return () => { + if (progressTimerRef.current) clearInterval(progressTimerRef.current); + }; + }, []); + + // windHydrModel이 visibleModels에 없으면 자동으로 적절한 모델로 전환 + useEffect(() => { + if (visibleModels.size === 0) return; + if (!visibleModels.has(windHydrModel as PredictionModel)) { + const preferred: PredictionModel[] = ['OpenDrift', 'POSEIDON', 'KOSPS']; + const next = preferred.find(m => visibleModels.has(m)) ?? Array.from(visibleModels)[0]; + setWindHydrModel(next); + } + }, [visibleModels, windHydrModel]); + + // 플레이어 재생 애니메이션 (1x = 1초/스텝, 2x = 0.5초/스텝, 4x = 0.25초/스텝) + const timeSteps = useMemo(() => { + if (oilTrajectory.length === 0) return []; + const unique = [...new Set(oilTrajectory.map(p => p.time))].sort((a, b) => a - b); + return unique; + }, [oilTrajectory]); + + const maxTime = timeSteps[timeSteps.length - 1] ?? predictionTime; + + // 유향유속/풍향풍속 데이터 — 선택한 모델 기준으로 파생 + const windHydrModelOptions = useMemo(() => Array.from(visibleModels), [visibleModels]) + const windData = useMemo( + () => windDataByModel[windHydrModel] ?? [], + [windDataByModel, windHydrModel] + ) + const hydrData = useMemo( + () => hydrDataByModel[windHydrModel] ?? [], + [hydrDataByModel, windHydrModel] + ) + + useEffect(() => { + if (!isPlaying || timeSteps.length === 0) return; + if (currentStep >= maxTime) { + setIsPlaying(false); + return; + } + const ms = 1000 / playSpeed; + const id = setInterval(() => { + setCurrentStep(prev => { + const idx = timeSteps.indexOf(prev); + if (idx < 0 || idx >= timeSteps.length - 1) { + setIsPlaying(false); + return timeSteps[timeSteps.length - 1]; + } + return timeSteps[idx + 1]; + }); + }, ms); + return () => clearInterval(id); + }, [isPlaying, currentStep, playSpeed, timeSteps, maxTime]); // 분석 목록에서 사고명 클릭 시 const handleSelectAnalysis = async (analysis: Analysis) => { + setIsPlaying(false) + setCurrentStep(0) setSelectedAnalysis(analysis) + setCenterPoints([]) + if (analysis.occurredAt) { + setAccidentTime(analysis.occurredAt.slice(0, 16)) + } if (analysis.lon != null && analysis.lat != null) { setIncidentCoord({ lon: analysis.lon, lat: analysis.lat }) + setFlyToCoord({ lon: analysis.lon, lat: analysis.lat }) } // 유종 매핑 const oilTypeMap: Record = { @@ -326,6 +481,7 @@ export function OilSpillView() { if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON') if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift') setSelectedModels(models) + setVisibleModels(models) // 분석 상세 로딩 (선박/기상 정보) try { const detail = await fetchPredictionDetail(analysis.acdntSn) @@ -336,82 +492,410 @@ export function OilSpillView() { // 분석 화면으로 전환 setActiveSubTab('analysis') - // 데모 궤적 자동 생성 (화면 진입 즉시 시각화) const coord = (analysis.lon != null && analysis.lat != null) ? { lon: analysis.lon, lat: analysis.lat } : incidentCoord const demoModels = Array.from(models.size > 0 ? models : new Set(['KOSPS'])) - const demoTrajectory = generateDemoTrajectory(coord, demoModels, parseInt(analysis.duration) || 48) + + // 완료된 모델이 있는 경우 실제 궤적 로드, 없으면 데모로 fallback + const hasCompletedModel = + analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed'; + if (hasCompletedModel) { + try { + const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel } = await fetchAnalysisTrajectory(analysis.acdntSn) + if (trajectory && trajectory.length > 0) { + setOilTrajectory(trajectory) + if (summary) setSimulationSummary(summary) + setCenterPoints(cp ?? []) + setWindDataByModel(wdByModel ?? {}); + setHydrDataByModel(hdByModel ?? {}); + if (sbModel) setSummaryByModel(sbModel); + if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings)) + setSensitiveResources(DEMO_SENSITIVE_RESOURCES) + // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 + if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { + pendingPlayRef.current = true + } else { + setIsPlaying(true) + } + return + } + } catch (err) { + console.error('[prediction] trajectory 로딩 실패, 데모로 fallback:', err) + } + } + + // 데모 궤적 생성 (fallback) — stale wind/current 데이터 초기화 + setWindDataByModel({}) + setHydrDataByModel({}) + setSummaryByModel({}) + const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48) setOilTrajectory(demoTrajectory) - const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) - setBoomLines(demoBooms) + if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings)) setSensitiveResources(DEMO_SENSITIVE_RESOURCES) + // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 + if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { + pendingPlayRef.current = true + } else { + setIsPlaying(true) + } } const handleMapClick = (lon: number, lat: number) => { if (isDrawingBoom) { setDrawingPoints(prev => [...prev, { lat, lon }]) + } else if (drawAnalysisMode === 'polygon') { + setAnalysisPolygonPoints(prev => [...prev, { lat, lon }]) } else { setIncidentCoord({ lon, lat }) setIsSelectingLocation(false) } } - const handleRunSimulation = async () => { - if (selectedModels.size === 0) return - setIsRunningSimulation(true) + const handleStartPolygonDraw = () => { + setDrawAnalysisMode('polygon') + setAnalysisPolygonPoints([]) + setAnalysisResult(null) + } + const handleRunPolygonAnalysis = () => { + if (analysisPolygonPoints.length < 3) return + const currentParticles = oilTrajectory.filter(p => p.time === currentStep) + const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 + const inside = currentParticles.filter(p => pointInPolygon({ lat: p.lat, lon: p.lon }, analysisPolygonPoints)).length + const sensitiveCount = sensitiveResources.filter(r => pointInPolygon({ lat: r.lat, lon: r.lon }, analysisPolygonPoints)).length + setAnalysisResult({ + area: polygonAreaKm2(analysisPolygonPoints), + particleCount: inside, + particlePercent: Math.round((inside / totalIds) * 100), + sensitiveCount, + }) + setDrawAnalysisMode(null) + } + + const handleRunCircleAnalysis = () => { + if (!incidentCoord) return + const radiusM = circleRadiusNm * 1852 + const currentParticles = oilTrajectory.filter(p => p.time === currentStep) + const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 + const inside = currentParticles.filter(p => + haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: p.lat, lon: p.lon }) <= radiusM + ).length + const sensitiveCount = sensitiveResources.filter(r => + haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: r.lat, lon: r.lon }) <= radiusM + ).length + setAnalysisResult({ + area: circleAreaKm2(radiusM), + particleCount: inside, + particlePercent: Math.round((inside / totalIds) * 100), + sensitiveCount, + }) + } + + const handleCancelAnalysis = () => { + setDrawAnalysisMode(null) + setAnalysisPolygonPoints([]) + } + + const handleClearAnalysis = () => { + setDrawAnalysisMode(null) + setAnalysisPolygonPoints([]) + setAnalysisResult(null) + } + + const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => { + setIncidentCoord({ lat: result.lat, lon: result.lon }) + setFlyToCoord({ lat: result.lat, lon: result.lon }) + setAccidentTime(result.occurredAt.slice(0, 16)) + setOilType(result.oilType) + setSpillAmount(parseFloat(result.volume.toFixed(4))) + setSpillUnit('kL') + setSelectedAnalysis({ + acdntSn: result.acdntSn, + acdntNm: '', + occurredAt: result.occurredAt, + analysisDate: '', + requestor: '', + duration: '48', + oilType: result.oilType, + volume: result.volume, + location: '', + lat: result.lat, + lon: result.lon, + kospsStatus: 'pending', + poseidonStatus: 'pending', + opendriftStatus: 'pending', + backtrackStatus: 'pending', + analyst: '', + officeName: '', + acdntSttsCd: 'ACTIVE', + }) + }, []) + + const startProgressTimer = useCallback((runTimeHours: number) => { + const expectedMs = runTimeHours * 6000; + const startTime = Date.now(); + progressTimerRef.current = setInterval(() => { + const elapsed = Date.now() - startTime; + setSimulationProgress(Math.min(90, Math.round((elapsed / expectedMs) * 90))); + }, 500); + }, []); + + const stopProgressTimer = useCallback((completed: boolean) => { + if (progressTimerRef.current) { + clearInterval(progressTimerRef.current); + progressTimerRef.current = null; + } + if (completed) { + setSimulationProgress(100); + setTimeout(() => setSimulationProgress(0), 800); + } else { + setSimulationProgress(0); + } + }, []); + + const handleRunSimulation = async (overrides?: { + models?: Set; + oilType?: string; + spillAmount?: number; + spillType?: string; + predictionTime?: number; + incidentCoord?: { lat: number; lon: number } | null; + }) => { + // incidentName이 있으면 직접 입력 모드 — 기존 selectedAnalysis.acdntSn 무시하고 새 사고 생성 + const isDirectInput = incidentName.trim().length > 0; + const existingAcdntSn = isDirectInput + ? undefined + : (selectedAnalysis?.acdntSn ?? analysisDetail?.acdnt?.acdntSn); + + const effectiveCoord = overrides?.incidentCoord ?? incidentCoord; + if (!isDirectInput && !existingAcdntSn) return; + if (!effectiveCoord) return; + + const effectiveOilType = overrides?.oilType ?? oilType; + const effectiveSpillAmount = overrides?.spillAmount ?? spillAmount; + const effectiveSpillType = overrides?.spillType ?? spillType; + const effectivePredictionTime = overrides?.predictionTime ?? predictionTime; + const effectiveModels = overrides?.models ?? selectedModels; + + setIsRunningSimulation(true); + setSimulationSummary(null); + startProgressTimer(effectivePredictionTime); + let simulationSucceeded = false; try { - const models = Array.from(selectedModels) - const results = await Promise.all( - models.map(async (model) => { - const { data } = await api.post<{ trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }> }>('/simulation/run', { - model, - lat: incidentCoord.lat, - lon: incidentCoord.lon, - duration_hours: predictionTime, - oil_type: oilType, - spill_amount: spillAmount, - spill_type: spillType, - }) - return data.trajectory.map(p => ({ ...p, model })) - }) - ) + const payload: Record = { + acdntSn: existingAcdntSn, + lat: effectiveCoord.lat, + lon: effectiveCoord.lon, + runTime: effectivePredictionTime, + matTy: effectiveOilType, + matVol: effectiveSpillAmount, + spillTime: effectiveSpillType === '연속' ? effectivePredictionTime : 0, + startTime: accidentTime + ? `${accidentTime}:00` + : analysisDetail?.acdnt?.occurredAt, + models: Array.from(effectiveModels), + }; - setOilTrajectory(results.flat()) - } catch { - // 백엔드 미구현 — 클라이언트 데모 궤적 fallback - console.info('[prediction] 서버 시뮬레이션 미구현, 데모 궤적 생성') - const models = Array.from(selectedModels) - const demoTrajectory = generateDemoTrajectory(incidentCoord, models, predictionTime) - setOilTrajectory(demoTrajectory) + if (isDirectInput) { + payload.acdntNm = incidentName.trim(); + payload.spillUnit = spillUnit; + payload.spillTypeCd = spillType; + } - // AI 방어선 자동 생성 - const demoBooms = generateAIBoomLines(demoTrajectory, incidentCoord, algorithmSettings) - setBoomLines(demoBooms) + // 동기 방식: 예측 완료 시 결과를 직접 반환 (최대 35분 대기) + const { data } = await api.post('/simulation/run-model', payload, { + timeout: 35 * 60 * 1000, + }); - // 민감자원 로드 - setSensitiveResources(DEMO_SENSITIVE_RESOURCES) + // 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화 + if (data.acdntSn && isDirectInput) { + setSelectedAnalysis({ + acdntSn: data.acdntSn, + acdntNm: incidentName.trim(), + occurredAt: accidentTime ? `${accidentTime}:00` : '', + analysisDate: new Date().toISOString(), + requestor: '', + duration: String(predictionTime), + oilType, + volume: spillAmount, + location: '', + lat: effectiveCoord.lat, + lon: effectiveCoord.lon, + kospsStatus: 'pending', + poseidonStatus: 'pending', + opendriftStatus: 'pending', + backtrackStatus: 'pending', + analyst: '', + officeName: '', + } as Analysis); + setIncidentName(''); + } + + // 결과 처리 + const merged: OilParticle[] = []; + let latestSummary: SimulationSummary | null = null; + let latestCenterPoints: CenterPoint[] = []; + const newWindDataByModel: Record = {}; + const newHydrDataByModel: Record = {}; + const newSummaryByModel: Record = {}; + const errors: string[] = []; + + data.results.forEach(({ model, status, trajectory, summary, centerPoints, windData, hydrData, error }) => { + if (status === 'ERROR') { + errors.push(error || `${model} 분석 중 오류가 발생했습니다.`); + return; + } + if (trajectory) { + merged.push(...trajectory.map(p => ({ ...p, model }))); + } + if (summary) { + newSummaryByModel[model] = summary; + if (model === 'OpenDrift' || !latestSummary) latestSummary = summary; + } + if (windData) newWindDataByModel[model] = windData; + if (hydrData) newHydrDataByModel[model] = hydrData; + if (centerPoints) { + latestCenterPoints = [...latestCenterPoints, ...centerPoints.map(p => ({ ...p, model }))]; + } + }); + + if (merged.length > 0) { + setOilTrajectory(merged); + const doneModels = new Set( + data.results + .filter(r => r.status === 'DONE' && r.trajectory && r.trajectory.length > 0) + .map(r => r.model as PredictionModel) + ); + setVisibleModels(doneModels); + setSimulationSummary(latestSummary); + setCenterPoints(latestCenterPoints); + + const refWindData = newWindDataByModel['OpenDrift'] ?? Object.values(newWindDataByModel)[0]; + const refHydrData = newHydrDataByModel['OpenDrift'] ?? Object.values(newHydrDataByModel)[0]; + doneModels.forEach(model => { + if (!newWindDataByModel[model] && refWindData) newWindDataByModel[model] = refWindData; + if (!newHydrDataByModel[model] && refHydrData) newHydrDataByModel[model] = refHydrData; + }); + + setWindDataByModel(newWindDataByModel); + setHydrDataByModel(newHydrDataByModel); + setSummaryByModel(newSummaryByModel); + const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings); + setBoomLines(booms); + setSensitiveResources(DEMO_SENSITIVE_RESOURCES); + setCurrentStep(0); + setIsPlaying(true); + setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat }); + } + + if (errors.length > 0 && merged.length === 0) { + setSimulationError(errors.join('; ')); + } else { + simulationSucceeded = true; + } + } catch (err) { + const msg = + (err as { message?: string })?.message + ?? '시뮬레이션 실행 중 오류가 발생했습니다.'; + setSimulationError(msg); } finally { - setIsRunningSimulation(false) + stopProgressTimer(simulationSucceeded); + setIsRunningSimulation(false); } } + const handleOpenReport = () => { + const OIL_TYPE_CODE: Record = { + '벙커C유': 'BUNKER_C', '경유': 'DIESEL', '원유': 'CRUDE_OIL', '윤활유': 'LUBE_OIL', + }; + const accidentName = + selectedAnalysis?.acdntNm || + analysisDetail?.acdnt?.acdntNm || + incidentName || + '(미입력)'; + const occurTime = + selectedAnalysis?.occurredAt || + analysisDetail?.acdnt?.occurredAt || + accidentTime || + ''; + const wx = analysisDetail?.weather?.[0] ?? null; + + const payload: OilReportPayload = { + incident: { + name: accidentName, + occurTime, + location: selectedAnalysis?.location || analysisDetail?.acdnt?.location || '', + lat: incidentCoord?.lat ?? selectedAnalysis?.lat ?? null, + lon: incidentCoord?.lon ?? selectedAnalysis?.lon ?? null, + pollutant: OIL_TYPE_CODE[oilType] || oilType, + spillAmount: `${spillAmount} ${spillUnit}`, + shipName: analysisDetail?.vessels?.[0]?.vesselNm || '', + }, + pollution: { + spillAmount: `${spillAmount.toFixed(2)} ${spillUnit}`, + weathered: simulationSummary ? `${simulationSummary.weatheredVolume.toFixed(2)} m³` : '—', + seaRemain: simulationSummary ? `${simulationSummary.remainingVolume.toFixed(2)} m³` : '—', + pollutionArea: simulationSummary ? `${simulationSummary.pollutionArea.toFixed(2)} km²` : '—', + coastAttach: simulationSummary ? `${simulationSummary.beachedVolume.toFixed(2)} m³` : '—', + coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—', + oilType: OIL_TYPE_CODE[oilType] || oilType, + }, + weather: wx + ? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp } + : null, + spread: (() => { + const fmt = (model: string) => { + const s = summaryByModel[model]; + return s ? `${s.pollutionArea.toFixed(2)} km²` : '—'; + }; + return { kosps: fmt('KOSPS'), openDrift: fmt('OpenDrift'), poseidon: fmt('POSEIDON') }; + })(), + coastal: { + firstTime: (() => { + const beachedTimes = oilTrajectory.filter(p => p.stranded === 1).map(p => p.time); + if (beachedTimes.length === 0) return null; + const d = new Date(Math.min(...beachedTimes) * 1000); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + })(), + }, + hasSimulation: simulationSummary !== null, + mapData: incidentCoord ? { + center: [incidentCoord.lat, incidentCoord.lon], + zoom: 10, + trajectory: oilTrajectory, + currentStep, + centerPoints, + simulationStartTime: accidentTime, + } : null, + }; + + setOilReportPayload(payload); + setReportGenCategory(0); + navigateToTab('reports', 'generate'); + }; + return ( -
+
{/* Left Sidebar */} {activeSubTab === 'analysis' && ( setIsSelectingLocation(true)} + isSelectingLocation={isSelectingLocation} + onMapSelectClick={() => setIsSelectingLocation(prev => !prev)} onRunSimulation={handleRunSimulation} isRunningSimulation={isRunningSimulation} selectedModels={selectedModels} onModelsChange={setSelectedModels} + visibleModels={visibleModels} + onVisibleModelsChange={setVisibleModels} + hasResults={oilTrajectory.length > 0} predictionTime={predictionTime} onPredictionTimeChange={setPredictionTime} spillType={spillType} @@ -420,6 +904,10 @@ export function OilSpillView() { onOilTypeChange={setOilType} spillAmount={spillAmount} onSpillAmountChange={setSpillAmount} + incidentName={incidentName} + onIncidentNameChange={setIncidentName} + spillUnit={spillUnit} + onSpillUnitChange={setSpillUnit} boomLines={boomLines} onBoomLinesChange={setBoomLines} oilTrajectory={oilTrajectory} @@ -435,6 +923,7 @@ export function OilSpillView() { onLayerOpacityChange={setLayerOpacity} layerBrightness={layerBrightness} onLayerBrightnessChange={setLayerBrightness} + onImageAnalysisResult={handleImageAnalysisResult} /> )} @@ -450,10 +939,11 @@ export function OilSpillView() { <> visibleModels.has((p.model || 'OpenDrift') as PredictionModel))} selectedModels={selectedModels} boomLines={boomLines} isDrawingBoom={isDrawingBoom} @@ -462,159 +952,194 @@ export function OilSpillView() { layerBrightness={layerBrightness} sensitiveResources={sensitiveResources} lightMode - backtrackReplay={isReplayActive && replayShips.length > 0 ? { + centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))} + windData={windData} + hydrData={hydrData} + flyToTarget={flyToTarget} + fitBoundsTarget={fitBoundsTarget} + onIncidentFlyEnd={handleFlyEnd} + drawAnalysisMode={drawAnalysisMode} + analysisPolygonPoints={analysisPolygonPoints} + analysisCircleCenter={analysisCircleCenter} + analysisCircleRadiusM={analysisCircleRadiusM} + externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined} + backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? { isActive: true, ships: replayShips, - collisionEvent: collisionEvent || undefined, + collisionEvent: collisionEvent ?? null, replayFrame, totalFrames: TOTAL_REPLAY_FRAMES, incidentCoord, } : undefined} + showCurrent={displayControls.showCurrent} + showWind={displayControls.showWind} + showBeached={displayControls.showBeached} + showTimeLabel={displayControls.showTimeLabel} + simulationStartTime={accidentTime || undefined} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} - {!isReplayActive &&
- {/* 컨트롤 버튼 */} -
- {[ - { icon: '⏮', action: () => setTimelinePosition(0) }, - { icon: '◀', action: () => setTimelinePosition(Math.max(0, timelinePosition - 100 / 12)) }, - ].map((btn, i) => ( - - ))} - - {[ - { icon: '▶▶', action: () => setTimelinePosition(Math.min(100, timelinePosition + 100 / 12)) }, - { icon: '⏭', action: () => setTimelinePosition(100) }, - ].map((btn, i) => ( - - ))} -
- -
- - {/* 타임라인 슬라이더 */} -
- {/* 시간 라벨 */} -
- {['0h', '6h', '12h', '18h', '24h', '36h', '48h', '60h', '72h'].map((label, i) => { - const pos = [0, 8.33, 16.67, 25, 33.33, 50, 66.67, 83.33, 100][i] - const isActive = Math.abs(timelinePosition - pos) < 5 - return ( - setTimelinePosition(pos)}>{label} - ) - })} -
- - {/* 슬라이더 트랙 */} -
- {/* 트랙 레일 */} -
{ - const rect = e.currentTarget.getBoundingClientRect() - setTimelinePosition(Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))) - }} - > - {/* 진행 바 */} -
- {/* 주요 마커 */} - {[0, 16.67, 33.33, 50, 66.67, 83.33, 100].map((pos) => ( -
+ {!isReplayActive && (() => { + const progressPct = maxTime > 0 ? (currentStep / maxTime) * 100 : 0; + // 동적 라벨: 스텝 수에 따라 균등 분배 + const visibleLabels: number[] = (() => { + if (timeSteps.length === 0) return [0]; + if (timeSteps.length <= 8) return timeSteps; + const interval = Math.ceil(timeSteps.length / 7); + return timeSteps.filter((_, i) => i % interval === 0 || i === timeSteps.length - 1); + })(); + return ( +
+ {/* 컨트롤 버튼 */} +
+ {[ + { icon: '⏮', action: () => { setCurrentStep(timeSteps[0] ?? 0); setIsPlaying(false); } }, + { icon: '◀', action: () => { const idx = timeSteps.indexOf(currentStep); if (idx > 0) setCurrentStep(timeSteps[idx - 1]); } }, + ].map((btn, i) => ( + ))} - {/* 보조 마커 */} - {[8.33, 25].map((pos) => ( -
- ))} - {/* 방어선 설치 이벤트 마커 */} - {boomLines.length > 0 && [ - { pos: 4.2, label: '1차 방어선 설치 (+3h)' }, - { pos: 8.3, label: '2차 방어선 설치 (+6h)' }, - { pos: 12.5, label: '3차 방어선 설치 (+9h)' }, - ].slice(0, boomLines.length).map((bm, i) => ( -
🛡
+ + {[ + { icon: '▶▶', action: () => { const idx = timeSteps.indexOf(currentStep); if (idx < timeSteps.length - 1) setCurrentStep(timeSteps[idx + 1]); } }, + { icon: '⏭', action: () => { setCurrentStep(maxTime); setIsPlaying(false); } }, + ].map((btn, i) => ( + ))} +
+
- {/* 드래그 핸들 */} -
-
-
- {/* 시간 정보 */} -
-
- +{Math.round(timelinePosition * 72 / 100)}h — {(() => { - const d = new Date(); d.setHours(d.getHours() + Math.round(timelinePosition * 72 / 100)) - 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` - })()} -
-
- {[ - { label: '풍화율', value: `${Math.min(99, Math.round(timelinePosition * 0.4))}%` }, - { label: '면적', value: `${(timelinePosition * 0.08).toFixed(1)} km²` }, - { label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(timelinePosition * 0.2))}%` : '—', color: 'var(--boom)' }, - ].map((s, i) => ( -
- {s.label} - {s.value} + {/* 타임라인 슬라이더 */} +
+ {/* 동적 시간 라벨 */} +
+ {visibleLabels.map(t => { + const pos = maxTime > 0 ? (t / maxTime) * 100 : 0; + const isActive = t === currentStep; + return ( + setCurrentStep(t)}>{t}h + ) + })}
- ))} + + {/* 슬라이더 트랙 */} +
+
{ + if (timeSteps.length === 0) return; + const rect = e.currentTarget.getBoundingClientRect(); + const pct = (e.clientX - rect.left) / rect.width; + const targetTime = pct * maxTime; + const closest = timeSteps.reduce((a, b) => + Math.abs(b - targetTime) < Math.abs(a - targetTime) ? b : a + ); + setCurrentStep(closest); + }} + > + {/* 진행 바 */} +
+ {/* 스텝 마커 (각 타임스텝 위치에 틱 표시) */} + {timeSteps.map(t => { + const pos = maxTime > 0 ? (t / maxTime) * 100 : 0; + return ( +
+ ); + })} + {/* 방어선 설치 이벤트 마커 */} + {boomLines.length > 0 && [ + { pos: 4.2, label: '1차 방어선 설치 (+3h)' }, + { pos: 8.3, label: '2차 방어선 설치 (+6h)' }, + { pos: 12.5, label: '3차 방어선 설치 (+9h)' }, + ].slice(0, boomLines.length).map((bm, i) => ( +
🛡
+ ))} +
+ {/* 드래그 핸들 */} +
+
+
+ + {/* 시간 정보 */} +
+
+ +{currentStep}h — {(() => { + const base = accidentTime ? new Date(accidentTime) : new Date(); + const d = new Date(base.getTime() + currentStep * 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`; + })()} +
+
+ {[ + { label: '풍화율', value: `${Math.min(99, Math.round(progressPct * 0.4))}%` }, + { label: '면적', value: `${(progressPct * 0.08).toFixed(1)} km²` }, + { label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--boom)' }, + ].map((s, i) => ( +
+ {s.label} + {s.value} +
+ ))} +
+
-
-
} + ); + })()} {/* 역추적 리플레이 바 */} {isReplayActive && ( @@ -628,7 +1153,7 @@ export function OilSpillView() { onSpeedChange={setReplaySpeed} onClose={handleCloseReplay} replayShips={replayShips} - collisionEvent={collisionEvent || undefined} + collisionEvent={collisionEvent} /> )} @@ -636,7 +1161,49 @@ export function OilSpillView() {
{/* Right Panel */} - {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} oilTrajectory={oilTrajectory} />} + {activeSubTab === 'analysis' && ( + setRecalcModalOpen(true)} + onOpenReport={handleOpenReport} + detail={analysisDetail} + summary={simulationSummary} + displayControls={displayControls} + onDisplayControlsChange={setDisplayControls} + windHydrModel={windHydrModel} + windHydrModelOptions={windHydrModelOptions} + onWindHydrModelChange={setWindHydrModel} + analysisTab={analysisTab} + onSwitchAnalysisTab={setAnalysisTab} + drawAnalysisMode={drawAnalysisMode} + analysisPolygonPoints={analysisPolygonPoints} + circleRadiusNm={circleRadiusNm} + onCircleRadiusChange={setCircleRadiusNm} + analysisResult={analysisResult} + incidentCoord={incidentCoord} + onStartPolygonDraw={handleStartPolygonDraw} + onRunPolygonAnalysis={handleRunPolygonAnalysis} + onRunCircleAnalysis={handleRunCircleAnalysis} + onCancelAnalysis={handleCancelAnalysis} + onClearAnalysis={handleClearAnalysis} + /> + )} + + {/* 확산 예측 실행 중 로딩 오버레이 */} + {isRunningSimulation && ( + + )} + + {/* 확산 예측 에러 팝업 */} + {simulationError && ( + setSimulationError(null)} + /> + )} {/* 재계산 모달 */} { setOilType(params.oilType) @@ -655,7 +1222,14 @@ export function OilSpillView() { setPredictionTime(params.predictionTime) setIncidentCoord(params.incidentCoord) setSelectedModels(params.selectedModels) - handleRunSimulation() + handleRunSimulation({ + models: params.selectedModels, + oilType: params.oilType, + spillAmount: params.spillAmount, + spillType: params.spillType, + predictionTime: params.predictionTime, + incidentCoord: params.incidentCoord, + }) }} /> diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index 8770243..c8d7a57 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -1,19 +1,26 @@ -import { useState } from 'react' +import { useState, useRef } from 'react' import { decimalToDMS } from '@common/utils/coordinates' 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 onModelsChange: (models: Set) => void + visibleModels?: Set + onVisibleModelsChange?: (models: Set) => void + hasResults?: boolean predictionTime: number onPredictionTimeChange: (time: number) => void spillType: string @@ -22,18 +29,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 +59,57 @@ const PredictionInputSection = ({ onOilTypeChange, spillAmount, onSpillAmountChange, + incidentName, + onIncidentNameChange, + spillUnit, + onSpillUnitChange, + onImageAnalysisResult, }: PredictionInputSectionProps) => { const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct') - const [uploadedImage, setUploadedImage] = useState(null) - const [uploadedFileName, setUploadedFileName] = useState('') + const [uploadedFile, setUploadedFile] = useState(null) + const [isAnalyzing, setIsAnalyzing] = useState(false) + const [analyzeError, setAnalyzeError] = useState(null) + const [analyzeResult, setAnalyzeResult] = useState(null) + const fileInputRef = useRef(null) - const handleImageUpload = (e: React.ChangeEvent) => { - 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) => { + 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 +136,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]" /> 직접 입력 @@ -98,7 +146,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]" /> 이미지 업로드 @@ -107,43 +155,23 @@ const PredictionInputSection = ({ {/* Direct Input Mode */} {inputMode === 'direct' && ( <> - - + onIncidentNameChange(e.target.value)} + /> + )} {/* Image Upload Mode */} {inputMode === 'upload' && ( <> - - {}} - options={[ - { value: '', label: '여수 유조선 충돌 (INC-0042)' }, - { value: 'INC-0042', label: '여수 유조선 충돌 (INC-0042)' } - ]} - placeholder="사고 선택" - /> - - {/* Upload Success Message */} - {uploadedImage && ( -
- - 내 이미지가 업로드됨 -
- )} - - {/* File Upload Area */} - {!uploadedImage ? ( - ) : ( -
- 📄 {uploadedFileName || 'example_plot_0.gif'} +
+ 📄 {uploadedFile.name}
)} - {/* Dropdowns */} -
- {}} - options={[ - { value: '', label: '유출회사' }, - { value: 'company1', label: '회사A' }, - { value: 'company2', label: '회사B' } - ]} - placeholder="유출회사" - /> - {}} - options={[ - { value: '', label: '예상시각' }, - { value: '09:00', label: '09:00' }, - { value: '12:00', label: '12:00' } - ]} - placeholder="예상시각" - /> -
+ {/* 분석 실행 버튼 */} + + + {/* 에러 메시지 */} + {analyzeError && ( +
+ ⚠ {analyzeError} +
+ )} + + {/* 분석 완료 메시지 */} + {analyzeResult && ( +
+ ✓ 분석 완료
+ + 위도 {analyzeResult.lat.toFixed(4)} / 경도 {analyzeResult.lon.toFixed(4)}
+ 유종: {analyzeResult.oilType} / 면적: {analyzeResult.area.toFixed(1)} m² +
+
+ )} )} + {/* 사고 발생 시각 */} +
+ + onAccidentTimeChange(e.target.value)} + style={{ colorScheme: 'dark' }} + /> +
+ {/* Coordinates + Map Button */}
@@ -228,7 +286,7 @@ const PredictionInputSection = ({ value={incidentCoord?.lat ?? ''} onChange={(e) => { const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - onCoordChange({ ...incidentCoord, lat: isNaN(value) ? 0 : value }) + onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: isNaN(value) ? 0 : value }) }} placeholder="위도°" /> @@ -239,19 +297,21 @@ const PredictionInputSection = ({ value={incidentCoord?.lon ?? ''} onChange={(e) => { const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - onCoordChange({ ...incidentCoord, lon: isNaN(value) ? 0 : value }) + onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: isNaN(value) ? 0 : value }) }} placeholder="경도°" /> - +
{/* 도분초 표시 */} {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && ( -
+
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
)} @@ -297,8 +357,8 @@ const PredictionInputSection = ({ /> {}} + value={spillUnit} + onChange={onSpillUnitChange} options={[ { value: 'kL', label: 'kL' }, { value: 'ton', label: 'Ton' }, @@ -319,23 +379,11 @@ const PredictionInputSection = ({ />
- {/* Image Analysis Note (Upload Mode Only) */} - {inputMode === 'upload' && uploadedImage && ( -
- 📊 이미지 내 확산경로를 분석하였습니다. 각 방제요소 가이드 참고하세요. -
- )} - {/* Divider */}
{/* Model Selection (다중 선택) */} + {/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
{([ { id: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false }, @@ -351,18 +399,18 @@ const PredictionInputSection = ({ 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)) + } }} > {m.id}
))} + {/* 임시 비활성화 — OpenDrift만 구동 가능 (앙상블은 모든 모델 연동 후 활성화 예정)
{ @@ -372,8 +420,16 @@ const PredictionInputSection = ({ 앙상블
+ */}
+ {/* 모델 미선택 경고 */} + {selectedModels.size === 0 && ( +

+ ⚠ 예측 모델을 하나 이상 선택하세요. +

+ )} + {/* Run Button */} - + {/* 탭 전환 */} +
+ {(['polygon', 'circle'] as const).map((tab) => ( + + ))}
- {/* ── 다각형 분석 탭 ── */} + {/* 다각형 패널 */} {analysisTab === 'polygon' && ( - <> -

+

+

지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.

- - - )} - - {/* ── 원 분석 탭 ── */} - {analysisTab === 'circle' && ( - <> -

- 반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다. -

- - {/* 반경 선택 (NM) */} -
반경 선택 (NM)
-
- {NM_PRESETS.map(nm => ( - - ))} -
- - {/* 직접 입력 + 분석 실행 */} -
- 직접 입력 - 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" - /> - NM + {!drawAnalysisMode && !analysisResult && ( -
- - )} - - {/* 원 분석 결과 */} - {analysisTab === 'circle' && circleResult && ( -
-
⭕ 원 분석 결과 (반경 {circleResult.radiusNm} NM)
-
-
-
{circleResult.areaNm2.toFixed(1)}
-
면적 (NM²)
+ 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))' }} + > + 📐 다각형 분석수행 + + )} + {drawAnalysisMode === 'polygon' && ( +
+
+ 지도를 클릭하여 꼭짓점을 추가하세요
+ 현재 {analysisPolygonPoints.length}개 선택됨 +
+
+ + +
-
-
{circleResult.areaKm2.toFixed(1)}
-
면적 (km²)
-
-
-
{circleResult.circumferenceKm.toFixed(1)}
-
원 둘레 (km)
-
-
-
{(circleResult.radiusNm * 1.852).toFixed(1)}
-
반경 (km)
-
-
+ )} + {analysisResult && !drawAnalysisMode && ( + + )}
)} - {/* 다각형 분석 결과 */} - {analysisTab === 'polygon' && polygonResult && ( -
-
📐 Convex Hull 다각형 분석 결과
-
-
-
{polygonResult.areaKm2.toFixed(2)}
-
오염 면적 (km²)
-
-
-
{polygonResult.perimeterKm.toFixed(1)}
-
외곽 둘레 (km)
-
-
-
{polygonResult.particleCount.toLocaleString()}
-
분석 입자 수
-
-
-
{polygonResult.hullPoints}
-
외곽 꼭짓점
-
+ {/* 원 분석 패널 */} + {analysisTab === 'circle' && ( +
+

+ 반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다. +

+
반경 선택 (NM)
+
+ {[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => ( + + ))}
+
+ 직접 입력 + 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' }} + /> + NM + +
+ {analysisResult && ( + + )}
)} @@ -207,11 +223,11 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
- - - + + +
- +
@@ -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 ( -
) } + +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 ( +
+
+ {radiusNm && ( +
+ 분석 결과 + 반경 {radiusNm} NM +
+ )} +
+
+
{result.area.toFixed(2)}
+
분석면적(km²)
+
+
+
{result.particlePercent}%
+
오염비율
+
+
+
{pollutedArea}
+
오염면적(km²)
+
+
+
+ {summary && ( +
+ 해상잔존량 + {summary.remainingVolume.toFixed(2)} kL +
+ )} + {summary && ( +
+ 연안부착량 + {summary.beachedVolume.toFixed(2)} kL +
+ )} +
+ 민감자원 포함 + {result.sensitiveCount}개소 +
+
+
+ + {onRerun && ( + + )} +
+
+ ) +} diff --git a/frontend/src/tabs/prediction/components/SimulationErrorModal.tsx b/frontend/src/tabs/prediction/components/SimulationErrorModal.tsx new file mode 100644 index 0000000..17eec82 --- /dev/null +++ b/frontend/src/tabs/prediction/components/SimulationErrorModal.tsx @@ -0,0 +1,110 @@ +interface SimulationErrorModalProps { + message: string; + onClose: () => void; +} + +const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) => { + return ( +
+
+ {/* 아이콘 + 제목 */} +
+
+ + + +
+
+
+ 확산 예측 실패 +
+
+ 시뮬레이션 실행 중 오류가 발생했습니다 +
+
+
+ + {/* 에러 메시지 */} +
+ {message} +
+ + {/* 확인 버튼 */} + +
+
+ ); +}; + +export default SimulationErrorModal; diff --git a/frontend/src/tabs/prediction/components/SimulationLoadingOverlay.tsx b/frontend/src/tabs/prediction/components/SimulationLoadingOverlay.tsx new file mode 100644 index 0000000..4130de0 --- /dev/null +++ b/frontend/src/tabs/prediction/components/SimulationLoadingOverlay.tsx @@ -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 ( +
+
+ {/* 아이콘 + 제목 */} +
+
+ + + +
+
+
+ 확산 예측 분석 중 +
+
+ {statusText} +
+
+
+ + {/* 진행률 바 */} +
+
+
+
+
+ + {status === 'PENDING' ? '대기 중' : '분석 진행 중'} + + + {status === 'PENDING' ? '—' : `${displayProgress}%`} + +
+
+ + {/* 안내 문구 */} +
+ OpenDrift 모델로 유류 확산을 시뮬레이션하고 있습니다. +
+ 완료되면 자동으로 결과가 표시됩니다. +
+
+
+ ); +}; + +export default SimulationLoadingOverlay; diff --git a/frontend/src/tabs/prediction/components/leftPanelTypes.ts b/frontend/src/tabs/prediction/components/leftPanelTypes.ts index a7ab8a9..71a86de 100644 --- a/frontend/src/tabs/prediction/components/leftPanelTypes.ts +++ b/frontend/src/tabs/prediction/components/leftPanelTypes.ts @@ -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 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 onModelsChange: (models: Set) => void + visibleModels?: Set + onVisibleModelsChange?: (models: Set) => 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 { diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index fcd0d65..54a4f8e 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -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; + hydrDataByModel?: Record; + summaryByModel?: Record; +} + +export const fetchAnalysisTrajectory = async (acdntSn: number): Promise => { + const response = await api.get(`/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 => { + const formData = new FormData(); + formData.append('image', file); + const response = await api.post('/prediction/image-analyze', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 330_000, + }); + return response.data; +}; diff --git a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx index b9de350..c469a46 100755 --- a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx +++ b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx @@ -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 diff --git a/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx b/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx new file mode 100644 index 0000000..b9e7188 --- /dev/null +++ b/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx @@ -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) | 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 ( +
+ 확산 예측 데이터가 없습니다. 예측 탭에서 시뮬레이션을 실행 후 보고서를 생성하세요. +
+ ); + } + + return ( +
+ {/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */} +
+ + + {/* 캡처 이미지 오버레이 — 우측 상단 */} + {capturedImage && ( +
+
+ 확산예측 지도 캡처 +
+ + 📷 캡처 완료 + + +
+
+
+ )} +
+ + {/* 하단 안내 + 캡처 버튼 */} +
+

+ {capturedImage + ? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.' + : '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'} +

+ +
+
+ ); +}; + +export default OilSpreadMapPanel; diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index bb9cd2a..8821ca7 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -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(null) + // OIL 실 데이터 (없으면 sampleOilData fallback) + const [oilPayload, setOilPayload] = useState(null) + // 확산예측 지도 캡처 이미지 + const [oilMapCaptured, setOilMapCaptured] = useState(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 = `

${sec.desc}

`; + let content = `

`; + + // OIL 섹션에 실 데이터 삽입 + if (activeCat === 0) { + if (sec.id === 'oil-spread') { + const mapImg = oilMapCaptured + ? `` + : '
[확산예측 지도 미캡처]
'; + const spreadRows = oilPayload + ? [ + ['KOSPS', oilPayload.spread.kosps], + ['OpenDrift', oilPayload.spread.openDrift], + ['POSEIDON', oilPayload.spread.poseidon], + ] + : [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']]; + const tds = spreadRows.map(r => + `${r[0]}
${r[1]}` + ).join(''); + content = `${mapImg}${tds}
`; + } + } + if (activeCat === 0 && sec.id === 'oil-coastal') { + if (oilPayload) { + const coastLength = oilPayload.pollution.coastLength; + const hasNoCoastal = !coastLength || coastLength === '—' || coastLength.startsWith('0.00'); + content = hasNoCoastal + ? `

유출유의 해안 부착이 없습니다.

` + : `

최초 부착시간: ${oilPayload.coastal?.firstTime ?? '—'} / 부착 해안길이: ${coastLength}

`; + } + } + 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 + ? '

시뮬레이션이 실행되지 않아 오염량은 입력값 기준으로 표시됩니다.

' + : ''; + const trs = rows.map(r => + `${r[0]}${r[1]}${r[2]}${r[3]}` + ).join(''); + content = `${simBanner}${trs}
`; + } + } // HNS 섹션에 실 데이터 삽입 if (activeCat === 1 && hnsPayload) { @@ -256,14 +320,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { {/* ── 유출유 확산예측 섹션들 ── */} {sec.id === 'oil-spread' && ( <> -
- [확산예측 지도 - 범위 조절 작업] -
+ setOilMapCaptured(dataUrl)} + onReset={() => setOilMapCaptured(null)} + />
{[ - { 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) => (

{m.label}

@@ -274,41 +341,61 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { )} {sec.id === 'oil-pollution' && ( - - - - {[ - ['유출량', sampleOilData.pollution.spillAmount, '풍화량', sampleOilData.pollution.weathered], - ['해상잔유량', sampleOilData.pollution.seaRemain, '오염해역면적', sampleOilData.pollution.pollutionArea], - ['연안부착량', sampleOilData.pollution.coastAttach, '오염해안길이', sampleOilData.pollution.coastLength], - ].map((row, i) => ( - - - - - - - ))} - -
{row[0]}{row[1]}{row[2]}{row[3]}
- )} - {sec.id === 'oil-sensitive' && ( <> -

반경 10 NM 기준

-
- {sampleOilData.sensitive.map((item, i) => ( - {item.label} - ))} -
+ {oilPayload && !oilPayload.hasSimulation && ( +
+ 시뮬레이션이 실행되지 않아 오염량은 입력값 기준으로 표시됩니다. +
+ )} + + + + {[ + ['유출량', oilPayload?.pollution.spillAmount || '—', '풍화량', oilPayload?.pollution.weathered || '—'], + ['해상잔유량', oilPayload?.pollution.seaRemain || '—', '오염해역면적', oilPayload?.pollution.pollutionArea || '—'], + ['연안부착량', oilPayload?.pollution.coastAttach || '—', '오염해안길이', oilPayload?.pollution.coastLength || '—'], + ].map((row, i) => ( + + + + + + + ))} + +
{row[0]}{row[1]}{row[2]}{row[3]}
)} - {sec.id === 'oil-coastal' && ( -

- 최초 부착시간: {sampleOilData.coastal.firstTime} - {' / '} - 부착 해안길이: {sampleOilData.coastal.coastLength} + {sec.id === 'oil-sensitive' && ( +

+ 현재 민감자원 데이터가 없습니다.

)} + {sec.id === 'oil-coastal' && (() => { + if (!oilPayload) { + return ( +

+ 현재 해안 부착 데이터가 없습니다. +

+ ); + } + const coastLength = oilPayload.pollution.coastLength; + const hasNoCoastal = !coastLength || coastLength === '—' || coastLength.startsWith('0.00'); + if (hasNoCoastal) { + return ( +

+ 시뮬레이션 결과 유출유의 해안 부착이 없습니다. +

+ ); + } + return ( +

+ 최초 부착시간: {oilPayload.coastal?.firstTime ?? '—'} + {' / '} + 부착 해안길이: {coastLength} +

+ ); + })()} {sec.id === 'oil-defense' && (

방제자원 배치 계획에 따른 전략을 수립합니다.

@@ -318,10 +405,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
)} {sec.id === 'oil-tide' && ( -

- 고조: {sampleOilData.tide.highTide1} - {' / '}저조: {sampleOilData.tide.lowTide} - {' / '}고조: {sampleOilData.tide.highTide2} +

+ 현재 조석·기상 데이터가 없습니다.

)} @@ -342,7 +427,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { )}
{[ - { 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' && (
{[ - { 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) => (

{h.label}

@@ -374,10 +459,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { {sec.id === 'hns-substance' && (
{[ - { 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) => (
{r.k} @@ -386,25 +471,21 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { ))}
독성기준 - {hnsPayload?.substance.toxicity || sampleHnsData.substance.toxicity} + {hnsPayload?.substance.toxicity || '—'}
)} {sec.id === 'hns-ppe' && (
- {sampleHnsData.ppe.map((item, i) => ( - - 🛡 {item} - - ))} +
)} {sec.id === 'hns-facility' && (
{[ - { 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) => (
{f.icon}
@@ -422,10 +503,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { {sec.id === 'hns-weather' && (
{[ - { 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) => (
{w.icon}
@@ -440,10 +521,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { {sec.id === 'rescue-safety' && (
{[ - { 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) => (

{s.label}

@@ -454,26 +535,18 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { )} {sec.id === 'rescue-timeline' && (
- {[ - { 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) => ( -
- {e.time} - {e.event} -
- ))} +
+ +
)} {sec.id === 'rescue-casualty' && (
{[ - { 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) => (

{c.label}

@@ -494,30 +567,18 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { - {sampleRescueData.resources.map((r, i) => ( - - {r.type} - {r.name} - {r.eta} - - - {r.status} - - - - ))} + + — + )} {sec.id === 'rescue-grounding' && (
{[ - { 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) => (

{g.label}

@@ -529,10 +590,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { {sec.id === 'rescue-weather' && (
{[ - { 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) => (
{w.icon}
diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx index 014c1a6..ff027ea 100755 --- a/frontend/src/tabs/reports/components/ReportsView.tsx +++ b/frontend/src/tabs/reports/components/ReportsView.tsx @@ -11,6 +11,7 @@ import { generateReportHTML, exportAsPDF, exportAsHWP, + buildReportGetVal, typeColors, statusColors, analysisCatColors, @@ -130,6 +131,7 @@ export function ReportsView() { + @@ -145,6 +147,7 @@ export function ReportsView() { 관할 상태 수정 + 지도 다운로드 삭제 @@ -177,6 +180,12 @@ export function ReportsView() { {report.jurisdiction} {report.status} + + {(report.hasMapCapture || report.capturedMapImage) + ? 📷 + : + } + @@ -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') || '—'}
+ {previewReport.capturedMapImage && ( + 확산예측 지도 캡처 + )}
{/* 3. 초동조치 / 대응현황 */} diff --git a/frontend/src/tabs/reports/components/reportTypes.ts b/frontend/src/tabs/reports/components/reportTypes.ts index d4147dd..4999f62 100644 --- a/frontend/src/tabs/reports/components/reportTypes.ts +++ b/frontend/src/tabs/reports/components/reportTypes.ts @@ -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' }] }, ] }, { diff --git a/frontend/src/tabs/reports/components/reportUtils.ts b/frontend/src/tabs/reports/components/reportUtils.ts index 149de5f..8538a01 100644 --- a/frontend/src/tabs/reports/components/reportUtils.ts +++ b/frontend/src/tabs/reports/components/reportUtils.ts @@ -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 = `날짜조형간조1만조1간조2만조2` + const rows = tide.map(t => + `${t.date}${t.tideType}${t.lowTide1}${t.highTide1}${t.lowTide2}${t.highTide2}` + ).join('') + return `${header}${rows}
` +} + +function formatWeatherTable(weather: OilSpillReportData['weather']): string { + if (!weather?.length) return '' + const header = `시각풍향풍속유향유속파고` + const rows = weather.map(w => + `${w.time}${w.windDir}${w.windSpeed}${w.currentDir}${w.currentSpeed}${w.waveHeight}` + ).join('') + return `${header}${rows}
` +} + +function formatSpreadTable(spread: OilSpillReportData['spread']): string { + if (!spread?.length) return '' + const header = `경과시간풍화량해상잔유량연안부착량면적` + const rows = spread.map(s => + `${s.elapsed}${s.weathered}${s.seaRemain}${s.coastAttach}${s.area}` + ).join('') + return `${header}${rows}
` +} + +function formatSensitiveTable(r: OilSpillReportData): string { + const parts: string[] = [] + if (r.aquaculture?.length) { + const h = `종류면적거리` + const rows = r.aquaculture.map(a => `${a.type}${a.area}${a.distance}`).join('') + parts.push(`

양식업

${h}${rows}
`) + } + if (r.beaches?.length) { + const h = `해수욕장명거리` + const rows = r.beaches.map(b => `${b.name}${b.distance}`).join('') + parts.push(`

해수욕장

${h}${rows}
`) + } + if (r.markets?.length) { + const h = `수산시장명거리` + const rows = r.markets.map(m => `${m.name}${m.distance}`).join('') + parts.push(`

수산시장

${h}${rows}
`) + } + if (r.esi?.length) { + const h = `코드유형길이` + const rows = r.esi.map(e => `${e.code}${e.type}${e.length}`).join('') + parts.push(`

ESI 해안선

${h}${rows}
`) + } + if (r.species?.length) { + const h = `분류종명` + const rows = r.species.map(s => `${s.category}${s.species}`).join('') + parts.push(`

보호생물

${h}${rows}
`) + } + if (r.habitat?.length) { + const h = `유형면적` + const rows = r.habitat.map(h2 => `${h2.type}${h2.area}`).join('') + parts.push(`

서식지

${h}${rows}
`) + } + if (r.sensitivity?.length) { + const h = `민감도면적` + const rows = r.sensitivity.map(s => `${s.level}${s.area}`).join('') + parts.push(`

민감도 등급

${h}${rows}
`) + } + return parts.join('') +} + +function formatVesselsTable(vessels: OilSpillReportData['vessels']): string { + if (!vessels?.length) return '' + const header = `선명기관거리속력톤수회수장비오일붐` + const rows = vessels.map(v => + `${v.name}${v.org}${v.dist}${v.speed}${v.ton}${v.collectorType} ${v.collectorCap}${v.boomType} ${v.boomLength}` + ).join('') + return `${header}${rows}
` +} + +function formatRecoveryTable(recovery: OilSpillReportData['recovery']): string { + if (!recovery?.length) return '' + const header = `선박명회수 기간` + const rows = recovery.map(r => + `${r.shipName}${r.period}` + ).join('') + return `${header}${rows}
` +} + +function formatResultTable(result: OilSpillReportData['result']): string { + if (!result) return '' + return ` + + + +
유출총량${result.spillTotal}풍화총량${result.weatheredTotal}
회수총량${result.recoveredTotal}해상잔유량${result.seaRemainTotal}
연안부착량${result.coastAttachTotal}
` +} + +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] || '' + } +} diff --git a/frontend/src/tabs/reports/services/reportsApi.ts b/frontend/src/tabs/reports/services/reportsApi.ts index 33ee933..0bee564 100644 --- a/frontend/src/tabs/reports/services/reportsApi.ts +++ b/frontend/src/tabs/reports/services/reportsApi.ts @@ -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 { await api.post(`/reports/${sn}/update`, input); @@ -239,6 +243,7 @@ export async function saveReport(data: OilSpillReportData): Promise { 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 { 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; } diff --git a/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx b/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx new file mode 100644 index 0000000..db18f7c --- /dev/null +++ b/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx @@ -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(null) + const particlesRef = useRef([]) + const animFrameRef = useRef(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 +} diff --git a/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx b/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx index c521d5f..34b54c9 100755 --- a/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx +++ b/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx @@ -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(null) + const { current: mapRef } = useMap() + const canvasRef = useRef(null) + const imgRef = useRef(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 ( - - - - ) + 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 } diff --git a/frontend/src/tabs/weather/components/WeatherMapControls.tsx b/frontend/src/tabs/weather/components/WeatherMapControls.tsx new file mode 100644 index 0000000..56173c6 --- /dev/null +++ b/frontend/src/tabs/weather/components/WeatherMapControls.tsx @@ -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 ( +
+
+ {buttons.map(({ label, tooltip, onClick }) => ( +
+ +
+ {tooltip} +
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx b/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx index a3151d0..6800466 100755 --- a/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx +++ b/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx @@ -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 ( onStationClick(station)} > -
- +
+ + {/* 위쪽이 바람 방향을 나타내는 삼각형 */} + + +
+ - - - + {station.wind.speed.toFixed(1)} +
) })} - {/* 기상 데이터 라벨 — 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" > - {/* 관측소명 */}
{station.name}
- - {/* 수온 */}
- + {station.temperature.current.toFixed(1)} - - °C - + °C
- - {/* 파고 */}
- + {station.wave.height.toFixed(1)} - - m - + m
- - {/* 풍속 */}
- + {station.wind.speed.toFixed(1)} - - m/s - + m/s
) })} + */} ) } diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index c626b27..35414db 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -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 ( -
-
- - - -
-
- ) -} - /** * WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역) */ @@ -156,18 +126,20 @@ interface WeatherMapInnerProps { weatherStations: WeatherStation[] enabledLayers: Set selectedStationId: string | null - oceanForecastOpacity: number - selectedForecast: ReturnType['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 오버레이 */} - {/* 해황예보도 — MapLibre image source + raster layer */} + {/* 해황예보도 — 임시 비활성화 */} + + {/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */} + {/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */} @@ -216,8 +193,31 @@ function WeatherMapInner({ stations={weatherStations} /> + {/* 클릭 위치 마커 */} + {clickedLocation && ( + +
+ {/* 펄스 링 */} +
+
+
+
+ {/* 핀 꼬리 */} +
+ {/* 좌표 라벨 */} +
+ {clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E +
+
+ + )} + {/* 줌 컨트롤 */} - + ) } @@ -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('0') const [selectedStationRaw, setSelectedStation] = useState(null) const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>( null ) - const [enabledLayers, setEnabledLayers] = useState>(new Set(['wind', 'labels'])) - const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6) + const [enabledLayers, setEnabledLayers] = useState>(new Set(['wind'])) + // const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6) // 첫 관측소 자동 선택 (파생 값) const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null @@ -343,12 +344,6 @@ export function WeatherView() {
- -
- -
{/* 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} /> @@ -396,6 +392,7 @@ export function WeatherView() { /> 🌬️ 바람 벡터 + {/* 기상 데이터 레이어 — 임시 비활성화 + */} - {/* 해황예보도 레이어 */} + {/* 해황예보도 레이어 — 임시 비활성화
- - {enabledLayers.has('oceanForecast') && ( -
-
- 투명도: - - setOceanForecastOpacity(Number(e.target.value) / 100) - } - className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer" - /> - - {Math.round(oceanForecastOpacity * 100)}% - -
- - {availableTimes.length > 0 && ( -
-
예보 시간:
-
- {availableTimes.map((time) => ( - - ))} -
-
- )} - - {oceanLoading &&
로딩 중...
} - {oceanError &&
오류 발생
} - {selectedForecast && ( -
- 현재: {selectedForecast.name} •{' '} - {selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '} - {selectedForecast.hour}:00 -
- )} -
- )}
+ */}
@@ -538,6 +484,23 @@ export function WeatherView() {
+ {/* 해류 */} +
+
해류 (m/s)
+
+
+
+
+
+
+
+ 0.2 + 0.4 + 0.6 + 0.6+ +
+
+ {/* 파고 */}
파고 (m)
diff --git a/frontend/src/tabs/weather/hooks/useOceanForecast.ts b/frontend/src/tabs/weather/hooks/useOceanForecast.ts index 7273659..1b1b201 100755 --- a/frontend/src/tabs/weather/hooks/useOceanForecast.ts +++ b/frontend/src/tabs/weather/hooks/useOceanForecast.ts @@ -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}`)) diff --git a/frontend/src/tabs/weather/hooks/useWeatherData.ts b/frontend/src/tabs/weather/hooks/useWeatherData.ts index 37eb2df..909face 100755 --- a/frontend/src/tabs/weather/hooks/useWeatherData.ts +++ b/frontend/src/tabs/weather/hooks/useWeatherData.ts @@ -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 diff --git a/frontend/src/tabs/weather/services/khoaApi.ts b/frontend/src/tabs/weather/services/khoaApi.ts index 5a01d36..c3ec365 100755 --- a/frontend/src/tabs/weather/services/khoaApi.ts +++ b/frontend/src/tabs/weather/services/khoaApi.ts @@ -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 { 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