Merge remote-tracking branch 'origin/develop' into feature/cctv-hns-enhancements

This commit is contained in:
Nan Kyung Lee 2026-03-19 08:42:49 +09:00
커밋 6bea387ee2
67개의 변경된 파일6395개의 추가작업 그리고 1454개의 파일을 삭제

파일 보기

@ -5,29 +5,29 @@
},
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(curl -s *)",
"Bash(fnm *)",
"Bash(git add *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git diff *)",
"Bash(git fetch *)",
"Bash(git log *)",
"Bash(git merge *)",
"Bash(git pull *)",
"Bash(git remote *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git stash *)",
"Bash(git status)",
"Bash(git tag *)",
"Bash(curl -s *)",
"Bash(fnm *)"
"Bash(node *)",
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(npm test *)",
"Bash(npx *)"
],
"deny": [
"Bash(git push --force*)",
@ -83,5 +83,7 @@
]
}
]
}
},
"deny": [],
"allow": []
}

파일 보기

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

21
.gitignore vendored
파일 보기

@ -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/

파일 보기

@ -8,6 +8,7 @@
"name": "backend",
"version": "1.0.0",
"dependencies": {
"@types/multer": "^2.1.0",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@ -17,6 +18,7 @@
"google-auth-library": "^10.6.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"pg": "^8.19.0"
},
"devDependencies": {
@ -515,7 +517,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@ -526,7 +527,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -556,9 +556,7 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@ -569,7 +567,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@ -592,7 +589,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
@ -613,11 +609,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -639,21 +643,18 @@
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -663,7 +664,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@ -716,6 +716,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -837,6 +843,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -893,6 +916,21 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -1841,6 +1879,25 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -1992,7 +2049,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.12.0",
@ -2180,6 +2236,20 @@
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@ -2426,6 +2496,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -2564,6 +2651,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -2582,7 +2675,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
@ -2594,6 +2686,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",

파일 보기

@ -9,6 +9,7 @@
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@types/multer": "^2.1.0",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@ -18,6 +19,7 @@
"google-auth-library": "^10.6.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"pg": "^8.19.0"
},
"devDependencies": {

파일 보기

@ -1,8 +1,11 @@
import express from 'express';
import multer from 'multer';
import path from 'path';
import {
listMedia,
createMedia,
getMediaBySn,
fetchOriginalImage,
listCctv,
listSatRequests,
createSatRequest,
@ -10,6 +13,7 @@ import {
isValidSatStatus,
requestOilInference,
checkInferenceHealth,
stitchImages,
listDroneStreams,
startDroneStream,
stopDroneStream,
@ -19,6 +23,7 @@ import { isValidNumber } from '../middleware/security.js';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
const router = express.Router();
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
// ============================================================
// AERIAL_MEDIA 라우트
@ -68,6 +73,40 @@ router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async
}
});
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const sn = parseInt(req.params['sn'] as string, 10);
if (!isValidNumber(sn, 1, 999999)) {
res.status(400).json({ error: '유효하지 않은 미디어 번호' });
return;
}
const media = await getMediaBySn(sn);
if (!media) {
res.status(404).json({ error: '미디어를 찾을 수 없습니다.' });
return;
}
// fileId 추출: FILE_NM의 앞 36자가 UUID 형식인지 검증 (이미지 분석 생성 레코드만 다운로드 가능)
const fileId = media.fileNm.substring(0, 36);
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_PATTERN.test(fileId) || !media.equipNm) {
res.status(404).json({ error: '다운로드 가능한 이미지가 없습니다.' });
return;
}
const buffer = await fetchOriginalImage(media.equipNm, fileId);
const downloadName = media.orgnlNm ?? media.fileNm;
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(downloadName)}`);
res.send(buffer);
} catch (err) {
console.error('[aerial] 이미지 다운로드 오류:', err);
res.status(502).json({ error: '이미지 다운로드 실패' });
}
});
// ============================================================
// CCTV_CAMERA 라우트
// ============================================================
@ -527,6 +566,39 @@ router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requireP
}
});
// ============================================================
// STITCH (이미지 합성) 라우트
// ============================================================
// POST /api/aerial/stitch — 여러 이미지를 합성하여 JPEG 반환
router.post(
'/stitch',
requireAuth,
requirePermission('aerial', 'READ'),
stitchUpload.array('files', 6),
async (req, res) => {
try {
const files = req.files as Express.Multer.File[];
if (!files || files.length < 2) {
res.status(400).json({ error: '이미지를 최소 2장 이상 선택해주세요.' });
return;
}
const fileId = `stitch_${Date.now()}`;
const buffer = await stitchImages(files, fileId);
res.type('image/jpeg').send(buffer);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('abort') || message.includes('timeout')) {
console.error('[aerial] 스티칭 서버 타임아웃:', message);
res.status(504).json({ error: '이미지 합성 서버 응답 시간 초과' });
return;
}
console.error('[aerial] 이미지 합성 오류:', err);
res.status(503).json({ error: '이미지 합성 서버 연결 불가' });
}
}
);
// GET /api/aerial/oil-detect/health — 추론 서버 상태 확인
router.get('/oil-detect/health', requireAuth, async (_req, res) => {
const health = await checkInferenceHealth();

파일 보기

@ -53,6 +53,26 @@ function rowToMedia(r: Record<string, unknown>): AerialMediaItem {
};
}
export async function getMediaBySn(sn: number): Promise<AerialMediaItem | null> {
const { rows } = await wingPool.query(
`SELECT AERIAL_MEDIA_SN, ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH,
LON, LAT, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD,
TAKNG_DTM, FILE_SZ, RESOLUTION, REG_DTM
FROM wing.AERIAL_MEDIA WHERE AERIAL_MEDIA_SN = $1 AND USE_YN = 'Y'`,
[sn]
);
return rows.length > 0 ? rowToMedia(rows[0]) : null;
}
export async function fetchOriginalImage(camTy: string, fileId: string): Promise<Buffer> {
const res = await fetch(`${IMAGE_API_URL}/get-original-image/${camTy}/${fileId}`, {
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`이미지 서버 응답: ${res.status}`);
const base64 = await res.json() as string;
return Buffer.from(base64, 'base64');
}
export async function listMedia(input: ListMediaInput): Promise<AerialMediaItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"];
const params: (string | number)[] = [];
@ -113,8 +133,8 @@ export async function createMedia(input: {
TAKNG_DTM, FILE_SZ, RESOLUTION
) VALUES (
$1, $2, $3, $4,
$5, $6,
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::float, $6::float), 4326) END,
$5::float8, $6::float8,
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5, $6), 4326) END,
$7, $8, $9, $10,
$11, $12, $13
) RETURNING AERIAL_MEDIA_SN`,
@ -348,6 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
// OIL INFERENCE (GPU 서버 프록시)
// ============================================================
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
const INFERENCE_TIMEOUT_MS = 10_000;
@ -366,6 +387,28 @@ export interface OilInferenceResult {
regions: OilInferenceRegion[];
}
/** 여러 이미지를 이미지 분석 서버의 /stitch 엔드포인트로 전송해 합성 JPEG를 반환한다. */
export async function stitchImages(
files: Express.Multer.File[],
fileId: string
): Promise<Buffer> {
const form = new FormData();
form.append('fileId', fileId);
for (const f of files) {
form.append('files', new Blob([f.buffer], { type: f.mimetype }), f.originalname);
}
const response = await fetch(`${IMAGE_API_URL}/stitch`, {
method: 'POST',
body: form,
signal: AbortSignal.timeout(300_000),
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`stitch server responded ${response.status}: ${detail}`);
}
return Buffer.from(await response.arrayBuffer());
}
/** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
const controller = new AbortController();

파일 보기

@ -153,9 +153,9 @@ export function sanitizeQuery(req: Request, res: Response, next: NextFunction):
}
/**
* JSON ( 100kb)
* JSON ( 대응: 5mb)
*/
export const BODY_SIZE_LIMIT = '100kb'
export const BODY_SIZE_LIMIT = '5mb'
/**
*

파일 보기

@ -0,0 +1,174 @@
import crypto from 'crypto';
import { wingPool } from '../db/wingDb.js';
import { createMedia } from '../aerial/aerialService.js';
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
// 유류 클래스 → UI 유종명 매핑
const CLASS_ID_TO_OIL_TYPE: Record<string, string> = {
'검정': '벙커C유',
'갈색': '벙커C유',
'무지개': '경유',
'은색': '등유',
};
// 유종명 → DB 코드 매핑
const OIL_DB_CODE_MAP: Record<string, string> = {
'벙커C유': 'BUNKER_C',
'원유': 'CRUDE_OIL',
'경유': 'DIESEL',
'등유': 'GASOLINE',
};
interface OilPolygon {
classId: string;
area: number;
volume: number;
note: string;
thickness: number;
wkt: string;
}
interface ImageServerResponse {
meta: string;
data: OilPolygon[];
}
export interface ImageAnalyzeResult {
acdntSn: number;
lat: number;
lon: number;
oilType: string;
area: number;
volume: number;
fileId: string;
occurredAt: string;
}
/**
* mx15hdi CSV :
* Filename, Tlat_d, Tlat_m, Tlat_s, Tlon_d, Tlon_m, Tlon_s,
* Alat_d, Alat_m, Alat_s, Alon_d, Alon_m, Alon_s,
* Az, El, Alt, Date1, Date2, Date3, Time1, Time2, Time3
*/
function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: string } {
const parts = metaStr.split(',');
const tlat_d = parseFloat(parts[1]);
const tlat_m = parseFloat(parts[2]);
const tlat_s = parseFloat(parts[3]);
const tlon_d = parseFloat(parts[4]);
const tlon_m = parseFloat(parts[5]);
const tlon_s = parseFloat(parts[6]);
const lat = tlat_d + tlat_m / 60 + tlat_s / 3600;
const lon = tlon_d + tlon_m / 60 + tlon_s / 3600;
// Date: Date1(DD), Date2(MM), Date3(YYYY) / Time: Time1(HH), Time2(mm), Time3(ss)
const dd = (parts[16] ?? '01').padStart(2, '0');
const mm = (parts[17] ?? '01').padStart(2, '0');
const yyyy = parts[18] ?? new Date().getFullYear().toString();
const time1 = (parts[19] ?? '00').padStart(2, '0');
const time2 = (parts[20] ?? '00').padStart(2, '0');
const occurredAt = `${yyyy}-${mm}-${dd}T${time1}:${time2}:00+09:00`;
return { lat, lon, occurredAt };
}
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise<ImageAnalyzeResult> {
const fileId = crypto.randomUUID();
// camTy는 현재 "mx15hdi"로 하드코딩한다.
// TODO: 추후 이미지 EXIF에서 카메라 모델명을 읽어 camTy를 자동 판별하는 로직을
// 이미지 분석 서버(api.py)에 추가할 예정이다. (check_camera_info 함수 활용)
const camTy = 'mx15hdi';
// 이미지 분석 서버 호출
const formData = new FormData();
const blob = new Blob([imageBuffer]);
formData.append('camTy', camTy);
formData.append('fileId', fileId);
formData.append('image', blob, originalName);
let serverResponse: ImageServerResponse;
try {
const res = await fetch(`${IMAGE_API_URL}/run-script/`, {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(300_000),
});
if (!res.ok) {
const text = await res.text();
if (res.status === 400 && text.includes('GPS')) {
throw Object.assign(new Error('GPS_NOT_FOUND'), { code: 'GPS_NOT_FOUND' });
}
throw new Error(`이미지 분석 서버 오류: ${res.status} - ${text}`);
}
serverResponse = await res.json() as ImageServerResponse;
} catch (err: unknown) {
if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'GPS_NOT_FOUND') throw err;
if (err instanceof Error && err.name === 'TimeoutError') {
throw Object.assign(new Error('TIMEOUT'), { code: 'TIMEOUT' });
}
throw err;
}
// 응답 파싱
const { lat, lon, occurredAt } = parseMeta(serverResponse.meta);
const firstOil = serverResponse.data[0];
const oilType = firstOil ? (CLASS_ID_TO_OIL_TYPE[firstOil.classId] ?? '벙커C유') : '벙커C유';
const area = firstOil?.area ?? 0;
const volume = firstOil?.volume ?? 0;
// ACDNT INSERT
const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
const acdntRes = await wingPool.query(
`INSERT INTO wing.ACDNT
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
VALUES (
'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-' ||
LPAD(
(SELECT COALESCE(MAX(CAST(SPLIT_PART(ACDNT_CD, '-', 3) AS INTEGER)), 0) + 1
FROM wing.ACDNT
WHERE ACDNT_CD LIKE 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-%')::TEXT,
4, '0'
),
$1, '유류유출', $2, $3, $4, 'ACTIVE', 'Y', NOW()
)
RETURNING ACDNT_SN`,
[acdntNm, occurredAt, lat, lon]
);
const acdntSn: number = acdntRes.rows[0].acdnt_sn;
// SPIL_DATA INSERT (img_rslt_data에 분석 원본 저장)
await wingPool.query(
`INSERT INTO wing.SPIL_DATA
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`,
[
acdntSn,
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',
volume,
JSON.stringify(serverResponse),
]
);
// AERIAL_MEDIA INSERT (영상사진관리 목록에서 조회 가능하도록 저장)
const fileSizeMb = (imageBuffer.length / (1024 * 1024)).toFixed(1) + ' MB';
await createMedia({
fileNm: `${fileId}_${originalName}`,
orgnlNm: originalName,
acdntSn,
lon,
lat,
locDc: `${lon.toFixed(4)} + ${lat.toFixed(4)}`,
equipTpCd: 'drone',
equipNm: camTy,
mediaTpCd: '사진',
takngDtm: occurredAt,
fileSz: fileSizeMb,
});
return { acdntSn, lat, lon, oilType, area, volume, fileId, occurredAt };
}

파일 보기

@ -1,11 +1,15 @@
import express from 'express';
import multer from 'multer';
import {
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
createBacktrack, saveBoomLine, listBoomLines,
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
} from './predictionService.js';
import { analyzeImageFile } from './imageAnalyzeService.js';
import { isValidNumber } from '../middleware/security.js';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
const router = express.Router();
// GET /api/prediction/analyses — 분석 목록
@ -40,6 +44,26 @@ router.get('/analyses/:acdntSn', requireAuth, requirePermission('prediction', 'R
}
});
// GET /api/prediction/analyses/:acdntSn/trajectory — 최신 OpenDrift 결과 조회
router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try {
const acdntSn = parseInt(req.params.acdntSn as string, 10);
if (!isValidNumber(acdntSn, 1, 999999)) {
res.status(400).json({ error: '유효하지 않은 사고 번호' });
return;
}
const result = await getAnalysisTrajectory(acdntSn);
if (!result) {
res.json({ trajectory: null, summary: null });
return;
}
res.json(result);
} catch (err) {
console.error('[prediction] trajectory 조회 오류:', err);
res.status(500).json({ error: 'trajectory 조회 실패' });
}
});
// GET /api/prediction/backtrack — 사고별 역추적 목록
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try {
@ -124,4 +148,36 @@ router.post('/boom', requireAuth, requirePermission('prediction', 'CREATE'), asy
}
});
// POST /api/prediction/image-analyze — 이미지 업로드 분석
router.post(
'/image-analyze',
requireAuth,
requirePermission('prediction', 'CREATE'),
upload.single('image'),
async (req, res) => {
try {
if (!req.file) {
res.status(400).json({ error: '이미지 파일이 필요합니다' });
return;
}
const result = await analyzeImageFile(req.file.buffer, req.file.originalname);
res.json(result);
} catch (err: unknown) {
if (err instanceof Error) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'GPS_NOT_FOUND') {
res.status(422).json({ error: 'GPS_NOT_FOUND', message: 'GPS 정보가 없는 이미지입니다' });
return;
}
if (code === 'TIMEOUT') {
res.status(504).json({ error: 'TIMEOUT', message: '이미지 분석 서버 응답 시간 초과' });
return;
}
}
console.error('[prediction] 이미지 분석 오류:', err);
res.status(500).json({ error: '이미지 분석 실패' });
}
}
);
export default router;

파일 보기

@ -18,6 +18,7 @@ interface PredictionAnalysis {
backtrackStatus: string;
analyst: string;
officeName: string;
acdntSttsCd: string;
}
interface PredictionDetail {
@ -129,6 +130,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
SELECT
A.ACDNT_SN,
A.ACDNT_NM,
A.ACDNT_STTS_CD,
A.OCCRN_DTM,
A.LAT,
A.LNG,
@ -186,6 +188,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
analyst: String(row['analyst_nm'] ?? ''),
officeName: String(row['office_nm'] ?? ''),
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
}));
}
@ -404,6 +407,166 @@ export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLine
return { boomLineSn: Number((rows[0] as Record<string, unknown>)['boom_line_sn']) };
}
interface TrajectoryParticle {
lat: number;
lon: number;
stranded?: 0 | 1;
}
interface TrajectoryWindPoint {
lat: number;
lon: number;
wind_speed: number;
wind_direction: number;
}
interface TrajectoryHydrGrid {
lonInterval: number[];
boundLonLat: { top: number; bottom: number; left: number; right: number };
rows: number;
cols: number;
latInterval: number[];
}
interface TrajectoryTimeStep {
particles: TrajectoryParticle[];
remaining_volume_m3: number;
weathered_volume_m3: number;
pollution_area_km2: number;
beached_volume_m3: number;
pollution_coast_length_m: number;
center_lat?: number;
center_lon?: number;
wind_data?: TrajectoryWindPoint[];
hydr_data?: [number[][], number[][]];
hydr_grid?: TrajectoryHydrGrid;
}
// ALGO_CD → 프론트엔드 모델명 매핑
const ALGO_CD_TO_MODEL: Record<string, string> = {
'OPENDRIFT': 'OpenDrift',
'POSEIDON': 'POSEIDON',
};
interface SingleModelTrajectoryResult {
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
summary: {
remainingVolume: number;
weatheredVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
};
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
windData: TrajectoryWindPoint[][];
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
}
interface TrajectoryResult {
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>;
summary: {
remainingVolume: number;
weatheredVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
};
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
windDataByModel: Record<string, TrajectoryWindPoint[][]>;
hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]>;
summaryByModel: Record<string, SingleModelTrajectoryResult['summary']>;
}
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult {
const trajectory = rawResult.flatMap((step, stepIdx) =>
step.particles.map((p, i) => ({
lat: p.lat,
lon: p.lon,
time: stepIdx,
particle: i,
stranded: p.stranded,
model,
}))
);
const lastStep = rawResult[rawResult.length - 1];
const summary = {
remainingVolume: lastStep.remaining_volume_m3,
weatheredVolume: lastStep.weathered_volume_m3,
pollutionArea: lastStep.pollution_area_km2,
beachedVolume: lastStep.beached_volume_m3,
pollutionCoastLength: lastStep.pollution_coast_length_m,
};
const centerPoints = rawResult
.map((step, stepIdx) =>
step.center_lat != null && step.center_lon != null
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model }
: null
)
.filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null);
const windData = rawResult.map((step) => step.wind_data ?? []);
const hydrData = rawResult.map((step) =>
step.hydr_data && step.hydr_grid
? { value: step.hydr_data, grid: step.hydr_grid }
: null
);
return { trajectory, summary, centerPoints, windData, hydrData };
}
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
// 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회
const sql = `
SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
ORDER BY CMPL_DTM DESC
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return null;
// 모든 모델의 파티클을 하나의 배열로 병합
let mergedTrajectory: TrajectoryResult['trajectory'] = [];
let allCenterPoints: TrajectoryResult['centerPoints'] = [];
// summary: 가장 최근 완료된 OpenDrift 기준, 없으면 POSEIDON 기준
let baseResult: SingleModelTrajectoryResult | null = null;
const windDataByModel: Record<string, TrajectoryWindPoint[][]> = {};
const hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]> = {};
const summaryByModel: Record<string, SingleModelTrajectoryResult['summary']> = {};
// OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근)
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
const baseRow = opendriftRow ?? poseidonRow ?? null;
for (const row of rows as Array<Record<string, unknown>>) {
if (!row['rslt_data']) continue;
const algoCd = String(row['algo_cd'] ?? '');
const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd;
const parsed = transformTrajectoryResult(row['rslt_data'] as TrajectoryTimeStep[], modelName);
mergedTrajectory = mergedTrajectory.concat(parsed.trajectory);
allCenterPoints = allCenterPoints.concat(parsed.centerPoints);
windDataByModel[modelName] = parsed.windData;
hydrDataByModel[modelName] = parsed.hydrData;
summaryByModel[modelName] = parsed.summary;
if (row === baseRow) {
baseResult = parsed;
}
}
if (!baseResult) return null;
return {
trajectory: mergedTrajectory,
summary: baseResult.summary,
centerPoints: allCenterPoints,
windDataByModel,
hydrDataByModel,
summaryByModel,
};
}
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
const sql = `
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,

파일 보기

@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
// ============================================================
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
try {
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections } = req.body;
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg } = req.body;
const result = await createReport({
tmplSn,
ctgrSn,
@ -101,6 +101,7 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
jrsdCd,
sttsCd,
authorId: req.user!.sub,
mapCaptureImg,
sections,
});
res.status(201).json(result);
@ -124,8 +125,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
return;
}
const { title, jrsdCd, sttsCd, acdntSn, sections } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections }, req.user!.sub);
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub);
res.json({ success: true });
} catch (err) {
if (err instanceof AuthError) {

파일 보기

@ -62,6 +62,7 @@ interface ReportListItem {
authorName: string;
regDtm: string;
mdfcnDtm: string | null;
hasMapCapture: boolean;
}
interface SectionData {
@ -74,6 +75,7 @@ interface SectionData {
interface ReportDetail extends ReportListItem {
acdntSn: number | null;
sections: SectionData[];
mapCaptureImg: string | null;
}
interface ListReportsInput {
@ -100,6 +102,7 @@ interface CreateReportInput {
jrsdCd?: string;
sttsCd?: string;
authorId: string;
mapCaptureImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
@ -108,6 +111,7 @@ interface UpdateReportInput {
jrsdCd?: string;
sttsCd?: string;
acdntSn?: number | null;
mapCaptureImg?: string | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
@ -256,7 +260,8 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM
r.REG_DTM, r.MDFCN_DTM,
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
@ -281,6 +286,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
authorName: r.author_name || '',
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
hasMapCapture: r.has_map_capture,
})),
totalCount,
page,
@ -294,7 +300,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG,
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
@ -331,6 +338,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
authorName: r.author_name || '',
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
mapCaptureImg: r.map_capture_img,
hasMapCapture: r.has_map_capture,
sections: sectRes.rows.map((s) => ({
sectCd: s.sect_cd,
includeYn: s.include_yn,
@ -350,8 +359,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
await client.query('BEGIN');
const res = await client.query(
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING REPORT_SN`,
[
input.tmplSn || null,
@ -361,6 +370,7 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
input.jrsdCd || null,
input.sttsCd || 'DRAFT',
input.authorId,
input.mapCaptureImg || null,
]
);
const reportSn = res.rows[0].report_sn;
@ -432,6 +442,10 @@ export async function updateReport(
sets.push(`ACDNT_SN = $${idx++}`);
params.push(input.acdntSn);
}
if (input.mapCaptureImg !== undefined) {
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
params.push(input.mapCaptureImg);
}
params.push(reportSn);
await client.query(

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

파일 보기

@ -158,7 +158,8 @@ app.use('/api/audit', auditRouter)
// API 라우트 — 업무
app.use('/api/board', boardRouter)
app.use('/api/layers', layersRouter)
app.use('/api/simulation', simulationLimiter, simulationRouter)
app.use('/api/simulation/run', simulationLimiter) // 시뮬레이션 실행만 엄격 제한 (status 폴링 제외)
app.use('/api/simulation', simulationRouter)
app.use('/api/hns', hnsRouter)
app.use('/api/reports', reportsRouter)
app.use('/api/assets', assetsRouter)

파일 보기

@ -93,6 +93,7 @@ const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [
{ id: 'board', label: '게시판', icon: '📌', enabled: true, order: 8 },
{ id: 'weather', label: '기상정보', icon: '⛅', enabled: true, order: 9 },
{ id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 },
{ id: 'monitor', label: '실시간 상황관리', icon: '🛰', enabled: true, order: 11 },
]
const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id)
@ -103,18 +104,23 @@ export async function getMenuConfig(): Promise<MenuConfigItem[]> {
try {
const parsed = JSON.parse(val) as MenuConfigItem[]
const defaultMap = new Map(DEFAULT_MENU_CONFIG.map(m => [m.id, m]))
return parsed
const dbMap = new Map(
parsed
.filter(item => VALID_MENU_IDS.includes(item.id))
.map(item => {
const defaults = defaultMap.get(item.id)!
.map(item => [item.id, item])
)
// DEFAULT 기준으로 머지 (DB에 없는 항목은 기본값 사용)
return DEFAULT_MENU_CONFIG
.map(defaultItem => {
const dbItem = dbMap.get(defaultItem.id)
if (!dbItem) return defaultItem
return {
id: item.id,
label: item.label || defaults.label,
icon: item.icon || defaults.icon,
enabled: item.enabled,
order: item.order,
id: dbItem.id,
label: dbItem.label || defaultItem.label,
icon: dbItem.icon || defaultItem.icon,
enabled: dbItem.enabled,
order: dbItem.order,
}
})
.sort((a, b) => a.order - b.order)

파일 보기

@ -278,7 +278,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(1, 'incidents', 'READ', 'Y'), (1, 'incidents', 'CREATE', 'Y'), (1, 'incidents', 'UPDATE', 'Y'), (1, 'incidents', 'DELETE', 'Y'),
(1, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'),
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y'),
(1, 'monitor', 'READ', 'Y');
-- HQ_CLEANUP (ROLE_SN=2): 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
@ -292,7 +293,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'),
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'),
(2, 'admin', 'READ', 'N');
(2, 'admin', 'READ', 'N'),
(2, 'monitor', 'READ', 'Y');
-- MANAGER (ROLE_SN=3): admin 탭 제외, RCUD 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
@ -306,7 +308,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), (3, 'incidents', 'DELETE', 'Y'),
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
(3, 'admin', 'READ', 'N');
(3, 'admin', 'READ', 'N'),
(3, 'monitor', 'READ', 'Y');
-- USER (ROLE_SN=4): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
@ -320,7 +323,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
(4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
(4, 'weather', 'READ', 'Y'),
(4, 'admin', 'READ', 'N');
(4, 'admin', 'READ', 'N'),
(4, 'monitor', 'READ', 'Y');
-- VIEWER (ROLE_SN=5): 제한적 탭의 READ만 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
@ -334,7 +338,8 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(5, 'incidents', 'READ', 'Y'),
(5, 'board', 'READ', 'Y'),
(5, 'weather', 'READ', 'Y'),
(5, 'admin', 'READ', 'N');
(5, 'admin', 'READ', 'N'),
(5, 'monitor', 'READ', 'Y');
-- ============================================================

파일 보기

@ -299,6 +299,7 @@ CREATE TABLE SPIL_DATA (
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리
FCST_HR INTEGER, -- 예측시간
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
IMG_RSLT_DATA JSONB, -- 이미지 분석 결과 (2024-06 추가)
CONSTRAINT PK_SPIL_DATA PRIMARY KEY (SPIL_DATA_SN),
CONSTRAINT FK_SPIL_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE
);
@ -320,7 +321,8 @@ COMMENT ON COLUMN SPIL_DATA.REG_DTM IS '등록일시';
-- ============================================================
CREATE TABLE PRED_EXEC (
PRED_EXEC_SN SERIAL NOT NULL, -- 예측실행순번
SPIL_DATA_SN INTEGER NOT NULL, -- 유출정보순번
SPIL_DATA_SN INTEGER, -- 유출정보순번 (NULL 허용 — 사고 미연결 단독 실행 대응)
ACDNT_SN INTEGER NOT NULL, -- 사고순번 (사고 참조, 유출정보 미연결 시에도 사고는 필수)
ALGO_CD VARCHAR(20) NOT NULL, -- 알고리즘코드
EXEC_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- 실행상태코드
BGNG_DTM TIMESTAMPTZ, -- 시작일시
@ -328,6 +330,7 @@ CREATE TABLE PRED_EXEC (
REQD_SEC INTEGER, -- 소요시간초
RSLT_DATA JSONB, -- 결과데이터
ERR_MSG TEXT, -- 오류메시지
EXEC_NM VARCHAR(100), -- 실행명
CONSTRAINT PK_PRED_EXEC PRIMARY KEY (PRED_EXEC_SN),
CONSTRAINT FK_PRED_SPIL FOREIGN KEY (SPIL_DATA_SN) REFERENCES SPIL_DATA(SPIL_DATA_SN) ON DELETE CASCADE,
CONSTRAINT CK_PRED_STTS CHECK (EXEC_STTS_CD IN ('PENDING','RUNNING','COMPLETED','FAILED'))
@ -335,14 +338,16 @@ CREATE TABLE PRED_EXEC (
COMMENT ON TABLE PRED_EXEC IS '예측실행';
COMMENT ON COLUMN PRED_EXEC.PRED_EXEC_SN IS '예측실행순번';
COMMENT ON COLUMN PRED_EXEC.SPIL_DATA_SN IS '유출정보순번 (유출정보 참조)';
COMMENT ON COLUMN PRED_EXEC.ALGO_CD IS '알고리즘코드 (ALGO: GNOME, OSCAR 등)';
COMMENT ON COLUMN PRED_EXEC.SPIL_DATA_SN IS '유출정보순번 (FK → SPIL_DATA, NULL 허용)';
COMMENT ON COLUMN PRED_EXEC.ACDNT_SN IS '사고순번 (사고 참조)';
COMMENT ON COLUMN PRED_EXEC.ALGO_CD IS '알고리즘코드 (ALGO: GNOME, OSCAR, OPENDRIFT 등)';
COMMENT ON COLUMN PRED_EXEC.EXEC_STTS_CD IS '실행상태코드 (PENDING:대기, RUNNING:실행중, COMPLETED:완료, FAILED:실패)';
COMMENT ON COLUMN PRED_EXEC.BGNG_DTM IS '시작일시';
COMMENT ON COLUMN PRED_EXEC.CMPL_DTM IS '완료일시';
COMMENT ON COLUMN PRED_EXEC.REQD_SEC IS '소요시간초 (실행 소요 시간, 초 단위)';
COMMENT ON COLUMN PRED_EXEC.RSLT_DATA IS '결과데이터 (JSON 형식 예측 결과)';
COMMENT ON COLUMN PRED_EXEC.ERR_MSG IS '오류메시지';
COMMENT ON COLUMN PRED_EXEC.EXEC_NM IS '실행명 (EXPC_{timestamp} 형식, OpenDrift 연동용)';
-- ============================================================

파일 보기

@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS REPORT (
USE_YN CHAR(1) DEFAULT 'Y',
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
MDFCN_DTM TIMESTAMPTZ,
MAP_CAPTURE_IMG TEXT
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
);

파일 보기

@ -45,6 +45,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA (
SPIL_TP_CD VARCHAR(20),
FCST_HR INTEGER,
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(),
IMG_RSLT_DATA JSONB,
CONSTRAINT PK_SPIL_DATA PRIMARY KEY (SPIL_DATA_SN),
CONSTRAINT FK_SPIL_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE
);
@ -54,6 +55,7 @@ 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,
SPIL_DATA_SN INTEGER,
ACDNT_SN INTEGER NOT NULL,
ALGO_CD VARCHAR(20) NOT NULL,
EXEC_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PENDING',
@ -62,12 +64,14 @@ CREATE TABLE IF NOT EXISTS 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_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 (

파일 보기

@ -4,7 +4,7 @@
연동할 수 있도록 정리한 문서이다.
공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것.
> **최종 갱신**: 2026-03-01 (CSS 리팩토링 + MapLibre GL + deck.gl 전환 반영)
> **최종 갱신**: 2026-03-11 (KHOA API 교체 + Vite CORS 프록시 추가)
---
@ -1312,6 +1312,25 @@ app.use(helmet({
}));
```
### Vite 개발 서버 프록시
외부 API 이미지의 CORS 문제를 해결하기 위해 `vite.config.ts`에 프록시를 설정한다:
```typescript
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/daily_ocean': {
target: 'https://www.khoa.go.kr',
changeOrigin: true,
},
},
},
```
적용되는 보안 헤더:
- `X-Content-Type-Options: nosniff` (MIME 스니핑 방지)
- `X-Frame-Options: DENY` (클릭재킹 방지)

파일 보기

@ -657,6 +657,7 @@ Settings -> Actions -> Secrets -> Add Secret
- API 호출이 CORS 에러를 발생시키면 백엔드 `FRONTEND_URL` 환경변수를 확인한다.
- 개발 환경에서는 `localhost:5173`, `localhost:5174`, `localhost:3000`이 자동 허용된다.
- KHOA 해양 이미지(`/daily_ocean`)는 Vite 프록시 경유: `vite.config.ts``proxy` 설정 확인
**타입 에러:**

191
docs/PREDICTION-GUIDE.md Normal file
파일 보기

@ -0,0 +1,191 @@
# 확산 예측 기능 가이드
> 대상: 확산 예측(OpenDrift) 기능 개발 및 유지보수 담당자
---
## 1. 아키텍처 개요
**폴링 방식** — HTTP 연결 불안정 문제 해결을 위해 비동기 폴링 구조를 채택했다.
```
[프론트] 실행 버튼
→ POST /api/simulation/run 즉시 { execSn, status:'RUNNING' } 반환
→ "분석 중..." UI 표시
→ 3초마다 GET /api/simulation/status/:execSn 폴링
[Express 백엔드]
→ PRED_EXEC INSERT (PENDING)
→ POST Python /run-model 즉시 { job_id } 수신
→ 응답 즉시 반환 (프론트 블록 없음)
→ 백그라운드: 3초마다 Python GET /status/:job_id 폴링
→ DONE 시 PRED_EXEC UPDATE (결과 JSONB 저장)
[Python FastAPI :5003]
→ 동시 처리 초과 시 503 즉시 반환
→ 여유 시 job_id 반환 + 백그라운드 OpenDrift 시뮬레이션 실행
→ NC 결과 → JSON 변환 → 상태 DONE
```
---
## 2. DB 스키마 (PRED_EXEC)
```sql
PRED_EXEC_SN SERIAL PRIMARY KEY
ACDNT_SN INTEGER NOT NULL -- 사고 FK
SPIL_DATA_SN INTEGER -- 유출정보 FK (NULL 허용)
EXEC_NM VARCHAR(100) UNIQUE -- EXPC_{timestamp} 형식
ALGO_CD VARCHAR(20) NOT NULL -- 'OPENDRIFT'
EXEC_STTS_CD VARCHAR(20) DEFAULT 'PENDING'
-- PENDING | RUNNING | COMPLETED | FAILED
BGNG_DTM TIMESTAMPTZ
CMPL_DTM TIMESTAMPTZ
REQD_SEC INTEGER
RSLT_DATA JSONB -- 시뮬레이션 결과 전체
ERR_MSG TEXT
```
인덱스: `IDX_PRED_STTS` (EXEC_STTS_CD), `uix_pred_exec_nm` (EXEC_NM, partial)
---
## 3. Python FastAPI 엔드포인트 (포트 5003)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/get-received-date` | 최신 예보 수신 가능 날짜 |
| GET | `/get-uv/{datetime}/{category}` | 바람/해류 U/V 벡터 (`wind`\|`hydr`) |
| POST | `/check-nc` | NetCDF 파일 존재 여부 확인 |
| POST | `/run-model` | 시뮬레이션 제출 → 즉시 `job_id` 반환 |
| GET | `/status/{job_id}` | 시뮬레이션 진행 상태 조회 |
### POST /run-model 입력 파라미터
```json
{
"startTime": "2025-01-15 12:00:00", // KST (내부 UTC 변환)
"runTime": 72, // 예측 시간 (시간)
"matTy": "CRUDE OIL", // OpenDrift 유류명
"matVol": 100.0, // 시간당 유출량 (m³/hr)
"lon": 126.1,
"lat": 36.6,
"spillTime": 12, // 유출 지속 시간 (0=순간)
"name": "EXPC_1710000000000"
}
```
### 유류 코드 매핑 (DB → OpenDrift)
| DB SPIL_MAT_CD | OpenDrift 이름 |
|---------------|---------------|
| CRUD | CRUDE OIL |
| DSEL | DIESEL |
| BNKR | BUNKER |
| HEFO | IFO 180 |
---
## 4. Express 백엔드 주요 엔드포인트
파일: [backend/src/routes/simulation.ts](../backend/src/routes/simulation.ts)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/simulation/run` | 시뮬레이션 제출 → `execSn` 즉시 반환 |
| GET | `/api/simulation/status/:execSn` | 프론트 폴링용 상태 조회 |
파일: [backend/src/prediction/predictionService.ts](../backend/src/prediction/predictionService.ts)
- `fetchPredictionList()` — PRED_EXEC 목록 조회
- `fetchTrajectoryResult()` — 저장된 결과 조회 (`RSLT_DATA` JSONB 파싱)
---
## 5. 프론트엔드 주요 파일
| 파일 | 역할 |
|------|------|
| [frontend/src/tabs/prediction/components/OilSpillView.tsx](../frontend/src/tabs/prediction/components/OilSpillView.tsx) | 예측 탭 메인 뷰, 시뮬레이션 실행·폴링 상태 관리 |
| [frontend/src/tabs/prediction/hooks/](../frontend/src/tabs/prediction/hooks/) | `useSimulationStatus` 폴링 훅 |
| [frontend/src/tabs/prediction/services/predictionApi.ts](../frontend/src/tabs/prediction/services/predictionApi.ts) | API 요청 함수 + 타입 정의 |
| [frontend/src/tabs/prediction/components/RightPanel.tsx](../frontend/src/tabs/prediction/components/RightPanel.tsx) | 풍화량·잔류량·오염면적 표시 (마지막 스텝 실제 값) |
| [frontend/src/common/components/map/HydrParticleOverlay.tsx](../frontend/src/common/components/map/HydrParticleOverlay.tsx) | 해류 파티클 Canvas 오버레이 |
### 핵심 타입 (predictionApi.ts)
```typescript
interface HydrGrid {
lonInterval: number[];
latInterval: number[];
boundLonLat: { top: number; bottom: number; left: number; right: number };
rows: number; cols: number;
}
interface HydrDataStep {
value: [number[][], number[][]]; // [u_2d, v_2d]
grid: HydrGrid;
}
```
### 폴링 훅 패턴
```typescript
useQuery({
queryKey: ['simulationStatus', execSn],
queryFn: () => api.get(`/api/simulation/status/${execSn}`),
enabled: execSn !== null,
refetchInterval: (data) =>
data?.status === 'DONE' || data?.status === 'ERROR' ? false : 3000,
});
```
---
## 6. Python 코드 위치 (prediction/)
```
prediction/opendrift/
├── api.py FastAPI 진입점 (수정 필요: 폴링 지원 + CORS)
├── config.py 경로 설정 (수정 필요: 환경변수화)
├── createJsonResult.py NC → JSON 변환 (핵심 후처리)
├── coastline/ TN_SHORLINE.shp (한국 해안선)
├── startup.sh / shutdown.sh
├── .env.example 환경변수 샘플
└── environment-opendrift.yml conda 환경 재현용
```
---
## 7. 환경변수
### backend/.env
```bash
PYTHON_API_URL=http://localhost:5003
```
### prediction/opendrift/.env
```bash
MPR_STORAGE_ROOT=/data/storage # NetCDF 기상·해양 데이터 루트
MPR_RESULT_ROOT=./result # 시뮬레이션 결과 저장 경로
MAX_CONCURRENT_JOBS=4 # 동시 처리 최대 수
```
---
## 8. 위험 요소
| 위험 | 내용 |
|------|------|
| NetCDF 파일 부재 | `MPR_STORAGE_ROOT` 경로에 KMA GDAPS·MOHID NC 파일 필요. 없으면 시뮬레이션 불가 |
| conda 환경 | `opendrift` conda 환경 설치 필요 (`environment-opendrift.yml`) |
| Workers 포화 | 동시 4개 초과 시 503 반환 → `MAX_CONCURRENT_JOBS` 조정 |
| 결과 용량 | 12시간 결과 ≈ 1500KB/건. 90일 주기 `RSLT_DATA = NULL` 정리 권장 |
---
## 9. 관련 문서
- [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) — Express API 개발 패턴
- [COMMON-GUIDE.md](./COMMON-GUIDE.md) — 인증·상태관리 공통 로직

파일 보기

@ -4,8 +4,97 @@
## [Unreleased]
## [2026-03-18]
### 추가
- 관리자: 방제장비 현황 패널 (CleanupEquipPanel) — 관할청·유형별 필터, 자산 수량 조회
- 관리자: 자산 현행화 업로드 패널 (AssetUploadPanel) — 엑셀/CSV 드래그 드롭 업로드
### 변경
- trajectory API 모델별 windData/hydrData 분리 반환
- 예측 서비스(predictionService) 개선
- 보고서: 유출유 확산 지도 패널 및 보고서 생성기 개선
- 관리자: 권한/메뉴 구성 업데이트, AdminView 패널 등록
- prediction/image 이미지 분석 서버 분리 (디렉토리 제거)
### 기타
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결)
- DB: monitor 권한 트리 마이그레이션(022) 추가, auth_init 갱신
## [2026-03-17]
### 추가
- 다중 모델 시뮬레이션 지원 (OpenDrift + POSEIDON 병렬 실행 및 결과 병합)
## [2026-03-16]
### 추가
- 보고서 확산예측 지도 캡처 기능 (OilSpreadMapPanel, MAP_CAPTURE_IMG DB 컬럼)
- 실시간 드론 지도 뷰 — 드론 위치 아이콘 + 클릭 스트림 연결
- CCTV 지도/리스트 뷰 전환 + CCTV 아이콘 + 다크 팝업 UI
- KBS CCTV HLS 직접 재생 + CCTV 위치 지도 + 좌표 정확도 개선
- 사용자 매뉴얼 팝업 기능 추가
- 확산예측 지도 밝은 해도 스타일 적용 (육지 회색 + 바다 파랑)
- KOSPS/앙상블 준비중 팝업 + 기본 모델 POSEIDON 변경
- 오염분석 원 분석 기능 — 중심점/반경 입력으로 원형 오염 면적 계산
- 오일펜스 배치 가이드 UI 개선
### 수정
- geo.ts 중복 함수 제거 및 null 좌표 참조 오류 수정
### 변경
- 확산 예측 요약 폰트/레이아웃을 오염 종합 상황과 통일
- 오염분석 UI 개선 — HTML 디자인 참고 반영
- 범례 UI 개선 — HTML 참고 디자인 반영
- 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일
### 기타
- 프론트엔드 포트 변경(5174) + CORS 허용
## [2026-03-13]
### 추가
- 오염분석 다각형/원 분석 기능 구현
- 시뮬레이션 에러 모달 추가
- 해류 캔버스 파티클 레이어 추가
### 수정
- useSubMenu useEffect import 누락 수정
### 변경
- 보고서 해안부착 현황 개선
### 기타
- 팀 워크플로우 동기화 (v1.6.1)
## [2026-03-11]
### 추가
- KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환
- 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선
- 기상 정보 기상 레이어 업데이트
- CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지)
- 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
- CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
- 유류오염보장계약 시드 데이터 추가 (1391건)
- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조)
- flyTo 완료 후 자동 재생 기능
- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경)
- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합
- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml)
### 수정
- /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정
### 변경
- 이미지 분석/보고서/항공 UI 개선
- CCTV/관리자 고도화
### 문서
- 프로젝트 문서 최신화 (KHOA API, Vite 프록시)
### 기타
- CLAUDE_BOT_TOKEN 갱신
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결, 적용일 갱신)
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)

파일 보기

@ -25,6 +25,7 @@
"@vis.gl/react-maplibre": "^8.1.0",
"axios": "^1.13.5",
"emoji-mart": "^5.6.0",
"exifr": "^7.1.3",
"hls.js": "^1.6.15",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",
@ -3848,6 +3849,12 @@
"node": ">=0.10.0"
}
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT"
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",

파일 보기

@ -27,6 +27,7 @@
"@vis.gl/react-maplibre": "^8.1.0",
"axios": "^1.13.5",
"emoji-mart": "^5.6.0",
"exifr": "^7.1.3",
"hls.js": "^1.6.15",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",

파일 보기

@ -97,6 +97,8 @@ function App() {
return <AdminView />
case 'rescue':
return <RescueView />
case 'monitor':
return null
default:
return <div className="flex items-center justify-center h-full text-text-3"> ...</div>
}

파일 보기

@ -39,9 +39,13 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{/* Left Section */}
<div className="flex items-center gap-4">
{/* Logo */}
<div className="flex items-center">
<button
onClick={() => tabs[0] && onTabChange(tabs[0].id as MainTab)}
className="flex items-center hover:opacity-80 transition-opacity cursor-pointer"
title="홈으로 이동"
>
<img src="/wing_logo_white.svg" alt="WING 해양환경 위기대응" className="h-3.5" />
</div>
</button>
{/* Divider */}
<div className="w-px h-6 bg-border-light" />
@ -50,17 +54,28 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
<div className="flex gap-0.5">
{tabs.map((tab) => {
const isIncident = tab.id === 'incidents'
const isMonitor = tab.id === 'monitor'
const handleClick = () => {
if (isMonitor) {
window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'https://kcg.gc-si.dev', '_blank')
} else {
onTabChange(tab.id as MainTab)
}
}
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id as MainTab)}
onClick={handleClick}
title={tab.label}
className={`
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
font-korean tracking-[0.2px]
${isIncident ? 'font-extrabold border-l border-l-[rgba(99,102,241,0.2)] ml-1' : 'font-semibold'}
${isMonitor ? 'border-l border-l-[rgba(239,68,68,0.25)] ml-1 flex items-center gap-1.5' : ''}
${
activeTab === tab.id
isMonitor
? 'text-[#f87171] hover:text-[#fca5a5] hover:bg-[rgba(239,68,68,0.1)]'
: activeTab === tab.id
? isIncident
? 'text-[#a5b4fc] bg-[rgba(99,102,241,0.18)] shadow-[0_0_8px_rgba(99,102,241,0.3)]'
: 'text-[#22d3ee] bg-[rgba(6,182,212,0.15)] shadow-[0_0_8px_rgba(6,182,212,0.3)]'
@ -70,8 +85,20 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
}
`}
>
{isMonitor ? (
<>
<span className="hidden xl:flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#f87171] animate-pulse inline-block" />
{tab.label}
</span>
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
</>
) : (
<>
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
<span className="hidden xl:inline">{tab.label}</span>
</>
)}
</button>
)
})}

파일 보기

@ -0,0 +1,159 @@
import { useEffect, useRef } from 'react';
import { useMap } from '@vis.gl/react-maplibre';
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
interface HydrParticleOverlayProps {
hydrStep: HydrDataStep | null;
lightMode?: boolean;
}
const PARTICLE_COUNT = 3000;
const MAX_AGE = 300;
const SPEED_SCALE = 0.1;
const DT = 600;
const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수
const NUM_ALPHA_BANDS = 4; // stroke 배치 단위
interface TrailPoint { x: number; y: number; }
interface Particle {
lon: number;
lat: number;
trail: TrailPoint[];
age: number;
}
export default function HydrParticleOverlay({ hydrStep, lightMode = false }: HydrParticleOverlayProps) {
const { current: map } = useMap();
const animRef = useRef<number>();
useEffect(() => {
if (!map || !hydrStep) return;
const container = map.getContainer();
const canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:5;';
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
container.appendChild(canvas);
const ctx = canvas.getContext('2d')!;
const { value: [u2d, v2d], grid } = hydrStep;
const { boundLonLat, lonInterval, latInterval } = grid;
const lons: number[] = [boundLonLat.left];
for (const d of lonInterval) lons.push(lons[lons.length - 1] + d);
const lats: number[] = [boundLonLat.bottom];
for (const d of latInterval) lats.push(lats[lats.length - 1] + d);
function getUV(lon: number, lat: number): [number, number] {
let col = -1, row = -1;
for (let i = 0; i < lons.length - 1; i++) {
if (lon >= lons[i] && lon < lons[i + 1]) { col = i; break; }
}
for (let i = 0; i < lats.length - 1; i++) {
if (lat >= lats[i] && lat < lats[i + 1]) { row = i; break; }
}
if (col < 0 || row < 0) return [0, 0];
const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]);
const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]);
const u00 = u2d[row]?.[col] ?? 0, u01 = u2d[row]?.[col + 1] ?? u00;
const u10 = u2d[row + 1]?.[col] ?? u00, u11 = u2d[row + 1]?.[col + 1] ?? u00;
const v00 = v2d[row]?.[col] ?? 0, v01 = v2d[row]?.[col + 1] ?? v00;
const v10 = v2d[row + 1]?.[col] ?? v00, v11 = v2d[row + 1]?.[col + 1] ?? v00;
const u = u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy;
const v = v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy;
return [u, v];
}
const bbox = boundLonLat;
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({
lon: bbox.left + Math.random() * (bbox.right - bbox.left),
lat: bbox.bottom + Math.random() * (bbox.top - bbox.bottom),
trail: [],
age: Math.floor(Math.random() * MAX_AGE),
}));
function resetParticle(p: Particle) {
p.lon = bbox.left + Math.random() * (bbox.right - bbox.left);
p.lat = bbox.bottom + Math.random() * (bbox.top - bbox.bottom);
p.trail = [];
p.age = 0;
}
// 지도 이동/줌 시 화면 좌표가 틀어지므로 trail 초기화
const onMove = () => { for (const p of particles) p.trail = []; };
map.on('move', onMove);
function animate() {
// 매 프레임 완전 초기화 → 잔상 없음
ctx.clearRect(0, 0, canvas.width, canvas.height);
// alpha band별 세그먼트 버퍼 (드로우 콜 최소화)
const bands: [number, number, number, number][][] =
Array.from({ length: NUM_ALPHA_BANDS }, () => []);
for (const p of particles) {
const [u, v] = getUV(p.lon, p.lat);
const speed = Math.sqrt(u * u + v * v);
if (speed < 0.001) { resetParticle(p); continue; }
const cosLat = Math.cos(p.lat * Math.PI / 180);
p.lon += u * SPEED_SCALE * DT / (cosLat * 111320);
p.lat += v * SPEED_SCALE * DT / 111320;
p.age++;
if (
p.lon < bbox.left || p.lon > bbox.right ||
p.lat < bbox.bottom || p.lat > bbox.top ||
p.age > MAX_AGE
) { resetParticle(p); continue; }
const curr = map.project([p.lon, p.lat]);
if (!curr) continue;
p.trail.push({ x: curr.x, y: curr.y });
if (p.trail.length > TRAIL_LENGTH) p.trail.shift();
if (p.trail.length < 2) continue;
for (let i = 1; i < p.trail.length; i++) {
const t = i / p.trail.length; // 0=oldest, 1=newest
const band = Math.min(NUM_ALPHA_BANDS - 1, Math.floor(t * NUM_ALPHA_BANDS));
const a = p.trail[i - 1], b = p.trail[i];
bands[band].push([a.x, a.y, b.x, b.y]);
}
}
// alpha band별 일괄 렌더링
ctx.lineWidth = 0.8;
for (let b = 0; b < NUM_ALPHA_BANDS; b++) {
const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255];
ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`;
ctx.beginPath();
for (const [x1, y1, x2, y2] of bands[b]) {
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
ctx.stroke();
}
animRef.current = requestAnimationFrame(animate);
}
animRef.current = requestAnimationFrame(animate);
const onResize = () => {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
};
map.on('resize', onResize);
return () => {
cancelAnimationFrame(animRef.current!);
map.off('resize', onResize);
map.off('move', onMove);
canvas.remove();
};
}, [map, hydrStep, lightMode]);
return null;
}

파일 보기

@ -1,13 +1,15 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers'
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers'
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
import type { StyleSpecification } from 'maplibre-gl'
import type { MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { layerDatabase } from '@common/services/layerService'
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'
import HydrParticleOverlay from './HydrParticleOverlay'
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { createBacktrackLayers } from './BacktrackReplayOverlay'
@ -17,8 +19,8 @@ import { useMapStore } from '@common/store/mapStore'
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''
// 남해안 중심 좌표 (여수 앞바다)
const DEFAULT_CENTER: [number, number] = [34.5, 127.8]
// 인천 송도 국제도시
const DEFAULT_CENTER: [number, number] = [37.39, 126.64]
const DEFAULT_ZOOM = 10
// CartoDB Dark Matter 스타일
@ -319,7 +321,7 @@ interface MapViewProps {
incidentCoord?: { lon: number; lat: number }
isSelectingLocation?: boolean
onMapClick?: (lon: number, lat: number) => void
oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>
oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: string; stranded?: 0 | 1 }>
selectedModels?: Set<PredictionModel>
dispersionResult?: DispersionResult | null
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>
@ -337,9 +339,29 @@ interface MapViewProps {
incidentCoord: { lat: number; lon: number }
}
sensitiveResources?: SensitiveResource[]
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
flyToTarget?: { lng: number; lat: number; zoom?: number } | null
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
windData?: Array<Array<{ lat: number; lon: number; wind_speed: number; wind_direction: number }>>
hydrData?: (HydrDataStep | null)[]
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
externalCurrentTime?: number
mapCaptureRef?: React.MutableRefObject<(() => Promise<string | null>) | null>
onIncidentFlyEnd?: () => void
flyToIncident?: { lon: number; lat: number }
showCurrent?: boolean
showWind?: boolean
showBeached?: boolean
showTimeLabel?: boolean
simulationStartTime?: string
drawAnalysisMode?: 'polygon' | 'circle' | null
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
analysisCircleCenter?: { lat: number; lon: number } | null
analysisCircleRadiusM?: number
/** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */
lightMode?: boolean
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
showOverlays?: boolean
}
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@ -350,6 +372,63 @@ function DeckGLOverlay({ layers }: { layers: any[] }) {
return null
}
// flyTo 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FlyToController({ flyToTarget }: { flyToTarget?: { lng: number; lat: number; zoom?: number } | null }) {
const { current: map } = useMap()
useEffect(() => {
if (!map || !flyToTarget) return
map.flyTo({
center: [flyToTarget.lng, flyToTarget.lat],
zoom: flyToTarget.zoom ?? 10,
duration: 1200,
})
}, [flyToTarget, map])
return null
}
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FitBoundsController({ fitBoundsTarget }: { fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null }) {
const { current: map } = useMap()
useEffect(() => {
if (!map || !fitBoundsTarget) return
map.fitBounds(
[[fitBoundsTarget.west, fitBoundsTarget.south], [fitBoundsTarget.east, fitBoundsTarget.north]],
{ padding: 80, duration: 1200, maxZoom: 12 }
)
}, [fitBoundsTarget, map])
return null
}
// Map 중앙 좌표 + 줌 추적 컴포넌트 (Map 내부에서 useMap() 사용)
function MapCenterTracker({
onCenterChange,
}: {
onCenterChange: (lat: number, lng: number, zoom: number) => void;
}) {
const { current: map } = useMap()
useEffect(() => {
if (!map) return
const update = () => {
const center = map.getCenter()
const zoom = map.getZoom()
onCenterChange(center.lat, center.lng, zoom)
}
update()
map.on('move', update)
map.on('zoom', update)
return () => {
map.off('move', update)
map.off('zoom', update)
}
}, [map, onCenterChange])
return null
}
// 3D 모드 pitch/bearing 제어 컴포넌트 (Map 내부에서 useMap() 사용)
function MapPitchController({ threeD }: { threeD: boolean }) {
const { current: map } = useMap()
@ -365,14 +444,18 @@ function MapPitchController({ threeD }: { threeD: boolean }) {
}
// 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트)
function MapFlyToIncident({ lon, lat }: { lon?: number; lat?: number }) {
function MapFlyToIncident({ coord, onFlyEnd }: { coord?: { lon: number; lat: number }; onFlyEnd?: () => void }) {
const { current: map } = useMap()
const onFlyEndRef = useRef(onFlyEnd)
useEffect(() => { onFlyEndRef.current = onFlyEnd }, [onFlyEnd])
useEffect(() => {
if (!map || lon == null || lat == null) return
if (!map || !coord) return
const { lon, lat } = coord
const doFly = () => {
map.flyTo({ center: [lon, lat], zoom: 12, duration: 1200 })
map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 })
map.once('moveend', () => onFlyEndRef.current?.())
}
if (map.loaded()) {
@ -380,20 +463,39 @@ function MapFlyToIncident({ lon, lat }: { lon?: number; lat?: number }) {
} else {
map.once('load', doFly)
}
}, [lon, lat, map])
}, [coord, map]) // 객체 참조 추적: 같은 좌표라도 새 객체면 effect 재실행
return null
}
// 지도 캡처 지원 (preserveDrawingBuffer 필요)
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => string | null) | null> }) {
// 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지)
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => Promise<string | null>) | null> }) {
const { current: map } = useMap();
useEffect(() => {
if (!map) return;
captureRef.current = () => {
try { return map.getCanvas().toDataURL('image/png'); }
catch { return null; }
};
captureRef.current = () =>
new Promise<string | null>((resolve) => {
map.once('render', () => {
try {
// WebGL 캔버스는 alpha=0 투명 배경이므로 불투명 배경과 합성 후 추출
// 최대 1200px로 리사이즈 + JPEG 압축으로 전송 크기 절감
const src = map.getCanvas();
const maxW = 1200;
const scale = src.width > maxW ? maxW / src.width : 1;
const composite = document.createElement('canvas');
composite.width = Math.round(src.width * scale);
composite.height = Math.round(src.height * scale);
const ctx = composite.getContext('2d')!;
ctx.fillStyle = '#0f1117';
ctx.fillRect(0, 0, composite.width, composite.height);
ctx.drawImage(src, 0, 0, composite.width, composite.height);
resolve(composite.toDataURL('image/jpeg', 0.82));
} catch {
resolve(null);
}
});
map.triggerRepaint();
});
}, [map, captureRef]);
return null;
}
@ -423,15 +525,42 @@ export function MapView({
layerBrightness = 50,
backtrackReplay,
sensitiveResources = [],
flyToTarget,
fitBoundsTarget,
centerPoints = [],
windData = [],
hydrData = [],
externalCurrentTime,
mapCaptureRef,
onIncidentFlyEnd,
flyToIncident,
showCurrent = true,
showWind = true,
showBeached = false,
showTimeLabel = false,
simulationStartTime,
drawAnalysisMode = null,
analysisPolygonPoints = [],
analysisCircleCenter,
analysisCircleRadiusM = 0,
lightMode = false,
showOverlays = true,
}: MapViewProps) {
const { mapToggles } = useMapStore()
const isControlled = externalCurrentTime !== undefined
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
const [currentTime, setCurrentTime] = useState(0)
const [mapCenter, setMapCenter] = useState<[number, number]>(DEFAULT_CENTER)
const [mapZoom, setMapZoom] = useState<number>(DEFAULT_ZOOM)
const [internalCurrentTime, setInternalCurrentTime] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(1)
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
setMapCenter([lat, lng])
setMapZoom(zoom)
}, [])
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat
@ -442,33 +571,34 @@ export function MapView({
setPopupInfo(null)
}, [onMapClick])
// 애니메이션 재생 로직
// 애니메이션 재생 로직 (외부 제어 모드에서는 비활성)
useEffect(() => {
if (!isPlaying || oilTrajectory.length === 0) return
if (isControlled || !isPlaying || oilTrajectory.length === 0) return
const maxTime = Math.max(...oilTrajectory.map(p => p.time))
if (currentTime >= maxTime) {
if (internalCurrentTime >= maxTime) {
setIsPlaying(false)
return
}
const interval = setInterval(() => {
setCurrentTime(prev => {
setInternalCurrentTime(prev => {
const next = prev + (1 * playbackSpeed)
return next > maxTime ? maxTime : next
})
}, 200)
return () => clearInterval(interval)
}, [isPlaying, currentTime, playbackSpeed, oilTrajectory])
}, [isControlled, isPlaying, internalCurrentTime, playbackSpeed, oilTrajectory])
// 시뮬레이션 시작 시 자동으로 애니메이션 재생
// 시뮬레이션 시작 시 자동으로 애니메이션 재생 (외부 제어 모드에서는 비활성)
useEffect(() => {
if (isControlled) return
if (oilTrajectory.length > 0) {
setCurrentTime(0)
setInternalCurrentTime(0)
setIsPlaying(true)
}
}, [oilTrajectory.length])
}, [isControlled, oilTrajectory.length])
// WMS 레이어 목록
const wmsLayers = useMemo(() => {
@ -493,6 +623,9 @@ export function MapView({
// --- 유류 확산 입자 (ScatterplotLayer) ---
const visibleParticles = oilTrajectory.filter(p => p.time <= currentTime)
const activeStep = visibleParticles.length > 0
? Math.max(...visibleParticles.map(p => p.time))
: -1
if (visibleParticles.length > 0) {
result.push(
new ScatterplotLayer({
@ -501,8 +634,15 @@ export function MapView({
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
getRadius: 3,
getFillColor: (d: (typeof visibleParticles)[0]) => {
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
const modelKey = (d.model || Array.from(selectedModels)[0] || 'OpenDrift') as PredictionModel
// 1순위: stranded 입자 → showBeached=true 시 모델 색, false 시 회색
if (d.stranded === 1) return showBeached
? hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
: [130, 130, 130, 70] as [number, number, number, number]
// 2순위: 현재 활성 스텝 → 모델 기본 색상
if (d.time === activeStep) return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
// 3순위: 과거 스텝 → 회색 + 투명
return [130, 130, 130, 70] as [number, number, number, number]
},
radiusMinPixels: 2.5,
radiusMaxPixels: 5,
@ -517,6 +657,7 @@ export function MapView({
content: (
<div className="text-xs">
<strong>{modelKey} #{(d.particle ?? 0) + 1}</strong>
{d.stranded === 1 && <span className="text-red-400"> ( )</span>}
<br />
: +{d.time}h
<br />
@ -527,7 +668,32 @@ export function MapView({
}
},
updateTriggers: {
getFillColor: [selectedModels],
getFillColor: [selectedModels, currentTime, showBeached],
},
})
)
}
// --- 육지부착 hollow ring (stranded 모양 구분) ---
const strandedParticles = showBeached ? visibleParticles.filter(p => p.stranded === 1) : []
if (strandedParticles.length > 0) {
result.push(
new ScatterplotLayer({
id: 'oil-stranded-ring',
data: strandedParticles,
getPosition: (d: (typeof strandedParticles)[0]) => [d.lon, d.lat],
stroked: true,
filled: false,
getLineColor: (d: (typeof strandedParticles)[0]) => {
const modelKey = (d.model || Array.from(selectedModels)[0] || 'OpenDrift') as PredictionModel
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 255)
},
lineWidthMinPixels: 2,
getRadius: 4,
radiusMinPixels: 5,
radiusMaxPixels: 8,
updateTriggers: {
getLineColor: [selectedModels],
},
})
)
@ -629,6 +795,91 @@ export function MapView({
)
}
// --- 오염분석 다각형 그리기 ---
if (analysisPolygonPoints.length > 0) {
if (analysisPolygonPoints.length >= 3) {
result.push(
new PolygonLayer({
id: 'analysis-polygon-fill',
data: [{ polygon: analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]) }],
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
getFillColor: [168, 85, 247, 40],
getLineColor: [168, 85, 247, 220],
getLineWidth: 2,
stroked: true,
filled: true,
lineWidthMinPixels: 2,
})
)
}
result.push(
new PathLayer({
id: 'analysis-polygon-outline',
data: [{
path: [
...analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]),
...(analysisPolygonPoints.length >= 3 ? [[analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [number, number]] : []),
],
}],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [168, 85, 247, 220],
getWidth: 2,
getDashArray: [8, 4],
dashJustified: true,
widthMinPixels: 2,
})
)
result.push(
new ScatterplotLayer({
id: 'analysis-polygon-points',
data: analysisPolygonPoints.map(p => ({ position: [p.lon, p.lat] as [number, number] })),
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 5,
getFillColor: [168, 85, 247, 255],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 5,
radiusMaxPixels: 8,
})
)
}
// --- 오염분석 원 그리기 ---
if (analysisCircleCenter) {
result.push(
new ScatterplotLayer({
id: 'analysis-circle-center',
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 6,
getFillColor: [168, 85, 247, 255],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 6,
radiusMaxPixels: 9,
})
)
if (analysisCircleRadiusM > 0) {
result.push(
new ScatterplotLayer({
id: 'analysis-circle-area',
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: analysisCircleRadiusM,
radiusUnits: 'meters',
getFillColor: [168, 85, 247, 35],
getLineColor: [168, 85, 247, 200],
getLineWidth: 2,
stroked: true,
filled: true,
lineWidthMinPixels: 2,
})
)
}
}
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
@ -839,7 +1090,7 @@ export function MapView({
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`,
getSize: 12,
getColor: [255, 255, 255, 200],
getColor: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [number, number, number, number],
getPixelOffset: [0, -20],
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
@ -852,37 +1103,113 @@ export function MapView({
)
}
// --- 해류 화살표 (TextLayer) ---
if (incidentCoord) {
const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = []
const gridSize = 5
const spacing = 0.04 // 약 4km 간격
const mainBearing = 200 // SSW 방향 (도)
// --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
if (visibleCenters.length > 0) {
// 모델별 그룹핑 (Record 사용 — Map 컴포넌트와 이름 충돌 회피)
const modelGroups: Record<string, typeof visibleCenters> = {}
visibleCenters.forEach(p => {
const key = p.model || 'OpenDrift'
if (!modelGroups[key]) modelGroups[key] = []
modelGroups[key].push(p)
})
for (let row = -gridSize; row <= gridSize; row++) {
for (let col = -gridSize; col <= gridSize; col++) {
const lat = incidentCoord.lat + row * spacing
const lon = incidentCoord.lon + col * spacing / Math.cos(incidentCoord.lat * Math.PI / 180)
// 사고 지점에서 멀어질수록 해류 방향 약간 변화
const distFactor = Math.sqrt(row * row + col * col) / gridSize
const localBearing = mainBearing + (col * 3) + (row * 2)
const speed = 0.3 + (1 - distFactor) * 0.2
currentArrows.push({ lon, lat, bearing: localBearing, speed })
Object.entries(modelGroups).forEach(([model, points]) => {
const modelColor = hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 210)
if (points.length >= 2) {
result.push(
new PathLayer({
id: `center-path-${model}`,
data: [{ path: points.map((p: { lon: number; lat: number }) => [p.lon, p.lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: modelColor,
getWidth: 2,
widthMinPixels: 2,
widthMaxPixels: 4,
})
)
}
result.push(
new ScatterplotLayer({
id: `center-points-${model}`,
data: points,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getRadius: 5,
getFillColor: modelColor,
radiusMinPixels: 4,
radiusMaxPixels: 8,
pickable: false,
})
)
if (showTimeLabel) {
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
const pad = (n: number) => String(n).padStart(2, '0');
result.push(
new TextLayer({
id: `time-labels-${model}`,
data: points,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getText: (d: { time: number }) => {
if (baseTime) {
const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000);
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
}
return `+${d.time}h`;
},
getSize: 12,
getColor: hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 240),
getPixelOffset: [0, 16] as [number, number],
fontWeight: 'bold',
outlineWidth: 2,
outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [number, number, number, number],
billboard: true,
sizeUnits: 'pixels' as const,
updateTriggers: {
getText: [simulationStartTime, currentTime],
},
})
)
}
})
}
// --- 바람 화살표 (TextLayer) ---
if (incidentCoord && windData.length > 0 && showWind) {
type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number }
const activeWindStep = windData[currentTime] ?? windData[0] ?? []
const currentArrows: ArrowPoint[] = activeWindStep
.filter((d) => d.wind_speed != null && d.wind_direction != null)
.map((d) => ({
lon: d.lon,
lat: d.lat,
bearing: d.wind_direction,
speed: d.wind_speed,
}))
result.push(
new TextLayer({
id: 'current-arrows',
data: currentArrows,
getPosition: (d: (typeof currentArrows)[0]) => [d.lon, d.lat],
getPosition: (d: ArrowPoint) => [d.lon, d.lat],
getText: () => '➤',
getAngle: (d: (typeof currentArrows)[0]) => -d.bearing + 90,
getAngle: (d: ArrowPoint) => -d.bearing + 90,
getSize: 22,
getColor: [6, 182, 212, 100],
getColor: (d: ArrowPoint): [number, number, number, number] => {
const s = d.speed
if (s < 3) return [6, 182, 212, 130] // cyan-500: calm
if (s < 7) return [34, 197, 94, 150] // green-500: light
if (s < 12) return [234, 179, 8, 170] // yellow-500: moderate
if (s < 17) return [249, 115, 22, 190] // orange-500: fresh
return [239, 68, 68, 210] // red-500: strong
},
characterSet: 'auto',
sizeUnits: 'pixels' as const,
billboard: true,
updateTriggers: {
getColor: [currentTime, windData],
getAngle: [currentTime, windData],
},
})
)
}
@ -892,7 +1219,9 @@ export function MapView({
oilTrajectory, currentTime, selectedModels,
boomLines, isDrawingBoom, drawingPoints,
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
sensitiveResources,
sensitiveResources, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
])
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
@ -908,17 +1237,23 @@ export function MapView({
}}
mapStyle={currentMapStyle}
className="w-full h-full"
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }}
onClick={handleMapClick}
attributionControl={false}
preserveDrawingBuffer={true}
>
{/* 지도 캡처 셋업 */}
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
{/* 지도 중앙 좌표 + 줌 추적 */}
<MapCenterTracker onCenterChange={handleMapCenterChange} />
{/* 3D 모드 pitch 제어 */}
<MapPitchController threeD={mapToggles.threeD} />
{/* 사고 지점 변경 시 지도 이동 */}
<MapFlyToIncident lon={incidentCoord?.lon} lat={incidentCoord?.lat} />
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */}
<FlyToController flyToTarget={flyToTarget} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
{/* WMS 레이어 */}
{wmsLayers.map(layer => (
@ -946,6 +1281,11 @@ export function MapView({
{/* deck.gl 오버레이 (인터리브드: 일반 레이어) */}
<DeckGLOverlay layers={deckLayers} />
{/* 해류 파티클 오버레이 */}
{hydrData.length > 0 && showCurrent && (
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} lightMode={lightMode} />
)}
{/* 사고 위치 마커 (MapLibre Marker) */}
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !(dispersionHeatmap && dispersionHeatmap.length > 0) && (
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
@ -983,26 +1323,37 @@ export function MapView({
({drawingPoints.length} )
</div>
)}
{drawAnalysisMode === 'polygon' && (
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
({analysisPolygonPoints.length})
</div>
)}
{drawAnalysisMode === 'circle' && (
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
</div>
)}
{/* 기상청 연계 정보 */}
<WeatherInfoPanel position={currentPosition} />
{showOverlays && <WeatherInfoPanel position={currentPosition} />}
{/* 범례 */}
<MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />
{showOverlays && <MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />}
{/* 좌표 표시 */}
<CoordinateDisplay
position={incidentCoord ? [incidentCoord.lat, incidentCoord.lon] : currentPosition}
/>
{showOverlays && <CoordinateDisplay
position={mapCenter}
zoom={mapZoom}
/>}
{/* 타임라인 컨트롤 */}
{oilTrajectory.length > 0 && (
{/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */}
{!isControlled && oilTrajectory.length > 0 && (
<TimelineControl
currentTime={currentTime}
maxTime={Math.max(...oilTrajectory.map(p => p.time))}
isPlaying={isPlaying}
playbackSpeed={playbackSpeed}
onTimeChange={setCurrentTime}
onTimeChange={setInternalCurrentTime}
onPlayPause={() => setIsPlaying(!isPlaying)}
onSpeedChange={setPlaybackSpeed}
/>
@ -1188,16 +1539,23 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], select
}
// 좌표 표시
function CoordinateDisplay({ position }: { position: [number, number] }) {
function CoordinateDisplay({ position, zoom }: { position: [number, number]; zoom: number }) {
const [lat, lng] = position
const latDirection = lat >= 0 ? 'N' : 'S'
const lngDirection = lng >= 0 ? 'E' : 'W'
// MapLibre 줌 → 축척 변환 (96 DPI 기준)
const metersPerPixel = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom))
const scaleRatio = Math.round(metersPerPixel * (96 / 0.0254))
const scaleLabel = scaleRatio >= 1000000
? `1:${(scaleRatio / 1000000).toFixed(1)}M`
: `1:${scaleRatio.toLocaleString()}`
return (
<div className="cod">
<span> <span className="cov">{Math.abs(lat).toFixed(4)}°{latDirection}</span></span>
<span> <span className="cov">{Math.abs(lng).toFixed(4)}°{lngDirection}</span></span>
<span> <span className="cov">1:50,000</span></span>
<span> <span className="cov">{scaleLabel}</span></span>
</div>
)
}
@ -1274,7 +1632,11 @@ function TimelineControl({
</div>
<div className="tli">
{/* eslint-disable-next-line react-hooks/purity */}
<div className="tlct">+{currentTime.toFixed(0)}h {new Date(Date.now() + currentTime * 3600000).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} KST</div>
<div className="tlct">+{currentTime.toFixed(0)}h {(() => {
const base = simulationStartTime ? new Date(simulationStartTime) : new Date();
const d = new Date(base.getTime() + currentTime * 3600 * 1000);
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`;
})()}</div>
<div className="tlss">
<div className="tls"><span className="tlsl"></span><span className="tlsv">{progressPercent.toFixed(0)}%</span></div>
<div className="tls"><span className="tlsl"></span><span className="tlsv">{playbackSpeed}×</span></div>

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useEffect, useSyncExternalStore } from 'react'
import type { MainTab } from '../types/navigation'
import { useAuthStore } from '@common/store/authStore'
import { API_BASE_URL } from '@common/services/api'
@ -38,7 +38,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
],
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 }

파일 보기

@ -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;

파일 보기

@ -1 +1 @@
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin';
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'monitor' | 'admin';

파일 보기

@ -281,6 +281,30 @@ export function generateAIBoomLines(
return boomLines
}
/** Ray casting — 점이 다각형 내부인지 판정 */
export function pointInPolygon(
point: { lat: number; lon: number },
polygon: { lat: number; lon: number }[]
): boolean {
if (polygon.length < 3) return false
let inside = false
const x = point.lon
const y = point.lat
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].lon, yi = polygon[i].lat
const xj = polygon[j].lon, yj = polygon[j].lat
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
if (intersect) inside = !inside
}
return inside
}
/** 원 면적 (km²) */
export function circleAreaKm2(radiusM: number): number {
return Math.PI * (radiusM / 1000) ** 2
}
/** 차단 시뮬레이션 실행 */
export function runContainmentAnalysis(
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,

파일 보기

@ -0,0 +1,27 @@
import type { ImageAnalyzeResult } from '@tabs/prediction/services/predictionApi';
/**
* () .
* registerMainTabSwitcher / navigateToTab .
*/
interface PendingImageAnalysis extends ImageAnalyzeResult {
autoRun: boolean;
}
let _pending: PendingImageAnalysis | null = null;
/** 분석 결과를 시그널에 저장한다. navigateToTab 호출 직전에 사용한다. */
export function setPendingImageAnalysis(data: PendingImageAnalysis): void {
_pending = data;
}
/**
* .
* OilSpillView 1 .
*/
export function consumePendingImageAnalysis(): PendingImageAnalysis | null {
const value = _pending;
_pending = null;
return value;
}

파일 보기

@ -8,6 +8,8 @@ import MenusPanel from './MenusPanel';
import SettingsPanel from './SettingsPanel';
import BoardMgmtPanel from './BoardMgmtPanel';
import VesselSignalPanel from './VesselSignalPanel';
import CleanupEquipPanel from './CleanupEquipPanel';
import AssetUploadPanel from './AssetUploadPanel';
/** 기존 패널이 있는 메뉴 ID 매핑 */
const PANEL_MAP: Record<string, () => JSX.Element> = {
@ -19,6 +21,8 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
board: () => <BoardMgmtPanel initialCategory="DATA" />,
qna: () => <BoardMgmtPanel initialCategory="QNA" />,
'collect-vessel-signal': () => <VesselSignalPanel />,
'cleanup-equip': () => <CleanupEquipPanel />,
'asset-upload': () => <AssetUploadPanel />,
};
export function AdminView() {

파일 보기

@ -0,0 +1,257 @@
import { useState, useEffect, useRef } from 'react';
import { fetchUploadLogs } from '@tabs/assets/services/assetsApi';
import type { UploadLogItem } from '@tabs/assets/services/assetsApi';
const ASSET_CATEGORIES = ['전체', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '오일붐', '흡착재', '기타'];
const JURISDICTIONS = ['전체', '남해청', '서해청', '중부청', '동해청', '제주청'];
const PERM_ITEMS = [
{ icon: '👑', role: '시스템관리자', desc: '전체 자산 업로드/삭제 가능', bg: 'rgba(245,158,11,0.15)', color: 'text-yellow-400' },
{ icon: '🔧', role: '운영관리자', desc: '관할청 내 자산 업로드 가능', bg: 'rgba(6,182,212,0.15)', color: 'text-primary-cyan' },
{ icon: '👁', role: '조회자', desc: '현황 조회만 가능', bg: 'rgba(148,163,184,0.15)', color: 'text-text-2' },
{ icon: '🚫', role: '게스트', desc: '접근 불가', bg: 'rgba(239,68,68,0.1)', color: 'text-red-400' },
];
function formatDate(dtm: string) {
const d = new Date(dtm);
if (isNaN(d.getTime())) return dtm;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function AssetUploadPanel() {
const [assetCategory, setAssetCategory] = useState('전체');
const [jurisdiction, setJurisdiction] = useState('전체');
const [uploadMode, setUploadMode] = useState<'add' | 'replace'>('add');
const [uploaded, setUploaded] = useState(false);
const [uploadHistory, setUploadHistory] = useState<UploadLogItem[]>([]);
const [dragging, setDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
fetchUploadLogs(10)
.then(setUploadHistory)
.catch(err => console.error('[AssetUploadPanel] 이력 로드 실패:', err));
}, []);
useEffect(() => {
return () => {
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
};
}, []);
const handleFileSelect = (file: File | null) => {
if (!file) return;
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext !== 'xlsx' && ext !== 'csv') return;
setSelectedFile(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0] ?? null;
handleFileSelect(file);
};
const handleUpload = () => {
if (!selectedFile) return;
setUploaded(true);
resetTimerRef.current = setTimeout(() => {
setUploaded(false);
setSelectedFile(null);
}, 3000);
};
return (
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="px-6 py-4 border-b border-border flex-shrink-0">
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> </p>
</div>
{/* 본문 */}
<div className="flex-1 overflow-auto p-6">
<div className="flex gap-6 h-full">
{/* 좌측: 파일 업로드 */}
<div className="flex-1 max-w-[560px] space-y-4">
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
</div>
<div className="px-5 py-4 space-y-4">
{/* 드롭존 */}
<div
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${
dragging
? 'border-primary-cyan bg-[rgba(6,182,212,0.05)]'
: 'border-border hover:border-primary-cyan/50 bg-bg-2'
}`}
>
<div className="text-3xl mb-2 opacity-40">📁</div>
{selectedFile ? (
<div className="text-xs font-semibold text-primary-cyan font-korean mb-1">{selectedFile.name}</div>
) : (
<>
<div className="text-xs font-semibold text-text-2 font-korean mb-1"> </div>
<div className="text-[10px] text-text-3 font-korean mb-3">(.xlsx), CSV · 10MB</div>
<button
type="button"
className="px-4 py-1.5 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0
hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
onClick={e => { e.stopPropagation(); fileInputRef.current?.click(); }}
>
</button>
</>
)}
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.csv"
className="hidden"
onChange={e => handleFileSelect(e.target.files?.[0] ?? null)}
/>
</div>
{/* 자산 분류 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5"> </label>
<select
value={assetCategory}
onChange={e => setAssetCategory(e.target.value)}
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md
text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
{ASSET_CATEGORIES.map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{/* 대상 관할 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5"> </label>
<select
value={jurisdiction}
onChange={e => setJurisdiction(e.target.value)}
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md
text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
{JURISDICTIONS.map(j => (
<option key={j} value={j}>{j}</option>
))}
</select>
</div>
{/* 업로드 방식 */}
<div>
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5"> </label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-text-2 font-korean">
<input
type="radio"
checked={uploadMode === 'add'}
onChange={() => setUploadMode('add')}
className="accent-primary-cyan"
/>
( + )
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-text-2 font-korean">
<input
type="radio"
checked={uploadMode === 'replace'}
onChange={() => setUploadMode('replace')}
className="accent-primary-cyan"
/>
( )
</label>
</div>
</div>
{/* 업로드 버튼 */}
<button
type="button"
onClick={handleUpload}
disabled={!selectedFile || uploaded}
className={`w-full py-2.5 text-xs font-semibold rounded-md transition-all font-korean disabled:opacity-50 ${
uploaded
? 'bg-[rgba(34,197,94,0.15)] text-status-green border border-status-green/30'
: 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
}`}
>
{uploaded ? '✅ 업로드 완료!' : '📤 업로드 실행'}
</button>
</div>
</div>
</div>
{/* 우측 */}
<div className="w-[400px] space-y-4 flex-shrink-0">
{/* 수정 권한 체계 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
</div>
<div className="px-5 py-4 space-y-2">
{PERM_ITEMS.map(p => (
<div
key={p.role}
className="flex items-center gap-3 px-4 py-3 bg-bg-2 border border-border rounded-md"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0"
style={{ background: p.bg }}
>
{p.icon}
</div>
<div>
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
<div className="text-[10px] text-text-3 font-korean mt-0.5">{p.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* 최근 업로드 이력 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
</div>
<div className="px-5 py-4 space-y-2">
{uploadHistory.length === 0 ? (
<div className="text-[11px] text-text-3 font-korean text-center py-4"> .</div>
) : uploadHistory.map(h => (
<div
key={h.logSn}
className="flex justify-between items-center px-4 py-3 bg-bg-2 border border-border rounded-md"
>
<div>
<div className="text-xs font-semibold text-text-1 font-korean">{h.fileNm}</div>
<div className="text-[10px] text-text-3 mt-0.5 font-korean">
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}
</div>
</div>
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold
bg-[rgba(34,197,94,0.15)] text-status-green flex-shrink-0">
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default AssetUploadPanel;

파일 보기

@ -0,0 +1,230 @@
import { useState, useEffect, useMemo } from 'react';
import { fetchOrganizations } from '@tabs/assets/services/assetsApi';
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi';
import { typeTagCls } from '@tabs/assets/components/assetTypes';
const PAGE_SIZE = 20;
const regionShort = (j: string) =>
j.includes('남해') ? '남해청' : j.includes('서해') ? '서해청' :
j.includes('중부') ? '중부청' : j.includes('동해') ? '동해청' :
j.includes('제주') ? '제주청' : j;
function CleanupEquipPanel() {
const [organizations, setOrganizations] = useState<AssetOrgCompat[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [regionFilter, setRegionFilter] = useState('전체');
const [typeFilter, setTypeFilter] = useState('전체');
const [currentPage, setCurrentPage] = useState(1);
const load = () => {
setLoading(true);
fetchOrganizations()
.then(setOrganizations)
.catch(err => console.error('[CleanupEquipPanel] 데이터 로드 실패:', err))
.finally(() => setLoading(false));
};
useEffect(() => {
let cancelled = false;
fetchOrganizations()
.then(data => { if (!cancelled) setOrganizations(data); })
.catch(err => console.error('[CleanupEquipPanel] 데이터 로드 실패:', err))
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const typeOptions = useMemo(() => {
const set = new Set(organizations.map(o => o.type));
return Array.from(set).sort();
}, [organizations]);
const filtered = useMemo(() =>
organizations
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
.filter(o => typeFilter === '전체' || o.type === typeFilter)
.filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)),
[organizations, regionFilter, typeFilter, searchTerm]
);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const handleFilterChange = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLSelectElement>) => {
setter(e.target.value);
setCurrentPage(1);
};
const pageNumbers = (() => {
const range: number[] = [];
const start = Math.max(1, safePage - 2);
const end = Math.min(totalPages, safePage + 2);
for (let i = start; i <= end; i++) range.push(i);
return range;
})();
return (
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> {filtered.length} </p>
</div>
<div className="flex items-center gap-3">
<select
value={regionFilter}
onChange={handleFilterChange(setRegionFilter)}
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
<option value="전체"> </option>
<option value="남해"></option>
<option value="서해"></option>
<option value="중부"></option>
<option value="동해"></option>
<option value="제주"></option>
</select>
<select
value={typeFilter}
onChange={handleFilterChange(setTypeFilter)}
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
<option value="전체"> </option>
{typeOptions.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
<input
type="text"
placeholder="기관명, 주소 검색..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
/>
<button
onClick={load}
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-2 border border-border text-text-2 hover:border-primary-cyan hover:text-primary-cyan transition-all font-korean"
>
</button>
</div>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">
...
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1">
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean"></th>
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<tr>
<td colSpan={11} className="px-6 py-10 text-center text-xs text-text-3 font-korean">
.
</td>
</tr>
) : paged.map((org, idx) => (
<tr key={org.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-4 py-3 text-[11px] text-text-3 font-mono text-center">
{(safePage - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>
{org.type}
</span>
</td>
<td className="px-4 py-3 text-[11px] text-text-2 font-korean">
{regionShort(org.jurisdiction)}
</td>
<td className="px-4 py-3 text-[11px] text-text-1 font-korean font-semibold">
{org.name}
</td>
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
{org.address}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
{org.vessel > 0 ? <span className="text-text-1">{org.vessel}</span> : <span className="text-text-3"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
{org.skimmer > 0 ? <span className="text-text-1">{org.skimmer}</span> : <span className="text-text-3"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
{org.pump > 0 ? <span className="text-text-1">{org.pump}</span> : <span className="text-text-3"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
{org.vehicle > 0 ? <span className="text-text-1">{org.vehicle}</span> : <span className="text-text-3"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
{org.sprayer > 0 ? <span className="text-text-1">{org.sprayer}</span> : <span className="text-text-3"></span>}
</td>
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
{org.totalAssets.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* 페이지네이션 */}
{!loading && filtered.length > 0 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
<span className="text-[11px] text-text-3 font-korean">
{(safePage - 1) * PAGE_SIZE + 1}{Math.min(safePage * PAGE_SIZE, filtered.length)} / {filtered.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={safePage === 1}
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
>
&lt;
</button>
{pageNumbers.map(p => (
<button
key={p}
onClick={() => setCurrentPage(p)}
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
style={p === safePage
? { borderColor: 'var(--cyan)', color: 'var(--cyan)', background: 'rgba(6,182,212,0.1)' }
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
}
>
{p}
</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={safePage === totalPages}
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
>
&gt;
</button>
</div>
</div>
)}
</div>
);
}
export default CleanupEquipPanel;

파일 보기

@ -294,6 +294,7 @@ interface RolePermTabProps {
setSelectedRoleSn: (sn: number | null) => void
dirty: boolean
saving: boolean
saveError: string | null
handleSave: () => Promise<void>
handleToggleExpand: (code: string) => void
handleTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void
@ -328,6 +329,7 @@ function RolePermTab({
setSelectedRoleSn,
dirty,
saving,
saveError,
handleSave,
handleToggleExpand,
handleTogglePerm,
@ -378,6 +380,9 @@ function RolePermTab({
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
{saveError && (
<span className="text-[11px] text-status-red font-korean">{saveError}</span>
)}
</div>
{/* 역할 탭 바 */}
@ -861,6 +866,7 @@ function PermissionsPanel() {
const [permTree, setPermTree] = useState<PermTreeNode[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [showCreateForm, setShowCreateForm] = useState(false)
const [newRoleCode, setNewRoleCode] = useState('')
@ -962,6 +968,7 @@ function PermissionsPanel() {
const handleSave = async () => {
setSaving(true)
setSaveError(null)
try {
for (const role of roles) {
const perms = rolePerms.get(role.sn)
@ -981,6 +988,7 @@ function PermissionsPanel() {
setDirty(false)
} catch (err) {
console.error('권한 저장 실패:', err)
setSaveError('권한 저장에 실패했습니다. 다시 시도해주세요.')
} finally {
setSaving(false)
}
@ -1096,6 +1104,7 @@ function PermissionsPanel() {
setSelectedRoleSn={setSelectedRoleSn}
dirty={dirty}
saving={saving}
saveError={saveError}
handleSave={handleSave}
handleToggleExpand={handleToggleExpand}
handleTogglePerm={handleTogglePerm}

파일 보기

@ -51,6 +51,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [
id: 'coast-guard-assets', label: '해경자산',
children: [
{ id: 'cleanup-equip', label: '방제장비' },
{ id: 'asset-upload', label: '자산현행화' },
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
{ id: 'vessel-materials', label: '방제선 보유자재' },
{ id: 'cleanup-resource', label: '방제자원' },

파일 보기

@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { fetchAerialMedia } from '../services/aerialApi'
import { fetchAerialMedia, downloadAerialMedia } from '../services/aerialApi'
import type { AerialMediaItem } from '../services/aerialApi'
import { navigateToTab } from '@common/hooks/useSubMenu'
// ── Helpers ──
@ -48,6 +49,9 @@ export function MediaManagement() {
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState('latest')
const [showUpload, setShowUpload] = useState(false)
const [downloadingId, setDownloadingId] = useState<number | null>(null)
const [bulkDownloading, setBulkDownloading] = useState(false)
const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(null)
const modalRef = useRef<HTMLDivElement>(null)
const loadData = useCallback(async () => {
@ -118,6 +122,38 @@ export function MediaManagement() {
})
}
const handleBulkDownload = async () => {
if (bulkDownloading || selectedIds.size === 0) return
setBulkDownloading(true)
let success = 0
const total = selectedIds.size
for (const sn of selectedIds) {
const item = mediaItems.find(f => f.aerialMediaSn === sn)
if (!item) continue
try {
await downloadAerialMedia(sn, item.orgnlNm ?? item.fileNm)
success++
} catch {
// 실패 건 스킵
}
}
setBulkDownloading(false)
setDownloadResult({ total, success })
}
const handleDownload = async (e: React.MouseEvent, item: AerialMediaItem) => {
e.stopPropagation()
if (downloadingId !== null) return
setDownloadingId(item.aerialMediaSn)
try {
await downloadAerialMedia(item.aerialMediaSn, item.orgnlNm ?? item.fileNm)
} catch {
alert('다운로드 실패: 이미지를 찾을 수 없습니다.')
} finally {
setDownloadingId(null)
}
}
const droneCount = mediaItems.filter(f => f.equipTpCd === 'drone').length
const planeCount = mediaItems.filter(f => f.equipTpCd === 'plane').length
const satCount = mediaItems.filter(f => f.equipTpCd === 'satellite').length
@ -254,8 +290,12 @@ export function MediaManagement() {
<td className="px-2 py-2 text-[11px] font-mono">{f.fileSz ?? '—'}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution ?? '—'}</td>
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
<button className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors">
📥
<button
onClick={(e) => handleDownload(e, f)}
disabled={downloadingId === f.aerialMediaSn}
className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors disabled:opacity-50"
>
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
</button>
</td>
</tr>
@ -274,15 +314,47 @@ export function MediaManagement() {
<button onClick={toggleAll} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-3 border border-border text-text-2 hover:bg-bg-hover transition-colors font-korean">
</button>
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean">
📥
<button
onClick={handleBulkDownload}
disabled={bulkDownloading || selectedIds.size === 0}
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean disabled:opacity-50"
>
{bulkDownloading ? '⏳ 다운로드 중...' : '📥 선택 다운로드'}
</button>
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-primary-purple border border-primary-purple/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean">
🧩
<button
onClick={() => navigateToTab('prediction', 'analysis')}
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-primary-purple border border-primary-purple/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean"
>
🔬
</button>
</div>
</div>
{/* 선택 다운로드 결과 팝업 */}
{downloadResult && (
<div className="fixed inset-0 z-[300] bg-black/60 backdrop-blur-sm flex items-center justify-center">
<div className="bg-bg-1 border border-border rounded-md p-6 w-72 text-center">
<div className="text-2xl mb-3">📥</div>
<div className="text-sm font-bold font-korean mb-3"> </div>
<div className="text-[13px] font-korean text-text-2 mb-1">
<span className="text-primary-cyan font-bold">{downloadResult.total}</span>
</div>
<div className="text-[13px] font-korean text-text-2 mb-4">
<span className="text-status-green font-bold">{downloadResult.success}</span>
{downloadResult.total - downloadResult.success > 0 && (
<> / <span className="text-status-red font-bold">{downloadResult.total - downloadResult.success}</span> </>
)}
</div>
<button
onClick={() => setDownloadResult(null)}
className="px-6 py-2 text-sm font-semibold rounded bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
>
</button>
</div>
</div>
)}
{/* Upload Modal */}
{showUpload && (
<div className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center">

파일 보기

@ -1,212 +1,425 @@
import { useState } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react';
import * as exifr from 'exifr';
import { stitchImages } from '../services/aerialApi';
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
import { navigateToTab } from '@common/hooks/useSubMenu';
import { decimalToDMS } from '@common/utils/coordinates';
// ── Types & Mock Data ──
const MAX_IMAGES = 6;
interface MosaicImage {
id: string
filename: string
status: 'done' | 'processing' | 'waiting'
hasOil: boolean
interface ImageExif {
lat: number | null;
lon: number | null;
altitude: number | null;
make: string | null;
model: string | null;
dateTime: Date | string | null;
exposureTime: number | null;
fNumber: number | null;
iso: number | null;
focalLength: number | null;
imageWidth: number | null;
imageHeight: number | null;
}
const mosaicImages: MosaicImage[] = [
{ id: 'T1', filename: '드론_001.jpg', status: 'done', hasOil: true },
{ id: 'T2', filename: '드론_002.jpg', status: 'done', hasOil: true },
{ id: 'T3', filename: '드론_003.jpg', status: 'done', hasOil: true },
{ id: 'T4', filename: '드론_004.jpg', status: 'done', hasOil: true },
{ id: 'T5', filename: '드론_005.jpg', status: 'processing', hasOil: false },
{ id: 'T6', filename: '드론_006.jpg', status: 'waiting', hasOil: false },
]
function formatFileSize(bytes?: number): string | null {
if (bytes == null) return null;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ── Component ──
function formatDateTime(dt: Date | string | null): string | null {
if (!dt) return null;
if (dt instanceof Date) {
return dt.toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
return String(dt);
}
interface MetaRowProps {
label: string;
value: string | null | undefined;
}
function MetaRow({ label, value }: MetaRowProps) {
if (value == null) return null;
return (
<div className="flex justify-between gap-2 py-0.5 border-b border-border/40 last:border-0 font-korean">
<span className="text-text-3 shrink-0">{label}</span>
<span className="text-text-1 text-right break-all">{value}</span>
</div>
);
}
export function OilAreaAnalysis() {
const [activeStep, setActiveStep] = useState(1)
const [analyzing, setAnalyzing] = useState(false)
const [analyzed, setAnalyzed] = useState(false)
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [imageExifs, setImageExifs] = useState<(ImageExif | undefined)[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);
const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null);
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
const [isStitching, setIsStitching] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const processedFilesRef = useRef<Set<File>>(new Set());
const handleAnalyze = () => {
setAnalyzing(true)
setTimeout(() => {
setAnalyzing(false)
setAnalyzed(true)
}, 1500)
}
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
useEffect(() => {
return () => {
previewUrls.forEach(url => URL.revokeObjectURL(url));
if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stepCls = (idx: number) => {
if (idx < activeStep) return 'border-status-green text-status-green bg-[rgba(34,197,94,0.05)]'
if (idx === activeStep) return 'border-primary-cyan text-primary-cyan bg-[rgba(6,182,212,0.05)]'
return 'border-border text-text-3 bg-bg-3'
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF 전체 추출
useEffect(() => {
selectedFiles.forEach((file, i) => {
if (processedFilesRef.current.has(file)) return;
processedFilesRef.current.add(file);
exifr.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false })
.then(exif => {
const info: ImageExif = {
lat: exif?.latitude ?? null,
lon: exif?.longitude ?? null,
altitude: exif?.GPSAltitude ?? null,
make: exif?.Make ?? null,
model: exif?.Model ?? null,
dateTime: exif?.DateTimeOriginal ?? null,
exposureTime: exif?.ExposureTime ?? null,
fNumber: exif?.FNumber ?? null,
iso: exif?.ISO ?? null,
focalLength: exif?.FocalLength ?? null,
imageWidth: exif?.ImageWidth ?? exif?.ExifImageWidth ?? null,
imageHeight: exif?.ImageHeight ?? exif?.ExifImageHeight ?? null,
};
setImageExifs(prev => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = info;
return updated;
});
})
.catch(() => {
setImageExifs(prev => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = {
lat: null, lon: null, altitude: null,
make: null, model: null, dateTime: null,
exposureTime: null, fNumber: null, iso: null,
focalLength: null, imageWidth: null, imageHeight: null,
};
return updated;
});
});
});
}, [selectedFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
const incoming = Array.from(e.target.files ?? []);
if (incoming.length === 0) return;
setSelectedFiles(prev => {
const merged = [...prev, ...incoming].slice(0, MAX_IMAGES);
if (prev.length + incoming.length > MAX_IMAGES) {
setError(`최대 ${MAX_IMAGES}장까지 선택할 수 있습니다.`);
}
return merged;
});
// setSelectedFiles updater 밖에서 독립 호출 — updater 내부 side effect는
// React Strict Mode의 이중 호출로 인해 URL이 중복 생성되는 버그를 유발함
setPreviewUrls(prev => {
const available = MAX_IMAGES - prev.length;
const toAdd = incoming.slice(0, available);
return [...prev, ...toAdd.map(f => URL.createObjectURL(f))];
});
// input 초기화 (동일 파일 재선택 허용)
e.target.value = '';
}, []);
const handleRemoveFile = useCallback((idx: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== idx));
setPreviewUrls(prev => {
URL.revokeObjectURL(prev[idx]);
return prev.filter((_, i) => i !== idx);
});
setImageExifs(prev => prev.filter((_, i) => i !== idx));
setSelectedImageIndex(prev => {
if (prev === null) return null;
if (prev === idx) return null;
if (prev > idx) return prev - 1;
return prev;
});
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
setStitchedBlob(null);
if (stitchedPreviewUrl) {
URL.revokeObjectURL(stitchedPreviewUrl);
setStitchedPreviewUrl(null);
}
setError(null);
}, [stitchedPreviewUrl]);
const handleStitch = async () => {
if (selectedFiles.length < 2) {
setError('이미지를 2장 이상 선택해주세요.');
return;
}
setError(null);
setIsStitching(true);
try {
const blob = await stitchImages(selectedFiles);
if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl);
setStitchedBlob(blob);
setStitchedPreviewUrl(URL.createObjectURL(blob));
} catch (err) {
const msg =
err instanceof Error
? err.message
: (err as { message?: string }).message ?? '이미지 합성에 실패했습니다.';
const status = err instanceof Error ? 0 : (err as { status?: number }).status ?? 0;
setError(status === 504 ? '이미지 합성 서버 응답 시간이 초과되었습니다.' : msg);
} finally {
setIsStitching(false);
}
};
const handleAnalyze = async () => {
if (!stitchedBlob) return;
setError(null);
setIsAnalyzing(true);
try {
const stitchedFile = new File([stitchedBlob], `stitch_${Date.now()}.jpg`, { type: 'image/jpeg' });
const result = await analyzeImage(stitchedFile);
setPendingImageAnalysis({ ...result, autoRun: true });
navigateToTab('prediction', 'analysis');
} catch (err) {
const msg = err instanceof Error ? err.message : '분석에 실패했습니다.';
setError(msg.includes('GPS') ? '이미지에 GPS 정보가 없습니다. GPS 정보가 포함된 이미지를 사용해주세요.' : msg);
setIsAnalyzing(false);
}
};
const canStitch = selectedFiles.length >= 2 && !isStitching && !isAnalyzing;
const canAnalyze = stitchedBlob !== null && !isStitching && !isAnalyzing;
return (
<div className="flex gap-5 h-full overflow-hidden">
{/* Left Panel */}
<div className="w-[340px] min-w-[340px] flex flex-col overflow-y-auto scrollbar-thin">
<div className="text-sm font-bold mb-1 font-korean">🧩 </div>
<div className="text-[11px] text-text-3 mb-4 font-korean"> .</div>
{/* ── Left Panel ── */}
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
<div className="text-sm font-bold mb-1 font-korean">🧩 </div>
<div className="text-[11px] text-text-3 mb-4 font-korean">
.
</div>
{/* Step Indicator */}
<div className="flex gap-2 mb-3">
{['① 사진 선택', '② 정합·합성', '③ 면적 산정'].map((label, i) => (
{/* 이미지 선택 버튼 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<button
key={i}
onClick={() => setActiveStep(i)}
className={`flex-1 py-2 rounded-sm border text-center text-[10px] font-semibold font-korean cursor-pointer transition-colors ${stepCls(i)}`}
onClick={() => fileInputRef.current?.click()}
disabled={selectedFiles.length >= MAX_IMAGES || isStitching || isAnalyzing}
className="w-full py-2 mb-3 border border-dashed border-border rounded-sm text-xs font-korean text-text-2
hover:border-primary-cyan hover:text-primary-cyan transition-colors cursor-pointer
disabled:opacity-40 disabled:cursor-not-allowed"
>
{label}
+ ({selectedFiles.length}/{MAX_IMAGES})
</button>
))}
</div>
{/* Selected Images */}
<div className="text-[11px] font-bold mb-2 font-korean"> (6)</div>
<div className="flex flex-col gap-1 mb-3.5">
{['여수항_드론_001.jpg', '여수항_드론_002.jpg', '여수항_드론_003.jpg', '여수항_드론_004.jpg', '여수항_드론_005.jpg', '여수항_드론_006.jpg'].map((name, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 border border-border rounded-sm text-[11px] font-korean">
<span>🛸</span>
<span className="flex-1 truncate">{name}</span>
<span className={`text-[9px] font-semibold ${
i < 4 ? 'text-status-green' : i === 4 ? 'text-status-orange' : 'text-text-3'
}`}>
{i < 4 ? '✓ 정합' : i === 4 ? '⏳ 정합중' : '대기'}
</span>
{/* 선택된 이미지 목록 */}
{selectedFiles.length > 0 && (
<>
<div className="text-[11px] font-bold mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1 mb-3">
{selectedFiles.map((file, i) => (
<div key={`${file.name}-${i}`}>
<div
className={`flex items-center gap-2 px-2 py-1.5 bg-bg-3 border rounded-sm text-[11px] font-korean cursor-pointer transition-colors
${selectedImageIndex === i ? 'border-primary-cyan' : 'border-border'}`}
onClick={() => setSelectedImageIndex(i)}
>
<span className="text-primary-cyan">📷</span>
<span className="flex-1 truncate text-text-1">{file.name}</span>
<button
onClick={e => { e.stopPropagation(); handleRemoveFile(i); }}
disabled={isStitching || isAnalyzing}
className="text-text-3 hover:text-status-red transition-colors cursor-pointer
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
title="제거"
>
</button>
</div>
{selectedImageIndex === i && imageExifs[i] !== undefined && (
<div className="mt-1 mb-1 px-2 py-1.5 bg-bg-0 border border-border/60 rounded-sm text-[11px] font-korean">
<MetaRow label="파일 크기" value={formatFileSize(file.size)} />
<MetaRow
label="해상도"
value={imageExifs[i]!.imageWidth && imageExifs[i]!.imageHeight
? `${imageExifs[i]!.imageWidth} × ${imageExifs[i]!.imageHeight}`
: null}
/>
<MetaRow label="촬영일시" value={formatDateTime(imageExifs[i]!.dateTime)} />
<MetaRow
label="장비"
value={imageExifs[i]!.make || imageExifs[i]!.model
? [imageExifs[i]!.make, imageExifs[i]!.model].filter(Boolean).join(' ')
: null}
/>
<MetaRow
label="위도"
value={imageExifs[i]!.lat !== null ? decimalToDMS(imageExifs[i]!.lat!, true) : null}
/>
<MetaRow
label="경도"
value={imageExifs[i]!.lon !== null ? decimalToDMS(imageExifs[i]!.lon!, false) : null}
/>
<MetaRow
label="고도"
value={imageExifs[i]!.altitude !== null ? `${imageExifs[i]!.altitude!.toFixed(1)} m` : null}
/>
<MetaRow
label="셔터속도"
value={imageExifs[i]!.exposureTime
? imageExifs[i]!.exposureTime! < 1
? `1/${Math.round(1 / imageExifs[i]!.exposureTime!)}s`
: `${imageExifs[i]!.exposureTime}s`
: null}
/>
<MetaRow
label="조리개"
value={imageExifs[i]!.fNumber ? `f/${imageExifs[i]!.fNumber}` : null}
/>
<MetaRow
label="ISO"
value={imageExifs[i]!.iso ? String(imageExifs[i]!.iso) : null}
/>
<MetaRow
label="초점거리"
value={imageExifs[i]!.focalLength ? `${imageExifs[i]!.focalLength} mm` : null}
/>
</div>
)}
</div>
))}
</div>
</>
)}
{/* Analysis Parameters */}
<div className="text-[11px] font-bold mb-2 font-korean"> </div>
<div className="flex flex-col gap-1.5 mb-3.5">
{[
['촬영 고도', '120 m'],
['GSD (지상해상도)', '3.2 cm/px'],
['오버랩 비율', '80% / 70%'],
['좌표계', 'EPSG:5186'],
['유종 판별 기준', 'NDVI + NIR'],
['유막 두께 추정', 'Bonn Agreement'],
].map(([label, value], i) => (
<div key={i} className="flex justify-between items-center text-[11px]">
<span className="text-text-3 font-korean">{label}</span>
<span className="font-mono font-semibold">{value}</span>
</div>
))}
{/* 에러 메시지 */}
{error && (
<div className="mb-3 px-2.5 py-2 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.3)] rounded-sm text-[11px] text-status-red font-korean">
{error}
</div>
)}
{/* Action Buttons */}
{/* 이미지 합성 버튼 */}
<button
onClick={handleStitch}
disabled={!canStitch}
className="w-full py-2.5 mb-2 rounded-sm text-[12px] font-bold font-korean cursor-pointer transition-colors
border border-primary-cyan text-primary-cyan bg-[rgba(6,182,212,0.06)]
hover:bg-[rgba(6,182,212,0.15)] disabled:opacity-40 disabled:cursor-not-allowed"
>
{isStitching ? '⏳ 합성 중...' : stitchedBlob ? '✅ 다시 합성' : '🔗 이미지 합성'}
</button>
{/* 분석 시작 버튼 */}
<button
onClick={handleAnalyze}
disabled={analyzing}
className={`w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none mb-2 transition-colors ${
analyzed
? 'bg-[rgba(34,197,94,0.15)] text-status-green border border-status-green'
: 'text-white'
}`}
style={!analyzed ? { background: 'linear-gradient(135deg, var(--cyan), var(--blue))' } : undefined}
disabled={!canAnalyze}
className="w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none transition-colors
disabled:opacity-40 disabled:cursor-not-allowed text-white"
style={canAnalyze ? { background: 'linear-gradient(135deg, var(--cyan), var(--blue))' } : { background: 'var(--bg-3)' }}
>
{analyzing ? '⏳ 분석중...' : analyzed ? '✅ 분석 완료!' : '🧩 면적분석 실행'}
</button>
<button className="w-full py-2.5 border border-border bg-bg-3 text-text-2 rounded-sm text-xs font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
📥 (GeoTIFF)
{isAnalyzing ? '⏳ 분석 중...' : '🧩 분석 시작'}
</button>
</div>
{/* Right Panel */}
{/* ── Right Panel ── */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold font-korean">🗺 </span>
<div className="flex gap-1.5">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(239,68,68,0.1)] text-status-red font-semibold font-korean"> </span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(6,182,212,0.1)] text-primary-cyan font-semibold font-korean"> </span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.1)] text-status-green font-semibold font-korean"> 96.2%</span>
</div>
</div>
{/* Image Grid 3×2 */}
{/* 3×2 이미지 그리드 */}
<div className="text-[11px] font-bold mb-2 font-korean"> </div>
<div className="grid grid-cols-3 gap-1.5 mb-3">
{mosaicImages.map(img => (
<div key={img.id} className="bg-bg-3 border border-border rounded-sm overflow-hidden cursor-pointer hover:border-border-light transition-colors">
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
<div
className="h-[100px] relative flex items-center justify-center overflow-hidden"
style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}
key={i}
className={`bg-bg-3 border rounded-sm overflow-hidden flex flex-col transition-colors
${previewUrls[i] ? 'cursor-pointer' : ''}
${selectedImageIndex === i ? 'border-primary-cyan' : 'border-border'}`}
style={{ height: '300px' }}
onClick={() => { if (previewUrls[i]) setSelectedImageIndex(i); }}
>
{img.hasOil && (
<div
className="absolute inset-0"
style={{
background: 'rgba(239,68,68,0.15)',
border: '1px solid rgba(239,68,68,0.35)',
clipPath: 'polygon(20% 30%,60% 15%,85% 40%,70% 80%,30% 75%,10% 50%)',
}}
{previewUrls[i] ? (
<>
<div className="flex-1 min-h-0 overflow-hidden">
<img
src={previewUrls[i]}
alt={selectedFiles[i]?.name ?? ''}
className="w-full h-full object-cover"
/>
</div>
<div className="px-2 py-1 bg-bg-0 border-t border-border shrink-0 flex items-start justify-between gap-1">
<div className="text-[10px] text-text-2 truncate font-korean flex-1 min-w-0">
{selectedFiles[i]?.name}
</div>
{imageExifs[i] === undefined ? (
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS ...</div>
) : imageExifs[i]?.lat !== null ? (
<div className="text-[10px] text-primary-cyan font-mono leading-tight text-right shrink-0">
{decimalToDMS(imageExifs[i]!.lat!, true)}<br />
{decimalToDMS(imageExifs[i]!.lon!, false)}
</div>
) : (
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS </div>
)}
<div className="text-lg font-bold text-white/[0.08] font-mono">{img.id}</div>
<div className={`absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded-md text-[9px] font-bold font-korean ${
img.status === 'done' && img.hasOil ? 'bg-[rgba(239,68,68,0.2)] text-status-red' :
img.status === 'processing' ? 'bg-[rgba(249,115,22,0.2)] text-status-orange' :
'bg-[rgba(100,116,139,0.2)] text-text-3'
}`}>
{img.status === 'done' && img.hasOil ? '유막' : img.status === 'processing' ? '정합중' : '대기'}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-text-3 text-lg font-mono opacity-20">
{i + 1}
</div>
<div className="px-2 py-1.5 flex justify-between items-center text-[10px] font-korean text-text-2">
<span>{img.filename}</span>
<span className={
img.status === 'done' ? 'text-status-green' :
img.status === 'processing' ? 'text-status-orange' :
'text-text-3'
}>
{img.status === 'done' ? '✓' : img.status === 'processing' ? '⏳' : '—'}
</span>
</div>
)}
</div>
))}
</div>
{/* Merged Result Preview */}
<div className="relative h-[140px] bg-bg-0 border border-border rounded-sm overflow-hidden mb-3">
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 40% 50%, rgba(10,25,40,0.7), rgba(8,14,26,0.95))' }}>
<div className="absolute border border-dashed rounded flex items-center justify-center text-[10px] font-korean" style={{ top: '15%', left: '10%', width: '65%', height: '70%', borderColor: 'rgba(6,182,212,0.3)', color: 'rgba(6,182,212,0.5)' }}>
(3×2 )
{/* 합성 결과 */}
<div className="text-[11px] font-bold mb-2 font-korean"> </div>
<div
className="relative bg-bg-0 border border-border rounded-sm overflow-hidden flex items-center justify-center"
style={{ minHeight: '160px', flex: '1 1 0' }}
>
{stitchedPreviewUrl ? (
<img
src={stitchedPreviewUrl}
alt="합성 결과"
className="max-w-full max-h-full object-contain"
/>
) : (
<div className="text-[12px] text-text-3 font-korean text-center px-4">
{isStitching
? '⏳ 이미지를 합성하고 있습니다...'
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
</div>
<div className="absolute" style={{ top: '22%', left: '18%', width: '35%', height: '40%', background: 'rgba(239,68,68,0.12)', border: '1.5px solid rgba(239,68,68,0.4)', borderRadius: '30% 50% 40% 60%' }} />
<div className="absolute" style={{ top: '40%', left: '38%', width: '20%', height: '30%', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '50% 30% 60% 40%' }} />
</div>
<div className="absolute bottom-1.5 left-2.5 text-[9px] text-text-3 font-mono">34.7312°N, 127.6845°E</div>
<div className="absolute bottom-1.5 right-2.5 text-[9px] text-text-3 font-mono"> 1:2,500</div>
</div>
{/* Analysis Results */}
<div className="p-4 bg-bg-3 border border-border rounded-md">
<div className="text-xs font-bold mb-2.5 font-korean">📊 </div>
<div className="grid grid-cols-3 gap-2">
{[
{ value: '0.42 km²', label: '유막 면적', color: 'text-status-red' },
{ value: '12.6 kL', label: '추정 유출량', color: 'text-status-orange' },
{ value: '1.84 km²', label: '합성 영역 면적', color: 'text-primary-cyan' },
].map((r, i) => (
<div key={i} className="text-center py-2.5 px-2 bg-bg-0 border border-border rounded-sm">
<div className={`text-lg font-bold font-mono ${r.color}`}>{r.value}</div>
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{r.label}</div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-1.5 mt-2.5 text-[11px]">
{[
['두꺼운 유막 (>1mm)', '0.08 km²', 'text-status-red'],
['얇은 유막 (<1mm)', '0.34 km²', 'text-status-orange'],
['무지개 빛깔', '0.12 km²', 'text-status-yellow'],
['Bonn 코드', 'Code 3~4', 'text-text-1'],
].map(([label, value, color], i) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
<span className="text-text-3 font-korean">{label}</span>
<span className={`font-semibold font-mono ${color}`}>{value}</span>
</div>
))}
)}
</div>
</div>
</div>
</div>
)
);
}

파일 보기

@ -104,6 +104,33 @@ export async function createSatRequest(
return response.data;
}
export async function downloadAerialMedia(sn: number, fileName: string): Promise<void> {
const res = await api.get(`/aerial/media/${sn}/download`, { responseType: 'blob' });
const url = URL.createObjectURL(res.data as Blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* /aerial/stitch JPEG Blob을 .
* FastAPI /stitch pic_gps.py .
*/
export async function stitchImages(files: File[]): Promise<Blob> {
const form = new FormData();
files.forEach(f => form.append('files', f));
const response = await api.post<Blob>('/aerial/stitch', form, {
responseType: 'blob',
timeout: 310_000,
headers: { 'Content-Type': undefined }, // 기본 application/json 제거 → 브라우저가 multipart/form-data 자동 설정
});
return response.data;
}
// === DRONE STREAM ===
export interface DroneStreamItem {
id: string;

파일 보기

@ -10,13 +10,19 @@ export function LeftPanel({
selectedAnalysis,
enabledLayers,
onToggleLayer,
accidentTime,
onAccidentTimeChange,
incidentCoord,
onCoordChange,
isSelectingLocation,
onMapSelectClick,
onRunSimulation,
isRunningSimulation,
selectedModels,
onModelsChange,
visibleModels,
onVisibleModelsChange,
hasResults,
predictionTime,
onPredictionTimeChange,
spillType,
@ -25,6 +31,10 @@ export function LeftPanel({
onOilTypeChange,
spillAmount,
onSpillAmountChange,
incidentName,
onIncidentNameChange,
spillUnit,
onSpillUnitChange,
boomLines,
onBoomLinesChange,
oilTrajectory,
@ -40,12 +50,13 @@ export function LeftPanel({
onLayerOpacityChange,
layerBrightness,
onLayerBrightnessChange,
onImageAnalysisResult,
}: LeftPanelProps) {
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
predictionInput: true,
incident: false,
impactResources: false,
infoLayer: true,
infoLayer: false,
oilBoom: false,
})
@ -64,13 +75,19 @@ export function LeftPanel({
<PredictionInputSection
expanded={expandedSections.predictionInput}
onToggle={() => toggleSection('predictionInput')}
accidentTime={accidentTime}
onAccidentTimeChange={onAccidentTimeChange}
incidentCoord={incidentCoord}
onCoordChange={onCoordChange}
isSelectingLocation={isSelectingLocation}
onMapSelectClick={onMapSelectClick}
onRunSimulation={onRunSimulation}
isRunningSimulation={isRunningSimulation}
selectedModels={selectedModels}
onModelsChange={onModelsChange}
visibleModels={visibleModels}
onVisibleModelsChange={onVisibleModelsChange}
hasResults={hasResults}
predictionTime={predictionTime}
onPredictionTimeChange={onPredictionTimeChange}
spillType={spillType}
@ -79,6 +96,11 @@ export function LeftPanel({
onOilTypeChange={onOilTypeChange}
spillAmount={spillAmount}
onSpillAmountChange={onSpillAmountChange}
incidentName={incidentName}
onIncidentNameChange={onIncidentNameChange}
spillUnit={spillUnit}
onSpillUnitChange={onSpillUnitChange}
onImageAnalysisResult={onImageAnalysisResult}
/>
{/* Incident Section */}
@ -96,45 +118,73 @@ export function LeftPanel({
</div>
{expandedSections.incident && (
selectedAnalysis ? (
<div className="px-4 pb-4 space-y-3">
{/* Status Badge */}
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]">
<span className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
{(() => {
const statusMap: Record<string, { label: string; style: string; dot: string }> = {
ACTIVE: {
label: '진행중',
style: 'bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]',
dot: 'bg-status-red animate-pulse',
},
INVESTIGATING: {
label: '조사중',
style: 'bg-[rgba(249,115,22,0.15)] text-status-orange border border-[rgba(249,115,22,0.3)]',
dot: 'bg-status-orange animate-pulse',
},
CLOSED: {
label: '종료',
style: 'bg-[rgba(100,116,139,0.15)] text-text-3 border border-[rgba(100,116,139,0.3)]',
dot: 'bg-text-3',
},
}
const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE']
return (
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold ${s.style}`}>
<span className={`w-1.5 h-1.5 rounded-full ${s.dot}`} />
{s.label}
</div>
)
})()}
{/* Info Grid */}
<div className="grid gap-1">
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'}</span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.acdntSn}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.name || '씨프린스호'}</span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.acdntNm || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis?.occurredAt || '2025-02-10 06:30'}</span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.oilType || 'BUNKER_C'}</span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.oilType || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'}</span>
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : ''}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.analyst || '남해청, 방재과'}</span>
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.analyst || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis?.location || '여수 돌산 남방 5NM'}</span>
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis.location || '—'}</span>
</div>
</div>
</div>
) : (
<div className="px-4 pb-4">
<p className="text-[11px] text-text-3 font-korean text-center py-2"> .</p>
</div>
)
)}
</div>
@ -178,7 +228,7 @@ export function LeftPanel({
boomLines={boomLines}
onBoomLinesChange={onBoomLinesChange}
oilTrajectory={oilTrajectory}
incidentCoord={incidentCoord}
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
algorithmSettings={algorithmSettings}
onAlgorithmSettingsChange={onAlgorithmSettingsChange}
isDrawingBoom={isDrawingBoom}

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

파일 보기

@ -1,19 +1,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<PredictionModel>
onModelsChange: (models: Set<PredictionModel>) => void
visibleModels?: Set<PredictionModel>
onVisibleModelsChange?: (models: Set<PredictionModel>) => void
hasResults?: boolean
predictionTime: number
onPredictionTimeChange: (time: number) => void
spillType: string
@ -22,18 +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<string | null>(null)
const [uploadedFileName, setUploadedFileName] = useState<string>('')
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [analyzeError, setAnalyzeError] = useState<string | null>(null)
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setUploadedFileName(file.name)
const reader = new FileReader()
reader.onload = (event) => {
setUploadedImage(event.target?.result as string)
}
reader.readAsDataURL(file)
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null
setUploadedFile(file)
setAnalyzeError(null)
setAnalyzeResult(null)
}
const removeUploadedImage = () => {
setUploadedImage(null)
setUploadedFileName('')
const handleRemoveFile = () => {
setUploadedFile(null)
setAnalyzeError(null)
setAnalyzeResult(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleAnalyze = async () => {
if (!uploadedFile) return
setIsAnalyzing(true)
setAnalyzeError(null)
try {
const result = await analyzeImage(uploadedFile)
setAnalyzeResult(result)
onImageAnalysisResult?.(result)
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const res = (err as { response?: { data?: { error?: string } } }).response
if (res?.data?.error === 'GPS_NOT_FOUND') {
setAnalyzeError('GPS 정보가 없는 이미지입니다')
return
}
if (res?.data?.error === 'TIMEOUT') {
setAnalyzeError('분석 서버 응답 없음 (시간 초과)')
return
}
}
setAnalyzeError('이미지 분석 중 오류가 발생했습니다')
} finally {
setIsAnalyzing(false)
}
}
return (
@ -88,7 +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]"
/>
</label>
@ -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]"
/>
</label>
@ -107,43 +155,23 @@ const PredictionInputSection = ({
{/* Direct Input Mode */}
{inputMode === 'direct' && (
<>
<input className="prd-i" placeholder="사고명 직접 입력" />
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" />
<input
className="prd-i"
placeholder="사고명 직접 입력"
value={incidentName}
onChange={(e) => onIncidentNameChange(e.target.value)}
/>
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
</>
)}
{/* Image Upload Mode */}
{inputMode === 'upload' && (
<>
<input className="prd-i" placeholder="여수 유조선 충돌" />
<ComboBox
className="prd-i"
value=""
onChange={() => {}}
options={[
{ value: '', label: '여수 유조선 충돌 (INC-0042)' },
{ value: 'INC-0042', label: '여수 유조선 충돌 (INC-0042)' }
]}
placeholder="사고 선택"
/>
{/* Upload Success Message */}
{uploadedImage && (
<div className="flex items-center gap-[6px] text-[10px] font-semibold text-[#22c55e] rounded"
style={{
padding: '6px 8px',
background: 'rgba(34,197,94,0.1)',
border: '1px solid rgba(34,197,94,0.3)',
borderRadius: 'var(--rS)',
}}>
<span className="text-[12px]"></span>
</div>
)}
{/* File Upload Area */}
{!uploadedImage ? (
<label className="flex items-center justify-center text-[11px] text-text-3 cursor-pointer"
{/* 파일 선택 영역 */}
{!uploadedFile ? (
<label
className="flex items-center justify-center text-[11px] text-text-3 cursor-pointer"
style={{
padding: '20px',
background: 'var(--bg0)',
@ -158,66 +186,96 @@ const PredictionInputSection = ({
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--bd)'
e.currentTarget.style.background = 'var(--bg0)'
}}>
}}
>
📁
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
onChange={handleFileSelect}
className="hidden"
/>
</label>
) : (
<div className="flex items-center justify-between font-mono text-[10px] bg-bg-0 border border-border"
style={{
padding: '8px 10px',
borderRadius: 'var(--rS)',
}}>
<span className="text-text-2">📄 {uploadedFileName || 'example_plot_0.gif'}</span>
<div
className="flex items-center justify-between font-mono text-[10px] bg-bg-0 border border-border"
style={{ padding: '8px 10px', borderRadius: 'var(--rS)' }}
>
<span className="text-text-2">📄 {uploadedFile.name}</span>
<button
onClick={removeUploadedImage}
onClick={handleRemoveFile}
className="text-[10px] text-text-3 bg-transparent border-none cursor-pointer"
style={{ padding: '2px 6px', transition: '0.15s' }}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--red)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--t3)'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--red)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--t3)' }}
>
</button>
</div>
)}
{/* Dropdowns */}
<div className="grid grid-cols-2 gap-1">
<ComboBox
className="prd-i"
value=""
onChange={() => {}}
options={[
{ value: '', label: '유출회사' },
{ value: 'company1', label: '회사A' },
{ value: 'company2', label: '회사B' }
]}
placeholder="유출회사"
/>
<ComboBox
className="prd-i"
value=""
onChange={() => {}}
options={[
{ value: '', label: '예상시각' },
{ value: '09:00', label: '09:00' },
{ value: '12:00', label: '12:00' }
]}
placeholder="예상시각"
/>
{/* 분석 실행 버튼 */}
<button
className="prd-btn pri"
style={{ padding: '7px', fontSize: '11px' }}
onClick={handleAnalyze}
disabled={!uploadedFile || isAnalyzing}
>
{isAnalyzing ? '⏳ 분석 중...' : '🔍 이미지 분석 실행'}
</button>
{/* 에러 메시지 */}
{analyzeError && (
<div
className="text-[10px] font-semibold"
style={{
padding: '6px 8px',
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 'var(--rS)',
color: 'var(--red)',
}}
>
{analyzeError}
</div>
)}
{/* 분석 완료 메시지 */}
{analyzeResult && (
<div
className="text-[10px] font-semibold"
style={{
padding: '6px 8px',
background: 'rgba(34,197,94,0.1)',
border: '1px solid rgba(34,197,94,0.3)',
borderRadius: 'var(--rS)',
color: '#22c55e',
lineHeight: 1.6,
}}
>
<br />
<span className="font-normal text-text-3">
{analyzeResult.lat.toFixed(4)} / {analyzeResult.lon.toFixed(4)}<br />
: {analyzeResult.oilType} / : {analyzeResult.area.toFixed(1)} m²
</span>
</div>
)}
</>
)}
{/* 사고 발생 시각 */}
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-text-3 font-korean"> (KST)</label>
<input
className="prd-i"
type="datetime-local"
value={accidentTime}
onChange={(e) => onAccidentTimeChange(e.target.value)}
style={{ colorScheme: 'dark' }}
/>
</div>
{/* Coordinates + Map Button */}
<div className="flex flex-col gap-1">
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
@ -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="경도°"
/>
<button className="prd-map-btn" onClick={onMapSelectClick}>📍 </button>
<button
className={`prd-map-btn${isSelectingLocation ? ' active' : ''}`}
onClick={onMapSelectClick}
>📍 </button>
</div>
{/* 도분초 표시 */}
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
style={{
padding: '4px 8px',
borderRadius: 'var(--rS)',
}}>
<div
className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}
>
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
</div>
)}
@ -297,8 +357,8 @@ const PredictionInputSection = ({
/>
<ComboBox
className="prd-i"
value="kL"
onChange={() => {}}
value={spillUnit}
onChange={onSpillUnitChange}
options={[
{ value: 'kL', label: 'kL' },
{ value: 'ton', label: 'Ton' },
@ -319,23 +379,11 @@ const PredictionInputSection = ({
/>
</div>
{/* Image Analysis Note (Upload Mode Only) */}
{inputMode === 'upload' && uploadedImage && (
<div className="text-[9px] text-text-3 leading-[1.4]"
style={{
padding: '8px',
background: 'rgba(59,130,246,0.08)',
border: '1px solid rgba(59,130,246,0.2)',
borderRadius: 'var(--rS)',
}}>
📊 . .
</div>
)}
{/* Divider */}
<div className="h-px bg-border my-0.5" />
{/* Model Selection (다중 선택) */}
{/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
<div className="flex flex-wrap gap-[3px]">
{([
{ 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))
}
}}
>
<span className="prd-md" style={{ background: m.color }} />
{m.id}
</div>
))}
{/* OpenDrift ( )
<div
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
onClick={() => {
@ -372,8 +420,16 @@ const PredictionInputSection = ({
<span className="prd-md" style={{ background: 'var(--purple)' }} />
</div>
*/}
</div>
{/* 모델 미선택 경고 */}
{selectedModels.size === 0 && (
<p className="text-[10px] text-status-red font-korean">
.
</p>
)}
{/* Run Button */}
<button
className="prd-btn pri mt-0.5"

파일 보기

@ -1,27 +1,58 @@
import { useState } from 'react'
import type { PredictionDetail } from '../services/predictionApi'
import { analyzeSpillPolygon } from '@common/utils/geo'
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
import type { DisplayControls } from './OilSpillView'
interface AnalysisResult {
area: number
particleCount: number
particlePercent: number
sensitiveCount: number
}
interface RightPanelProps {
onOpenBacktrack?: () => void
onOpenRecalc?: () => void
onOpenReport?: () => void
detail?: PredictionDetail | null
oilTrajectory?: Array<{ lat: number; lon: number; time: number }>
summary?: SimulationSummary | null
displayControls?: DisplayControls
onDisplayControlsChange?: (controls: DisplayControls) => void
windHydrModel?: string
windHydrModelOptions?: string[]
onWindHydrModelChange?: (model: string) => void
analysisTab?: 'polygon' | 'circle'
onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void
drawAnalysisMode?: 'polygon' | null
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
circleRadiusNm?: number
onCircleRadiusChange?: (nm: number) => void
analysisResult?: AnalysisResult | null
incidentCoord?: { lat: number; lon: number } | null
onStartPolygonDraw?: () => void
onRunPolygonAnalysis?: () => void
onRunCircleAnalysis?: () => void
onCancelAnalysis?: () => void
onClearAnalysis?: () => void
}
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, oilTrajectory = [] }: RightPanelProps) {
export function RightPanel({
onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary,
displayControls, onDisplayControlsChange,
windHydrModel, windHydrModelOptions = [], onWindHydrModelChange,
analysisTab = 'polygon', onSwitchAnalysisTab,
drawAnalysisMode, analysisPolygonPoints = [],
circleRadiusNm = 5, onCircleRadiusChange,
analysisResult,
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
onCancelAnalysis, onClearAnalysis,
}: RightPanelProps) {
const vessel = detail?.vessels?.[0]
const vessel2 = detail?.vessels?.[1]
const spill = detail?.spill
const insurance = vessel?.insuranceData as Array<{ type: string; insurer: string; value: string; currency: string }> | null
const [shipExpanded, setShipExpanded] = useState(false)
const [insuranceExpanded, setInsuranceExpanded] = useState(false)
const [polygonResult, setPolygonResult] = useState<{ areaKm2: number; perimeterKm: number; particleCount: number; hullPoints: number } | null>(null)
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
const [circleRadiusNm, setCircleRadiusNm] = useState('5')
const [circleResult, setCircleResult] = useState<{ areaKm2: number; areaNm2: number; circumferenceKm: number; radiusNm: number } | null>(null)
const NM_PRESETS = [1, 3, 5, 10, 15, 20, 30, 50]
return (
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
@ -38,167 +69,152 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
{/* 표시 정보 제어 */}
<Section title="표시 정보 제어">
<div className="grid grid-cols-2 gap-x-2.5 gap-y-1">
<CheckboxLabel checked>/</CheckboxLabel>
<CheckboxLabel checked>/</CheckboxLabel>
<CheckboxLabel></CheckboxLabel>
<CheckboxLabel></CheckboxLabel>
<CheckboxLabel> </CheckboxLabel>
<CheckboxLabel></CheckboxLabel>
<ControlledCheckbox
checked={displayControls?.showCurrent ?? true}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showCurrent: v })}
>/</ControlledCheckbox>
<ControlledCheckbox
checked={displayControls?.showWind ?? true}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showWind: v })}
>/</ControlledCheckbox>
<ControlledCheckbox
checked={displayControls?.showBeached ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
></ControlledCheckbox>
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
</ControlledCheckbox>
<ControlledCheckbox
checked={displayControls?.showTimeLabel ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })}
> </ControlledCheckbox>
</div>
{windHydrModelOptions.length > 1 && (
<div className="flex items-center gap-2 mt-1.5">
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap"> </span>
<select
value={windHydrModel}
onChange={e => onWindHydrModelChange?.(e.target.value)}
className="flex-1 text-[9px] bg-bg-3 border border-border rounded px-1 py-0.5 text-text-2 font-korean"
>
{windHydrModelOptions.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
)}
</Section>
{/* 오염분석 */}
<Section title="오염분석">
{/* 탭 버튼 */}
<div className="flex gap-1.5 mb-2.5">
{/* 탭 전환 */}
<div className="flex gap-[3px] mb-2">
{(['polygon', 'circle'] as const).map((tab) => (
<button
onClick={() => setAnalysisTab('polygon')}
className="flex-1 py-1.5 px-2 rounded text-[10px] font-semibold font-korean cursor-pointer transition-colors"
style={analysisTab === 'polygon'
? { background: 'rgba(6,182,212,0.12)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
: { background: 'var(--bg0)', border: '1px solid var(--bd)', color: 'var(--t3)' }
}
> </button>
<button
onClick={() => setAnalysisTab('circle')}
className="flex-1 py-1.5 px-2 rounded text-[10px] font-semibold font-korean cursor-pointer transition-colors"
style={analysisTab === 'circle'
? { background: 'rgba(6,182,212,0.12)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
: { background: 'var(--bg0)', border: '1px solid var(--bd)', color: 'var(--t3)' }
}
> </button>
</div>
{/* ── 다각형 분석 탭 ── */}
{analysisTab === 'polygon' && (
<>
<p className="text-[9px] text-text-3 font-korean leading-relaxed mb-2">
.
</p>
<button
onClick={() => {
if (oilTrajectory.length < 3) {
alert('확산 예측을 먼저 실행하세요.')
return
}
const result = analyzeSpillPolygon(oilTrajectory)
setPolygonResult({ areaKm2: result.areaKm2, perimeterKm: result.perimeterKm, particleCount: result.particleCount, hullPoints: result.hull.length })
}}
className="w-full py-2 px-3 rounded text-[10px] font-bold font-korean cursor-pointer"
style={{ background: 'linear-gradient(to right, #a855f7, var(--cyan))', color: '#fff' }}
key={tab}
onClick={() => { onSwitchAnalysisTab?.(tab); onClearAnalysis?.() }}
className={`flex-1 py-1.5 px-1 rounded text-[9px] font-semibold font-korean border transition-colors ${
analysisTab === tab
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)] text-primary-cyan'
: 'border-border bg-bg-3 text-text-3 hover:text-text-2'
}`}
>
📐
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
</button>
</>
)}
{/* ── 원 분석 탭 ── */}
{analysisTab === 'circle' && (
<>
<p className="text-[9px] text-text-3 font-korean leading-relaxed mb-2">
(NM) .
</p>
{/* 반경 선택 (NM) */}
<div className="text-[9px] font-bold text-text-2 font-korean mb-1.5"> (NM)</div>
<div className="grid grid-cols-6 gap-1 mb-2">
{NM_PRESETS.map(nm => (
<button
key={nm}
onClick={() => setCircleRadiusNm(String(nm))}
className="py-1.5 rounded text-[10px] font-bold font-mono cursor-pointer transition-colors"
style={parseFloat(circleRadiusNm) === nm
? { background: 'rgba(6,182,212,0.2)', border: '1px solid var(--cyan)', color: 'var(--cyan)' }
: { background: 'var(--bg0)', border: '1px solid var(--bd)', color: 'var(--t3)' }
}
>{nm}</button>
))}
</div>
{/* 직접 입력 + 분석 실행 */}
<div className="flex items-center gap-2">
<span className="text-[9px] text-text-3 font-korean shrink-0"> </span>
{/* 다각형 패널 */}
{analysisTab === 'polygon' && (
<div>
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
.
</p>
{!drawAnalysisMode && !analysisResult && (
<button
onClick={onStartPolygonDraw}
className="w-full py-2 rounded text-[10px] font-bold font-korean text-white mb-0 transition-opacity hover:opacity-90"
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
>
📐
</button>
)}
{drawAnalysisMode === 'polygon' && (
<div className="space-y-2">
<div className="text-[9px] text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
<br />
<span className="text-text-3"> {analysisPolygonPoints.length} </span>
</div>
<div className="flex gap-1.5">
<button
onClick={onRunPolygonAnalysis}
disabled={analysisPolygonPoints.length < 3}
className="flex-1 py-1.5 rounded text-[10px] font-bold font-korean text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
>
</button>
<button
onClick={onCancelAnalysis}
className="py-1.5 px-2 rounded text-[10px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
>
</button>
</div>
</div>
)}
{analysisResult && !drawAnalysisMode && (
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} onRerun={onStartPolygonDraw} />
)}
</div>
)}
{/* 원 분석 패널 */}
{analysisTab === 'circle' && (
<div>
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
(NM) .
</p>
<div className="text-[9px] font-semibold text-text-2 font-korean mb-1.5"> (NM)</div>
<div className="flex flex-wrap gap-1 mb-2">
{[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => (
<button
key={nm}
onClick={() => onCircleRadiusChange?.(nm)}
className={`w-8 h-7 rounded text-[10px] font-semibold font-mono border transition-all ${
circleRadiusNm === nm
? 'border-primary-cyan bg-[rgba(6,182,212,0.1)] text-primary-cyan'
: 'border-border bg-bg-0 text-text-3 hover:text-text-2'
}`}
>
{nm}
</button>
))}
</div>
<div className="flex items-center gap-1.5 mb-2.5">
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap"> </span>
<input
type="number"
step="0.1"
min="0.1"
max="100"
step="0.1"
value={circleRadiusNm}
onChange={e => setCircleRadiusNm(e.target.value)}
className="w-[60px] px-2 py-1.5 bg-bg-3 border border-border rounded text-[10px] font-mono text-text-1 text-center outline-none focus:border-[var(--cyan)] transition-colors"
onChange={(e) => onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)}
className="w-14 text-center py-1 px-1 bg-bg-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan"
style={{ colorScheme: 'dark' }}
/>
<span className="text-[9px] text-text-3 font-korean shrink-0">NM</span>
<span className="text-[9px] text-text-3 font-korean">NM</span>
<button
onClick={() => {
const nm = parseFloat(circleRadiusNm)
if (isNaN(nm) || nm <= 0) {
alert('반경을 올바르게 입력하세요.')
return
}
const km = nm * 1.852
const areaKm2 = Math.PI * km * km
const areaNm2 = Math.PI * nm * nm
const circumferenceKm = 2 * Math.PI * km
setCircleResult({ areaKm2, areaNm2, circumferenceKm, radiusNm: nm })
}}
disabled={!circleRadiusNm || parseFloat(circleRadiusNm) <= 0}
className="ml-auto px-3 py-1.5 rounded text-[10px] font-bold font-korean cursor-pointer shrink-0 transition-colors"
style={{
background: 'rgba(6,182,212,0.15)',
border: '1px solid var(--cyan)',
color: 'var(--cyan)',
}}
> </button>
onClick={onRunCircleAnalysis}
className="ml-auto py-1 px-3 rounded text-[9px] font-bold font-korean text-white transition-opacity hover:opacity-90"
style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}
>
</button>
</div>
</>
{analysisResult && (
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} radiusNm={circleRadiusNm} />
)}
{/* 원 분석 결과 */}
{analysisTab === 'circle' && circleResult && (
<div className="flex flex-col gap-1.5 mt-2.5">
<div className="text-[9px] font-bold text-primary-cyan font-korean"> ( {circleResult.radiusNm} NM)</div>
<div className="grid grid-cols-2 gap-1">
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-primary-cyan">{circleResult.areaNm2.toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (NM²)</div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-status-orange">{circleResult.areaKm2.toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km²)</div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-text-1">{circleResult.circumferenceKm.toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km)</div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-text-1">{(circleResult.radiusNm * 1.852).toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km)</div>
</div>
</div>
</div>
)}
{/* 다각형 분석 결과 */}
{analysisTab === 'polygon' && polygonResult && (
<div className="flex flex-col gap-1.5">
<div className="text-[9px] font-bold text-purple-400 font-korean mb-0.5">📐 Convex Hull </div>
<div className="grid grid-cols-2 gap-1">
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-purple-400">{polygonResult.areaKm2.toFixed(2)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km²)</div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-primary-cyan">{polygonResult.perimeterKm.toFixed(1)}</div>
<div className="text-[7px] text-text-3 font-korean"> (km)</div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-status-orange">{polygonResult.particleCount.toLocaleString()}</div>
<div className="text-[7px] text-text-3 font-korean"> </div>
</div>
<div className="text-center py-2 px-1 bg-bg-0 border border-border rounded">
<div className="text-sm font-extrabold font-mono text-text-1">{polygonResult.hullPoints}</div>
<div className="text-[7px] text-text-3 font-korean"> </div>
</div>
</div>
</div>
)}
</Section>
@ -207,11 +223,11 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
<Section title="오염 종합 상황" badge="위험" badgeColor="red">
<div className="grid grid-cols-2 gap-0.5 text-[9px]">
<StatBox label="유출량" value={spill?.volume != null ? spill.volume.toFixed(2) : '—'} unit={spill?.unit || 'kl'} color="var(--t1)" />
<StatBox label="풍화량" value="0.43" unit="kl" color="var(--orange)" />
<StatBox label="해상잔존" value="9.57" unit="kl" color="var(--blue)" />
<StatBox label="연안부착" value="0.00" unit="kl" color="var(--red)" />
<StatBox label="풍화량" value={summary ? summary.weatheredVolume.toFixed(2) : '—'} unit="m³" color="var(--orange)" />
<StatBox label="해상잔존" value={summary ? summary.remainingVolume.toFixed(2) : '—'} unit="m³" color="var(--blue)" />
<StatBox label="연안부착" value={summary ? summary.beachedVolume.toFixed(2) : '—'} unit="m³" color="var(--red)" />
<div className="col-span-2">
<StatBox label="오염해역면적" value="8.56" unit="㎢" color="var(--cyan)" />
<StatBox label="오염해역면적" value={summary ? summary.pollutionArea.toFixed(2) : '—'} unit="km²" color="var(--cyan)" />
</div>
</div>
</Section>
@ -383,17 +399,33 @@ function Section({
)
}
function CheckboxLabel({ checked, children }: { checked?: boolean; children: string }) {
function ControlledCheckbox({
checked,
onChange,
children,
disabled = false,
}: {
checked: boolean;
onChange: (v: boolean) => void;
children: string;
disabled?: boolean;
}) {
return (
<label className="flex items-center gap-1.5 text-[10px] text-text-2 font-korean cursor-pointer">
<label
className={`flex items-center gap-1.5 text-[10px] font-korean cursor-pointer ${
disabled ? 'text-text-3 cursor-not-allowed opacity-40' : 'text-text-2'
}`}
>
<input
type="checkbox"
defaultChecked={checked}
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="w-[13px] h-[13px] accent-[var(--cyan)]"
/>
{children}
</label>
)
);
}
function StatBox({
@ -580,3 +612,78 @@ function InsuranceCard({
</div>
)
}
function PollResult({
result,
summary,
onClear,
onRerun,
radiusNm,
}: {
result: AnalysisResult
summary?: SimulationSummary | null
onClear?: () => void
onRerun?: () => void
radiusNm?: number
}) {
const pollutedArea = (result.area * result.particlePercent / 100).toFixed(2)
return (
<div className="mt-1 p-2.5 bg-bg-0 border border-[rgba(168,85,247,0.2)] rounded-md" style={{ position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, var(--purple), var(--cyan))' }} />
{radiusNm && (
<div className="flex justify-between items-center mb-2">
<span className="text-[10px] font-semibold text-text-1 font-korean"> </span>
<span className="text-[9px] font-semibold text-primary-cyan font-mono"> {radiusNm} NM</span>
</div>
)}
<div className="grid grid-cols-3 gap-1 mb-2">
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--red)' }}>{result.area.toFixed(2)}</div>
<div className="text-[7px] text-text-3 font-korean mt-0.5">(km²)</div>
</div>
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--orange)' }}>{result.particlePercent}%</div>
<div className="text-[7px] text-text-3 font-korean mt-0.5"></div>
</div>
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--cyan)' }}>{pollutedArea}</div>
<div className="text-[7px] text-text-3 font-korean mt-0.5">(km²)</div>
</div>
</div>
<div className="space-y-1 text-[9px] font-korean">
{summary && (
<div className="flex justify-between">
<span className="text-text-3"></span>
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
</div>
)}
{summary && (
<div className="flex justify-between">
<span className="text-text-3"></span>
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
</div>
)}
<div className="flex justify-between">
<span className="text-text-3"> </span>
<span className="font-semibold font-mono" style={{ color: 'var(--orange)' }}>{result.sensitiveCount}</span>
</div>
</div>
<div className="flex gap-1.5 mt-2">
<button
onClick={onClear}
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
>
</button>
{onRerun && (
<button
onClick={onRerun}
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-[rgba(168,85,247,0.3)] text-purple-400 hover:bg-[rgba(168,85,247,0.08)] transition-colors"
>
</button>
)}
</div>
</div>
)
}

파일 보기

@ -0,0 +1,110 @@
interface SimulationErrorModalProps {
message: string;
onClose: () => void;
}
const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) => {
return (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 50,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(10, 14, 26, 0.75)',
backdropFilter: 'blur(4px)',
}}
>
<div
style={{
width: 360,
background: 'var(--bg1)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 'var(--rM)',
padding: '28px 24px',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{/* 아이콘 + 제목 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(239, 68, 68, 0.12)',
border: '1px solid rgba(239, 68, 68, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
fill="rgb(239, 68, 68)"
opacity="0.9"
/>
</svg>
</div>
<div>
<div style={{ color: 'var(--t1)', fontSize: 14, fontWeight: 600 }}>
</div>
<div style={{ color: 'var(--t3)', fontSize: 12, marginTop: 2 }}>
</div>
</div>
</div>
{/* 에러 메시지 */}
<div
style={{
background: 'rgba(239, 68, 68, 0.06)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: 'var(--rS)',
padding: '10px 14px',
color: 'rgb(252, 165, 165)',
fontSize: 13,
lineHeight: 1.6,
wordBreak: 'break-word',
}}
>
{message}
</div>
{/* 확인 버튼 */}
<button
onClick={onClose}
style={{
marginTop: 4,
padding: '8px 0',
background: 'rgba(239, 68, 68, 0.15)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: 'var(--rS)',
color: 'rgb(252, 165, 165)',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.25)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.15)';
}}
>
</button>
</div>
</div>
);
};
export default SimulationErrorModal;

파일 보기

@ -0,0 +1,123 @@
interface SimulationLoadingOverlayProps {
status: 'PENDING' | 'RUNNING';
progress?: number;
}
const SimulationLoadingOverlay = ({ status, progress }: SimulationLoadingOverlayProps) => {
const displayProgress = progress ?? 0;
const statusText = status === 'PENDING' ? '모델 초기화 중...' : '입자 추적 계산 중...';
return (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 50,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(10, 14, 26, 0.75)',
backdropFilter: 'blur(4px)',
}}
>
<div
style={{
width: 320,
background: 'var(--bg1)',
border: '1px solid var(--bd)',
borderRadius: 'var(--rM)',
padding: '28px 24px',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{/* 아이콘 + 제목 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(6, 182, 212, 0.12)',
border: '1px solid rgba(6, 182, 212, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14l-4-4 1.41-1.41L11 13.17l6.59-6.59L19 8l-8 8z"
fill="var(--cyan)"
opacity="0.8"
/>
</svg>
</div>
<div>
<div style={{ color: 'var(--t1)', fontSize: 14, fontWeight: 600 }}>
</div>
<div style={{ color: 'var(--t3)', fontSize: 12, marginTop: 2 }}>
{statusText}
</div>
</div>
</div>
{/* 진행률 바 */}
<div>
<div
style={{
height: 6,
background: 'rgba(255, 255, 255, 0.06)',
borderRadius: 999,
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${displayProgress}%`,
background: 'linear-gradient(90deg, var(--cyan), var(--blue))',
borderRadius: 999,
transition: 'width 0.6s ease',
}}
/>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: 8,
}}
>
<span style={{ color: 'var(--t3)', fontSize: 11 }}>
{status === 'PENDING' ? '대기 중' : '분석 진행 중'}
</span>
<span style={{ color: 'var(--cyan)', fontSize: 12, fontWeight: 600 }}>
{status === 'PENDING' ? '—' : `${displayProgress}%`}
</span>
</div>
</div>
{/* 안내 문구 */}
<div
style={{
color: 'var(--t3)',
fontSize: 11,
lineHeight: 1.6,
borderTop: '1px solid var(--bdL)',
paddingTop: 12,
}}
>
OpenDrift .
<br />
.
</div>
</div>
</div>
);
};
export default SimulationLoadingOverlay;

파일 보기

@ -1,18 +1,25 @@
import type { PredictionModel } from './OilSpillView'
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
import type { Analysis } from './AnalysisListTable'
import type { ImageAnalyzeResult } from '../services/predictionApi'
export interface LeftPanelProps {
selectedAnalysis?: Analysis | null
enabledLayers: Set<string>
onToggleLayer: (layerId: string, enabled: boolean) => void
incidentCoord: { lon: number; lat: number }
accidentTime: string
onAccidentTimeChange: (time: string) => void
incidentCoord: { lon: number; lat: number } | null
onCoordChange: (coord: { lon: number; lat: number }) => void
isSelectingLocation: boolean
onMapSelectClick: () => void
onRunSimulation: () => void
isRunningSimulation: boolean
selectedModels: Set<PredictionModel>
onModelsChange: (models: Set<PredictionModel>) => void
visibleModels?: Set<PredictionModel>
onVisibleModelsChange?: (models: Set<PredictionModel>) => void
hasResults?: boolean
predictionTime: number
onPredictionTimeChange: (time: number) => void
spillType: string
@ -21,6 +28,10 @@ export interface LeftPanelProps {
onOilTypeChange: (type: string) => void
spillAmount: number
onSpillAmountChange: (amount: number) => void
incidentName: string
onIncidentNameChange: (name: string) => void
spillUnit: string
onSpillUnitChange: (unit: string) => void
// 오일펜스 배치 관련
boomLines: BoomLine[]
onBoomLinesChange: (lines: BoomLine[]) => void
@ -38,6 +49,8 @@ export interface LeftPanelProps {
onLayerOpacityChange: (val: number) => void
layerBrightness: number
onLayerBrightnessChange: (val: number) => void
// 이미지 분석 결과 콜백
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
}
export interface ExpandedSections {

파일 보기

@ -18,6 +18,7 @@ export interface PredictionAnalysis {
backtrackStatus: string;
analyst: string;
officeName: string;
acdntSttsCd: string;
}
export interface PredictionDetail {
@ -115,3 +116,128 @@ export const createBacktrack = async (input: {
const response = await api.post<{ backtrackSn: number }>('/prediction/backtrack', input);
return response.data;
};
// ============================================================
// 확산 예측 시뮬레이션 (OpenDrift 연동)
// ============================================================
export interface SimulationRunResponse {
success: boolean;
execSn: number; // 하위 호환 유지 (첫 번째 모델의 execSn)
execSns: Array<{ model: string; execSn: number }>;
acdntSn: number | null;
status: 'RUNNING';
}
export interface WindPoint {
lat: number;
lon: number;
wind_speed: number;
wind_direction: number;
}
export interface HydrGrid {
lonInterval: number[];
boundLonLat: { top: number; bottom: number; left: number; right: number };
rows: number;
cols: number;
latInterval: number[];
}
export interface HydrDataStep {
value: [number[][], number[][]]; // [u_2d, v_2d]
grid: HydrGrid;
}
export interface CenterPoint {
lat: number;
lon: number;
time: number;
model?: string;
}
export interface OilParticle {
lat: number;
lon: number;
time: number;
particle?: number;
stranded?: 0 | 1;
model?: string;
}
export interface SimulationSummary {
remainingVolume: number;
weatheredVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
}
export interface SimulationStatusResponse {
status: 'PENDING' | 'RUNNING' | 'DONE' | 'ERROR';
progress?: number;
trajectory?: OilParticle[];
summary?: SimulationSummary;
centerPoints?: CenterPoint[];
windData?: WindPoint[][];
hydrData?: (HydrDataStep | null)[];
error?: string;
}
export interface RunModelSyncResult {
model: string;
execSn: number;
status: 'DONE' | 'ERROR';
trajectory?: OilParticle[];
summary?: SimulationSummary;
centerPoints?: CenterPoint[];
windData?: WindPoint[][];
hydrData?: (HydrDataStep | null)[];
error?: string;
}
export interface RunModelSyncResponse {
success: boolean;
acdntSn: number | null;
execSns: Array<{ model: string; execSn: number }>;
results: RunModelSyncResult[];
}
export interface TrajectoryResponse {
trajectory: OilParticle[] | null;
summary: SimulationSummary | null;
centerPoints?: CenterPoint[];
windDataByModel?: Record<string, WindPoint[][]>;
hydrDataByModel?: Record<string, (HydrDataStep | null)[]>;
summaryByModel?: Record<string, SimulationSummary>;
}
export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<TrajectoryResponse> => {
const response = await api.get<TrajectoryResponse>(`/prediction/analyses/${acdntSn}/trajectory`);
return response.data;
};
// ============================================================
// 이미지 업로드 분석
// ============================================================
export interface ImageAnalyzeResult {
acdntSn: number;
lat: number;
lon: number;
oilType: string;
area: number;
volume: number;
fileId: string;
occurredAt: string;
}
export const analyzeImage = async (file: File): Promise<ImageAnalyzeResult> => {
const formData = new FormData();
formData.append('image', file);
const response = await api.post<ImageAnalyzeResult>('/prediction/image-analyze', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 330_000,
});
return response.data;
};

파일 보기

@ -38,6 +38,8 @@ export interface OilSpillReportData {
etcEquipment: string
recovery: { shipName: string; period: string }[]
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
capturedMapImage?: string;
hasMapCapture?: boolean;
}
// eslint-disable-next-line react-refresh/only-export-components

파일 보기

@ -0,0 +1,107 @@
import { useRef, useState } from 'react';
import { MapView } from '@common/components/map/MapView';
import type { OilReportPayload } from '@common/hooks/useSubMenu';
interface OilSpreadMapPanelProps {
mapData: OilReportPayload['mapData'];
capturedImage: string | null;
onCapture: (dataUrl: string) => void;
onReset: () => void;
}
const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSpreadMapPanelProps) => {
const captureRef = useRef<(() => Promise<string | null>) | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const handleCapture = async () => {
if (!captureRef.current) return;
setIsCapturing(true);
const dataUrl = await captureRef.current();
setIsCapturing(false);
if (dataUrl) {
onCapture(dataUrl);
}
};
if (!mapData) {
return (
<div className="w-full h-[280px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
. .
</div>
);
}
return (
<div className="mb-4">
{/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */}
<div className="relative w-full rounded-lg border border-border overflow-hidden" style={{ height: '620px' }}>
<MapView
center={mapData.center}
zoom={mapData.zoom}
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
oilTrajectory={mapData.trajectory}
externalCurrentTime={mapData.currentStep}
centerPoints={mapData.centerPoints}
showBeached={true}
showTimeLabel={true}
simulationStartTime={mapData.simulationStartTime || undefined}
mapCaptureRef={captureRef}
showOverlays={false}
lightMode
/>
{/* 캡처 이미지 오버레이 — 우측 상단 */}
{capturedImage && (
<div className="absolute top-3 right-3 z-10" style={{ width: '220px' }}>
<div
className="rounded-lg overflow-hidden"
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
>
<img src={capturedImage} alt="확산예측 지도 캡처" className="w-full block" />
<div
className="flex items-center justify-between px-2.5 py-1.5"
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
>
<span className="text-[10px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
📷
</span>
<button
onClick={onReset}
className="text-[10px] font-korean hover:text-text-1 transition-colors"
style={{ color: 'rgba(148,163,184,0.8)' }}
>
</button>
</div>
</div>
</div>
)}
</div>
{/* 하단 안내 + 캡처 버튼 */}
<div className="flex items-center justify-between mt-2">
<p className="text-[10px] text-text-3 font-korean">
{capturedImage
? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.'
: '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'}
</p>
<button
onClick={handleCapture}
disabled={isCapturing || !!capturedImage}
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
style={{
background: capturedImage ? 'rgba(6,182,212,0.06)' : 'rgba(6,182,212,0.12)',
border: '1px solid rgba(6,182,212,0.4)',
color: capturedImage ? 'rgba(6,182,212,0.5)' : '#06b6d4',
opacity: isCapturing ? 0.6 : 1,
cursor: capturedImage ? 'default' : 'pointer',
}}
>
{capturedImage ? '✓ 캡처됨' : '📷 이 범위로 캡처'}
</button>
</div>
</div>
);
};
export default OilSpreadMapPanel;

파일 보기

@ -2,13 +2,11 @@ import { useState, useEffect } from 'react';
import {
createEmptyReport,
} from './OilSpillReportTemplate';
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload } from '@common/hooks/useSubMenu';
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
import OilSpreadMapPanel from './OilSpreadMapPanel';
import { saveReport } from '../services/reportsApi';
import {
CATEGORIES,
sampleOilData,
sampleHnsData,
sampleRescueData,
type ReportCategory,
type ReportSection,
} from './reportTypes';
@ -32,6 +30,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
// HNS 실 데이터 (없으면 sampleHnsData fallback)
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
// OIL 실 데이터 (없으면 sampleOilData fallback)
const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(null)
// 확산예측 지도 캡처 이미지
const [oilMapCaptured, setOilMapCaptured] = useState<string | null>(null)
// 외부에서 카테고리 힌트가 변경되면 반영
useEffect(() => {
@ -44,6 +46,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
// HNS 데이터 소비
const payload = consumeHnsReportPayload()
if (payload) setHnsPayload(payload)
// OIL 예측 데이터 소비
const oilData = consumeOilReportPayload()
if (oilData) setOilPayload(oilData)
}, [])
const cat = CATEGORIES[activeCat]
@ -65,8 +70,22 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
report.status = '완료'
report.author = '시스템 자동생성'
if (activeCat === 0) {
report.incident.pollutant = sampleOilData.pollution.oilType
report.incident.spillAmount = sampleOilData.pollution.spillAmount
if (oilPayload) {
report.incident.name = oilPayload.incident.name;
report.incident.occurTime = oilPayload.incident.occurTime;
report.incident.location = oilPayload.incident.location;
report.incident.lat = String(oilPayload.incident.lat ?? '');
report.incident.lon = String(oilPayload.incident.lon ?? '');
report.incident.shipName = oilPayload.incident.shipName;
report.incident.pollutant = oilPayload.pollution.oilType;
report.incident.spillAmount = oilPayload.pollution.spillAmount;
} else {
report.incident.pollutant = '';
report.incident.spillAmount = '';
}
}
if (activeCat === 0 && oilMapCaptured) {
report.capturedMapImage = oilMapCaptured;
}
try {
await saveReport(report)
@ -80,7 +99,52 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
const handleDownload = () => {
const secColor = cat.color === 'var(--cyan)' ? '#06b6d4' : cat.color === 'var(--orange)' ? '#f97316' : '#ef4444';
const sectionHTML = activeSections.map(sec => {
let content = `<p style="font-size:12px;color:#666;">${sec.desc}</p>`;
let content = `<p style="font-size:12px;color:#999;">—</p>`;
// OIL 섹션에 실 데이터 삽입
if (activeCat === 0) {
if (sec.id === 'oil-spread') {
const mapImg = oilMapCaptured
? `<img src="${oilMapCaptured}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:8px;margin-bottom:12px;" />`
: '<div style="height:60px;background:#f5f5f5;border:1px solid #ddd;border-radius:8px;display:flex;align-items:center;justify-content:center;color:#999;margin-bottom:12px;font-size:12px;">[확산예측 지도 미캡처]</div>';
const spreadRows = oilPayload
? [
['KOSPS', oilPayload.spread.kosps],
['OpenDrift', oilPayload.spread.openDrift],
['POSEIDON', oilPayload.spread.poseidon],
]
: [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']];
const tds = spreadRows.map(r =>
`<td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>${r[0]}</b><br/>${r[1]}</td>`
).join('');
content = `${mapImg}<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr>${tds}</tr></table>`;
}
}
if (activeCat === 0 && sec.id === 'oil-coastal') {
if (oilPayload) {
const coastLength = oilPayload.pollution.coastLength;
const hasNoCoastal = !coastLength || coastLength === '—' || coastLength.startsWith('0.00');
content = hasNoCoastal
? `<p style="font-size:12px;">유출유의 해안 부착이 없습니다.</p>`
: `<p style="font-size:12px;">최초 부착시간: <b>${oilPayload.coastal?.firstTime ?? '—'}</b> / 부착 해안길이: <b>${coastLength}</b></p>`;
}
}
if (activeCat === 0 && oilPayload) {
if (sec.id === 'oil-pollution') {
const rows = [
['유출량', oilPayload.pollution.spillAmount, '풍화량', oilPayload.pollution.weathered],
['해상잔유량', oilPayload.pollution.seaRemain, '오염해역면적', oilPayload.pollution.pollutionArea],
['연안부착량', oilPayload.pollution.coastAttach, '오염해안길이', oilPayload.pollution.coastLength],
];
const simBanner = !oilPayload.hasSimulation
? '<p style="font-size:10px;color:#f97316;margin-bottom:8px;">시뮬레이션이 실행되지 않아 오염량은 입력값 기준으로 표시됩니다.</p>'
: '';
const trs = rows.map(r =>
`<tr><td style="padding:6px 8px;border:1px solid #ddd;color:#888;">${r[0]}</td><td style="padding:6px 8px;border:1px solid #ddd;font-weight:bold;text-align:right;">${r[1]}</td><td style="padding:6px 8px;border:1px solid #ddd;color:#888;">${r[2]}</td><td style="padding:6px 8px;border:1px solid #ddd;font-weight:bold;text-align:right;">${r[3]}</td></tr>`
).join('');
content = `${simBanner}<table style="width:100%;border-collapse:collapse;font-size:12px;">${trs}</table>`;
}
}
// HNS 섹션에 실 데이터 삽입
if (activeCat === 1 && hnsPayload) {
@ -256,14 +320,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{/* ── 유출유 확산예측 섹션들 ── */}
{sec.id === 'oil-spread' && (
<>
<div className="w-full h-[140px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
[ - ]
</div>
<OilSpreadMapPanel
mapData={oilPayload?.mapData ?? null}
capturedImage={oilMapCaptured}
onCapture={(dataUrl) => setOilMapCaptured(dataUrl)}
onReset={() => setOilMapCaptured(null)}
/>
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'KOSPS', value: sampleOilData.spread.kosps, color: '#06b6d4' },
{ label: 'OpenDrift', value: sampleOilData.spread.openDrift, color: '#ef4444' },
{ label: 'POSEIDON', value: sampleOilData.spread.poseidon, color: '#f97316' },
{ label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' },
{ label: 'OpenDrift', value: oilPayload?.spread.openDrift || '—', color: '#ef4444' },
{ label: 'POSEIDON', value: oilPayload?.spread.poseidon || '—', color: '#f97316' },
].map((m, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[10px] text-text-3 font-korean mb-1">{m.label}</p>
@ -274,13 +341,19 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</>
)}
{sec.id === 'oil-pollution' && (
<>
{oilPayload && !oilPayload.hasSimulation && (
<div className="mb-3 px-3 py-2 rounded text-[10px] font-korean" style={{ background: 'rgba(249,115,22,0.08)', border: '1px solid rgba(249,115,22,0.3)', color: '#f97316' }}>
.
</div>
)}
<table className="w-full table-fixed border-collapse">
<colgroup><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /></colgroup>
<tbody>
{[
['유출량', sampleOilData.pollution.spillAmount, '풍화량', sampleOilData.pollution.weathered],
['해상잔유량', sampleOilData.pollution.seaRemain, '오염해역면적', sampleOilData.pollution.pollutionArea],
['연안부착량', sampleOilData.pollution.coastAttach, '오염해안길이', sampleOilData.pollution.coastLength],
['유출량', oilPayload?.pollution.spillAmount || '—', '풍화량', oilPayload?.pollution.weathered || '—'],
['해상잔유량', oilPayload?.pollution.seaRemain || '—', '오염해역면적', oilPayload?.pollution.pollutionArea || '—'],
['연안부착량', oilPayload?.pollution.coastAttach || '—', '오염해안길이', oilPayload?.pollution.coastLength || '—'],
].map((row, i) => (
<tr key={i} className="border-b border-border">
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[0]}</td>
@ -291,24 +364,38 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
))}
</tbody>
</table>
)}
{sec.id === 'oil-sensitive' && (
<>
<p className="text-[11px] text-text-3 font-korean mb-3"> 10 NM </p>
<div className="flex flex-wrap gap-2">
{sampleOilData.sensitive.map((item, i) => (
<span key={i} className="px-3 py-1.5 text-[11px] font-semibold rounded-md bg-bg-3 border border-border text-text-2 font-korean">{item.label}</span>
))}
</div>
</>
)}
{sec.id === 'oil-coastal' && (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{sampleOilData.coastal.firstTime}</span>
{' / '}
: <span className="font-semibold text-text-1">{sampleOilData.coastal.coastLength}</span>
{sec.id === 'oil-sensitive' && (
<p className="text-[12px] text-text-3 font-korean italic">
.
</p>
)}
{sec.id === 'oil-coastal' && (() => {
if (!oilPayload) {
return (
<p className="text-[12px] text-text-3 font-korean italic">
.
</p>
);
}
const coastLength = oilPayload.pollution.coastLength;
const hasNoCoastal = !coastLength || coastLength === '—' || coastLength.startsWith('0.00');
if (hasNoCoastal) {
return (
<p className="text-[12px] text-text-2 font-korean">
<span className="font-semibold text-text-1"> </span>.
</p>
);
}
return (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{oilPayload.coastal?.firstTime ?? '—'}</span>
{' / '}
: <span className="font-semibold text-text-1">{coastLength}</span>
</p>
);
})()}
{sec.id === 'oil-defense' && (
<div className="text-[12px] text-text-3 font-korean">
<p className="mb-2"> .</p>
@ -318,10 +405,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div>
)}
{sec.id === 'oil-tide' && (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide1}</span>
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.lowTide}</span>
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide2}</span>
<p className="text-[12px] text-text-3 font-korean italic">
· .
</p>
)}
@ -342,7 +427,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
)}
<div className="grid grid-cols-3 gap-3">
{[
{ label: hnsPayload?.atm.model || 'ALOHA', value: hnsPayload?.atm.maxDistance || sampleHnsData.atm.aloha, color: '#f97316', desc: '최대 확산거리' },
{ label: hnsPayload?.atm.model || 'ALOHA', value: hnsPayload?.atm.maxDistance || '—', color: '#f97316', desc: '최대 확산거리' },
{ label: '최대 농도', value: hnsPayload?.maxConcentration || '—', color: '#ef4444', desc: '지상 1.5m 기준' },
{ label: 'AEGL-1 면적', value: hnsPayload?.aeglAreas.aegl1 || '—', color: '#06b6d4', desc: '확산 영향 면적' },
].map((m, i) => (
@ -358,9 +443,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{sec.id === 'hns-hazard' && (
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'AEGL-3 구역', value: hnsPayload?.hazard.aegl3 || sampleHnsData.hazard.erpg3, area: hnsPayload?.aeglAreas.aegl3, color: '#ef4444', desc: '생명 위협' },
{ label: 'AEGL-2 구역', value: hnsPayload?.hazard.aegl2 || sampleHnsData.hazard.erpg2, area: hnsPayload?.aeglAreas.aegl2, color: '#f97316', desc: '건강 피해' },
{ label: 'AEGL-1 구역', value: hnsPayload?.hazard.aegl1 || sampleHnsData.hazard.evacuation, area: hnsPayload?.aeglAreas.aegl1, color: '#eab308', desc: '불쾌감' },
{ label: 'AEGL-3 구역', value: hnsPayload?.hazard.aegl3 || '—', area: hnsPayload?.aeglAreas.aegl3, color: '#ef4444', desc: '생명 위협' },
{ label: 'AEGL-2 구역', value: hnsPayload?.hazard.aegl2 || '—', area: hnsPayload?.aeglAreas.aegl2, color: '#f97316', desc: '건강 피해' },
{ label: 'AEGL-1 구역', value: hnsPayload?.hazard.aegl1 || '—', area: hnsPayload?.aeglAreas.aegl1, color: '#eab308', desc: '불쾌감' },
].map((h, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[9px] font-bold font-korean mb-1" style={{ color: h.color }}>{h.label}</p>
@ -374,10 +459,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{sec.id === 'hns-substance' && (
<div className="grid grid-cols-2 gap-2 text-[11px]">
{[
{ k: '물질명', v: hnsPayload?.substance.name || sampleHnsData.substance.name },
{ k: 'UN번호', v: hnsPayload?.substance.un || sampleHnsData.substance.un },
{ k: 'CAS번호', v: hnsPayload?.substance.cas || sampleHnsData.substance.cas },
{ k: '위험등급', v: hnsPayload?.substance.class || sampleHnsData.substance.class },
{ k: '물질명', v: hnsPayload?.substance.name || '—' },
{ k: 'UN번호', v: hnsPayload?.substance.un || '—' },
{ k: 'CAS번호', v: hnsPayload?.substance.cas || '—' },
{ k: '위험등급', v: hnsPayload?.substance.class || '—' },
].map((r, i) => (
<div key={i} className="flex justify-between px-3 py-2 bg-bg-1 rounded border border-border">
<span className="text-text-3 font-korean">{r.k}</span>
@ -386,25 +471,21 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
))}
<div className="col-span-2 flex justify-between px-3 py-2 bg-bg-1 rounded border border-[rgba(239,68,68,0.3)]">
<span className="text-text-3 font-korean"></span>
<span className="text-[var(--red)] font-semibold font-mono text-[10px]">{hnsPayload?.substance.toxicity || sampleHnsData.substance.toxicity}</span>
<span className="text-[var(--red)] font-semibold font-mono text-[10px]">{hnsPayload?.substance.toxicity || '—'}</span>
</div>
</div>
)}
{sec.id === 'hns-ppe' && (
<div className="flex flex-wrap gap-2">
{sampleHnsData.ppe.map((item, i) => (
<span key={i} className="px-3 py-1.5 text-[11px] font-semibold rounded-md border text-text-2 font-korean" style={{ background: 'rgba(249,115,22,0.06)', borderColor: 'rgba(249,115,22,0.2)' }}>
🛡 {item}
</span>
))}
<span className="text-text-3 font-korean text-[11px]"></span>
</div>
)}
{sec.id === 'hns-facility' && (
<div className="grid grid-cols-3 gap-3">
{[
{ label: '인근 학교', value: `${sampleHnsData.facility.schools}개소`, icon: '🏫' },
{ label: '의료시설', value: `${sampleHnsData.facility.hospitals}개소`, icon: '🏥' },
{ label: '주변 인구', value: sampleHnsData.facility.population, icon: '👥' },
{ label: '인근 학교', value: '—', icon: '🏫' },
{ label: '의료시설', value: '—', icon: '🏥' },
{ label: '주변 인구', value: '—', icon: '👥' },
].map((f, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<div className="text-[18px] mb-1">{f.icon}</div>
@ -422,10 +503,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{sec.id === 'hns-weather' && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: '풍향', value: hnsPayload?.weather.windDir || 'NE 42°', icon: '🌬' },
{ label: '풍속', value: hnsPayload?.weather.windSpeed || '5.2 m/s', icon: '💨' },
{ label: '대기안정도', value: hnsPayload?.weather.stability || 'D (중립)', icon: '🌡' },
{ label: '기온', value: hnsPayload?.weather.temperature || '8.5°C', icon: '☀️' },
{ label: '풍향', value: hnsPayload?.weather.windDir || '', icon: '🌬' },
{ label: '풍속', value: hnsPayload?.weather.windSpeed || '', icon: '💨' },
{ label: '대기안정도', value: hnsPayload?.weather.stability || '', icon: '🌡' },
{ label: '기온', value: hnsPayload?.weather.temperature || '', icon: '☀️' },
].map((w, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
<div className="text-[16px] mb-0.5">{w.icon}</div>
@ -440,10 +521,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{sec.id === 'rescue-safety' && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'GM (복원력)', value: sampleRescueData.safety.gm, color: '#f97316' },
{ label: '경사각 (Heel)', value: sampleRescueData.safety.heel, color: '#ef4444' },
{ label: '트림 (Trim)', value: sampleRescueData.safety.trim, color: '#06b6d4' },
{ label: '안전 상태', value: sampleRescueData.safety.status, color: '#f97316' },
{ label: 'GM (복원력)', value: '—', color: '#f97316' },
{ label: '경사각 (Heel)', value: '—', color: '#ef4444' },
{ label: '트림 (Trim)', value: '—', color: '#06b6d4' },
{ label: '안전 상태', value: '—', color: '#f97316' },
].map((s, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
<p className="text-[9px] text-text-3 font-korean mb-1">{s.label}</p>
@ -454,26 +535,18 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
)}
{sec.id === 'rescue-timeline' && (
<div className="flex flex-col gap-2">
{[
{ time: '06:28', event: '충돌 발생 — ORIENTAL GLORY ↔ HAI FENG 168', color: '#ef4444' },
{ time: '06:30', event: 'No.1P 탱크 파공, 벙커C유 유출 개시', color: '#f97316' },
{ time: '06:35', event: 'VHF Ch.16 조난통신, 해경 출동 요청', color: '#eab308' },
{ time: '07:15', event: '해경 3009함 현장 도착, 방제 개시', color: '#06b6d4' },
].map((e, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-bg-1 rounded border border-border">
<span className="font-mono text-[11px] font-bold min-w-[40px]" style={{ color: e.color }}>{e.time}</span>
<span className="text-[11px] text-text-2 font-korean">{e.event}</span>
<div className="flex items-center gap-3 px-3 py-2 bg-bg-1 rounded border border-border">
<span className="text-[11px] text-text-3 font-korean"></span>
</div>
))}
</div>
)}
{sec.id === 'rescue-casualty' && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: '총원', value: sampleRescueData.casualty.total },
{ label: '구조완료', value: sampleRescueData.casualty.rescued, color: '#22c55e' },
{ label: '실종', value: sampleRescueData.casualty.missing, color: '#ef4444' },
{ label: '부상', value: sampleRescueData.casualty.injured, color: '#f97316' },
{ label: '총원', value: '—' },
{ label: '구조완료', value: '—', color: '#22c55e' },
{ label: '실종', value: '—', color: '#ef4444' },
{ label: '부상', value: '—', color: '#f97316' },
].map((c, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[9px] text-text-3 font-korean mb-1">{c.label}</p>
@ -494,30 +567,18 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</tr>
</thead>
<tbody>
{sampleRescueData.resources.map((r, i) => (
<tr key={i} className="border-b border-border">
<td className="px-3 py-2 text-text-2 font-korean">{r.type}</td>
<td className="px-3 py-2 text-text-1 font-mono font-semibold">{r.name}</td>
<td className="px-3 py-2 text-text-2 text-center font-mono">{r.eta}</td>
<td className="px-3 py-2 text-center">
<span className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean" style={{
background: r.status === '투입중' ? 'rgba(34,197,94,0.15)' : r.status === '이동중' ? 'rgba(249,115,22,0.15)' : 'rgba(138,150,168,0.15)',
color: r.status === '투입중' ? '#22c55e' : r.status === '이동중' ? '#f97316' : '#8a96a8',
}}>
{r.status}
</span>
</td>
<tr className="border-b border-border">
<td colSpan={4} className="px-3 py-3 text-center text-text-3 font-korean text-[11px]"></td>
</tr>
))}
</tbody>
</table>
)}
{sec.id === 'rescue-grounding' && (
<div className="grid grid-cols-3 gap-3">
{[
{ label: '좌초 위험도', value: sampleRescueData.grounding.risk, color: '#ef4444' },
{ label: '최근 천해', value: sampleRescueData.grounding.nearestShallow, color: '#f97316' },
{ label: '현재 수심', value: sampleRescueData.grounding.depth, color: '#06b6d4' },
{ label: '좌초 위험도', value: '—', color: '#ef4444' },
{ label: '최근 천해', value: '—', color: '#f97316' },
{ label: '현재 수심', value: '—', color: '#06b6d4' },
].map((g, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[9px] text-text-3 font-korean mb-1">{g.label}</p>
@ -529,10 +590,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{sec.id === 'rescue-weather' && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: '파고', value: '1.5 m', icon: '🌊' },
{ label: '풍속', value: '5.2 m/s', icon: '🌬' },
{ label: '조류', value: '1.2 kts NE', icon: '🌀' },
{ label: '시정', value: '8 km', icon: '👁' },
{ label: '파고', value: '', icon: '🌊' },
{ label: '풍속', value: '', icon: '🌬' },
{ label: '조류', value: '', icon: '🌀' },
{ label: '시정', value: '', icon: '👁' },
].map((w, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
<div className="text-[16px] mb-0.5">{w.icon}</div>

파일 보기

@ -11,6 +11,7 @@ import {
generateReportHTML,
exportAsPDF,
exportAsHWP,
buildReportGetVal,
typeColors,
statusColors,
analysisCatColors,
@ -130,6 +131,7 @@ export function ReportsView() {
<col style={{ width: '7%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '5%' }} />
<col style={{ width: '5%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '4%' }} />
</colgroup>
@ -145,6 +147,7 @@ export function ReportsView() {
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
</tr>
@ -177,6 +180,12 @@ export function ReportsView() {
<td className="px-3 py-3 text-[11px] text-text-2 text-center font-korean">{report.jurisdiction}</td>
<td className="px-3 py-3 text-center"><span className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean" style={{ background: statusColors[report.status]?.bg, color: statusColors[report.status]?.text }}>{report.status}</span></td>
<td className="px-3 py-3 text-center"><button onClick={async () => { try { const detail = await loadReportDetail(parseInt(report.id, 10)); setView({ screen: 'edit', data: detail }) } catch { setView({ screen: 'edit', data: { ...report } }) } }} className="text-[11px] text-primary-cyan hover:underline font-korean"></button></td>
<td className="px-3 py-3 text-center">
{(report.hasMapCapture || report.capturedMapImage)
? <span title="확산예측 지도 캡처 있음" className="text-[14px]">📷</span>
: <span className="text-[11px] text-text-3"></span>
}
</td>
<td className="px-3 py-3 text-center"><button className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded bg-[rgba(239,68,68,0.12)] text-[#ef4444] border border-[rgba(239,68,68,0.25)] hover:bg-[rgba(239,68,68,0.2)] transition-all">PDF</button></td>
<td className="px-3 py-3 text-center"><button onClick={() => handleDelete(report.id)} className="w-7 h-7 rounded flex items-center justify-center text-status-red hover:bg-[rgba(239,68,68,0.1)] transition-all text-sm">🗑</button></td>
</tr>
@ -276,16 +285,7 @@ export function ReportsView() {
onClick={() => {
const tpl = templateTypes.find(t => t.id === previewReport.reportType)
if (tpl) {
const getVal = (key: string) => {
if (key === 'author') return previewReport.author
if (key.startsWith('incident.')) {
const f = key.split('.')[1]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (previewReport.incident as any)[f] || ''
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (previewReport as any)[key] || ''
}
const getVal = buildReportGetVal(previewReport)
const html = generateReportHTML(tpl.label, { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }, tpl.sections, getVal)
exportAsPDF(html, previewReport.title || tpl.label)
}
@ -299,16 +299,7 @@ export function ReportsView() {
onClick={() => {
const tpl = templateTypes.find(t => t.id === previewReport.reportType) as TemplateType | undefined
if (tpl) {
const getVal = (key: string) => {
if (key === 'author') return previewReport.author
if (key.startsWith('incident.')) {
const f = key.split('.')[1]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (previewReport.incident as any)[f] || ''
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (previewReport as any)[key] || ''
}
const getVal = buildReportGetVal(previewReport)
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
const filename = previewReport.title || tpl.label
exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename)
@ -369,6 +360,14 @@ export function ReportsView() {
previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`,
].filter(Boolean).join('\n') || '—'}
</div>
{previewReport.capturedMapImage && (
<img
src={previewReport.capturedMapImage}
alt="확산예측 지도 캡처"
className="w-full rounded-lg border border-border mt-3"
style={{ maxHeight: '300px', objectFit: 'contain' }}
/>
)}
</div>
{/* 3. 초동조치 / 대응현황 */}

파일 보기

@ -75,15 +75,14 @@ export const templateTypes: TemplateType[] = [
{ 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' },
]},
{ title: '3. 조석 현황', fields: [{ key: '__tide', label: '', type: 'textarea' }] },
{ title: '4. 해양기상 현황', fields: [{ key: '__weather', label: '', type: 'textarea' }] },
{ title: '5. 확산예측 결과', fields: [{ key: '__spread', label: '', type: 'textarea' }] },
{ title: '6. 민감자원 현황', fields: [{ key: '__sensitive', label: '', type: 'textarea' }] },
{ title: '7. 방제 자원', fields: [{ key: '__vessels', label: '', type: 'textarea' }] },
{ title: '8. 회수·처리 현황',fields: [{ key: '__recovery', label: '', type: 'textarea' }] },
{ title: '9. 최종 결과', fields: [{ key: '__result', label: '', type: 'textarea' }] },
{ title: '10. 분석 의견', fields: [{ key: 'analysis', label: '', type: 'textarea' }] },
]
},
{

파일 보기

@ -86,3 +86,122 @@ export function inferAnalysisCategory(report: OilSpillReportData): string {
if (t.includes('유출유') || t.includes('확산예측') || t.includes('민감자원') || t.includes('유출사고') || t.includes('오염') || t.includes('방제') || rt === '유출유 보고' || rt === '예측보고서') return '유출유 확산예측'
return ''
}
// ─── PDF/HWP 섹션 포맷 헬퍼 ─────────────────────────────────
const TH = 'padding:6px 8px;border:1px solid #d1d5db;background:#f0f4f8;font-weight:600;font-size:11px;'
const TD = 'padding:6px 8px;border:1px solid #d1d5db;font-size:11px;'
const TABLE = 'width:100%;border-collapse:collapse;'
function formatTideTable(tide: OilSpillReportData['tide']): string {
if (!tide?.length) return ''
const header = `<tr><th style="${TH}">날짜</th><th style="${TH}">조형</th><th style="${TH}">간조1</th><th style="${TH}">만조1</th><th style="${TH}">간조2</th><th style="${TH}">만조2</th></tr>`
const rows = tide.map(t =>
`<tr><td style="${TD}">${t.date}</td><td style="${TD}">${t.tideType}</td><td style="${TD}">${t.lowTide1}</td><td style="${TD}">${t.highTide1}</td><td style="${TD}">${t.lowTide2}</td><td style="${TD}">${t.highTide2}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatWeatherTable(weather: OilSpillReportData['weather']): string {
if (!weather?.length) return ''
const header = `<tr><th style="${TH}">시각</th><th style="${TH}">풍향</th><th style="${TH}">풍속</th><th style="${TH}">유향</th><th style="${TH}">유속</th><th style="${TH}">파고</th></tr>`
const rows = weather.map(w =>
`<tr><td style="${TD}">${w.time}</td><td style="${TD}">${w.windDir}</td><td style="${TD}">${w.windSpeed}</td><td style="${TD}">${w.currentDir}</td><td style="${TD}">${w.currentSpeed}</td><td style="${TD}">${w.waveHeight}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatSpreadTable(spread: OilSpillReportData['spread']): string {
if (!spread?.length) return ''
const header = `<tr><th style="${TH}">경과시간</th><th style="${TH}">풍화량</th><th style="${TH}">해상잔유량</th><th style="${TH}">연안부착량</th><th style="${TH}">면적</th></tr>`
const rows = spread.map(s =>
`<tr><td style="${TD}">${s.elapsed}</td><td style="${TD}">${s.weathered}</td><td style="${TD}">${s.seaRemain}</td><td style="${TD}">${s.coastAttach}</td><td style="${TD}">${s.area}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatSensitiveTable(r: OilSpillReportData): string {
const parts: string[] = []
if (r.aquaculture?.length) {
const h = `<tr><th style="${TH}">종류</th><th style="${TH}">면적</th><th style="${TH}">거리</th></tr>`
const rows = r.aquaculture.map(a => `<tr><td style="${TD}">${a.type}</td><td style="${TD}">${a.area}</td><td style="${TD}">${a.distance}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">양식업</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.beaches?.length) {
const h = `<tr><th style="${TH}">해수욕장명</th><th style="${TH}">거리</th></tr>`
const rows = r.beaches.map(b => `<tr><td style="${TD}">${b.name}</td><td style="${TD}">${b.distance}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">해수욕장</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.markets?.length) {
const h = `<tr><th style="${TH}">수산시장명</th><th style="${TH}">거리</th></tr>`
const rows = r.markets.map(m => `<tr><td style="${TD}">${m.name}</td><td style="${TD}">${m.distance}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">수산시장</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.esi?.length) {
const h = `<tr><th style="${TH}">코드</th><th style="${TH}">유형</th><th style="${TH}">길이</th></tr>`
const rows = r.esi.map(e => `<tr><td style="${TD}">${e.code}</td><td style="${TD}">${e.type}</td><td style="${TD}">${e.length}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">ESI 해안선</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.species?.length) {
const h = `<tr><th style="${TH}">분류</th><th style="${TH}">종명</th></tr>`
const rows = r.species.map(s => `<tr><td style="${TD}">${s.category}</td><td style="${TD}">${s.species}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">보호생물</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.habitat?.length) {
const h = `<tr><th style="${TH}">유형</th><th style="${TH}">면적</th></tr>`
const rows = r.habitat.map(h2 => `<tr><td style="${TD}">${h2.type}</td><td style="${TD}">${h2.area}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">서식지</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.sensitivity?.length) {
const h = `<tr><th style="${TH}">민감도</th><th style="${TH}">면적</th></tr>`
const rows = r.sensitivity.map(s => `<tr><td style="${TD}">${s.level}</td><td style="${TD}">${s.area}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">민감도 등급</p><table style="${TABLE}">${h}${rows}</table>`)
}
return parts.join('')
}
function formatVesselsTable(vessels: OilSpillReportData['vessels']): string {
if (!vessels?.length) return ''
const header = `<tr><th style="${TH}">선명</th><th style="${TH}">기관</th><th style="${TH}">거리</th><th style="${TH}">속력</th><th style="${TH}">톤수</th><th style="${TH}">회수장비</th><th style="${TH}">오일붐</th></tr>`
const rows = vessels.map(v =>
`<tr><td style="${TD}">${v.name}</td><td style="${TD}">${v.org}</td><td style="${TD}">${v.dist}</td><td style="${TD}">${v.speed}</td><td style="${TD}">${v.ton}</td><td style="${TD}">${v.collectorType} ${v.collectorCap}</td><td style="${TD}">${v.boomType} ${v.boomLength}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatRecoveryTable(recovery: OilSpillReportData['recovery']): string {
if (!recovery?.length) return ''
const header = `<tr><th style="${TH}">선박명</th><th style="${TH}">회수 기간</th></tr>`
const rows = recovery.map(r =>
`<tr><td style="${TD}">${r.shipName}</td><td style="${TD}">${r.period}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatResultTable(result: OilSpillReportData['result']): string {
if (!result) return ''
return `<table style="${TABLE}">
<tr><td style="${TH}"></td><td style="${TD}">${result.spillTotal}</td><td style="${TH}"></td><td style="${TD}">${result.weatheredTotal}</td></tr>
<tr><td style="${TH}"></td><td style="${TD}">${result.recoveredTotal}</td><td style="${TH}"></td><td style="${TD}">${result.seaRemainTotal}</td></tr>
<tr><td style="${TH}"></td><td style="${TD}" colspan="3">${result.coastAttachTotal}</td></tr>
</table>`
}
export function buildReportGetVal(report: OilSpillReportData) {
return (key: string): string => {
if (key === 'author') return report.author ?? ''
if (key.startsWith('incident.')) {
const f = key.split('.')[1]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (report.incident as any)[f] || ''
}
if (key === '__tide') return formatTideTable(report.tide)
if (key === '__weather') return formatWeatherTable(report.weather)
if (key === '__spread') return formatSpreadTable(report.spread)
if (key === '__sensitive') return formatSensitiveTable(report)
if (key === '__vessels') return formatVesselsTable(report.vessels)
if (key === '__recovery') return formatRecoveryTable(report.recovery)
if (key === '__result') return formatResultTable(report.result)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (report as any)[key] || ''
}
}

파일 보기

@ -62,6 +62,7 @@ export interface ApiReportListItem {
authorName: string;
regDtm: string;
mdfcnDtm: string | null;
hasMapCapture?: boolean;
}
export interface ApiReportSectionData {
@ -74,6 +75,7 @@ export interface ApiReportSectionData {
export interface ApiReportDetail extends ApiReportListItem {
acdntSn: number | null;
sections: ApiReportSectionData[];
mapCaptureImg?: string | null;
}
export interface ApiReportListResponse {
@ -176,6 +178,7 @@ export async function createReportApi(input: {
title: string;
jrsdCd?: string;
sttsCd?: string;
mapCaptureImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<{ sn: number }> {
const res = await api.post<{ sn: number }>('/reports', input);
@ -187,6 +190,7 @@ export async function updateReportApi(sn: number, input: {
jrsdCd?: string;
sttsCd?: string;
acdntSn?: number | null;
mapCaptureImg?: string | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<void> {
await api.post(`/reports/${sn}/update`, input);
@ -239,6 +243,7 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction,
sttsCd,
mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined,
sections,
});
return existingSn;
@ -250,6 +255,7 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction,
sttsCd,
mapCaptureImg: data.capturedMapImage || undefined,
sections,
});
return result.sn;
@ -266,6 +272,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
analysisCategory: (item.ctgrCd ? CTGR_CODE_TO_CAT[item.ctgrCd] : '') || '',
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
hasMapCapture: item.hasMapCapture,
// 목록에서는 섹션 데이터 없음 — 빈 기본값
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
tide: [], weather: [], spread: [],
@ -337,6 +344,10 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
}
}
if (detail.mapCaptureImg) {
reportData.capturedMapImage = detail.mapCaptureImg;
}
return reportData;
}

파일 보기

@ -0,0 +1,324 @@
import { useEffect, useRef, useCallback } from 'react'
import { useMap } from '@vis.gl/react-maplibre'
import type { Map as MapLibreMap } from 'maplibre-gl'
interface CurrentVectorPoint {
lat: number
lon: number
u: number // 동서 방향 속도 (양수=동, 음수=서) [m/s]
v: number // 남북 방향 속도 (양수=북, 음수=남) [m/s]
}
interface Particle {
x: number
y: number
age: number
maxAge: number
}
interface OceanCurrentParticleLayerProps {
visible: boolean
}
// 해류 속도 기반 색상
function getCurrentColor(speed: number): string {
if (speed < 0.2) return 'rgba(59, 130, 246, 0.8)' // 파랑
if (speed < 0.4) return 'rgba(6, 182, 212, 0.8)' // 청록
if (speed < 0.6) return 'rgba(34, 197, 94, 0.8)' // 초록
return 'rgba(249, 115, 22, 0.8)' // 주황
}
// 한반도 육지 영역 판별 (간략화된 폴리곤)
const isOnLand = (lat: number, lon: number): boolean => {
const peninsula: [number, number][] = [
[38.5, 124.5], [38.5, 128.3],
[37.8, 128.8], [37.0, 129.2],
[36.0, 129.5], [35.1, 129.2],
[34.8, 128.6], [34.5, 127.8],
[34.3, 126.5], [34.8, 126.1],
[35.5, 126.0], [36.0, 126.3],
[36.8, 126.0], [37.5, 126.2],
[38.5, 124.5],
]
// 제주도 영역
if (lat >= 33.1 && lat <= 33.7 && lon >= 126.1 && lon <= 127.0) return true
// Ray casting algorithm
let inside = false
for (let i = 0, j = peninsula.length - 1; i < peninsula.length; j = i++) {
const [yi, xi] = peninsula[i]
const [yj, xj] = peninsula[j]
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
inside = !inside
}
}
return inside
}
// 한국 해역의 해류 u,v 벡터 데이터 생성 (Mock)
const generateOceanCurrentData = (): CurrentVectorPoint[] => {
const data: CurrentVectorPoint[] = []
for (let lat = 33.5; lat <= 38.0; lat += 0.8) {
for (let lon = 125.0; lon <= 130.5; lon += 0.8) {
if (isOnLand(lat, lon)) continue
let u = 0
let v = 0
if (lon > 128.5) {
// 동해 — 북동진하는 동한난류
u = 0.2 + Math.random() * 0.2 // 동쪽 0.2~0.4
v = 0.3 + Math.random() * 0.2 // 북쪽 0.3~0.5
} else if (lon < 126.5) {
// 서해 — 북진
u = -0.05 + Math.random() * 0.1 // 동서 -0.05~0.05
v = 0.15 + Math.random() * 0.15 // 북쪽 0.15~0.3
} else {
// 남해 — 동진
u = 0.3 + Math.random() * 0.2 // 동쪽 0.3~0.5
v = -0.05 + Math.random() * 0.15 // 남북 -0.05~0.1
}
data.push({ lat, lon, u, v })
}
}
return data
}
// 해류 데이터는 한 번만 생성
const CURRENT_DATA = generateOceanCurrentData()
// IDW 보간으로 특정 위치의 u,v 벡터 추정 → speed/direction 반환
function interpolateCurrent(
lat: number,
lon: number,
points: CurrentVectorPoint[]
): { speed: number; direction: number } {
if (points.length === 0) return { speed: 0.3, direction: 90 }
let totalWeight = 0
let weightedU = 0
let weightedV = 0
for (const point of points) {
const dist = Math.sqrt(
Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2)
)
const weight = 1 / Math.pow(Math.max(dist, 0.01), 2)
totalWeight += weight
weightedU += point.u * weight
weightedV += point.v * weight
}
const u = weightedU / totalWeight
const v = weightedV / totalWeight
const speed = Math.sqrt(u * u + v * v)
// u=동(+), v=북(+) → 화면 방향: sin=동(+x), -cos=남(+y)
const direction = (Math.atan2(u, v) * 180) / Math.PI
return { speed, direction: (direction + 360) % 360 }
}
// MapLibre map.unproject()를 통해 픽셀 → 경위도 변환
function containerPointToLatLng(
map: MapLibreMap,
x: number,
y: number
): { lat: number; lng: number } {
const lngLat = map.unproject([x, y])
return { lat: lngLat.lat, lng: lngLat.lng }
}
const PARTICLE_COUNT = 400
const FADE_ALPHA = 0.93
/**
* OceanCurrentParticleLayer
*
* Canvas 2D + requestAnimationFrame
* u,v IDW
* 대비: 적은 , ,
*/
export function OceanCurrentParticleLayer({ visible }: OceanCurrentParticleLayerProps) {
const { current: mapRef } = useMap()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const particlesRef = useRef<Particle[]>([])
const animFrameRef = useRef<number>(0)
const initParticles = useCallback((width: number, height: number) => {
particlesRef.current = []
for (let i = 0; i < PARTICLE_COUNT; i++) {
particlesRef.current.push({
x: Math.random() * width,
y: Math.random() * height,
age: Math.floor(Math.random() * 150),
maxAge: 120 + Math.floor(Math.random() * 60),
})
}
}, [])
useEffect(() => {
const map = mapRef?.getMap()
if (!map) return
if (!visible) {
if (canvasRef.current) {
canvasRef.current.remove()
canvasRef.current = null
}
cancelAnimationFrame(animFrameRef.current)
return
}
const container = map.getContainer()
// Canvas 생성 또는 재사용
let canvas = canvasRef.current
if (!canvas) {
canvas = document.createElement('canvas')
canvas.style.position = 'absolute'
canvas.style.top = '0'
canvas.style.left = '0'
canvas.style.pointerEvents = 'none'
canvas.style.zIndex = '440'
container.appendChild(canvas)
canvasRef.current = canvas
}
const resize = () => {
if (!canvas) return
const { clientWidth: w, clientHeight: h } = container
canvas.width = w
canvas.height = h
}
resize()
const ctx = canvas.getContext('2d')
if (!ctx) return
initParticles(canvas.width, canvas.height)
// 오프스크린 캔버스 (트레일 효과)
let offCanvas: HTMLCanvasElement | null = null
let offCtx: CanvasRenderingContext2D | null = null
function animate() {
if (!ctx || !canvas) return
// 오프스크린 캔버스 크기 동기화
if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) {
offCanvas = document.createElement('canvas')
offCanvas.width = canvas.width
offCanvas.height = canvas.height
offCtx = offCanvas.getContext('2d')
}
if (!offCtx) return
// 트레일 페이드 효과 (느린 페이드 = 부드러운 흐름)
offCtx.globalCompositeOperation = 'destination-in'
offCtx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`
offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height)
offCtx.globalCompositeOperation = 'source-over'
// 현재 지도 bounds 확인
const bounds = map!.getBounds()
for (const particle of particlesRef.current) {
particle.age++
// 수명 초과 시 리셋
if (particle.age > particle.maxAge) {
particle.x = Math.random() * canvas.width
particle.y = Math.random() * canvas.height
particle.age = 0
particle.maxAge = 120 + Math.floor(Math.random() * 60)
continue
}
const { lat, lng } = containerPointToLatLng(map!, particle.x, particle.y)
// 화면 밖이면 리셋
if (!bounds.contains([lng, lat])) {
particle.x = Math.random() * canvas.width
particle.y = Math.random() * canvas.height
particle.age = 0
continue
}
// 육지 위이면 리셋
if (isOnLand(lat, lng)) {
particle.x = Math.random() * canvas.width
particle.y = Math.random() * canvas.height
particle.age = 0
continue
}
const current = interpolateCurrent(lat, lng, CURRENT_DATA)
const rad = (current.direction * Math.PI) / 180
const pixelSpeed = current.speed * 2.0
const newX = particle.x + Math.sin(rad) * pixelSpeed
const newY = particle.y + -Math.cos(rad) * pixelSpeed
// 다음 위치가 육지이면 리셋
const nextPos = containerPointToLatLng(map!, newX, newY)
if (isOnLand(nextPos.lat, nextPos.lng)) {
particle.x = Math.random() * canvas.width
particle.y = Math.random() * canvas.height
particle.age = 0
continue
}
const oldX = particle.x
const oldY = particle.y
particle.x = newX
particle.y = newY
// 파티클 트레일 그리기
const alpha = 1 - particle.age / particle.maxAge
offCtx.strokeStyle = getCurrentColor(current.speed).replace('0.8', String(alpha * 0.8))
offCtx.lineWidth = 0.8
offCtx.beginPath()
offCtx.moveTo(oldX, oldY)
offCtx.lineTo(particle.x, particle.y)
offCtx.stroke()
}
// 메인 캔버스에 합성 (배경 오버레이 없이 파티클만)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(offCanvas, 0, 0)
animFrameRef.current = requestAnimationFrame(animate)
}
animate()
// 지도 이동/줌 시 리셋
const onMoveEnd = () => {
resize()
if (canvas) initParticles(canvas.width, canvas.height)
if (offCanvas && canvas) {
offCanvas.width = canvas.width
offCanvas.height = canvas.height
}
}
map.on('moveend', onMoveEnd)
map.on('zoomend', onMoveEnd)
return () => {
cancelAnimationFrame(animFrameRef.current)
map.off('moveend', onMoveEnd)
map.off('zoomend', onMoveEnd)
if (canvasRef.current) {
canvasRef.current.remove()
canvasRef.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapRef, visible, initParticles])
return null
}

파일 보기

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Source, Layer } from '@vis.gl/react-maplibre'
import { useEffect, useRef } from 'react'
import { useMap } from '@vis.gl/react-maplibre'
import type { OceanForecastData } from '../services/khoaApi'
interface OceanForecastOverlayProps {
@ -8,62 +8,118 @@ interface OceanForecastOverlayProps {
visible?: boolean
}
// 한국 해역 범위 (MapLibre image source용 좌표 배열)
// [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se]
// [lon, lat] 순서
const KOREA_IMAGE_COORDINATES: [[number, number], [number, number], [number, number], [number, number]] = [
[124.5, 33.0], // 남서 (제주 남쪽)
[124.5, 38.5], // 북서
[132.0, 38.5], // 북동 (동해 북쪽)
[132.0, 33.0], // 남동
]
// 한국 해역 범위 [lon, lat]
const BOUNDS = {
nw: [124.5, 38.5] as [number, number],
ne: [132.0, 38.5] as [number, number],
se: [132.0, 33.0] as [number, number],
sw: [124.5, 33.0] as [number, number],
}
// www.khoa.go.kr 이미지는 CORS 미지원 → Vite 프록시 경유
function toProxyUrl(url: string): string {
return url.replace('https://www.khoa.go.kr', '')
}
/**
* OceanForecastOverlay
*
* 기존: react-leaflet ImageOverlay + LatLngBounds
* : @vis.gl/react-maplibre Source(type=image) + Layer(type=raster)
*
* MapLibre image source는 Map
* MapLibre raster layer는 deck.gl ,
* WindParticleLayer와 canvas를 map .
* z-index 500 WindParticleLayer(450) .
*/
export function OceanForecastOverlay({
forecast,
opacity = 0.6,
visible = true,
}: OceanForecastOverlayProps) {
const [loadedUrl, setLoadedUrl] = useState<string | null>(null)
const { current: mapRef } = useMap()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const imgRef = useRef<HTMLImageElement | null>(null)
useEffect(() => {
if (!forecast?.filePath) return
let cancelled = false
const img = new Image()
img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) }
img.onerror = () => { if (!cancelled) setLoadedUrl(null) }
img.src = forecast.filePath
return () => { cancelled = true }
}, [forecast?.filePath])
const map = mapRef?.getMap()
if (!map) return
const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath
const container = map.getContainer()
if (!visible || !forecast || !imageLoaded) {
return null
// canvas 생성 (최초 1회)
if (!canvasRef.current) {
const canvas = document.createElement('canvas')
canvas.style.position = 'absolute'
canvas.style.top = '0'
canvas.style.left = '0'
canvas.style.pointerEvents = 'none'
canvas.style.zIndex = '500' // WindParticleLayer(450) 위
container.appendChild(canvas)
canvasRef.current = canvas
}
return (
<Source
id="ocean-forecast-image"
type="image"
url={forecast.filePath}
coordinates={KOREA_IMAGE_COORDINATES}
>
<Layer
id="ocean-forecast-raster"
type="raster"
paint={{
'raster-opacity': opacity,
'raster-resampling': 'linear',
}}
/>
</Source>
)
const canvas = canvasRef.current
if (!visible || !forecast?.imgFilePath) {
canvas.style.display = 'none'
return
}
canvas.style.display = 'block'
const proxyUrl = toProxyUrl(forecast.imgFilePath)
function draw() {
const img = imgRef.current
if (!canvas || !img || !img.complete || img.naturalWidth === 0) return
const { clientWidth: w, clientHeight: h } = container
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, w, h)
// 4개 꼭짓점을 픽셀 좌표로 변환
const nw = map!.project(BOUNDS.nw)
const ne = map!.project(BOUNDS.ne)
const sw = map!.project(BOUNDS.sw)
const x = Math.min(nw.x, sw.x)
const y = nw.y
const w2 = ne.x - nw.x
const h2 = sw.y - nw.y
ctx.globalAlpha = opacity
ctx.drawImage(img, x, y, w2, h2)
}
// 이미지가 바뀌었으면 새로 로드
if (!imgRef.current || imgRef.current.dataset.src !== proxyUrl) {
const img = new Image()
img.dataset.src = proxyUrl
img.onload = draw
img.src = proxyUrl
imgRef.current = img
} else {
draw()
}
map.on('move', draw)
map.on('zoom', draw)
map.on('resize', draw)
return () => {
map.off('move', draw)
map.off('zoom', draw)
map.off('resize', draw)
}
}, [mapRef, visible, forecast?.imgFilePath, opacity])
// 언마운트 시 canvas 제거
useEffect(() => {
return () => {
canvasRef.current?.remove()
canvasRef.current = null
}
}, [])
return null
}

파일 보기

@ -0,0 +1,48 @@
import { useMap } from '@vis.gl/react-maplibre'
interface WeatherMapControlsProps {
center: [number, number]
zoom: number
}
export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) {
const { current: map } = useMap()
const buttons = [
{
label: '+',
tooltip: '확대',
onClick: () => map?.zoomIn(),
},
{
label: '',
tooltip: '축소',
onClick: () => map?.zoomOut(),
},
{
label: '🎯',
tooltip: '한국 해역 초기화',
onClick: () => map?.flyTo({ center, zoom, duration: 1000 }),
},
]
return (
<div className="absolute top-4 right-4 z-10">
<div className="flex flex-col gap-2">
{buttons.map(({ label, tooltip, onClick }) => (
<div key={tooltip} className="relative group">
<button
onClick={onClick}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
{label}
</button>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-2 px-2 py-1 text-xs bg-bg-0 text-text-1 border border-border rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-20">
{tooltip}
</div>
</div>
))}
</div>
</div>
)
}

파일 보기

@ -77,8 +77,6 @@ export function WeatherMapOverlay({
stations.map((station) => {
const isSelected = selectedStationId === station.id
const color = getWindHexColor(station.wind.speed, isSelected)
const size = Math.min(40 + station.wind.speed * 2, 80)
return (
<Marker
key={`wind-${station.id}`}
@ -87,35 +85,34 @@ export function WeatherMapOverlay({
anchor="center"
onClick={() => onStationClick(station)}
>
<div
style={{
width: size,
height: size,
transform: `rotate(${station.wind.direction}deg)`,
}}
className="flex items-center justify-center cursor-pointer"
>
<div className="flex items-center gap-1 cursor-pointer">
<div style={{ transform: `rotate(${station.wind.direction}deg)` }}>
<svg
width={size}
height={size}
width={24}
height={24}
viewBox="0 0 24 24"
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
>
<path
d="M12 2L12 20M12 2L8 6M12 2L16 6M12 20L8 16M12 20L16 16"
stroke={color}
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
{/* 위쪽이 바람 방향을 나타내는 삼각형 */}
<polygon
points="12,2 4,22 12,16 20,22"
fill={color}
opacity="0.9"
/>
<circle cx="12" cy="12" r="3" fill={color} opacity="0.8" />
</svg>
</div>
<span
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
className="text-xs font-bold leading-none"
>
{station.wind.speed.toFixed(1)}
</span>
</div>
</Marker>
)
})}
{/* 기상 데이터 라벨 — MapLibre Marker */}
{/*
{enabledLayers.has('labels') &&
stations.map((station) => {
const isSelected = selectedStationId === station.id
@ -139,7 +136,6 @@ export function WeatherMapOverlay({
}}
className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer"
>
{/* 관측소명 */}
<div
style={{
color: textColor,
@ -150,8 +146,6 @@ export function WeatherMapOverlay({
>
{station.name}
</div>
{/* 수온 */}
<div className="flex items-center gap-1.5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
@ -160,22 +154,12 @@ export function WeatherMapOverlay({
🌡
</div>
<div className="flex items-baseline gap-0.5">
<span
className="text-sm font-bold text-white"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.temperature.current.toFixed(1)}
</span>
<span
className="text-[10px] text-white opacity-90"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
°C
</span>
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
</div>
</div>
{/* 파고 */}
<div className="flex items-center gap-1.5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
@ -184,22 +168,12 @@ export function WeatherMapOverlay({
🌊
</div>
<div className="flex items-baseline gap-0.5">
<span
className="text-sm font-bold text-white"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.wave.height.toFixed(1)}
</span>
<span
className="text-[10px] text-white opacity-90"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
m
</span>
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
</div>
</div>
{/* 풍속 */}
<div className="flex items-center gap-1.5">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
@ -208,24 +182,17 @@ export function WeatherMapOverlay({
💨
</div>
<div className="flex items-baseline gap-0.5">
<span
className="text-sm font-bold text-white"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
{station.wind.speed.toFixed(1)}
</span>
<span
className="text-[10px] text-white opacity-90"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
m/s
</span>
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
</div>
</div>
</div>
</Marker>
)
})}
*/}
</>
)
}

파일 보기

@ -1,17 +1,19 @@
import { useState, useMemo, useCallback } from 'react'
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import type { Layer } from '@deck.gl/core'
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { WeatherRightPanel } from './WeatherRightPanel'
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
import { OceanForecastOverlay } from './OceanForecastOverlay'
import { useOceanCurrentLayers } from './OceanCurrentLayer'
// import { OceanForecastOverlay } from './OceanForecastOverlay'
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
import { WindParticleLayer } from './WindParticleLayer'
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData'
import { useOceanForecast } from '../hooks/useOceanForecast'
// import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls'
type TimeOffset = '0' | '3' | '6' | '9'
@ -117,38 +119,6 @@ function DeckGLOverlay({ layers }: { layers: Layer[] }) {
return null
}
// 줌 컨트롤
function WeatherMapControls() {
const { current: map } = useMap()
return (
<div className="absolute top-4 right-4 z-10">
<div className="flex flex-col gap-2">
<button
onClick={() => map?.zoomIn()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
+
</button>
<button
onClick={() => map?.zoomOut()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
</button>
<button
onClick={() =>
map?.flyTo({ center: WEATHER_MAP_CENTER, zoom: WEATHER_MAP_ZOOM, duration: 1000 })
}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
>
&#x1F3AF;
</button>
</div>
</div>
)
}
/**
* WeatherMapInner Map (useMap / useControl )
*/
@ -156,18 +126,20 @@ interface WeatherMapInnerProps {
weatherStations: WeatherStation[]
enabledLayers: Set<string>
selectedStationId: string | null
oceanForecastOpacity: number
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
onStationClick: (station: WeatherStation) => void
mapCenter: [number, number]
mapZoom: number
clickedLocation: { lat: number; lon: number } | null
}
function WeatherMapInner({
weatherStations,
enabledLayers,
selectedStationId,
oceanForecastOpacity,
selectedForecast,
onStationClick,
mapCenter,
mapZoom,
clickedLocation,
}: WeatherMapInnerProps) {
// deck.gl layers 조합
const weatherDeckLayers = useWeatherDeckLayers(
@ -176,18 +148,18 @@ function WeatherMapInner({
selectedStationId,
onStationClick
)
const oceanCurrentLayers = useOceanCurrentLayers({
visible: enabledLayers.has('oceanCurrent'),
opacity: 0.7,
})
// const oceanCurrentLayers = useOceanCurrentLayers({
// visible: enabledLayers.has('oceanCurrent'),
// opacity: 0.7,
// })
const waterTempLayers = useWaterTemperatureLayers({
visible: enabledLayers.has('waterTemperature'),
opacity: 0.5,
})
const deckLayers = useMemo(
() => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers],
[oceanCurrentLayers, waterTempLayers, weatherDeckLayers]
() => [...waterTempLayers, ...weatherDeckLayers],
[waterTempLayers, weatherDeckLayers]
)
return (
@ -195,11 +167,16 @@ function WeatherMapInner({
{/* deck.gl 오버레이 */}
<DeckGLOverlay layers={deckLayers} />
{/* 해황예보도 — MapLibre image source + raster layer */}
{/*
<OceanForecastOverlay
forecast={selectedForecast}
opacity={oceanForecastOpacity}
visible={enabledLayers.has('oceanForecast')}
/> */}
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
<OceanCurrentParticleLayer
visible={enabledLayers.has('oceanCurrentParticle')}
/>
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
@ -216,8 +193,31 @@ function WeatherMapInner({
stations={weatherStations}
/>
{/* 클릭 위치 마커 */}
{clickedLocation && (
<Marker
longitude={clickedLocation.lon}
latitude={clickedLocation.lat}
anchor="bottom"
>
<div className="flex flex-col items-center pointer-events-none">
{/* 펄스 링 */}
<div className="relative flex items-center justify-center">
<div className="absolute w-8 h-8 rounded-full border-2 border-primary-cyan animate-ping opacity-60" />
<div className="w-4 h-4 rounded-full bg-primary-cyan border-2 border-white shadow-lg" />
</div>
{/* 핀 꼬리 */}
<div className="w-px h-3 bg-primary-cyan" />
{/* 좌표 라벨 */}
<div className="px-2 py-1 bg-bg-0/90 border border-primary-cyan rounded text-[10px] text-primary-cyan whitespace-nowrap backdrop-blur-sm">
{clickedLocation.lat.toFixed(3)}°N&nbsp;{clickedLocation.lon.toFixed(3)}°E
</div>
</div>
</Marker>
)}
{/* 줌 컨트롤 */}
<WeatherMapControls />
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
</>
)
}
@ -225,21 +225,22 @@ function WeatherMapInner({
export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
const {
selectedForecast,
availableTimes,
loading: oceanLoading,
error: oceanError,
selectForecast,
} = useOceanForecast('KOREA')
// const {
// selectedForecast,
// availableTimes,
// loading: oceanLoading,
// error: oceanError,
// selectForecast,
// } = useOceanForecast('KOREA')
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
null
)
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind', 'labels']))
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
// 첫 관측소 자동 선택 (파생 값)
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
@ -343,12 +344,6 @@ export function WeatherView() {
</div>
<div className="flex-1" />
<div className="px-6">
<button className="px-4 py-2 text-xs font-semibold rounded bg-status-red text-white hover:opacity-90 transition-opacity">
🚨
</button>
</div>
</div>
{/* Map */}
@ -368,9 +363,10 @@ export function WeatherView() {
weatherStations={weatherStations}
enabledLayers={enabledLayers}
selectedStationId={selectedStation?.id || null}
oceanForecastOpacity={oceanForecastOpacity}
selectedForecast={selectedForecast}
onStationClick={handleStationClick}
mapCenter={WEATHER_MAP_CENTER}
mapZoom={WEATHER_MAP_ZOOM}
clickedLocation={selectedLocation}
/>
</Map>
@ -396,6 +392,7 @@ export function WeatherView() {
/>
<span className="text-xs text-text-2">🌬 </span>
</label>
{/*
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@ -405,6 +402,7 @@ export function WeatherView() {
/>
<span className="text-xs text-text-2">📊 </span>
</label>
*/}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@ -426,11 +424,11 @@ export function WeatherView() {
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('oceanCurrent')}
onChange={() => toggleLayer('oceanCurrent')}
checked={enabledLayers.has('oceanCurrentParticle')}
onChange={() => toggleLayer('oceanCurrentParticle')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">🌊 </span>
<span className="text-xs text-text-2">🌊 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@ -442,7 +440,7 @@ export function WeatherView() {
<span className="text-xs text-text-2">🌡 </span>
</label>
{/* 해황예보도 레이어 */}
{/*
<div className="pt-2 mt-2 border-t border-border">
<label className="flex items-center gap-2 cursor-pointer">
<input
@ -453,60 +451,8 @@ export function WeatherView() {
/>
<span className="text-xs text-text-2">🌊 </span>
</label>
{enabledLayers.has('oceanForecast') && (
<div className="mt-2 ml-6 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-text-3">:</span>
<input
type="range"
min="0"
max="100"
value={oceanForecastOpacity * 100}
onChange={(e) =>
setOceanForecastOpacity(Number(e.target.value) / 100)
}
className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-xs text-text-3 w-8">
{Math.round(oceanForecastOpacity * 100)}%
</span>
</div>
{availableTimes.length > 0 && (
<div className="space-y-1">
<div className="text-xs text-text-3"> :</div>
<div className="max-h-32 overflow-y-auto space-y-1">
{availableTimes.map((time) => (
<button
key={`${time.day}-${time.hour}`}
onClick={() => selectForecast(time.day, time.hour)}
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
selectedForecast?.day === time.day &&
selectedForecast?.hour === time.hour
? 'bg-primary-cyan text-bg-0 font-semibold'
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
}`}
>
{time.label}
</button>
))}
</div>
</div>
)}
{oceanLoading && <div className="text-xs text-text-3"> ...</div>}
{oceanError && <div className="text-xs text-status-red"> </div>}
{selectedForecast && (
<div className="text-xs text-text-3 pt-2 border-t border-border">
: {selectedForecast.name} {' '}
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '}
{selectedForecast.hour}:00
</div>
)}
</div>
)}
</div>
*/}
</div>
</div>
@ -538,6 +484,23 @@ export function WeatherView() {
</div>
</div>
{/* 해류 */}
<div className="pt-2 border-t border-border">
<div className="font-semibold text-text-2 mb-1"> (m/s)</div>
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
</div>
<div className="flex justify-between text-text-3 text-[9px]">
<span>0.2</span>
<span>0.4</span>
<span>0.6</span>
<span>0.6+</span>
</div>
</div>
{/* 파고 */}
<div className="pt-2 border-t border-border">
<div className="font-semibold text-text-2 mb-1"> (m)</div>

파일 보기

@ -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}`))

파일 보기

@ -88,6 +88,7 @@ export function useWeatherData(stations: WeatherStation[]) {
const obs = await getRecentObservation(obsCode)
if (obs) {
const r = (n: number) => Math.round(n * 10) / 10
const windSpeed = r(obs.wind_speed ?? 8.5)

파일 보기

@ -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,21 +22,22 @@ 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: {
interface OceanForecastApiResponse {
header: { resultCode: string; resultMsg: string }
body: {
items: { item: OceanForecastData[] }
pageNo: number
numOfRows: number
totalCount: number
}
}
}
/**
@ -49,9 +50,9 @@ export async function getOceanForecast(
): Promise<OceanForecastData[]> {
try {
const params = new URLSearchParams({
ServiceKey: API_KEY,
type: regionType,
ResultType: 'json'
serviceKey: API_KEY,
areaCode: regionType,
type: 'json',
})
const response = await fetch(`${BASE_URL}?${params}`)
@ -60,20 +61,8 @@ export async function getOceanForecast(
throw new Error(`HTTP Error: ${response.status}`)
}
const data = await response.json()
// API 응답 구조에 따라 데이터 추출
if (data?.result?.data) {
return data.result.data
}
// 응답이 배열 형태인 경우
if (Array.isArray(data)) {
return data
}
console.warn('Unexpected API response structure:', data)
return []
const data = await response.json() as OceanForecastApiResponse
return data?.body?.items?.item ?? []
} catch (error) {
console.error('해황예보도 조회 오류:', error)
@ -89,10 +78,9 @@ export async function getOceanForecast(
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
if (!forecasts || forecasts.length === 0) return null
// 날짜와 시간을 기준으로 정렬
const sorted = [...forecasts].sort((a, b) => {
const dateTimeA = `${a.day}${a.hour}`
const dateTimeB = `${b.day}${b.hour}`
const dateTimeA = `${a.ofcFrcstYmd}${a.ofcFrcstTm}`
const dateTimeB = `${b.ofcFrcstYmd}${b.ofcFrcstTm}`
return dateTimeB.localeCompare(dateTimeA)
})
@ -112,7 +100,7 @@ export function getForecastByTime(
targetHour: string
): OceanForecastData | null {
return (
forecasts.find((f) => f.day === targetDay && f.hour === targetHour) || null
forecasts.find((f) => f.ofcFrcstYmd === targetDay && f.ofcFrcstTm === targetHour) || null
)
}
@ -157,23 +145,25 @@ export async function getRecentObservation(obsCode: string): Promise<RecentObser
})
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const item = data?.result?.data?.[0]
const item = data.body ? data.body.items.item[0] : null
if (!item) return null
return {
water_temp: item.water_temp != null ? parseFloat(item.water_temp) : null,
air_temp: item.air_temp != null ? parseFloat(item.air_temp) : null,
air_pres: item.air_pres != null ? parseFloat(item.air_pres) : null,
wind_dir: item.wind_dir != null ? parseFloat(item.wind_dir) : null,
wind_speed: item.wind_speed != null ? parseFloat(item.wind_speed) : null,
current_dir: item.current_dir != null ? parseFloat(item.current_dir) : null,
current_speed: item.current_speed != null ? parseFloat(item.current_speed) : null,
tide_level: item.tide_level != null ? parseFloat(item.tide_level) : null,
water_temp: item.wtem != null ? parseFloat(item.wtem) : null,
air_temp: item.artmp != null ? parseFloat(item.artmp) : null,
air_pres: item.atmpr != null ? parseFloat(item.atmpr) : null,
wind_dir: item.wndrct != null ? parseFloat(item.wndrct) : null,
wind_speed: item.wspd != null ? parseFloat(item.wspd) : null,
current_dir: item.crdir != null ? parseFloat(item.crdir) : null,
current_speed: item.crsp != null ? parseFloat(item.crsp) : null,
tide_level: item.bscTdlvHgt != null ? parseFloat(item.bscTdlvHgt) : null,
}
} catch (error) {
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)

파일 보기

@ -13,6 +13,10 @@ export default defineConfig({
target: 'http://localhost:3001',
changeOrigin: true,
},
'/daily_ocean': {
target: 'https://www.khoa.go.kr',
changeOrigin: true,
},
},
},
resolve: {