feat(prediction): OpenDrift ���� Ȯ�� �ùķ��̼� ���� + �̹��� �м� Docker ȯ�� #83
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-06",
|
||||
"applied_date": "2026-03-09",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -55,6 +55,14 @@ wing_source_*.tar.gz
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# prediction/ Python 엔진 (로컬 실행 결과물)
|
||||
prediction/**/__pycache__/
|
||||
prediction/**/*.pyc
|
||||
prediction/opendrift/result/
|
||||
prediction/opendrift/logs/
|
||||
prediction/opendrift/uvicorn.pid
|
||||
prediction/opendrift/.env
|
||||
|
||||
# HNS manual images (large binary)
|
||||
frontend/public/hns-manual/pages/
|
||||
frontend/public/hns-manual/images/
|
||||
@ -63,6 +71,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/
|
||||
|
||||
@ -7,6 +7,8 @@ import {
|
||||
createSatRequest,
|
||||
updateSatRequestStatus,
|
||||
isValidSatStatus,
|
||||
requestOilInference,
|
||||
checkInferenceHealth,
|
||||
} from './aerialService.js';
|
||||
import { isValidNumber } from '../middleware/security.js';
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
||||
@ -221,4 +223,44 @@ router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'C
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// OIL INFERENCE 라우트
|
||||
// ============================================================
|
||||
|
||||
// POST /api/aerial/oil-detect — 오일 유출 감지 (GPU 추론 서버 프록시)
|
||||
// base64 이미지 전송을 위해 3MB JSON 파서 적용
|
||||
router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const { image } = req.body;
|
||||
if (!image || typeof image !== 'string') {
|
||||
res.status(400).json({ error: 'image (base64) 필드가 필요합니다' });
|
||||
return;
|
||||
}
|
||||
|
||||
// base64 크기 제한 (약 2MB 이미지)
|
||||
if (image.length > 3_000_000) {
|
||||
res.status(400).json({ error: '이미지 크기가 너무 큽니다 (최대 2MB)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await requestOilInference(image);
|
||||
res.json(result);
|
||||
} 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();
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@ -339,3 +339,62 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
||||
[sttsCd, sn]
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OIL INFERENCE (GPU 서버 프록시)
|
||||
// ============================================================
|
||||
|
||||
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
||||
const INFERENCE_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface OilInferenceRegion {
|
||||
classId: number;
|
||||
className: string;
|
||||
pixelCount: number;
|
||||
percentage: number;
|
||||
thicknessMm: number;
|
||||
}
|
||||
|
||||
export interface OilInferenceResult {
|
||||
mask: string; // base64 uint8 array (values 0-4)
|
||||
width: number;
|
||||
height: number;
|
||||
regions: OilInferenceRegion[];
|
||||
}
|
||||
|
||||
/** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */
|
||||
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${OIL_INFERENCE_URL}/inference`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: imageBase64 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Inference server responded ${response.status}: ${detail}`);
|
||||
}
|
||||
|
||||
return await response.json() as OilInferenceResult;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/** GPU 추론 서버 헬스체크 */
|
||||
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${OIL_INFERENCE_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`status ${response.status}`);
|
||||
return await response.json() as { status: string; device?: string };
|
||||
} catch {
|
||||
return { status: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,10 +72,16 @@ export function requirePermission(resource: string, operation: string = 'READ')
|
||||
req.resolvedPermissions = userInfo.permissions
|
||||
}
|
||||
|
||||
const allowedOps = req.resolvedPermissions[resource]
|
||||
if (allowedOps && allowedOps.includes(operation)) {
|
||||
next()
|
||||
return
|
||||
// 정확한 리소스 매칭 → 부모 리소스 fallback (board:notice → board)
|
||||
let cursor: string | undefined = resource
|
||||
while (cursor) {
|
||||
const allowedOps = req.resolvedPermissions[cursor]
|
||||
if (allowedOps && allowedOps.includes(operation)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
const colonIdx = cursor.lastIndexOf(':')
|
||||
cursor = colonIdx > 0 ? cursor.substring(0, colonIdx) : undefined
|
||||
}
|
||||
|
||||
res.status(403).json({ error: '접근 권한이 없습니다.' })
|
||||
|
||||
@ -112,6 +112,23 @@ export async function login(
|
||||
return userInfo
|
||||
}
|
||||
|
||||
/** AUTH_PERM_TREE 없이 플랫 권한을 RSRC_CD + OPER_CD 기준으로 조회 */
|
||||
async function flatPermissionsFallback(userId: string): Promise<Record<string, string[]>> {
|
||||
const permsResult = await authPool.query(
|
||||
`SELECT DISTINCT p.RSRC_CD as rsrc_cd, p.OPER_CD as oper_cd
|
||||
FROM AUTH_PERM p
|
||||
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
||||
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
||||
[userId]
|
||||
)
|
||||
const perms: Record<string, string[]> = {}
|
||||
for (const p of permsResult.rows) {
|
||||
if (!perms[p.rsrc_cd]) perms[p.rsrc_cd] = []
|
||||
if (!perms[p.rsrc_cd].includes(p.oper_cd)) perms[p.rsrc_cd].push(p.oper_cd)
|
||||
}
|
||||
return perms
|
||||
}
|
||||
|
||||
export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
||||
const userResult = await authPool.query(
|
||||
`SELECT u.USER_ID as user_id, u.USER_ACNT as user_acnt, u.USER_NM as user_nm,
|
||||
@ -170,30 +187,15 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
||||
permissions = grantedSetToRecord(granted)
|
||||
} else {
|
||||
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
|
||||
const permsResult = await authPool.query(
|
||||
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
||||
FROM AUTH_PERM p
|
||||
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
||||
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
||||
[userId]
|
||||
)
|
||||
permissions = {}
|
||||
for (const p of permsResult.rows) {
|
||||
permissions[p.rsrc_cd] = ['READ']
|
||||
}
|
||||
permissions = await flatPermissionsFallback(userId)
|
||||
}
|
||||
} catch {
|
||||
// AUTH_PERM_TREE 테이블 미존재 시 fallback
|
||||
const permsResult = await authPool.query(
|
||||
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
||||
FROM AUTH_PERM p
|
||||
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
||||
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
||||
[userId]
|
||||
)
|
||||
permissions = {}
|
||||
for (const p of permsResult.rows) {
|
||||
permissions[p.rsrc_cd] = ['READ']
|
||||
try {
|
||||
permissions = await flatPermissionsFallback(userId)
|
||||
} catch {
|
||||
console.error('[auth] 권한 조회 fallback 실패, 빈 권한 반환')
|
||||
permissions = {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { Router } from 'express'
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
import {
|
||||
listPosts, getPost, createPost, updatePost, deletePost,
|
||||
listPosts, getPost, createPost, updatePost, deletePost, adminDeletePost,
|
||||
listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
|
||||
} from './boardService.js'
|
||||
|
||||
@ -209,4 +209,22 @@ router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/board/admin-delete — 관리자 전용 게시글 삭제 (소유자 검증 없음)
|
||||
router.post('/admin-delete', requireAuth, requirePermission('admin', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const { sn } = req.body
|
||||
const postSn = typeof sn === 'number' ? sn : parseInt(sn, 10)
|
||||
if (isNaN(postSn)) {
|
||||
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
|
||||
return
|
||||
}
|
||||
await adminDeletePost(postSn)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
|
||||
console.error('[board] 관리자 삭제 오류:', err)
|
||||
res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@ -398,3 +398,18 @@ export async function deletePost(postSn: number, requesterId: string): Promise<v
|
||||
[postSn]
|
||||
)
|
||||
}
|
||||
|
||||
/** 관리자 전용 삭제 — 소유자 검증 없이 논리 삭제 */
|
||||
export async function adminDeletePost(postSn: number): Promise<void> {
|
||||
const existing = await wingPool.query(
|
||||
`SELECT POST_SN FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
|
||||
[postSn]
|
||||
)
|
||||
if (existing.rows.length === 0) {
|
||||
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
await wingPool.query(
|
||||
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
|
||||
[postSn]
|
||||
)
|
||||
}
|
||||
|
||||
185
backend/src/inference/oil_inference_server.py
Normal file
185
backend/src/inference/oil_inference_server.py
Normal file
@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
오일 유출 감지 추론 서버 (GPU)
|
||||
시립대 starsafire ResNet101+DANet 모델 기반
|
||||
|
||||
실행: uvicorn oil_inference_server:app --host 0.0.0.0 --port 8090
|
||||
모델 파일 필요: ./V7_SPECIAL.py, ./epoch_165.pth (같은 디렉토리)
|
||||
"""
|
||||
|
||||
import os
|
||||
import io
|
||||
import base64
|
||||
import logging
|
||||
from collections import Counter
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
# ── MMSegmentation (지연 임포트 — 서버 시작 시 로드) ─────────────────────────
|
||||
model = None
|
||||
DEVICE = os.getenv("INFERENCE_DEVICE", "cuda:0")
|
||||
|
||||
CLASSES = ("background", "black", "brown", "rainbow", "silver")
|
||||
PALETTE = [
|
||||
[0, 0, 0], # 0: background
|
||||
[0, 0, 204], # 1: black oil (에멀전)
|
||||
[180, 180, 180], # 2: brown oil (원유)
|
||||
[255, 255, 0], # 3: rainbow oil (박막)
|
||||
[178, 102, 255], # 4: silver oil (극박막)
|
||||
]
|
||||
THICKNESS_MM = {
|
||||
1: 1.0, # black
|
||||
2: 0.1, # brown
|
||||
3: 0.0003, # rainbow
|
||||
4: 0.0001, # silver
|
||||
}
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("oil-inference")
|
||||
|
||||
# ── FastAPI App ──────────────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(title="Oil Spill Inference Server", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class InferenceRequest(BaseModel):
|
||||
image: str # base64 encoded JPEG/PNG
|
||||
|
||||
|
||||
class OilRegionResult(BaseModel):
|
||||
classId: int
|
||||
className: str
|
||||
pixelCount: int
|
||||
percentage: float
|
||||
thicknessMm: float
|
||||
|
||||
|
||||
class InferenceResponse(BaseModel):
|
||||
mask: str # base64 encoded uint8 array (values 0-4)
|
||||
width: int
|
||||
height: int
|
||||
regions: list[OilRegionResult]
|
||||
|
||||
|
||||
# ── Model Loading ────────────────────────────────────────────────────────────
|
||||
|
||||
def load_model():
|
||||
"""모델을 로드한다. 서버 시작 시 1회 호출."""
|
||||
global model
|
||||
try:
|
||||
from mmseg.apis import init_segmentor
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, "V7_SPECIAL.py")
|
||||
checkpoint_path = os.path.join(script_dir, "epoch_165.pth")
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
logger.error(f"Config not found: {config_path}")
|
||||
return False
|
||||
if not os.path.exists(checkpoint_path):
|
||||
logger.error(f"Checkpoint not found: {checkpoint_path}")
|
||||
return False
|
||||
|
||||
logger.info(f"Loading model on {DEVICE}...")
|
||||
model = init_segmentor(config_path, checkpoint_path, device=DEVICE)
|
||||
model.PALETTE = PALETTE
|
||||
logger.info("Model loaded successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Model loading failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
success = load_model()
|
||||
if not success:
|
||||
logger.warning("Model not loaded — inference will be unavailable")
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok" if model is not None else "model_not_loaded",
|
||||
"device": DEVICE,
|
||||
"classes": list(CLASSES),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/inference", response_model=InferenceResponse)
|
||||
async def inference(req: InferenceRequest):
|
||||
if model is None:
|
||||
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||
|
||||
try:
|
||||
# 1. Base64 → numpy array
|
||||
img_bytes = base64.b64decode(req.image)
|
||||
img_pil = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
img_np = np.array(img_pil)
|
||||
|
||||
# 2. 임시 파일로 저장 (mmseg inference_segmentor는 파일 경로 필요)
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
cv2.imwrite(tmp_path, cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR))
|
||||
|
||||
# 3. 추론
|
||||
from mmseg.apis import inference_segmentor
|
||||
result = inference_segmentor(model, tmp_path)
|
||||
seg_map = result[0] # (H, W) uint8, values 0-4
|
||||
|
||||
# 임시 파일 삭제
|
||||
os.unlink(tmp_path)
|
||||
|
||||
h, w = seg_map.shape
|
||||
total_pixels = h * w
|
||||
|
||||
# 4. 클래스별 통계
|
||||
counter = Counter(seg_map.flatten().tolist())
|
||||
regions = []
|
||||
for class_id in range(1, 5): # 1-4 (skip background)
|
||||
count = counter.get(class_id, 0)
|
||||
if count > 0:
|
||||
regions.append(OilRegionResult(
|
||||
classId=class_id,
|
||||
className=CLASSES[class_id],
|
||||
pixelCount=count,
|
||||
percentage=round(count / total_pixels * 100, 2),
|
||||
thicknessMm=THICKNESS_MM[class_id],
|
||||
))
|
||||
|
||||
# 5. 마스크를 base64로 인코딩
|
||||
mask_bytes = seg_map.astype(np.uint8).tobytes()
|
||||
mask_b64 = base64.b64encode(mask_bytes).decode("ascii")
|
||||
|
||||
return InferenceResponse(
|
||||
mask=mask_b64,
|
||||
width=w,
|
||||
height=h,
|
||||
regions=regions,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Inference error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8090)
|
||||
8
backend/src/inference/requirements.txt
Normal file
8
backend/src/inference/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
torch>=1.13.0
|
||||
mmcv-full>=1.7.0
|
||||
mmsegmentation>=0.30.0
|
||||
opencv-python-headless>=4.8.0
|
||||
numpy>=1.24.0
|
||||
Pillow>=10.0.0
|
||||
@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
import {
|
||||
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
||||
createBacktrack, saveBoomLine, listBoomLines,
|
||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||
} from './predictionService.js';
|
||||
import { isValidNumber } from '../middleware/security.js';
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
||||
@ -40,6 +40,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 {
|
||||
|
||||
@ -404,6 +404,100 @@ 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;
|
||||
}
|
||||
|
||||
interface TrajectoryResult {
|
||||
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1 }>;
|
||||
summary: {
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
pollutionArea: number;
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
};
|
||||
centerPoints: Array<{ lat: number; lon: number; time: number }>;
|
||||
windData: TrajectoryWindPoint[][];
|
||||
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
|
||||
}
|
||||
|
||||
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryResult {
|
||||
const trajectory = rawResult.flatMap((step, stepIdx) =>
|
||||
step.particles.map((p, i) => ({
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
time: stepIdx,
|
||||
particle: i,
|
||||
stranded: p.stranded,
|
||||
}))
|
||||
);
|
||||
const lastStep = rawResult[rawResult.length - 1];
|
||||
const summary = {
|
||||
remainingVolume: lastStep.remaining_volume_m3,
|
||||
weatheredVolume: lastStep.weathered_volume_m3,
|
||||
pollutionArea: lastStep.pollution_area_km2,
|
||||
beachedVolume: lastStep.beached_volume_m3,
|
||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||
};
|
||||
const centerPoints = rawResult
|
||||
.map((step, stepIdx) =>
|
||||
step.center_lat != null && step.center_lon != null
|
||||
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx }
|
||||
: null
|
||||
)
|
||||
.filter((p): p is { lat: number; lon: number; time: number } => 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> {
|
||||
const sql = `
|
||||
SELECT RSLT_DATA FROM wing.PRED_EXEC
|
||||
WHERE ACDNT_SN = $1 AND ALGO_CD = 'OPENDRIFT' AND EXEC_STTS_CD = 'COMPLETED'
|
||||
ORDER BY CMPL_DTM DESC LIMIT 1
|
||||
`;
|
||||
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||
if (rows.length === 0 || !rows[0].rslt_data) return null;
|
||||
return transformTrajectoryResult(rows[0].rslt_data as TrajectoryTimeStep[]);
|
||||
}
|
||||
|
||||
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
||||
const sql = `
|
||||
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
||||
|
||||
@ -1,227 +1,452 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { wingPool } from '../db/wingDb.js'
|
||||
import { requireAuth } from '../auth/authMiddleware.js'
|
||||
import {
|
||||
isValidLatitude,
|
||||
isValidLongitude,
|
||||
isValidNumber,
|
||||
isAllowedValue,
|
||||
isValidStringLength,
|
||||
escapeHtml,
|
||||
} from '../middleware/security.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 허용된 모델 목록 (화이트리스트)
|
||||
const ALLOWED_MODELS = ['KOSPS', 'POSEIDON', 'OpenDrift', '앙상블'] as const
|
||||
type AllowedModel = typeof ALLOWED_MODELS[number]
|
||||
const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003'
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
|
||||
|
||||
// 허용된 유종 목록
|
||||
const ALLOWED_OIL_TYPES = ['원유', '벙커C유', '경유', '휘발유', '등유', '윤활유', '기타'] as const
|
||||
|
||||
// 허용된 유출 유형 목록
|
||||
const ALLOWED_SPILL_TYPES = ['연속유출', '순간유출'] as const
|
||||
|
||||
interface ParticlePoint {
|
||||
lat: number
|
||||
lon: number
|
||||
time: number
|
||||
particle: number
|
||||
// 유종 매핑: 한국어 UI 선택값 → OpenDrift 유종 코드
|
||||
// 추후 DB/설정 파일로 외부화 예정 (개발 단계 임시 구현)
|
||||
const OIL_TYPE_MAP: Record<string, string> = {
|
||||
'벙커C유': 'GENERIC BUNKER C',
|
||||
'경유': 'GENERIC DIESEL',
|
||||
'원유': 'WEST TEXAS INTERMEDIATE (WTI)',
|
||||
'중유': 'GENERIC HEAVY FUEL OIL',
|
||||
'등유': 'FUEL OIL NO.1 (KEROSENE)',
|
||||
'휘발유': 'GENERIC GASOLINE',
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/simulation/run
|
||||
* 오일 확산 시뮬레이션 실행
|
||||
*
|
||||
* 보안 조치:
|
||||
* - 화이트리스트 기반 모델명 검증
|
||||
* - 좌표 범위 검증 (위도 -90~90, 경도 -180~180)
|
||||
* - 숫자 범위 검증 (duration, spill_amount)
|
||||
* - 문자열 길이 제한
|
||||
*/
|
||||
router.post('/run', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { model, lat, lon, duration_hours, oil_type, spill_amount, spill_type } = req.body
|
||||
// 유종 매핑: 한국어 UI → DB 저장 코드
|
||||
const OIL_DB_CODE_MAP: Record<string, string> = {
|
||||
'벙커C유': 'BUNKER_C',
|
||||
'경유': 'DIESEL',
|
||||
'원유': 'CRUDE_OIL',
|
||||
'중유': 'HEAVY_FUEL_OIL',
|
||||
'등유': 'KEROSENE',
|
||||
'휘발유': 'GASOLINE',
|
||||
}
|
||||
|
||||
// 1. 필수 파라미터 존재 검증
|
||||
if (model === undefined || lat === undefined || lon === undefined || duration_hours === undefined) {
|
||||
// 유출 형태 매핑: 한국어 UI → DB 저장 코드
|
||||
const SPIL_TYPE_MAP: Record<string, string> = {
|
||||
'연속': 'CONTINUOUS',
|
||||
'비연속': 'DISCONTINUOUS',
|
||||
'순간 유출': 'INSTANT',
|
||||
}
|
||||
|
||||
// 단위 매핑: 한국어 UI → DB 저장 코드
|
||||
const UNIT_MAP: Record<string, string> = {
|
||||
'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/simulation/run
|
||||
// 확산 시뮬레이션 실행 (OpenDrift)
|
||||
// ============================================================
|
||||
/**
|
||||
* OpenDrift 확산 시뮬레이션을 실행한다.
|
||||
* Python FastAPI 서버에 작업을 제출하고 job_id를 받아
|
||||
* 백그라운드에서 폴링하며 결과를 DB에 저장한다.
|
||||
* 프론트엔드는 execSn으로 GET /status/:execSn을 폴링하여 결과를 수신한다.
|
||||
*/
|
||||
router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { acdntSn: rawAcdntSn, acdntNm, spillUnit, spillTypeCd,
|
||||
lat, lon, runTime, matTy, matVol, spillTime, startTime } = req.body
|
||||
|
||||
// 1. 필수 파라미터 검증
|
||||
if (lat === undefined || lon === undefined || runTime === undefined) {
|
||||
return res.status(400).json({
|
||||
error: '필수 파라미터 누락',
|
||||
required: ['model', 'lat', 'lon', 'duration_hours']
|
||||
required: ['lat', 'lon', 'runTime'],
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 모델명 화이트리스트 검증
|
||||
if (!isAllowedValue(model, [...ALLOWED_MODELS])) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 모델',
|
||||
message: `허용된 모델: ${ALLOWED_MODELS.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 위도/경도 범위 검증
|
||||
if (!isValidLatitude(lat)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 위도',
|
||||
message: '위도는 -90 ~ 90 범위의 숫자여야 합니다.'
|
||||
})
|
||||
return res.status(400).json({ error: '유효하지 않은 위도', message: '위도는 -90~90 범위여야 합니다.' })
|
||||
}
|
||||
if (!isValidLongitude(lon)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 경도',
|
||||
message: '경도는 -180 ~ 180 범위의 숫자여야 합니다.'
|
||||
})
|
||||
return res.status(400).json({ error: '유효하지 않은 경도', message: '경도는 -180~180 범위여야 합니다.' })
|
||||
}
|
||||
if (!isValidNumber(runTime, 1, 720)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 예측 시간', message: '예측 시간은 1~720 범위여야 합니다.' })
|
||||
}
|
||||
if (matVol !== undefined && !isValidNumber(matVol, 0, 1000000)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 유출량' })
|
||||
}
|
||||
if (matTy !== undefined && (typeof matTy !== 'string' || !isValidStringLength(matTy, 50))) {
|
||||
return res.status(400).json({ error: '유효하지 않은 유종' })
|
||||
}
|
||||
// acdntSn 없는 경우 acdntNm 필수
|
||||
if (!rawAcdntSn && (!acdntNm || typeof acdntNm !== 'string' || !acdntNm.trim())) {
|
||||
return res.status(400).json({ error: '사고를 선택하거나 사고명을 입력해야 합니다.' })
|
||||
}
|
||||
if (acdntNm && (typeof acdntNm !== 'string' || !isValidStringLength(acdntNm, 200))) {
|
||||
return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' })
|
||||
}
|
||||
|
||||
// 4. 예측 시간 범위 검증 (1~720시간 = 최대 30일)
|
||||
if (!isValidNumber(duration_hours, 1, 720)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 예측 시간',
|
||||
message: '예측 시간은 1~720 범위의 숫자여야 합니다.'
|
||||
})
|
||||
}
|
||||
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
|
||||
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
|
||||
let resolvedSpilDataSn: number | null = null
|
||||
|
||||
// 5. 선택적 파라미터 검증
|
||||
if (oil_type !== undefined) {
|
||||
if (typeof oil_type !== 'string' || !isValidStringLength(oil_type, 50)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 유종' })
|
||||
if (!resolvedAcdntSn && acdntNm) {
|
||||
try {
|
||||
const occrn = startTime ?? new Date().toISOString()
|
||||
const acdntRes = await wingPool.query(
|
||||
`INSERT INTO wing.ACDNT
|
||||
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
|
||||
VALUES (
|
||||
'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-' ||
|
||||
LPAD(
|
||||
(SELECT COALESCE(MAX(CAST(SPLIT_PART(ACDNT_CD, '-', 3) AS INTEGER)), 0) + 1
|
||||
FROM wing.ACDNT
|
||||
WHERE ACDNT_CD LIKE 'INC-' || EXTRACT(YEAR FROM NOW())::TEXT || '-%')::TEXT,
|
||||
4, '0'
|
||||
),
|
||||
$1, '유류유출', $2, $3, $4, 'ACTIVE', 'Y', NOW()
|
||||
)
|
||||
RETURNING ACDNT_SN`,
|
||||
[acdntNm.trim(), occrn, lat, lon]
|
||||
)
|
||||
resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number
|
||||
|
||||
const spilRes = await wingPool.query(
|
||||
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
RETURNING SPIL_DATA_SN`,
|
||||
[
|
||||
resolvedAcdntSn,
|
||||
OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C',
|
||||
matVol ?? 0,
|
||||
UNIT_MAP[spillUnit as string] ?? 'KL',
|
||||
SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS',
|
||||
runTime,
|
||||
]
|
||||
)
|
||||
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
|
||||
} catch (dbErr) {
|
||||
console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr)
|
||||
return res.status(500).json({ error: '사고 정보 생성 실패' })
|
||||
}
|
||||
}
|
||||
|
||||
if (spill_amount !== undefined) {
|
||||
if (!isValidNumber(spill_amount, 0, 1000000)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 유출량',
|
||||
message: '유출량은 0~1,000,000 범위의 숫자여야 합니다.'
|
||||
// 2. Python NC 파일 존재 여부 확인
|
||||
try {
|
||||
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lat, lon, startTime }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!checkRes.ok) {
|
||||
return res.status(409).json({
|
||||
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
|
||||
message: 'NC 파일이 준비되지 않았습니다.',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Python 서버 미기동 — 5번에서 처리
|
||||
}
|
||||
|
||||
if (spill_type !== undefined) {
|
||||
if (typeof spill_type !== 'string' || !isValidStringLength(spill_type, 50)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 유출 유형' })
|
||||
// 3. 기존 사고의 경우 SPIL_DATA_SN 조회
|
||||
if (resolvedAcdntSn && !resolvedSpilDataSn) {
|
||||
try {
|
||||
const spilRes = await wingPool.query(
|
||||
`SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`,
|
||||
[resolvedAcdntSn]
|
||||
)
|
||||
if (spilRes.rows.length > 0) {
|
||||
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
|
||||
}
|
||||
} catch (dbErr) {
|
||||
console.error('[simulation] SPIL_DATA 조회 실패:', dbErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 검증 완료 - 시뮬레이션 실행
|
||||
const trajectory = generateDemoTrajectory(
|
||||
lat,
|
||||
lon,
|
||||
duration_hours,
|
||||
model,
|
||||
20
|
||||
// 4. PRED_EXEC INSERT (PENDING) — ACDNT_SN 포함 (NOT NULL FK)
|
||||
const execNm = `EXPC_${Date.now()}`
|
||||
let predExecSn: number
|
||||
try {
|
||||
const insertRes = await wingPool.query(
|
||||
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM)
|
||||
VALUES ($1, $2, 'OPENDRIFT', 'PENDING', $3, NOW())
|
||||
RETURNING PRED_EXEC_SN`,
|
||||
[resolvedAcdntSn, resolvedSpilDataSn, execNm]
|
||||
)
|
||||
predExecSn = insertRes.rows[0].pred_exec_sn as number
|
||||
} catch (dbErr) {
|
||||
console.error('[simulation] PRED_EXEC INSERT 실패:', dbErr)
|
||||
return res.status(500).json({ error: '분석 기록 생성 실패' })
|
||||
}
|
||||
|
||||
// matTy 변환: 한국어 유종 → OpenDrift 유종 코드
|
||||
// 매핑 대상이 아니면 원본 값 그대로 사용 (영문 직접 입력 대응)
|
||||
const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined
|
||||
|
||||
// 5. Python /run-model 호출
|
||||
let jobId: string
|
||||
try {
|
||||
const pythonRes = await fetch(`${PYTHON_API_URL}/run-model`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lat,
|
||||
lon,
|
||||
startTime,
|
||||
runTime,
|
||||
matTy: odMatTy,
|
||||
matVol,
|
||||
spillTime,
|
||||
name: execNm,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (pythonRes.status === 503) {
|
||||
const errData = await pythonRes.json() as { error?: string }
|
||||
await wingPool.query(
|
||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
|
||||
[errData.error || '분석 서버 포화', predExecSn]
|
||||
)
|
||||
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
|
||||
}
|
||||
|
||||
if (!pythonRes.ok) {
|
||||
throw new Error(`Python 서버 응답 오류: ${pythonRes.status}`)
|
||||
}
|
||||
|
||||
const pythonData = await pythonRes.json() as { job_id: string }
|
||||
jobId = pythonData.job_id
|
||||
} catch {
|
||||
await wingPool.query(
|
||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
||||
[predExecSn]
|
||||
)
|
||||
return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 6. RUNNING 업데이트
|
||||
await wingPool.query(
|
||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='RUNNING' WHERE PRED_EXEC_SN=$1`,
|
||||
[predExecSn]
|
||||
)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
model: escapeHtml(String(model)),
|
||||
parameters: {
|
||||
lat,
|
||||
lon,
|
||||
duration_hours,
|
||||
oil_type: oil_type ? escapeHtml(String(oil_type)) : undefined,
|
||||
spill_amount,
|
||||
spill_type: spill_type ? escapeHtml(String(spill_type)) : undefined,
|
||||
},
|
||||
trajectory,
|
||||
metadata: {
|
||||
particle_count: 20,
|
||||
time_steps: duration_hours + 1,
|
||||
generated_at: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
// 7. 즉시 응답 (프론트엔드는 execSn으로 폴링, acdntSn은 신규 생성 사고 추적용)
|
||||
res.json({ success: true, execSn: predExecSn, acdntSn: resolvedAcdntSn, status: 'RUNNING' })
|
||||
|
||||
// 8. 백그라운드 폴링 시작
|
||||
pollAndSave(jobId, predExecSn).catch((err: unknown) =>
|
||||
console.error('[simulation] pollAndSave 오류:', err)
|
||||
)
|
||||
} catch {
|
||||
// 내부 오류 메시지 노출 방지
|
||||
res.status(500).json({
|
||||
error: '시뮬레이션 실행 실패',
|
||||
message: '서버 내부 오류가 발생했습니다.'
|
||||
})
|
||||
res.status(500).json({ error: '시뮬레이션 실행 실패', message: '서버 내부 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// GET /api/simulation/status/:execSn
|
||||
// 시뮬레이션 실행 상태 및 결과 조회
|
||||
// ============================================================
|
||||
/**
|
||||
* 데모 궤적 데이터 생성
|
||||
* PRED_EXEC 테이블에서 실행 상태를 조회한다.
|
||||
* DB 상태(COMPLETED/FAILED)를 프론트 상태(DONE/ERROR)로 매핑하여 반환한다.
|
||||
*/
|
||||
function generateDemoTrajectory(
|
||||
startLat: number,
|
||||
startLon: number,
|
||||
hours: number,
|
||||
model: string,
|
||||
particleCount: number
|
||||
): ParticlePoint[] {
|
||||
const trajectory: ParticlePoint[] = []
|
||||
|
||||
const modelFactors: Record<string, number> = {
|
||||
'KOSPS': 0.004,
|
||||
'POSEIDON': 0.006,
|
||||
'OpenDrift': 0.005,
|
||||
'앙상블': 0.0055
|
||||
router.get('/status/:execSn', requireAuth, async (req: Request, res: Response) => {
|
||||
const execSn = parseInt(req.params.execSn as string, 10)
|
||||
if (isNaN(execSn) || execSn <= 0) {
|
||||
return res.status(400).json({ error: '유효하지 않은 execSn' })
|
||||
}
|
||||
const spreadFactor = modelFactors[model] || 0.005
|
||||
|
||||
const windSpeed = 5.5
|
||||
const windDirection = 135
|
||||
const currentSpeed = 0.55
|
||||
const currentDirection = 120
|
||||
const waveHeight = 2.2
|
||||
try {
|
||||
const result = await wingPool.query(
|
||||
`SELECT pe.EXEC_STTS_CD, pe.RSLT_DATA, pe.ERR_MSG, pe.BGNG_DTM, sd.FCST_HR,
|
||||
(
|
||||
SELECT AVG(hist.REQD_SEC::FLOAT / hsd.FCST_HR)
|
||||
FROM wing.PRED_EXEC hist
|
||||
JOIN wing.SPIL_DATA hsd ON hist.SPIL_DATA_SN = hsd.SPIL_DATA_SN
|
||||
WHERE hist.ALGO_CD = pe.ALGO_CD
|
||||
AND hist.EXEC_STTS_CD = 'COMPLETED'
|
||||
AND hist.REQD_SEC IS NOT NULL AND hist.REQD_SEC > 0
|
||||
AND hsd.FCST_HR IS NOT NULL AND hsd.FCST_HR > 0
|
||||
) AS avg_sec_per_hr
|
||||
FROM wing.PRED_EXEC pe
|
||||
LEFT JOIN wing.SPIL_DATA sd ON pe.SPIL_DATA_SN = sd.SPIL_DATA_SN
|
||||
WHERE pe.PRED_EXEC_SN=$1`,
|
||||
[execSn]
|
||||
)
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: '분석 기록을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const windRadians = (windDirection * Math.PI) / 180
|
||||
const currentRadians = (currentDirection * Math.PI) / 180
|
||||
const row = result.rows[0]
|
||||
const dbStatus: string = row.exec_stts_cd as string
|
||||
// DB 상태 → API 상태 매핑
|
||||
const statusMap: Record<string, string> = {
|
||||
PENDING: 'PENDING',
|
||||
RUNNING: 'RUNNING',
|
||||
COMPLETED: 'DONE',
|
||||
FAILED: 'ERROR',
|
||||
}
|
||||
const status = statusMap[dbStatus] ?? dbStatus
|
||||
|
||||
const windWeight = 0.03
|
||||
const currentWeight = 0.07
|
||||
if (status === 'DONE' && row.rslt_data) {
|
||||
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(row.rslt_data as PythonTimeStep[])
|
||||
return res.json({ status, trajectory, summary, centerPoints, windData, hydrData })
|
||||
}
|
||||
|
||||
const mainDriftLat =
|
||||
Math.sin(windRadians) * windSpeed * windWeight +
|
||||
Math.sin(currentRadians) * currentSpeed * currentWeight
|
||||
if (status === 'ERROR') {
|
||||
return res.json({ status, error: (row.err_msg as string) || '분석 중 오류가 발생했습니다.' })
|
||||
}
|
||||
|
||||
const mainDriftLon =
|
||||
Math.cos(windRadians) * windSpeed * windWeight +
|
||||
Math.cos(currentRadians) * currentSpeed * currentWeight
|
||||
// PENDING/RUNNING: 경과 시간 기반 진행률 계산
|
||||
// 과거 실행의 초/예측시간 비율(avg_sec_per_hr) × 현재 fcst_hr로 추정, 이력 없으면 5초/hr 폴백
|
||||
let progress: number | undefined;
|
||||
if (status === 'RUNNING' && row.bgng_dtm) {
|
||||
const fcstHr = Number(row.fcst_hr) || 24;
|
||||
const avgSecPerHr = row.avg_sec_per_hr ? Number(row.avg_sec_per_hr) : 5;
|
||||
const estimatedSec = avgSecPerHr * fcstHr;
|
||||
const elapsedSec = (Date.now() - new Date(row.bgng_dtm as string).getTime()) / 1000;
|
||||
progress = Math.min(95, Math.floor((elapsedSec / estimatedSec) * 100));
|
||||
}
|
||||
|
||||
const dispersal = waveHeight * 0.001
|
||||
res.json({ status, ...(progress !== undefined && { progress }) })
|
||||
} catch {
|
||||
res.status(500).json({ error: '상태 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
for (let p = 0; p < particleCount; p++) {
|
||||
const initialSpread = 0.001
|
||||
const randomAngle = Math.random() * Math.PI * 2
|
||||
let particleLat = startLat + Math.sin(randomAngle) * initialSpread * Math.random()
|
||||
let particleLon = startLon + Math.cos(randomAngle) * initialSpread * Math.random()
|
||||
// ============================================================
|
||||
// 백그라운드 폴링
|
||||
// ============================================================
|
||||
async function pollAndSave(jobId: string, execSn: number): Promise<void> {
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
||||
|
||||
for (let h = 0; h <= hours; h++) {
|
||||
const mainMovementLat = mainDriftLat * h * 0.01
|
||||
const mainMovementLon = mainDriftLon * h * 0.01
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
|
||||
const turbulence = Math.sin(h * 0.3 + p * 0.5) * dispersal * h
|
||||
const turbulenceAngle = (h * 0.2 + p * 0.7) * Math.PI
|
||||
|
||||
trajectory.push({
|
||||
lat: particleLat + mainMovementLat + Math.sin(turbulenceAngle) * turbulence,
|
||||
lon: particleLon + mainMovementLon + Math.cos(turbulenceAngle) * turbulence,
|
||||
time: h,
|
||||
particle: p
|
||||
try {
|
||||
const pollRes = await fetch(`${PYTHON_API_URL}/status/${jobId}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!pollRes.ok) continue
|
||||
|
||||
const data = await pollRes.json() as PythonStatusResponse
|
||||
|
||||
if (data.status === 'DONE' && data.result) {
|
||||
await wingPool.query(
|
||||
`UPDATE wing.PRED_EXEC
|
||||
SET EXEC_STTS_CD='COMPLETED',
|
||||
RSLT_DATA=$1,
|
||||
CMPL_DTM=NOW(),
|
||||
REQD_SEC=EXTRACT(EPOCH FROM (NOW() - BGNG_DTM))::INTEGER
|
||||
WHERE PRED_EXEC_SN=$2`,
|
||||
[JSON.stringify(data.result), execSn]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === 'ERROR') {
|
||||
await wingPool.query(
|
||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
|
||||
[data.error ?? '분석 오류', execSn]
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 개별 폴링 오류는 무시하고 재시도
|
||||
}
|
||||
}
|
||||
|
||||
return trajectory
|
||||
// 타임아웃 처리
|
||||
await wingPool.query(
|
||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='분석 시간 초과 (30분)', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
||||
[execSn]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/simulation/status/:jobId
|
||||
* 시뮬레이션 작업 상태 확인
|
||||
*/
|
||||
router.get('/status/:jobId', async (req: Request, res: Response) => {
|
||||
const jobId = req.params.jobId as string
|
||||
// ============================================================
|
||||
// 타입 및 결과 변환
|
||||
// ============================================================
|
||||
interface PythonParticle {
|
||||
lat: number
|
||||
lon: number
|
||||
stranded?: 0 | 1
|
||||
}
|
||||
|
||||
// jobId 형식 검증 (영숫자, 하이픈만 허용)
|
||||
if (!jobId || !/^[a-zA-Z0-9-]+$/.test(jobId) || jobId.length > 50) {
|
||||
return res.status(400).json({ error: '유효하지 않은 작업 ID' })
|
||||
interface WindPoint {
|
||||
lat: number
|
||||
lon: number
|
||||
wind_speed: number
|
||||
wind_direction: number
|
||||
}
|
||||
|
||||
interface HydrGrid {
|
||||
lonInterval: number[]
|
||||
boundLonLat: { top: number; bottom: number; left: number; right: number }
|
||||
rows: number
|
||||
cols: number
|
||||
latInterval: number[]
|
||||
}
|
||||
|
||||
interface PythonTimeStep {
|
||||
particles: PythonParticle[]
|
||||
remaining_volume_m3: number
|
||||
weathered_volume_m3: number
|
||||
pollution_area_km2: number
|
||||
beached_volume_m3: number
|
||||
pollution_coast_length_m: number
|
||||
center_lat?: number
|
||||
center_lon?: number
|
||||
wind_data?: WindPoint[]
|
||||
hydr_data?: [number[][], number[][]]
|
||||
hydr_grid?: HydrGrid
|
||||
}
|
||||
|
||||
interface PythonStatusResponse {
|
||||
status: 'RUNNING' | 'DONE' | 'ERROR'
|
||||
result?: PythonTimeStep[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
function transformResult(rawResult: PythonTimeStep[]) {
|
||||
const trajectory = rawResult.flatMap((step, stepIdx) =>
|
||||
step.particles.map((p, i) => ({
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
time: stepIdx,
|
||||
particle: i,
|
||||
stranded: p.stranded,
|
||||
}))
|
||||
)
|
||||
const lastStep = rawResult[rawResult.length - 1]
|
||||
const summary = {
|
||||
remainingVolume: lastStep.remaining_volume_m3,
|
||||
weatheredVolume: lastStep.weathered_volume_m3,
|
||||
pollutionArea: lastStep.pollution_area_km2,
|
||||
beachedVolume: lastStep.beached_volume_m3,
|
||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||
}
|
||||
|
||||
res.json({
|
||||
jobId: escapeHtml(jobId),
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
message: 'Simulation completed'
|
||||
})
|
||||
})
|
||||
const centerPoints = rawResult
|
||||
.map((step, stepIdx) =>
|
||||
step.center_lat != null && step.center_lon != null
|
||||
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx }
|
||||
: null
|
||||
)
|
||||
.filter((p): p is { lat: number; lon: number; time: number } => p !== null)
|
||||
const windData = rawResult.map((step) => step.wind_data ?? [])
|
||||
const hydrData = rawResult.map((step) =>
|
||||
step.hydr_data && step.hydr_grid
|
||||
? { value: step.hydr_data, grid: step.hydr_grid }
|
||||
: null
|
||||
)
|
||||
return { trajectory, summary, centerPoints, windData, hydrData }
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
@ -97,9 +97,13 @@ app.use(cors({
|
||||
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
|
||||
const generalLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15분
|
||||
max: 200, // IP당 최대 200요청
|
||||
max: 500, // IP당 최대 500요청 (HLS 스트리밍 고려)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => {
|
||||
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
|
||||
return req.path.startsWith('/api/aerial/cctv/stream-proxy');
|
||||
},
|
||||
message: {
|
||||
error: '요청 횟수 초과',
|
||||
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'
|
||||
@ -153,7 +157,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)
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
assignRoles,
|
||||
approveUser,
|
||||
rejectUser,
|
||||
listOrgs,
|
||||
} from './userService.js'
|
||||
|
||||
const router = Router()
|
||||
@ -30,6 +31,17 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/users/orgs — 조직 목록 (/:id 보다 앞에 등록해야 함)
|
||||
router.get('/orgs', async (_req, res) => {
|
||||
try {
|
||||
const orgs = await listOrgs()
|
||||
res.json(orgs)
|
||||
} catch (err) {
|
||||
console.error('[users] 조직 목록 오류:', err)
|
||||
res.status(500).json({ error: '조직 목록 조회 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/users/:id
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -293,6 +293,32 @@ export async function changePassword(userId: string, newPassword: string): Promi
|
||||
)
|
||||
}
|
||||
|
||||
// ── 조직 목록 조회 ──
|
||||
|
||||
interface OrgItem {
|
||||
orgSn: number
|
||||
orgNm: string
|
||||
orgAbbrNm: string | null
|
||||
orgTpCd: string
|
||||
upperOrgSn: number | null
|
||||
}
|
||||
|
||||
export async function listOrgs(): Promise<OrgItem[]> {
|
||||
const { rows } = await authPool.query(
|
||||
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
|
||||
FROM AUTH_ORG
|
||||
WHERE USE_YN = 'Y'
|
||||
ORDER BY ORG_SN`
|
||||
)
|
||||
return rows.map((r: Record<string, unknown>) => ({
|
||||
orgSn: r.org_sn as number,
|
||||
orgNm: r.org_nm as string,
|
||||
orgAbbrNm: r.org_abbr_nm as string | null,
|
||||
orgTpCd: r.org_tp_cd as string,
|
||||
upperOrgSn: r.upper_org_sn as number | null,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function assignRoles(userId: string, roleSns: number[]): Promise<void> {
|
||||
await authPool.query('DELETE FROM AUTH_USER_ROLE WHERE USER_ID = $1', [userId])
|
||||
|
||||
|
||||
@ -254,10 +254,11 @@ CREATE INDEX IDX_AUDIT_LOG_DTM ON AUTH_AUDIT_LOG (REQ_DTM);
|
||||
-- 10. 초기 데이터: 역할
|
||||
-- ============================================================
|
||||
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
|
||||
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
|
||||
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
|
||||
('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'),
|
||||
('VIEWER', '뷰어', '조회 전용 접근 권한', 'N');
|
||||
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
|
||||
('HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'),
|
||||
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
|
||||
('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'),
|
||||
('VIEWER', '뷰어', '조회 전용 접근 권한', 'N');
|
||||
|
||||
|
||||
-- ============================================================
|
||||
@ -279,7 +280,7 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(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');
|
||||
|
||||
-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용
|
||||
-- HQ_CLEANUP (ROLE_SN=2): 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'),
|
||||
(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
|
||||
@ -289,38 +290,52 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', 'DELETE', 'Y'),
|
||||
(2, 'scat', 'READ', 'Y'), (2, 'scat', 'CREATE', 'Y'), (2, 'scat', 'UPDATE', 'Y'), (2, 'scat', 'DELETE', 'Y'),
|
||||
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
|
||||
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'), (2, 'board', 'DELETE', 'Y'),
|
||||
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', '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');
|
||||
|
||||
-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
||||
-- MANAGER (ROLE_SN=3): admin 탭 제외, RCUD 허용
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'),
|
||||
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'),
|
||||
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'),
|
||||
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'),
|
||||
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'),
|
||||
(3, 'assets', 'READ', 'N'),
|
||||
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'),
|
||||
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'),
|
||||
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'),
|
||||
(3, 'weather', 'READ', 'Y'),
|
||||
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'), (3, 'prediction', 'DELETE', 'Y'),
|
||||
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'), (3, 'hns', 'DELETE', 'Y'),
|
||||
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'), (3, 'rescue', 'DELETE', 'Y'),
|
||||
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'), (3, 'reports', 'DELETE', 'Y'),
|
||||
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'), (3, 'aerial', 'DELETE', 'Y'),
|
||||
(3, 'assets', 'READ', 'Y'), (3, 'assets', 'CREATE', 'Y'), (3, 'assets', 'UPDATE', 'Y'), (3, 'assets', 'DELETE', 'Y'),
|
||||
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'), (3, 'scat', 'DELETE', 'Y'),
|
||||
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), (3, 'incidents', 'DELETE', 'Y'),
|
||||
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
|
||||
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
|
||||
(3, 'admin', 'READ', 'N');
|
||||
|
||||
-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용
|
||||
-- USER (ROLE_SN=4): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(4, 'prediction', 'READ', 'Y'),
|
||||
(4, 'hns', 'READ', 'Y'),
|
||||
(4, 'rescue', 'READ', 'Y'),
|
||||
(4, 'reports', 'READ', 'N'),
|
||||
(4, 'aerial', 'READ', 'Y'),
|
||||
(4, 'prediction', 'READ', 'Y'), (4, 'prediction', 'CREATE', 'Y'), (4, 'prediction', 'UPDATE', 'Y'),
|
||||
(4, 'hns', 'READ', 'Y'), (4, 'hns', 'CREATE', 'Y'), (4, 'hns', 'UPDATE', 'Y'),
|
||||
(4, 'rescue', 'READ', 'Y'), (4, 'rescue', 'CREATE', 'Y'), (4, 'rescue', 'UPDATE', 'Y'),
|
||||
(4, 'reports', 'READ', 'Y'), (4, 'reports', 'CREATE', 'Y'), (4, 'reports', 'UPDATE', 'Y'),
|
||||
(4, 'aerial', 'READ', 'Y'), (4, 'aerial', 'CREATE', 'Y'), (4, 'aerial', 'UPDATE', 'Y'),
|
||||
(4, 'assets', 'READ', 'N'),
|
||||
(4, 'scat', 'READ', 'N'),
|
||||
(4, 'incidents', 'READ', 'Y'),
|
||||
(4, 'board', 'READ', 'Y'),
|
||||
(4, 'scat', 'READ', 'Y'), (4, 'scat', 'CREATE', 'Y'), (4, 'scat', 'UPDATE', 'Y'),
|
||||
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
|
||||
(4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
|
||||
(4, 'weather', 'READ', 'Y'),
|
||||
(4, 'admin', 'READ', 'N');
|
||||
|
||||
-- VIEWER (ROLE_SN=5): 제한적 탭의 READ만 허용
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(5, 'prediction', 'READ', 'Y'),
|
||||
(5, 'hns', 'READ', 'Y'),
|
||||
(5, 'rescue', 'READ', 'Y'),
|
||||
(5, 'reports', 'READ', 'N'),
|
||||
(5, 'aerial', 'READ', 'Y'),
|
||||
(5, 'assets', 'READ', 'N'),
|
||||
(5, 'scat', 'READ', 'N'),
|
||||
(5, 'incidents', 'READ', 'Y'),
|
||||
(5, 'board', 'READ', 'Y'),
|
||||
(5, 'weather', 'READ', 'Y'),
|
||||
(5, 'admin', 'READ', 'N');
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 12. 초기 데이터: 조직
|
||||
|
||||
@ -320,7 +320,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 +329,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 +337,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 연동용)';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@ -54,20 +54,23 @@ CREATE INDEX IF NOT EXISTS IDX_SPIL_ACDNT ON SPIL_DATA(ACDNT_SN);
|
||||
-- 3. 예측실행 (PRED_EXEC)
|
||||
CREATE TABLE IF NOT EXISTS PRED_EXEC (
|
||||
PRED_EXEC_SN SERIAL NOT NULL,
|
||||
ACDNT_SN INTEGER NOT NULL,
|
||||
SPIL_DATA_SN INTEGER,
|
||||
ACDNT_SN INTEGER NOT NULL,
|
||||
ALGO_CD VARCHAR(20) NOT NULL,
|
||||
EXEC_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
BGNG_DTM TIMESTAMPTZ,
|
||||
CMPL_DTM TIMESTAMPTZ,
|
||||
REQD_SEC INTEGER,
|
||||
RSLT_DATA JSONB,
|
||||
ERR_MSG TEXT,
|
||||
ERR_MSG TEXT,
|
||||
EXEC_NM VARCHAR(100),
|
||||
CONSTRAINT PK_PRED_EXEC PRIMARY KEY (PRED_EXEC_SN),
|
||||
CONSTRAINT FK_PRED_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE,
|
||||
CONSTRAINT CK_PRED_STTS CHECK (EXEC_STTS_CD IN ('PENDING','RUNNING','COMPLETED','FAILED'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_PRED_ACDNT ON PRED_EXEC(ACDNT_SN);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uix_pred_exec_nm ON PRED_EXEC (EXEC_NM) WHERE EXEC_NM IS NOT NULL;
|
||||
|
||||
-- 4. 사고별 기상정보 스냅샷 (ACDNT_WEATHER)
|
||||
CREATE TABLE IF NOT EXISTS ACDNT_WEATHER (
|
||||
|
||||
1396
database/migration/019_ship_insurance_seed.sql
Normal file
1396
database/migration/019_ship_insurance_seed.sql
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
32
database/migration/020_add_hq_cleanup_role.sql
Normal file
32
database/migration/020_add_hq_cleanup_role.sql
Normal file
@ -0,0 +1,32 @@
|
||||
-- ============================================================
|
||||
-- 020: 본청방제과 역할 추가
|
||||
-- ============================================================
|
||||
|
||||
-- 역할 추가 (이미 존재하면 무시)
|
||||
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN)
|
||||
SELECT 'HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP');
|
||||
|
||||
-- 본청방제과 권한 설정: 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
|
||||
DO $$
|
||||
DECLARE
|
||||
v_role_sn INT;
|
||||
BEGIN
|
||||
SELECT ROLE_SN INTO v_role_sn FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP';
|
||||
|
||||
-- 기존 권한 초기화 (재실행 안전)
|
||||
DELETE FROM AUTH_PERM WHERE ROLE_SN = v_role_sn;
|
||||
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||
(v_role_sn, 'prediction', 'READ', 'Y'), (v_role_sn, 'prediction', 'CREATE', 'Y'), (v_role_sn, 'prediction', 'UPDATE', 'Y'), (v_role_sn, 'prediction', 'DELETE', 'Y'),
|
||||
(v_role_sn, 'hns', 'READ', 'Y'), (v_role_sn, 'hns', 'CREATE', 'Y'), (v_role_sn, 'hns', 'UPDATE', 'Y'), (v_role_sn, 'hns', 'DELETE', 'Y'),
|
||||
(v_role_sn, 'rescue', 'READ', 'Y'), (v_role_sn, 'rescue', 'CREATE', 'Y'), (v_role_sn, 'rescue', 'UPDATE', 'Y'), (v_role_sn, 'rescue', 'DELETE', 'Y'),
|
||||
(v_role_sn, 'reports', 'READ', 'Y'), (v_role_sn, 'reports', 'CREATE', 'Y'), (v_role_sn, 'reports', 'UPDATE', 'Y'), (v_role_sn, 'reports', 'DELETE', 'Y'),
|
||||
(v_role_sn, 'aerial', 'READ', 'Y'), (v_role_sn, 'aerial', 'CREATE', 'Y'), (v_role_sn, 'aerial', 'UPDATE', 'Y'), (v_role_sn, 'aerial', 'DELETE', 'Y'),
|
||||
(v_role_sn, 'assets', 'READ', 'Y'), (v_role_sn, 'assets', 'CREATE', 'Y'), (v_role_sn, 'assets', 'UPDATE', 'Y'), (v_role_sn, 'assets', 'DELETE', 'Y'),
|
||||
(v_role_sn, 'scat', 'READ', 'Y'), (v_role_sn, 'scat', 'CREATE', 'Y'), (v_role_sn, 'scat', 'UPDATE', 'Y'), (v_role_sn, 'scat', 'DELETE', 'Y'),
|
||||
(v_role_sn, 'incidents', 'READ', 'Y'), (v_role_sn, 'incidents', 'CREATE', 'Y'), (v_role_sn, 'incidents', 'UPDATE', 'Y'), (v_role_sn, 'incidents', 'DELETE', 'Y'),
|
||||
(v_role_sn, 'board', 'READ', 'Y'), (v_role_sn, 'board', 'CREATE', 'Y'), (v_role_sn, 'board', 'UPDATE', 'Y'),
|
||||
(v_role_sn, 'weather', 'READ', 'Y'), (v_role_sn, 'weather', 'CREATE', 'Y'),
|
||||
(v_role_sn, 'admin', 'READ', 'N');
|
||||
END $$;
|
||||
191
docs/PREDICTION-GUIDE.md
Normal file
191
docs/PREDICTION-GUIDE.md
Normal file
@ -0,0 +1,191 @@
|
||||
# 확산 예측 기능 가이드
|
||||
|
||||
> 대상: 확산 예측(OpenDrift) 기능 개발 및 유지보수 담당자
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처 개요
|
||||
|
||||
**폴링 방식** — HTTP 연결 불안정 문제 해결을 위해 비동기 폴링 구조를 채택했다.
|
||||
|
||||
```
|
||||
[프론트] 실행 버튼
|
||||
→ POST /api/simulation/run 즉시 { execSn, status:'RUNNING' } 반환
|
||||
→ "분석 중..." UI 표시
|
||||
→ 3초마다 GET /api/simulation/status/:execSn 폴링
|
||||
|
||||
[Express 백엔드]
|
||||
→ PRED_EXEC INSERT (PENDING)
|
||||
→ POST Python /run-model 즉시 { job_id } 수신
|
||||
→ 응답 즉시 반환 (프론트 블록 없음)
|
||||
→ 백그라운드: 3초마다 Python GET /status/:job_id 폴링
|
||||
→ DONE 시 PRED_EXEC UPDATE (결과 JSONB 저장)
|
||||
|
||||
[Python FastAPI :5003]
|
||||
→ 동시 처리 초과 시 503 즉시 반환
|
||||
→ 여유 시 job_id 반환 + 백그라운드 OpenDrift 시뮬레이션 실행
|
||||
→ NC 결과 → JSON 변환 → 상태 DONE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. DB 스키마 (PRED_EXEC)
|
||||
|
||||
```sql
|
||||
PRED_EXEC_SN SERIAL PRIMARY KEY
|
||||
ACDNT_SN INTEGER NOT NULL -- 사고 FK
|
||||
SPIL_DATA_SN INTEGER -- 유출정보 FK (NULL 허용)
|
||||
EXEC_NM VARCHAR(100) UNIQUE -- EXPC_{timestamp} 형식
|
||||
ALGO_CD VARCHAR(20) NOT NULL -- 'OPENDRIFT'
|
||||
EXEC_STTS_CD VARCHAR(20) DEFAULT 'PENDING'
|
||||
-- PENDING | RUNNING | COMPLETED | FAILED
|
||||
BGNG_DTM TIMESTAMPTZ
|
||||
CMPL_DTM TIMESTAMPTZ
|
||||
REQD_SEC INTEGER
|
||||
RSLT_DATA JSONB -- 시뮬레이션 결과 전체
|
||||
ERR_MSG TEXT
|
||||
```
|
||||
|
||||
인덱스: `IDX_PRED_STTS` (EXEC_STTS_CD), `uix_pred_exec_nm` (EXEC_NM, partial)
|
||||
|
||||
---
|
||||
|
||||
## 3. Python FastAPI 엔드포인트 (포트 5003)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/get-received-date` | 최신 예보 수신 가능 날짜 |
|
||||
| GET | `/get-uv/{datetime}/{category}` | 바람/해류 U/V 벡터 (`wind`\|`hydr`) |
|
||||
| POST | `/check-nc` | NetCDF 파일 존재 여부 확인 |
|
||||
| POST | `/run-model` | 시뮬레이션 제출 → 즉시 `job_id` 반환 |
|
||||
| GET | `/status/{job_id}` | 시뮬레이션 진행 상태 조회 |
|
||||
|
||||
### POST /run-model 입력 파라미터
|
||||
|
||||
```json
|
||||
{
|
||||
"startTime": "2025-01-15 12:00:00", // KST (내부 UTC 변환)
|
||||
"runTime": 72, // 예측 시간 (시간)
|
||||
"matTy": "CRUDE OIL", // OpenDrift 유류명
|
||||
"matVol": 100.0, // 시간당 유출량 (m³/hr)
|
||||
"lon": 126.1,
|
||||
"lat": 36.6,
|
||||
"spillTime": 12, // 유출 지속 시간 (0=순간)
|
||||
"name": "EXPC_1710000000000"
|
||||
}
|
||||
```
|
||||
|
||||
### 유류 코드 매핑 (DB → OpenDrift)
|
||||
|
||||
| DB SPIL_MAT_CD | OpenDrift 이름 |
|
||||
|---------------|---------------|
|
||||
| CRUD | CRUDE OIL |
|
||||
| DSEL | DIESEL |
|
||||
| BNKR | BUNKER |
|
||||
| HEFO | IFO 180 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Express 백엔드 주요 엔드포인트
|
||||
|
||||
파일: [backend/src/routes/simulation.ts](../backend/src/routes/simulation.ts)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/simulation/run` | 시뮬레이션 제출 → `execSn` 즉시 반환 |
|
||||
| GET | `/api/simulation/status/:execSn` | 프론트 폴링용 상태 조회 |
|
||||
|
||||
파일: [backend/src/prediction/predictionService.ts](../backend/src/prediction/predictionService.ts)
|
||||
|
||||
- `fetchPredictionList()` — PRED_EXEC 목록 조회
|
||||
- `fetchTrajectoryResult()` — 저장된 결과 조회 (`RSLT_DATA` JSONB 파싱)
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 주요 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| [frontend/src/tabs/prediction/components/OilSpillView.tsx](../frontend/src/tabs/prediction/components/OilSpillView.tsx) | 예측 탭 메인 뷰, 시뮬레이션 실행·폴링 상태 관리 |
|
||||
| [frontend/src/tabs/prediction/hooks/](../frontend/src/tabs/prediction/hooks/) | `useSimulationStatus` 폴링 훅 |
|
||||
| [frontend/src/tabs/prediction/services/predictionApi.ts](../frontend/src/tabs/prediction/services/predictionApi.ts) | API 요청 함수 + 타입 정의 |
|
||||
| [frontend/src/tabs/prediction/components/RightPanel.tsx](../frontend/src/tabs/prediction/components/RightPanel.tsx) | 풍화량·잔류량·오염면적 표시 (마지막 스텝 실제 값) |
|
||||
| [frontend/src/common/components/map/HydrParticleOverlay.tsx](../frontend/src/common/components/map/HydrParticleOverlay.tsx) | 해류 파티클 Canvas 오버레이 |
|
||||
|
||||
### 핵심 타입 (predictionApi.ts)
|
||||
|
||||
```typescript
|
||||
interface HydrGrid {
|
||||
lonInterval: number[];
|
||||
latInterval: number[];
|
||||
boundLonLat: { top: number; bottom: number; left: number; right: number };
|
||||
rows: number; cols: number;
|
||||
}
|
||||
interface HydrDataStep {
|
||||
value: [number[][], number[][]]; // [u_2d, v_2d]
|
||||
grid: HydrGrid;
|
||||
}
|
||||
```
|
||||
|
||||
### 폴링 훅 패턴
|
||||
|
||||
```typescript
|
||||
useQuery({
|
||||
queryKey: ['simulationStatus', execSn],
|
||||
queryFn: () => api.get(`/api/simulation/status/${execSn}`),
|
||||
enabled: execSn !== null,
|
||||
refetchInterval: (data) =>
|
||||
data?.status === 'DONE' || data?.status === 'ERROR' ? false : 3000,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Python 코드 위치 (prediction/)
|
||||
|
||||
```
|
||||
prediction/opendrift/
|
||||
├── api.py FastAPI 진입점 (수정 필요: 폴링 지원 + CORS)
|
||||
├── config.py 경로 설정 (수정 필요: 환경변수화)
|
||||
├── createJsonResult.py NC → JSON 변환 (핵심 후처리)
|
||||
├── coastline/ TN_SHORLINE.shp (한국 해안선)
|
||||
├── startup.sh / shutdown.sh
|
||||
├── .env.example 환경변수 샘플
|
||||
└── environment-opendrift.yml conda 환경 재현용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 환경변수
|
||||
|
||||
### backend/.env
|
||||
|
||||
```bash
|
||||
PYTHON_API_URL=http://localhost:5003
|
||||
```
|
||||
|
||||
### prediction/opendrift/.env
|
||||
|
||||
```bash
|
||||
MPR_STORAGE_ROOT=/data/storage # NetCDF 기상·해양 데이터 루트
|
||||
MPR_RESULT_ROOT=./result # 시뮬레이션 결과 저장 경로
|
||||
MAX_CONCURRENT_JOBS=4 # 동시 처리 최대 수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 위험 요소
|
||||
|
||||
| 위험 | 내용 |
|
||||
|------|------|
|
||||
| NetCDF 파일 부재 | `MPR_STORAGE_ROOT` 경로에 KMA GDAPS·MOHID NC 파일 필요. 없으면 시뮬레이션 불가 |
|
||||
| conda 환경 | `opendrift` conda 환경 설치 필요 (`environment-opendrift.yml`) |
|
||||
| Workers 포화 | 동시 4개 초과 시 503 반환 → `MAX_CONCURRENT_JOBS` 조정 |
|
||||
| 결과 용량 | 12시간 결과 ≈ 1500KB/건. 90일 주기 `RSLT_DATA = NULL` 정리 권장 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 문서
|
||||
|
||||
- [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) — Express API 개발 패턴
|
||||
- [COMMON-GUIDE.md](./COMMON-GUIDE.md) — 인증·상태관리 공통 로직
|
||||
157
frontend/src/common/components/map/HydrParticleOverlay.tsx
Normal file
157
frontend/src/common/components/map/HydrParticleOverlay.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 }: 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++) {
|
||||
ctx.strokeStyle = `rgba(180, 210, 255, ${((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]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -8,6 +8,8 @@ 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 스타일
|
||||
@ -177,6 +179,13 @@ interface MapViewProps {
|
||||
incidentCoord: { lat: number; lon: number }
|
||||
}
|
||||
sensitiveResources?: SensitiveResource[]
|
||||
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 }>
|
||||
windData?: Array<Array<{ lat: number; lon: number; wind_speed: number; wind_direction: number }>>
|
||||
hydrData?: (HydrDataStep | null)[]
|
||||
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
|
||||
externalCurrentTime?: number
|
||||
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
||||
}
|
||||
|
||||
@ -188,6 +197,33 @@ 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
|
||||
}
|
||||
|
||||
// 3D 모드 pitch/bearing 제어 컴포넌트 (Map 내부에서 useMap() 사용)
|
||||
function MapPitchController({ threeD }: { threeD: boolean }) {
|
||||
const { current: map } = useMap()
|
||||
@ -261,14 +297,22 @@ export function MapView({
|
||||
layerBrightness = 50,
|
||||
backtrackReplay,
|
||||
sensitiveResources = [],
|
||||
flyToTarget,
|
||||
fitBoundsTarget,
|
||||
centerPoints = [],
|
||||
windData = [],
|
||||
hydrData = [],
|
||||
externalCurrentTime,
|
||||
mapCaptureRef,
|
||||
}: MapViewProps) {
|
||||
const { mapToggles } = useMapStore()
|
||||
const isControlled = externalCurrentTime !== undefined
|
||||
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
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 handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
@ -279,33 +323,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(() => {
|
||||
@ -330,6 +375,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({
|
||||
@ -338,8 +386,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)
|
||||
// 1순위: stranded 입자 → 빨간색
|
||||
if (d.stranded === 1) return [239, 68, 68, 220] as [number, number, number, number]
|
||||
// 2순위: 현재 활성 스텝 → 모델 기본 색상
|
||||
if (d.time === activeStep) {
|
||||
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
|
||||
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
|
||||
}
|
||||
// 3순위: 과거 스텝 → 회색 + 투명
|
||||
return [130, 130, 130, 70] as [number, number, number, number]
|
||||
},
|
||||
radiusMinPixels: 2.5,
|
||||
radiusMaxPixels: 5,
|
||||
@ -354,6 +409,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 />
|
||||
@ -364,7 +420,7 @@ export function MapView({
|
||||
}
|
||||
},
|
||||
updateTriggers: {
|
||||
getFillColor: [selectedModels],
|
||||
getFillColor: [selectedModels, currentTime],
|
||||
},
|
||||
})
|
||||
)
|
||||
@ -689,37 +745,73 @@ 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 >= 2) {
|
||||
result.push(
|
||||
new PathLayer({
|
||||
id: 'center-path',
|
||||
data: [{ path: visibleCenters.map(p => [p.lon, p.lat] as [number, number]) }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [255, 220, 50, 200],
|
||||
getWidth: 2,
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 4,
|
||||
})
|
||||
)
|
||||
}
|
||||
if (visibleCenters.length > 0) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'center-points',
|
||||
data: visibleCenters,
|
||||
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
|
||||
getRadius: 5,
|
||||
getFillColor: [255, 220, 50, 230],
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 8,
|
||||
pickable: false,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
// --- 바람 화살표 (TextLayer) ---
|
||||
if (incidentCoord && windData.length > 0) {
|
||||
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],
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -729,7 +821,7 @@ export function MapView({
|
||||
oilTrajectory, currentTime, selectedModels,
|
||||
boomLines, isDrawingBoom, drawingPoints,
|
||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||
sensitiveResources,
|
||||
sensitiveResources, centerPoints, windData,
|
||||
])
|
||||
|
||||
// 3D 모드에 따른 지도 스타일 전환
|
||||
@ -756,6 +848,10 @@ export function MapView({
|
||||
<MapPitchController threeD={mapToggles.threeD} />
|
||||
{/* 사고 지점 변경 시 지도 이동 */}
|
||||
<MapFlyToIncident lon={incidentCoord?.lon} lat={incidentCoord?.lat} />
|
||||
{/* 외부에서 flyTo 트리거 */}
|
||||
<FlyToController flyToTarget={flyToTarget} />
|
||||
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
||||
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
|
||||
|
||||
{/* WMS 레이어 */}
|
||||
{wmsLayers.map(layer => (
|
||||
@ -783,6 +879,11 @@ export function MapView({
|
||||
{/* deck.gl 오버레이 (인터리브드: 일반 레이어) */}
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
|
||||
{/* 해류 파티클 오버레이 */}
|
||||
{hydrData.length > 0 && (
|
||||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
|
||||
)}
|
||||
|
||||
{/* 사고 위치 마커 (MapLibre Marker) */}
|
||||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !(dispersionHeatmap && dispersionHeatmap.length > 0) && (
|
||||
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
|
||||
@ -832,14 +933,14 @@ export function MapView({
|
||||
position={incidentCoord ? [incidentCoord.lat, incidentCoord.lon] : currentPosition}
|
||||
/>
|
||||
|
||||
{/* 타임라인 컨트롤 */}
|
||||
{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}
|
||||
/>
|
||||
|
||||
@ -60,12 +60,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
||||
{ id: 'manual', label: '해경매뉴얼', icon: '📘' }
|
||||
],
|
||||
weather: null,
|
||||
admin: [
|
||||
{ id: 'users', label: '사용자 관리', icon: '👥' },
|
||||
{ id: 'permissions', label: '사용자 권한 관리', icon: '🔐' },
|
||||
{ id: 'menus', label: '메뉴 관리', icon: '📑' },
|
||||
{ id: 'settings', label: '시스템 설정', icon: '⚙️' }
|
||||
]
|
||||
admin: null // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
||||
}
|
||||
|
||||
// 전역 상태 관리 (간단한 방식)
|
||||
|
||||
@ -107,6 +107,20 @@ export async function assignRolesApi(id: string, roleSns: number[]): Promise<voi
|
||||
await api.put(`/users/${id}/roles`, { roleSns })
|
||||
}
|
||||
|
||||
// 조직 목록 API
|
||||
export interface OrgItem {
|
||||
orgSn: number
|
||||
orgNm: string
|
||||
orgAbbrNm: string | null
|
||||
orgTpCd: string
|
||||
upperOrgSn: number | null
|
||||
}
|
||||
|
||||
export async function fetchOrgs(): Promise<OrgItem[]> {
|
||||
const response = await api.get<OrgItem[]>('/users/orgs')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 역할/권한 API (ADMIN 전용)
|
||||
export interface RoleWithPermissions {
|
||||
sn: number
|
||||
|
||||
@ -259,6 +259,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;
|
||||
|
||||
14
frontend/src/tabs/admin/components/AdminPlaceholder.tsx
Normal file
14
frontend/src/tabs/admin/components/AdminPlaceholder.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
interface AdminPlaceholderProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 미구현 관리자 메뉴 placeholder */
|
||||
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<div className="text-4xl opacity-20">🚧</div>
|
||||
<div className="text-sm font-korean text-text-2 font-semibold">{label}</div>
|
||||
<div className="text-[11px] font-korean text-text-3">해당 기능은 준비 중입니다.</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AdminPlaceholder;
|
||||
156
frontend/src/tabs/admin/components/AdminSidebar.tsx
Normal file
156
frontend/src/tabs/admin/components/AdminSidebar.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import { useState } from 'react';
|
||||
import { ADMIN_MENU } from './adminMenuConfig';
|
||||
import type { AdminMenuItem } from './adminMenuConfig';
|
||||
|
||||
interface AdminSidebarProps {
|
||||
activeMenu: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/** 관리자 좌측 사이드바 — 9-섹션 아코디언 */
|
||||
const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||
// 초기: 첫 번째 섹션 열기
|
||||
const init = new Set<string>();
|
||||
if (ADMIN_MENU.length > 0) init.add(ADMIN_MENU[0].id);
|
||||
return init;
|
||||
});
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/** 재귀적으로 메뉴 아이템이 activeMenu를 포함하는지 확인 */
|
||||
const containsActive = (item: AdminMenuItem): boolean => {
|
||||
if (item.id === activeMenu) return true;
|
||||
return item.children?.some(c => containsActive(c)) ?? false;
|
||||
};
|
||||
|
||||
const renderLeaf = (item: AdminMenuItem, depth: number) => {
|
||||
const isActive = item.id === activeMenu;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item.id)}
|
||||
className="w-full text-left px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||
style={{
|
||||
paddingLeft: `${12 + depth * 14}px`,
|
||||
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
|
||||
color: isActive ? 'var(--cyan)' : 'var(--t2)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGroup = (item: AdminMenuItem, depth: number) => {
|
||||
const isOpen = expanded.has(item.id);
|
||||
const hasActiveChild = containsActive(item);
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<button
|
||||
onClick={() => {
|
||||
toggle(item.id);
|
||||
// 그룹 자체에 children의 첫 leaf가 있으면 자동 선택
|
||||
if (!isOpen && item.children) {
|
||||
const firstLeaf = findFirstLeaf(item.children);
|
||||
if (firstLeaf) onSelect(firstLeaf.id);
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||
style={{
|
||||
paddingLeft: `${12 + depth * 14}px`,
|
||||
color: hasActiveChild ? 'var(--cyan)' : 'var(--t2)',
|
||||
fontWeight: hasActiveChild ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && item.children && (
|
||||
<div className="flex flex-col gap-px">
|
||||
{item.children.map(child => renderItem(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = (item: AdminMenuItem, depth: number) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
return renderGroup(item, depth);
|
||||
}
|
||||
return renderLeaf(item, depth);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-bg-1 border-r border-border overflow-y-auto shrink-0"
|
||||
style={{ width: 240, scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-border bg-bg-2 shrink-0">
|
||||
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
|
||||
<span>⚙️</span> 관리자 설정
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 목록 */}
|
||||
<div className="flex flex-col gap-0.5 p-2">
|
||||
{ADMIN_MENU.map(section => {
|
||||
const isOpen = expanded.has(section.id);
|
||||
const hasActiveChild = containsActive(section);
|
||||
|
||||
return (
|
||||
<div key={section.id} className="mb-0.5">
|
||||
{/* 섹션 헤더 */}
|
||||
<button
|
||||
onClick={() => toggle(section.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-[11px] font-bold font-korean transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||
color: hasActiveChild ? 'var(--cyan)' : 'var(--t1)',
|
||||
}}
|
||||
>
|
||||
<span className="text-sm">{section.icon}</span>
|
||||
<span className="flex-1 text-left">{section.label}</span>
|
||||
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 하위 메뉴 */}
|
||||
{isOpen && section.children && (
|
||||
<div className="flex flex-col gap-px mt-0.5 ml-1">
|
||||
{section.children.map(child => renderItem(child, 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** children 중 첫 번째 leaf 노드를 찾는다 */
|
||||
function findFirstLeaf(items: AdminMenuItem[]): AdminMenuItem | null {
|
||||
for (const item of items) {
|
||||
if (!item.children || item.children.length === 0) return item;
|
||||
const found = findFirstLeaf(item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default AdminSidebar;
|
||||
@ -1,21 +1,42 @@
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
||||
import UsersPanel from './UsersPanel'
|
||||
import PermissionsPanel from './PermissionsPanel'
|
||||
import MenusPanel from './MenusPanel'
|
||||
import SettingsPanel from './SettingsPanel'
|
||||
import { useState } from 'react';
|
||||
import AdminSidebar from './AdminSidebar';
|
||||
import AdminPlaceholder from './AdminPlaceholder';
|
||||
import { findMenuLabel } from './adminMenuConfig';
|
||||
import UsersPanel from './UsersPanel';
|
||||
import PermissionsPanel from './PermissionsPanel';
|
||||
import MenusPanel from './MenusPanel';
|
||||
import SettingsPanel from './SettingsPanel';
|
||||
import BoardMgmtPanel from './BoardMgmtPanel';
|
||||
import VesselSignalPanel from './VesselSignalPanel';
|
||||
|
||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||
users: () => <UsersPanel />,
|
||||
permissions: () => <PermissionsPanel />,
|
||||
menus: () => <MenusPanel />,
|
||||
settings: () => <SettingsPanel />,
|
||||
notice: () => <BoardMgmtPanel initialCategory="NOTICE" />,
|
||||
board: () => <BoardMgmtPanel initialCategory="DATA" />,
|
||||
qna: () => <BoardMgmtPanel initialCategory="QNA" />,
|
||||
'collect-vessel-signal': () => <VesselSignalPanel />,
|
||||
};
|
||||
|
||||
// ─── AdminView ────────────────────────────────────────────
|
||||
export function AdminView() {
|
||||
const { activeSubTab } = useSubMenu('admin')
|
||||
const [activeMenu, setActiveMenu] = useState('users');
|
||||
|
||||
const renderContent = () => {
|
||||
const factory = PANEL_MAP[activeMenu];
|
||||
if (factory) return factory();
|
||||
const label = findMenuLabel(activeMenu) ?? activeMenu;
|
||||
return <AdminPlaceholder label={label} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden bg-bg-0">
|
||||
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{activeSubTab === 'users' && <UsersPanel />}
|
||||
{activeSubTab === 'permissions' && <PermissionsPanel />}
|
||||
{activeSubTab === 'menus' && <MenusPanel />}
|
||||
{activeSubTab === 'settings' && <SettingsPanel />}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
293
frontend/src/tabs/admin/components/BoardMgmtPanel.tsx
Normal file
293
frontend/src/tabs/admin/components/BoardMgmtPanel.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
fetchBoardPosts,
|
||||
adminDeleteBoardPost,
|
||||
type BoardPostItem,
|
||||
type BoardListResponse,
|
||||
} from '@tabs/board/services/boardApi';
|
||||
|
||||
// ─── 상수 ──────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const CATEGORY_TABS = [
|
||||
{ code: '', label: '전체' },
|
||||
{ code: 'NOTICE', label: '공지사항' },
|
||||
{ code: 'DATA', label: '게시판' },
|
||||
{ code: 'QNA', label: 'Q&A' },
|
||||
] as const;
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
NOTICE: '공지사항',
|
||||
DATA: '게시판',
|
||||
QNA: 'Q&A',
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 메인 패널 ─────────────────────────────────────────────
|
||||
interface BoardMgmtPanelProps {
|
||||
initialCategory?: string;
|
||||
}
|
||||
|
||||
export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelProps) {
|
||||
const [activeCategory, setActiveCategory] = useState(initialCategory);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<BoardListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchBoardPosts({
|
||||
categoryCd: activeCategory || undefined,
|
||||
search: search || undefined,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
setData(result);
|
||||
setSelected(new Set());
|
||||
} catch {
|
||||
console.error('게시글 목록 로드 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeCategory, search, page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const totalPages = data ? Math.ceil(data.totalCount / PAGE_SIZE) : 0;
|
||||
const items = data?.items ?? [];
|
||||
|
||||
const toggleSelect = (sn: number) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sn)) next.delete(sn);
|
||||
else next.add(sn);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === items.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(items.map(i => i.sn)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selected.size === 0) return;
|
||||
if (!confirm(`선택한 ${selected.size}건의 게시글을 삭제하시겠습니까?`)) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await Promise.all([...selected].map(sn => adminDeleteBoardPost(sn)));
|
||||
await load();
|
||||
} catch {
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearch(searchInput);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleCategoryChange = (code: string) => {
|
||||
setActiveCategory(code);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1">
|
||||
<h2 className="text-sm font-semibold text-text-1">게시판 관리</h2>
|
||||
<span className="text-xs text-text-3">
|
||||
총 {data?.totalCount ?? 0}건
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 탭 + 검색 */}
|
||||
<div className="flex items-center gap-3 px-5 py-2 border-b border-border-1">
|
||||
<div className="flex gap-1">
|
||||
{CATEGORY_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.code}
|
||||
onClick={() => handleCategoryChange(tab.code)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
activeCategory === tab.code
|
||||
? 'bg-blue-500/20 text-blue-400 font-medium'
|
||||
: 'text-text-3 hover:text-text-2 hover:bg-bg-2'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={handleSearch} className="flex gap-1 ml-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
placeholder="제목/작성자 검색"
|
||||
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1 placeholder:text-text-4 w-48"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center gap-2 px-5 py-2 border-b border-border-1">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={selected.size === 0 || deleting}
|
||||
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-bg-1 z-10">
|
||||
<tr className="border-b border-border-1 text-text-3">
|
||||
<th className="w-8 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={items.length > 0 && selected.size === items.length}
|
||||
onChange={toggleAll}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="w-12 py-2 text-center">번호</th>
|
||||
<th className="w-20 py-2 text-center">분류</th>
|
||||
<th className="py-2 text-left pl-3">제목</th>
|
||||
<th className="w-24 py-2 text-center">작성자</th>
|
||||
<th className="w-16 py-2 text-center">조회</th>
|
||||
<th className="w-36 py-2 text-center">등록일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-text-3">로딩 중...</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-text-3">게시글이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map(post => (
|
||||
<PostRow
|
||||
key={post.sn}
|
||||
post={post}
|
||||
checked={selected.has(post.sn)}
|
||||
onToggle={() => toggleSelect(post.sn)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-1 py-2 border-t border-border-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
|
||||
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9));
|
||||
const p = startPage + i;
|
||||
if (p > totalPages) return null;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`w-7 h-7 text-xs rounded ${
|
||||
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-text-3 hover:bg-bg-2'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 행 컴포넌트 ───────────────────────────────────────────
|
||||
interface PostRowProps {
|
||||
post: BoardPostItem;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function PostRow({ post, checked, onToggle }: PostRowProps) {
|
||||
return (
|
||||
<tr className="border-b border-border-1 hover:bg-bg-1/50 transition-colors">
|
||||
<td className="py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onToggle}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-center text-text-3">{post.sn}</td>
|
||||
<td className="py-2 text-center">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
||||
post.categoryCd === 'NOTICE' ? 'bg-red-500/15 text-red-400' :
|
||||
post.categoryCd === 'QNA' ? 'bg-purple-500/15 text-purple-400' :
|
||||
'bg-blue-500/15 text-blue-400'
|
||||
}`}>
|
||||
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pl-3 text-text-1 truncate max-w-[300px]">
|
||||
{post.pinnedYn === 'Y' && (
|
||||
<span className="text-[10px] text-orange-400 mr-1">[고정]</span>
|
||||
)}
|
||||
{post.title}
|
||||
</td>
|
||||
<td className="py-2 text-center text-text-2">{post.authorName}</td>
|
||||
<td className="py-2 text-center text-text-3">{post.viewCnt}</td>
|
||||
<td className="py-2 text-center text-text-3">{formatDate(post.regDtm)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
204
frontend/src/tabs/admin/components/VesselSignalPanel.tsx
Normal file
204
frontend/src/tabs/admin/components/VesselSignalPanel.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────
|
||||
const SIGNAL_SOURCES = ['VTS', 'VTS-AIS', 'V-PASS', 'E-NAVI', 'S&P AIS'] as const;
|
||||
type SignalSource = (typeof SIGNAL_SOURCES)[number];
|
||||
|
||||
interface SignalSlot {
|
||||
time: string; // HH:mm
|
||||
sources: Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
|
||||
}
|
||||
|
||||
// ─── 상수 ──────────────────────────────────────────────────
|
||||
const SOURCE_COLORS: Record<SignalSource, string> = {
|
||||
VTS: '#3b82f6',
|
||||
'VTS-AIS': '#a855f7',
|
||||
'V-PASS': '#22c55e',
|
||||
'E-NAVI': '#f97316',
|
||||
'S&P AIS': '#ec4899',
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
ok: '#22c55e',
|
||||
warn: '#eab308',
|
||||
error: '#ef4444',
|
||||
none: 'rgba(255,255,255,0.06)',
|
||||
};
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
function generateTimeSlots(date: string): SignalSlot[] {
|
||||
const now = new Date();
|
||||
const isToday = date === now.toISOString().slice(0, 10);
|
||||
const currentHour = isToday ? now.getHours() : 24;
|
||||
const currentMin = isToday ? now.getMinutes() : 0;
|
||||
|
||||
const slots: SignalSlot[] = [];
|
||||
for (let h = 0; h < 24; h++) {
|
||||
for (let m = 0; m < 60; m += 10) {
|
||||
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
const isPast = h < currentHour || (h === currentHour && m <= currentMin);
|
||||
|
||||
const sources = {} as Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
|
||||
for (const src of SIGNAL_SOURCES) {
|
||||
if (!isPast) {
|
||||
sources[src] = { count: 0, status: 'none' };
|
||||
} else {
|
||||
const rand = Math.random();
|
||||
const count = Math.floor(Math.random() * 200) + 10;
|
||||
sources[src] = {
|
||||
count,
|
||||
status: rand > 0.15 ? 'ok' : rand > 0.05 ? 'warn' : 'error',
|
||||
};
|
||||
}
|
||||
}
|
||||
slots.push({ time, sources });
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
// ─── 타임라인 바 (10분 단위 셀) ────────────────────────────
|
||||
function TimelineBar({ slots, source }: { slots: SignalSlot[]; source: SignalSource }) {
|
||||
if (slots.length === 0) return null;
|
||||
|
||||
// 144개 슬롯을 각각 1칸씩 렌더링 (10분 = 1칸)
|
||||
return (
|
||||
<div className="w-full h-5 overflow-hidden flex" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||
{slots.map((slot, i) => {
|
||||
const s = slot.sources[source];
|
||||
const color = STATUS_COLOR[s.status] || STATUS_COLOR.none;
|
||||
const statusLabel = s.status === 'ok' ? '정상' : s.status === 'warn' ? '지연' : s.status === 'error' ? '오류' : '미수신';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${100 / 144}%`,
|
||||
backgroundColor: color,
|
||||
borderRight: '0.5px solid rgba(0,0,0,0.15)',
|
||||
}}
|
||||
title={`${slot.time} ${statusLabel}${s.status !== 'none' ? ` (${s.count}건)` : ''}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 패널 ─────────────────────────────────────────────
|
||||
export default function VesselSignalPanel() {
|
||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [slots, setSlots] = useState<SignalSlot[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
// TODO: 실제 API 연동 시 fetch 호출로 교체
|
||||
setTimeout(() => {
|
||||
setSlots(generateTimeSlots(date));
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [date]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => load(), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
// 통계 계산
|
||||
const stats = SIGNAL_SOURCES.map(src => {
|
||||
let total = 0, ok = 0, warn = 0, error = 0;
|
||||
for (const slot of slots) {
|
||||
const s = slot.sources[src];
|
||||
if (s.status !== 'none') {
|
||||
total++;
|
||||
if (s.status === 'ok') ok++;
|
||||
else if (s.status === 'warn') warn++;
|
||||
else error++;
|
||||
}
|
||||
}
|
||||
return { src, total, ok, warn, error, rate: total > 0 ? ((ok / total) * 100).toFixed(1) : '-' };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-border-1">
|
||||
<h2 className="text-sm font-semibold text-text-1">선박신호 수신 현황</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1"
|
||||
/>
|
||||
<button
|
||||
onClick={load}
|
||||
className="px-3 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-xs text-text-3">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
{/* 좌측: 소스 라벨 고정 열 */}
|
||||
<div className="flex-shrink-0 flex flex-col" style={{ width: 64 }}>
|
||||
{/* 시간축 높이 맞춤 빈칸 */}
|
||||
<div className="h-5 mb-3" />
|
||||
{SIGNAL_SOURCES.map(src => {
|
||||
const c = SOURCE_COLORS[src];
|
||||
const st = stats.find(s => s.src === src)!;
|
||||
return (
|
||||
<div key={src} className="flex flex-col justify-center mb-4" style={{ height: 20 }}>
|
||||
<span className="text-[12px] font-semibold leading-tight" style={{ color: c }}>{src}</span>
|
||||
<span className="text-[10px] font-mono text-text-4 mt-0.5">{st.rate}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 우측: 시간축 + 타임라인 바 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* 시간 축 (상단) */}
|
||||
<div className="relative h-5 mb-3">
|
||||
{HOURS.map(h => (
|
||||
<span
|
||||
key={h}
|
||||
className="absolute text-[10px] text-text-3 font-mono"
|
||||
style={{ left: `${(h / 24) * 100}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
{String(h).padStart(2, '0')}시
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className="absolute text-[10px] text-text-3 font-mono"
|
||||
style={{ right: 0 }}
|
||||
>
|
||||
24시
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 소스별 타임라인 바 */}
|
||||
{SIGNAL_SOURCES.map(src => (
|
||||
<div key={src} className="mb-4 flex items-center" style={{ height: 20 }}>
|
||||
<TimelineBar slots={slots} source={src} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: 'var(--red)',
|
||||
HQ_CLEANUP: '#34d399',
|
||||
MANAGER: 'var(--orange)',
|
||||
USER: 'var(--cyan)',
|
||||
VIEWER: 'var(--t3)',
|
||||
|
||||
94
frontend/src/tabs/admin/components/adminMenuConfig.ts
Normal file
94
frontend/src/tabs/admin/components/adminMenuConfig.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/** 관리자 화면 9-섹션 메뉴 트리 */
|
||||
|
||||
export interface AdminMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
children?: AdminMenuItem[];
|
||||
}
|
||||
|
||||
export const ADMIN_MENU: AdminMenuItem[] = [
|
||||
{
|
||||
id: 'env-settings', label: '환경설정', icon: '⚙️',
|
||||
children: [
|
||||
{ id: 'menus', label: '메뉴관리' },
|
||||
{ id: 'settings', label: '시스템설정' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'user-info', label: '사용자정보', icon: '👥',
|
||||
children: [
|
||||
{ id: 'users', label: '사용자관리' },
|
||||
{ id: 'permissions', label: '권한관리' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'board-mgmt', label: '게시판관리', icon: '📋',
|
||||
children: [
|
||||
{ id: 'notice', label: '공지사항' },
|
||||
{ id: 'board', label: '게시판' },
|
||||
{ id: 'qna', label: 'QNA' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reference', label: '기준정보', icon: '🗺️',
|
||||
children: [
|
||||
{
|
||||
id: 'map-mgmt', label: '지도관리',
|
||||
children: [
|
||||
{ id: 'map-vector', label: '지도벡데이터' },
|
||||
{ id: 'map-layer', label: '레이어' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sensitive-map', label: '민감자원지도',
|
||||
children: [
|
||||
{ id: 'env-ecology', label: '환경/생태' },
|
||||
{ id: 'social-economy', label: '사회/경제' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'coast-guard-assets', label: '해경자산',
|
||||
children: [
|
||||
{ id: 'cleanup-equip', label: '방제장비' },
|
||||
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
|
||||
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
||||
{ id: 'cleanup-resource', label: '방제자원' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'external', label: '연계관리', icon: '🔗',
|
||||
children: [
|
||||
{
|
||||
id: 'collection', label: '수집자료',
|
||||
children: [
|
||||
{ id: 'collect-vessel-signal', label: '선박신호' },
|
||||
{ id: 'collect-hr', label: '인사정보' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'monitoring', label: '연계모니터링',
|
||||
children: [
|
||||
{ id: 'monitor-realtime', label: '실시간 관측자료' },
|
||||
{ id: 'monitor-forecast', label: '수치예측자료' },
|
||||
{ id: 'monitor-vessel', label: '선박위치정보' },
|
||||
{ id: 'monitor-hr', label: '인사' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 메뉴 ID로 라벨을 찾는 유틸리티 */
|
||||
export function findMenuLabel(id: string, items: AdminMenuItem[] = ADMIN_MENU): string | null {
|
||||
for (const item of items) {
|
||||
if (item.id === id) return item.label;
|
||||
if (item.children) {
|
||||
const found = findMenuLabel(id, item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { detectStreamType } from '../utils/streamUtils';
|
||||
import { useOilDetection } from '../hooks/useOilDetection';
|
||||
import OilDetectionOverlay from './OilDetectionOverlay';
|
||||
|
||||
interface CCTVPlayerProps {
|
||||
cameraNm: string;
|
||||
@ -9,6 +11,13 @@ interface CCTVPlayerProps {
|
||||
coordDc?: string | null;
|
||||
sourceNm?: string | null;
|
||||
cellIndex?: number;
|
||||
oilDetectionEnabled?: boolean;
|
||||
vesselDetectionEnabled?: boolean;
|
||||
intrusionDetectionEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CCTVPlayerHandle {
|
||||
capture: () => void;
|
||||
}
|
||||
|
||||
type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url';
|
||||
@ -21,15 +30,19 @@ function toProxyUrl(url: string): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
export function CCTVPlayer({
|
||||
export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
|
||||
cameraNm,
|
||||
streamUrl,
|
||||
sttsCd,
|
||||
coordDc,
|
||||
sourceNm,
|
||||
cellIndex = 0,
|
||||
}: CCTVPlayerProps) {
|
||||
oilDetectionEnabled = false,
|
||||
vesselDetectionEnabled = false,
|
||||
intrusionDetectionEnabled = false,
|
||||
}, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
@ -56,6 +69,73 @@ export function CCTVPlayer({
|
||||
? 'playing'
|
||||
: hlsPlayerState;
|
||||
|
||||
const { result: oilResult, isAnalyzing: oilAnalyzing, error: oilError } = useOilDetection({
|
||||
videoRef,
|
||||
enabled: oilDetectionEnabled && playerState === 'playing' && (streamType === 'hls' || streamType === 'mp4'),
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
capture: () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w * 2;
|
||||
canvas.height = h * 2;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(2, 2);
|
||||
|
||||
// 1) video frame
|
||||
const video = videoRef.current;
|
||||
if (video && video.readyState >= 2) {
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
}
|
||||
|
||||
// 2) oil detection overlay
|
||||
const overlayCanvas = container.querySelector<HTMLCanvasElement>('canvas');
|
||||
if (overlayCanvas) {
|
||||
ctx.drawImage(overlayCanvas, 0, 0, w, h);
|
||||
}
|
||||
|
||||
// 3) OSD: camera name + timestamp
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.fillRect(8, 8, ctx.measureText(cameraNm).width + 20, 22);
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText(cameraNm, 18, 23);
|
||||
|
||||
const ts = new Date().toLocaleString('ko-KR');
|
||||
ctx.font = '10px monospace';
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
const tsW = ctx.measureText(ts).width + 16;
|
||||
ctx.fillRect(8, h - 26, tsW, 20);
|
||||
ctx.fillStyle = '#a0aec0';
|
||||
ctx.fillText(ts, 16, h - 12);
|
||||
|
||||
// 4) oil detection info
|
||||
if (oilResult && oilResult.regions.length > 0) {
|
||||
const areaText = oilResult.totalAreaM2 >= 1000
|
||||
? `오일 감지: ${(oilResult.totalAreaM2 / 1_000_000).toFixed(1)} km² (${oilResult.totalPercentage.toFixed(1)}%)`
|
||||
: `오일 감지: ~${Math.round(oilResult.totalAreaM2)} m² (${oilResult.totalPercentage.toFixed(1)}%)`;
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
const atW = ctx.measureText(areaText).width + 16;
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.25)';
|
||||
ctx.fillRect(8, h - 48, atW, 18);
|
||||
ctx.fillStyle = '#f87171';
|
||||
ctx.fillText(areaText, 16, h - 34);
|
||||
}
|
||||
|
||||
// download
|
||||
const link = document.createElement('a');
|
||||
link.download = `CCTV_${cameraNm}_${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
},
|
||||
}), [cameraNm, oilResult]);
|
||||
|
||||
const destroyHls = useCallback(() => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
@ -185,7 +265,7 @@ export function CCTVPlayer({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="absolute inset-0">
|
||||
{/* 로딩 오버레이 */}
|
||||
{playerState === 'loading' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
|
||||
@ -207,13 +287,18 @@ export function CCTVPlayer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 오일 감지 오버레이 */}
|
||||
{oilDetectionEnabled && (
|
||||
<OilDetectionOverlay result={oilResult} isAnalyzing={oilAnalyzing} error={oilError} />
|
||||
)}
|
||||
|
||||
{/* MJPEG */}
|
||||
{streamType === 'mjpeg' && proxiedUrl && (
|
||||
<img
|
||||
src={proxiedUrl}
|
||||
alt={cameraNm}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
onError={() => setPlayerState('error')}
|
||||
onError={() => setHlsPlayerState('error')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -224,10 +309,28 @@ export function CCTVPlayer({
|
||||
title={cameraNm}
|
||||
className="absolute inset-0 w-full h-full border-none"
|
||||
allow="autoplay; encrypted-media"
|
||||
onError={() => setPlayerState('error')}
|
||||
onError={() => setHlsPlayerState('error')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 안전관리 감지 상태 배지 */}
|
||||
{(vesselDetectionEnabled || intrusionDetectionEnabled) && (
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
|
||||
{vesselDetectionEnabled && (
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
|
||||
style={{ background: 'rgba(59,130,246,.3)', color: '#93c5fd' }}>
|
||||
🚢 선박 출입 감지 중
|
||||
</div>
|
||||
)}
|
||||
{intrusionDetectionEnabled && (
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
|
||||
style={{ background: 'rgba(249,115,22,.3)', color: '#fdba74' }}>
|
||||
🚨 침입 감지 중
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OSD 오버레이 */}
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
|
||||
@ -245,6 +348,8 @@ export function CCTVPlayer({
|
||||
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20">
|
||||
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
CCTVPlayer.displayName = 'CCTVPlayer';
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { fetchCctvCameras } from '../services/aerialApi'
|
||||
import type { CctvCameraItem } from '../services/aerialApi'
|
||||
import { CCTVPlayer } from './CCTVPlayer'
|
||||
import type { CCTVPlayerHandle } from './CCTVPlayer'
|
||||
|
||||
/** KHOA HLS 스트림 베이스 URL */
|
||||
const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa';
|
||||
@ -54,6 +55,10 @@ export function CctvView() {
|
||||
const [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null)
|
||||
const [gridMode, setGridMode] = useState(1)
|
||||
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
|
||||
const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false)
|
||||
const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false)
|
||||
const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false)
|
||||
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@ -226,7 +231,45 @@ export function CctvView() {
|
||||
>{g.icon}</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors">📷 캡처</button>
|
||||
<button
|
||||
onClick={() => setOilDetectionEnabled(v => !v)}
|
||||
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||||
style={oilDetectionEnabled
|
||||
? { background: 'rgba(239,68,68,.15)', borderColor: 'rgba(239,68,68,.4)', color: 'var(--red)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
|
||||
}
|
||||
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '오일 유출 감지'}
|
||||
>
|
||||
{oilDetectionEnabled ? '🛢 감지 ON' : '🛢 오일 감지'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setVesselDetectionEnabled(v => !v)}
|
||||
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||||
style={vesselDetectionEnabled
|
||||
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.4)', color: 'var(--blue, #3b82f6)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
|
||||
}
|
||||
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '선박 출입 감지'}
|
||||
>
|
||||
{vesselDetectionEnabled ? '🚢 감지 ON' : '🚢 선박 출입'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIntrusionDetectionEnabled(v => !v)}
|
||||
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||||
style={intrusionDetectionEnabled
|
||||
? { background: 'rgba(249,115,22,.15)', borderColor: 'rgba(249,115,22,.4)', color: 'var(--orange, #f97316)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
|
||||
}
|
||||
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '침입 감지'}
|
||||
>
|
||||
{intrusionDetectionEnabled ? '🚨 감지 ON' : '🚨 침입 감지'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
playerRefs.current.forEach(r => r?.capture())
|
||||
}}
|
||||
className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors"
|
||||
>📷 캡처</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -242,12 +285,16 @@ export function CctvView() {
|
||||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
||||
{cam ? (
|
||||
<CCTVPlayer
|
||||
ref={el => { playerRefs.current[i] = el }}
|
||||
cameraNm={cam.cameraNm}
|
||||
streamUrl={cam.streamUrl}
|
||||
sttsCd={cam.sttsCd}
|
||||
coordDc={cam.coordDc}
|
||||
sourceNm={cam.sourceNm}
|
||||
cellIndex={i}
|
||||
oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9}
|
||||
vesselDetectionEnabled={vesselDetectionEnabled && gridMode !== 9}
|
||||
intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
||||
|
||||
161
frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx
Normal file
161
frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useRef, useEffect, memo } from 'react';
|
||||
import type { OilDetectionResult } from '../utils/oilDetection';
|
||||
import { OIL_CLASSES, OIL_CLASS_NAMES } from '../utils/oilDetection';
|
||||
|
||||
export interface OilDetectionOverlayProps {
|
||||
result: OilDetectionResult | null;
|
||||
isAnalyzing?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** 클래스 ID → RGBA 색상 (오버레이용) */
|
||||
const CLASS_COLORS: Record<number, [number, number, number, number]> = {
|
||||
1: [0, 0, 204, 90], // black oil → 파란색
|
||||
2: [180, 180, 180, 90], // brown oil → 회색
|
||||
3: [255, 255, 0, 90], // rainbow oil → 노란색
|
||||
4: [178, 102, 255, 90], // silver oil → 보라색
|
||||
};
|
||||
|
||||
const OilDetectionOverlay = memo(({ result, isAnalyzing = false, error = null }: OilDetectionOverlayProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const displayW = canvas.clientWidth;
|
||||
const displayH = canvas.clientHeight;
|
||||
|
||||
canvas.width = displayW * dpr;
|
||||
canvas.height = displayH * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.clearRect(0, 0, displayW, displayH);
|
||||
|
||||
if (!result || result.regions.length === 0) return;
|
||||
|
||||
const { mask, maskWidth, maskHeight } = result;
|
||||
|
||||
// 클래스별 색상으로 마스크 렌더링
|
||||
const offscreen = new OffscreenCanvas(maskWidth, maskHeight);
|
||||
const offCtx = offscreen.getContext('2d');
|
||||
if (offCtx) {
|
||||
const imageData = new ImageData(maskWidth, maskHeight);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
const classId = mask[i];
|
||||
if (classId === 0) continue; // background skip
|
||||
|
||||
const color = CLASS_COLORS[classId];
|
||||
if (!color) continue;
|
||||
|
||||
const pixelIdx = i * 4;
|
||||
imageData.data[pixelIdx] = color[0];
|
||||
imageData.data[pixelIdx + 1] = color[1];
|
||||
imageData.data[pixelIdx + 2] = color[2];
|
||||
imageData.data[pixelIdx + 3] = color[3];
|
||||
}
|
||||
offCtx.putImageData(imageData, 0, 0);
|
||||
ctx.drawImage(offscreen, 0, 0, displayW, displayH);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
const formatArea = (m2: number): string => {
|
||||
if (m2 >= 1000) {
|
||||
return `${(m2 / 1_000_000).toFixed(1)} km²`;
|
||||
}
|
||||
return `~${Math.round(m2)} m²`;
|
||||
};
|
||||
|
||||
const hasRegions = result !== null && result.regions.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className='absolute inset-0 w-full h-full pointer-events-none z-[15]'
|
||||
/>
|
||||
|
||||
{/* OSD — bottom-8로 좌표 OSD(bottom-2)와 겹침 방지 */}
|
||||
<div className='absolute bottom-8 left-2 z-20 flex flex-col items-start gap-1'>
|
||||
{/* 에러 표시 */}
|
||||
{error && (
|
||||
<div
|
||||
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.2)',
|
||||
border: '1px solid rgba(239,68,68,0.5)',
|
||||
color: '#f87171',
|
||||
}}
|
||||
>
|
||||
추론 서버 연결 불가
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 클래스별 감지 결과 */}
|
||||
{hasRegions && result !== null && (
|
||||
<>
|
||||
{result.regions.map((region) => {
|
||||
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
|
||||
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : '#f87171';
|
||||
const label = OIL_CLASS_NAMES[region.classId] || region.className;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={region.classId}
|
||||
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||
style={{
|
||||
background: `${color}33`,
|
||||
border: `1px solid ${color}80`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{label}: {formatArea(region.areaM2)} ({region.percentage.toFixed(1)}%)
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 합계 */}
|
||||
<div
|
||||
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.2)',
|
||||
border: '1px solid rgba(239,68,68,0.5)',
|
||||
color: '#f87171',
|
||||
}}
|
||||
>
|
||||
합계: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 감지 없음 */}
|
||||
{!hasRegions && !isAnalyzing && !error && (
|
||||
<div
|
||||
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||
style={{
|
||||
background: 'rgba(34,197,94,0.15)',
|
||||
border: '1px solid rgba(34,197,94,0.35)',
|
||||
color: '#4ade80',
|
||||
}}
|
||||
>
|
||||
감지 없음
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분석 중 */}
|
||||
{isAnalyzing && (
|
||||
<span className='text-[9px] font-korean text-text-3 animate-pulse px-1'>
|
||||
분석중...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
OilDetectionOverlay.displayName = 'OilDetectionOverlay';
|
||||
|
||||
export default OilDetectionOverlay;
|
||||
84
frontend/src/tabs/aerial/hooks/useOilDetection.ts
Normal file
84
frontend/src/tabs/aerial/hooks/useOilDetection.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { OilDetectionResult, OilDetectionConfig } from '../utils/oilDetection';
|
||||
import { detectOilSpillAPI, DEFAULT_OIL_DETECTION_CONFIG } from '../utils/oilDetection';
|
||||
|
||||
interface UseOilDetectionOptions {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
enabled: boolean;
|
||||
config?: Partial<OilDetectionConfig>;
|
||||
}
|
||||
|
||||
interface UseOilDetectionReturn {
|
||||
result: OilDetectionResult | null;
|
||||
isAnalyzing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useOilDetection(options: UseOilDetectionOptions): UseOilDetectionReturn {
|
||||
const { videoRef, enabled, config } = options;
|
||||
|
||||
const [result, setResult] = useState<OilDetectionResult | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const configRef = useRef<OilDetectionConfig>({
|
||||
...DEFAULT_OIL_DETECTION_CONFIG,
|
||||
...config,
|
||||
});
|
||||
const isBusyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
configRef.current = {
|
||||
...DEFAULT_OIL_DETECTION_CONFIG,
|
||||
...config,
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const analyze = useCallback(async () => {
|
||||
if (isBusyRef.current) return; // 이전 호출이 진행 중이면 스킵
|
||||
|
||||
const video = videoRef.current;
|
||||
if (!video || video.readyState < 2) return;
|
||||
|
||||
isBusyRef.current = true;
|
||||
setIsAnalyzing(true);
|
||||
|
||||
try {
|
||||
const detection = await detectOilSpillAPI(video, configRef.current);
|
||||
setResult(detection);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
// API 실패 시 이전 결과 유지, 에러 메시지만 갱신
|
||||
const message = err instanceof Error ? err.message : '추론 서버 연결 불가';
|
||||
setError(message);
|
||||
console.warn('[OilDetection] API 호출 실패:', message);
|
||||
} finally {
|
||||
isBusyRef.current = false;
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setResult(null);
|
||||
setIsAnalyzing(false);
|
||||
setError(null);
|
||||
isBusyRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
|
||||
// 첫 분석: 2초 후 (영상 로딩 대기)
|
||||
const firstTimeout = setTimeout(analyze, 2000);
|
||||
// 반복 분석
|
||||
const intervalId = setInterval(analyze, configRef.current.captureIntervalMs);
|
||||
|
||||
return () => {
|
||||
clearTimeout(firstTimeout);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [enabled, analyze]);
|
||||
|
||||
return { result, isAnalyzing, error };
|
||||
}
|
||||
165
frontend/src/tabs/aerial/utils/oilDetection.ts
Normal file
165
frontend/src/tabs/aerial/utils/oilDetection.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 오일 유출 감지 — GPU 추론 서버 API 연동
|
||||
*
|
||||
* 시립대(starsafire) ResNet101+DANet 모델 기반
|
||||
* 프레임 캡처 → base64 JPEG → POST /api/aerial/oil-detect → 세그멘테이션 결과
|
||||
*
|
||||
* 5개 클래스: background(0), black(1), brown(2), rainbow(3), silver(4)
|
||||
*/
|
||||
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OilDetectionConfig {
|
||||
captureIntervalMs: number; // API 호출 주기 (ms), default 5000
|
||||
coverageAreaM2: number; // 카메라 커버리지 면적 (m²), default 10000
|
||||
captureWidth: number; // 캡처 해상도 (너비), default 512
|
||||
}
|
||||
|
||||
/** 유류 클래스 정의 */
|
||||
export interface OilClass {
|
||||
classId: number;
|
||||
className: string;
|
||||
color: [number, number, number]; // RGB
|
||||
thicknessMm: number;
|
||||
}
|
||||
|
||||
/** 개별 유류 영역 (API 응답에서 변환) */
|
||||
export interface OilRegion {
|
||||
classId: number;
|
||||
className: string;
|
||||
pixelCount: number;
|
||||
percentage: number;
|
||||
areaM2: number;
|
||||
thicknessMm: number;
|
||||
}
|
||||
|
||||
/** 감지 결과 (오버레이에서 사용) */
|
||||
export interface OilDetectionResult {
|
||||
regions: OilRegion[];
|
||||
totalPercentage: number;
|
||||
totalAreaM2: number;
|
||||
mask: Uint8Array; // 클래스 인덱스 (0-4)
|
||||
maskWidth: number;
|
||||
maskHeight: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_OIL_DETECTION_CONFIG: OilDetectionConfig = {
|
||||
captureIntervalMs: 5000,
|
||||
coverageAreaM2: 10000,
|
||||
captureWidth: 512,
|
||||
};
|
||||
|
||||
/** 유류 클래스 팔레트 (시립대 starsafire 기준) */
|
||||
export const OIL_CLASSES: OilClass[] = [
|
||||
{ classId: 1, className: 'black', color: [0, 0, 204], thicknessMm: 1.0 },
|
||||
{ classId: 2, className: 'brown', color: [180, 180, 180], thicknessMm: 0.1 },
|
||||
{ classId: 3, className: 'rainbow', color: [255, 255, 0], thicknessMm: 0.0003 },
|
||||
{ classId: 4, className: 'silver', color: [178, 102, 255], thicknessMm: 0.0001 },
|
||||
];
|
||||
|
||||
export const OIL_CLASS_NAMES: Record<number, string> = {
|
||||
1: '에멀전(Black)',
|
||||
2: '원유(Brown)',
|
||||
3: '무지개막(Rainbow)',
|
||||
4: '은색막(Silver)',
|
||||
};
|
||||
|
||||
// ── Frame Capture ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 비디오 프레임을 캡처하여 base64 JPEG 문자열로 반환한다.
|
||||
*/
|
||||
export function captureFrameAsBase64(
|
||||
video: HTMLVideoElement,
|
||||
targetWidth: number,
|
||||
): string | null {
|
||||
if (video.readyState < 2 || video.videoWidth === 0) return null;
|
||||
|
||||
const aspect = video.videoHeight / video.videoWidth;
|
||||
const w = targetWidth;
|
||||
const h = Math.round(w * aspect);
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
// data:image/jpeg;base64,... → base64 부분만 추출
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
return dataUrl.split(',')[1] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── API Inference ──────────────────────────────────────────────────────────
|
||||
|
||||
interface ApiInferenceRegion {
|
||||
classId: number;
|
||||
className: string;
|
||||
pixelCount: number;
|
||||
percentage: number;
|
||||
thicknessMm: number;
|
||||
}
|
||||
|
||||
interface ApiInferenceResponse {
|
||||
mask: string; // base64 uint8 array
|
||||
width: number;
|
||||
height: number;
|
||||
regions: ApiInferenceRegion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GPU 추론 서버에 프레임을 전송하고 오일 감지 결과를 반환한다.
|
||||
*/
|
||||
export async function detectOilSpillAPI(
|
||||
video: HTMLVideoElement,
|
||||
config: OilDetectionConfig,
|
||||
): Promise<OilDetectionResult | null> {
|
||||
const imageBase64 = captureFrameAsBase64(video, config.captureWidth);
|
||||
if (!imageBase64) return null;
|
||||
|
||||
const response = await api.post<ApiInferenceResponse>('/aerial/oil-detect', {
|
||||
image: imageBase64,
|
||||
});
|
||||
|
||||
const { mask: maskB64, width, height, regions: apiRegions } = response.data;
|
||||
const totalPixels = width * height;
|
||||
|
||||
// base64 → Uint8Array
|
||||
const binaryStr = atob(maskB64);
|
||||
const mask = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
mask[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
// API 영역 → OilRegion 변환 (면적 계산 포함)
|
||||
const regions: OilRegion[] = apiRegions.map((r) => ({
|
||||
classId: r.classId,
|
||||
className: r.className,
|
||||
pixelCount: r.pixelCount,
|
||||
percentage: r.percentage,
|
||||
areaM2: (r.pixelCount / totalPixels) * config.coverageAreaM2,
|
||||
thicknessMm: r.thicknessMm,
|
||||
}));
|
||||
|
||||
const totalPercentage = regions.reduce((sum, r) => sum + r.percentage, 0);
|
||||
const totalAreaM2 = regions.reduce((sum, r) => sum + r.areaM2, 0);
|
||||
|
||||
return {
|
||||
regions,
|
||||
totalPercentage,
|
||||
totalAreaM2,
|
||||
mask,
|
||||
maskWidth: width,
|
||||
maskHeight: height,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
@ -74,6 +74,11 @@ export async function deleteBoardPost(sn: number): Promise<void> {
|
||||
await api.delete(`/board/${sn}`);
|
||||
}
|
||||
|
||||
/** 관리자 전용 삭제 — 소유자 검증 없음 */
|
||||
export async function adminDeleteBoardPost(sn: number): Promise<void> {
|
||||
await api.post('/board/admin-delete', { sn });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 매뉴얼 API
|
||||
// ============================================================
|
||||
|
||||
@ -36,8 +36,8 @@ export interface HNSInputParams {
|
||||
interface HNSLeftPanelProps {
|
||||
activeSubTab: 'analysis' | 'list';
|
||||
onSubTabChange: (tab: 'analysis' | 'list') => void;
|
||||
incidentCoord: { lon: number; lat: number };
|
||||
onCoordChange: (coord: { lon: number; lat: number }) => void;
|
||||
incidentCoord: { lon: number; lat: number } | null;
|
||||
onCoordChange: (coord: { lon: number; lat: number } | null) => void;
|
||||
onMapSelectClick: () => void;
|
||||
onRunPrediction: () => void;
|
||||
isRunningPrediction: boolean;
|
||||
@ -112,7 +112,7 @@ export function HNSLeftPanel({
|
||||
}, [loadedParams]);
|
||||
|
||||
// 기상정보 자동조회 (사고 발생 일시 기반)
|
||||
const weather = useWeatherFetch(incidentCoord.lat, incidentCoord.lon, accidentDate, accidentTime);
|
||||
const weather = useWeatherFetch(incidentCoord?.lat ?? 0, incidentCoord?.lon ?? 0, accidentDate, accidentTime);
|
||||
|
||||
// 물질 독성 정보
|
||||
const tox = getSubstanceToxicity(substance);
|
||||
@ -272,15 +272,23 @@ export function HNSLeftPanel({
|
||||
className="prd-i flex-1 font-mono"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord.lat.toFixed(4)}
|
||||
onChange={(e) => onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })}
|
||||
value={incidentCoord?.lat.toFixed(4) ?? ''}
|
||||
placeholder="위도"
|
||||
onChange={(e) => {
|
||||
const lat = parseFloat(e.target.value) || 0;
|
||||
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat });
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="prd-i flex-1 font-mono"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord.lon.toFixed(4)}
|
||||
onChange={(e) => onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })}
|
||||
value={incidentCoord?.lon.toFixed(4) ?? ''}
|
||||
placeholder="경도"
|
||||
onChange={(e) => {
|
||||
const lon = parseFloat(e.target.value) || 0;
|
||||
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon });
|
||||
}}
|
||||
/>
|
||||
<button className="prd-map-btn" onClick={onMapSelectClick}>
|
||||
📍 지도
|
||||
@ -290,7 +298,7 @@ export function HNSLeftPanel({
|
||||
{/* DMS 표시 */}
|
||||
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
||||
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
|
||||
{toDMS(incidentCoord.lat, 'lat')} / {toDMS(incidentCoord.lon, 'lon')}
|
||||
{incidentCoord ? `${toDMS(incidentCoord.lat, 'lat')} / ${toDMS(incidentCoord.lon, 'lon')}` : '지도에서 위치를 선택하세요'}
|
||||
</div>
|
||||
|
||||
{/* 유출형태 + 물질명 */}
|
||||
|
||||
@ -156,7 +156,7 @@ function DispersionTimeSlider({
|
||||
export function HNSView() {
|
||||
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
||||
const { user } = useAuthStore();
|
||||
const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 });
|
||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -184,6 +184,7 @@ export function HNSView() {
|
||||
setCurrentFrame(0);
|
||||
setIsPuffPlaying(false);
|
||||
setInputParams(null);
|
||||
setIncidentCoord(null);
|
||||
hasRunOnce.current = false;
|
||||
}, []);
|
||||
|
||||
@ -320,6 +321,11 @@ export function HNSView() {
|
||||
|
||||
try {
|
||||
const params = paramsOverride ?? inputParams;
|
||||
if (!incidentCoord) {
|
||||
alert('사고 지점을 먼저 지도에서 선택하세요.');
|
||||
setIsRunningPrediction(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
|
||||
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
|
||||
@ -694,7 +700,7 @@ export function HNSView() {
|
||||
) : (
|
||||
<>
|
||||
<MapView
|
||||
incidentCoord={incidentCoord}
|
||||
incidentCoord={incidentCoord ?? undefined}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={[]}
|
||||
|
||||
@ -10,8 +10,11 @@ export function LeftPanel({
|
||||
selectedAnalysis,
|
||||
enabledLayers,
|
||||
onToggleLayer,
|
||||
accidentTime,
|
||||
onAccidentTimeChange,
|
||||
incidentCoord,
|
||||
onCoordChange,
|
||||
isSelectingLocation,
|
||||
onMapSelectClick,
|
||||
onRunSimulation,
|
||||
isRunningSimulation,
|
||||
@ -25,6 +28,10 @@ export function LeftPanel({
|
||||
onOilTypeChange,
|
||||
spillAmount,
|
||||
onSpillAmountChange,
|
||||
incidentName,
|
||||
onIncidentNameChange,
|
||||
spillUnit,
|
||||
onSpillUnitChange,
|
||||
boomLines,
|
||||
onBoomLinesChange,
|
||||
oilTrajectory,
|
||||
@ -64,8 +71,11 @@ 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}
|
||||
@ -79,6 +89,10 @@ export function LeftPanel({
|
||||
onOilTypeChange={onOilTypeChange}
|
||||
spillAmount={spillAmount}
|
||||
onSpillAmountChange={onSpillAmountChange}
|
||||
incidentName={incidentName}
|
||||
onIncidentNameChange={onIncidentNameChange}
|
||||
spillUnit={spillUnit}
|
||||
onSpillUnitChange={onSpillUnitChange}
|
||||
/>
|
||||
|
||||
{/* Incident Section */}
|
||||
@ -178,7 +192,7 @@ export function LeftPanel({
|
||||
boomLines={boomLines}
|
||||
onBoomLinesChange={onBoomLinesChange}
|
||||
oilTrajectory={oilTrajectory}
|
||||
incidentCoord={incidentCoord}
|
||||
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
|
||||
algorithmSettings={algorithmSettings}
|
||||
onAlgorithmSettingsChange={onAlgorithmSettingsChange}
|
||||
isDrawingBoom={isDrawingBoom}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { LeftPanel } from './LeftPanel'
|
||||
import { RightPanel } from './RightPanel'
|
||||
import { MapView } from '@common/components/map/MapView'
|
||||
@ -12,8 +12,10 @@ import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/u
|
||||
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
||||
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail } from '../services/predictionApi'
|
||||
import type { PredictionDetail } from '../services/predictionApi'
|
||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi'
|
||||
import type { CenterPoint, HydrDataStep, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
|
||||
import { useSimulationStatus } from '../hooks/useSimulationStatus'
|
||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||
import { api } from '@common/services/api'
|
||||
import { generateAIBoomLines } from '@common/utils/geo'
|
||||
|
||||
@ -101,15 +103,23 @@ export const ALL_MODELS: PredictionModel[] = ['KOSPS', 'POSEIDON', 'OpenDrift']
|
||||
export function OilSpillView() {
|
||||
const { activeSubTab, setActiveSubTab } = useSubMenu('prediction')
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set())
|
||||
const [incidentCoord, setIncidentCoord] = useState({ lon: 127.6845, lat: 34.7312 })
|
||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null)
|
||||
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom?: number } | null>(null)
|
||||
const [fitBoundsTarget, setFitBoundsTarget] = useState<{ north: number; south: number; east: number; west: number } | null>(null)
|
||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false)
|
||||
const [oilTrajectory, setOilTrajectory] = useState<Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>>([])
|
||||
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([])
|
||||
const [windData, setWindData] = useState<WindPoint[][]>([])
|
||||
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([])
|
||||
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
|
||||
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['KOSPS']))
|
||||
const [predictionTime, setPredictionTime] = useState(48)
|
||||
const [accidentTime, setAccidentTime] = useState<string>('')
|
||||
const [spillType, setSpillType] = useState('연속')
|
||||
const [oilType, setOilType] = useState('벙커C유')
|
||||
const [spillAmount, setSpillAmount] = useState(100)
|
||||
const [incidentName, setIncidentName] = useState('')
|
||||
const [spillUnit, setSpillUnit] = useState('kL')
|
||||
|
||||
// 민감자원
|
||||
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
|
||||
@ -132,7 +142,7 @@ export function OilSpillView() {
|
||||
|
||||
// 타임라인 플레이어 상태
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [timelinePosition, setTimelinePosition] = useState(25) // 0~100%
|
||||
const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위)
|
||||
const [playSpeed, setPlaySpeed] = useState(1)
|
||||
|
||||
// 역추적 상태
|
||||
@ -152,26 +162,17 @@ export function OilSpillView() {
|
||||
// 역추적 API 데이터
|
||||
const [backtrackConditions, setBacktrackConditions] = useState<BacktrackConditions>({
|
||||
estimatedSpillTime: '', analysisRange: '±12시간', searchRadius: '10 NM',
|
||||
spillLocation: { lat: 34.7312, lon: 127.6845 }, totalVessels: 0,
|
||||
spillLocation: { lat: 37.3883, lon: 126.6435 }, totalVessels: 0,
|
||||
})
|
||||
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
|
||||
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
|
||||
|
||||
// 재계산 상태
|
||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
||||
const [currentExecSn, setCurrentExecSn] = useState<number | null>(null)
|
||||
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
||||
const { data: simStatus } = useSimulationStatus(currentExecSn)
|
||||
|
||||
// 분석 탭 초기 진입 시 기본 데모 자동 표시
|
||||
useEffect(() => {
|
||||
if (activeSubTab === 'analysis' && oilTrajectory.length === 0 && !selectedAnalysis) {
|
||||
const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set<PredictionModel>(['KOSPS']))
|
||||
const demoTrajectory = generateDemoTrajectory(incidentCoord, models, predictionTime)
|
||||
setOilTrajectory(demoTrajectory)
|
||||
const demoBooms = generateAIBoomLines(demoTrajectory, incidentCoord, algorithmSettings)
|
||||
setBoomLines(demoBooms)
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeSubTab])
|
||||
|
||||
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
||||
setEnabledLayers(prev => {
|
||||
@ -204,7 +205,7 @@ export function OilSpillView() {
|
||||
estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
|
||||
analysisRange: bt.anlysRange || '±12시간',
|
||||
searchRadius: bt.srchRadiusNm ? `${bt.srchRadiusNm} NM` : '10 NM',
|
||||
spillLocation: { lat: bt.lat || incidentCoord.lat, lon: bt.lon || incidentCoord.lon },
|
||||
spillLocation: { lat: bt.lat || incidentCoord?.lat || 0, lon: bt.lon || incidentCoord?.lon || 0 },
|
||||
totalVessels: bt.totalVessels || 0,
|
||||
})
|
||||
setBacktrackPhase('results')
|
||||
@ -225,7 +226,7 @@ export function OilSpillView() {
|
||||
setBacktrackModalOpen(true)
|
||||
setBacktrackConditions(prev => ({
|
||||
...prev,
|
||||
spillLocation: incidentCoord,
|
||||
spillLocation: incidentCoord ?? prev.spillLocation,
|
||||
}))
|
||||
if (selectedAnalysis) {
|
||||
loadBacktrackData(selectedAnalysis.acdntSn)
|
||||
@ -236,6 +237,7 @@ export function OilSpillView() {
|
||||
}
|
||||
|
||||
const handleRunBacktrackAnalysis = async () => {
|
||||
if (!incidentCoord) return
|
||||
setBacktrackPhase('analyzing')
|
||||
try {
|
||||
if (selectedAnalysis) {
|
||||
@ -290,10 +292,6 @@ export function OilSpillView() {
|
||||
// 역추적 리플레이 애니메이션
|
||||
useEffect(() => {
|
||||
if (!isReplayPlaying) return
|
||||
if (replayFrame >= TOTAL_REPLAY_FRAMES) {
|
||||
setIsReplayPlaying(false)
|
||||
return
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
setReplayFrame(prev => {
|
||||
const next = prev + 1
|
||||
@ -305,13 +303,94 @@ export function OilSpillView() {
|
||||
})
|
||||
}, 50 / replaySpeed)
|
||||
return () => clearInterval(interval)
|
||||
}, [isReplayPlaying, replayFrame, replaySpeed])
|
||||
}, [isReplayPlaying, replaySpeed])
|
||||
|
||||
// 시뮬레이션 폴링 결과 처리
|
||||
useEffect(() => {
|
||||
if (!simStatus) return;
|
||||
if (simStatus.status === 'DONE' && simStatus.trajectory) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setOilTrajectory(simStatus.trajectory);
|
||||
setSimulationSummary(simStatus.summary ?? null);
|
||||
setCenterPoints(simStatus.centerPoints ?? []);
|
||||
setWindData(simStatus.windData ?? []);
|
||||
setHydrData(simStatus.hydrData ?? []);
|
||||
setIsRunningSimulation(false);
|
||||
setCurrentExecSn(null);
|
||||
// AI 방어선 자동 생성
|
||||
if (incidentCoord) {
|
||||
const booms = generateAIBoomLines(simStatus.trajectory, incidentCoord, algorithmSettings);
|
||||
setBoomLines(booms);
|
||||
}
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
||||
// 예측 완료 시 궤적 전체가 보이도록 지도 fitBounds
|
||||
const particles = simStatus.trajectory;
|
||||
if (particles.length > 0) {
|
||||
const lats = particles.map(p => p.lat);
|
||||
const lons = particles.map(p => p.lon);
|
||||
setFitBoundsTarget({
|
||||
north: Math.max(...lats),
|
||||
south: Math.min(...lats),
|
||||
east: Math.max(...lons),
|
||||
west: Math.min(...lons),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (simStatus.status === 'ERROR') {
|
||||
setIsRunningSimulation(false);
|
||||
setCurrentExecSn(null);
|
||||
}
|
||||
}, [simStatus, incidentCoord, algorithmSettings]);
|
||||
|
||||
// trajectory 변경 시 플레이어 초기화 및 자동 재생
|
||||
useEffect(() => {
|
||||
if (oilTrajectory.length > 0) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [oilTrajectory.length]);
|
||||
|
||||
// 플레이어 재생 애니메이션 (1x = 1초/스텝, 2x = 0.5초/스텝, 4x = 0.25초/스텝)
|
||||
const timeSteps = useMemo(() => {
|
||||
if (oilTrajectory.length === 0) return [];
|
||||
const unique = [...new Set(oilTrajectory.map(p => p.time))].sort((a, b) => a - b);
|
||||
return unique;
|
||||
}, [oilTrajectory]);
|
||||
|
||||
const maxTime = timeSteps[timeSteps.length - 1] ?? predictionTime;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || timeSteps.length === 0) return;
|
||||
if (currentStep >= maxTime) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
const ms = 1000 / playSpeed;
|
||||
const id = setInterval(() => {
|
||||
setCurrentStep(prev => {
|
||||
const idx = timeSteps.indexOf(prev);
|
||||
if (idx < 0 || idx >= timeSteps.length - 1) {
|
||||
setIsPlaying(false);
|
||||
return timeSteps[timeSteps.length - 1];
|
||||
}
|
||||
return timeSteps[idx + 1];
|
||||
});
|
||||
}, ms);
|
||||
return () => clearInterval(id);
|
||||
}, [isPlaying, currentStep, playSpeed, timeSteps, maxTime]);
|
||||
|
||||
// 분석 목록에서 사고명 클릭 시
|
||||
const handleSelectAnalysis = async (analysis: Analysis) => {
|
||||
setSelectedAnalysis(analysis)
|
||||
setCenterPoints([])
|
||||
if (analysis.occurredAt) {
|
||||
setAccidentTime(analysis.occurredAt.slice(0, 16))
|
||||
}
|
||||
if (analysis.lon != null && analysis.lat != null) {
|
||||
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
|
||||
setFlyToTarget({ lng: analysis.lon, lat: analysis.lat, zoom: 11 })
|
||||
}
|
||||
// 유종 매핑
|
||||
const oilTypeMap: Record<string, string> = {
|
||||
@ -336,11 +415,32 @@ export function OilSpillView() {
|
||||
// 분석 화면으로 전환
|
||||
setActiveSubTab('analysis')
|
||||
|
||||
// 데모 궤적 자동 생성 (화면 진입 즉시 시각화)
|
||||
const coord = (analysis.lon != null && analysis.lat != null)
|
||||
? { lon: analysis.lon, lat: analysis.lat }
|
||||
: incidentCoord
|
||||
const demoModels = Array.from(models.size > 0 ? models : new Set<PredictionModel>(['KOSPS']))
|
||||
|
||||
// OpenDrift 완료된 경우 실제 궤적 로드, 없으면 데모로 fallback
|
||||
if (analysis.opendriftStatus === 'completed') {
|
||||
try {
|
||||
const { trajectory, summary, centerPoints: cp, windData: wd, hydrData: hd } = await fetchAnalysisTrajectory(analysis.acdntSn)
|
||||
if (trajectory && trajectory.length > 0) {
|
||||
setOilTrajectory(trajectory)
|
||||
if (summary) setSimulationSummary(summary)
|
||||
setCenterPoints(cp ?? [])
|
||||
setWindData(wd ?? [])
|
||||
setHydrData(hd ?? [])
|
||||
const booms = generateAIBoomLines(trajectory, coord, algorithmSettings)
|
||||
setBoomLines(booms)
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[prediction] trajectory 로딩 실패, 데모로 fallback:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 데모 궤적 생성 (fallback)
|
||||
const demoTrajectory = generateDemoTrajectory(coord, demoModels, parseInt(analysis.duration) || 48)
|
||||
setOilTrajectory(demoTrajectory)
|
||||
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
||||
@ -358,56 +458,91 @@ export function OilSpillView() {
|
||||
}
|
||||
|
||||
const handleRunSimulation = async () => {
|
||||
if (selectedModels.size === 0) return
|
||||
setIsRunningSimulation(true)
|
||||
// incidentName이 있으면 직접 입력 모드 — 기존 selectedAnalysis.acdntSn 무시하고 새 사고 생성
|
||||
const isDirectInput = incidentName.trim().length > 0;
|
||||
const existingAcdntSn = isDirectInput
|
||||
? undefined
|
||||
: (selectedAnalysis?.acdntSn ?? analysisDetail?.acdnt?.acdntSn);
|
||||
|
||||
// 선택 모드인데 사고도 없으면 실행 불가, 직접 입력 모드인데 사고명 없으면 실행 불가
|
||||
if (!isDirectInput && !existingAcdntSn) {
|
||||
return;
|
||||
}
|
||||
if (!incidentCoord) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRunningSimulation(true);
|
||||
setSimulationSummary(null);
|
||||
try {
|
||||
const models = Array.from(selectedModels)
|
||||
const results = await Promise.all(
|
||||
models.map(async (model) => {
|
||||
const { data } = await api.post<{ trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }> }>('/simulation/run', {
|
||||
model,
|
||||
lat: incidentCoord.lat,
|
||||
lon: incidentCoord.lon,
|
||||
duration_hours: predictionTime,
|
||||
oil_type: oilType,
|
||||
spill_amount: spillAmount,
|
||||
spill_type: spillType,
|
||||
})
|
||||
return data.trajectory.map(p => ({ ...p, model }))
|
||||
})
|
||||
)
|
||||
const payload: Record<string, unknown> = {
|
||||
acdntSn: existingAcdntSn,
|
||||
lat: incidentCoord.lat,
|
||||
lon: incidentCoord.lon,
|
||||
runTime: predictionTime,
|
||||
matTy: oilType,
|
||||
matVol: spillAmount,
|
||||
spillTime: spillType === '연속' ? predictionTime : 0,
|
||||
startTime: accidentTime
|
||||
? `${accidentTime}:00`
|
||||
: analysisDetail?.acdnt?.occurredAt,
|
||||
};
|
||||
|
||||
setOilTrajectory(results.flat())
|
||||
// 직접 입력 모드: 백엔드에서 ACDNT + SPIL_DATA 생성에 필요한 필드 추가
|
||||
if (isDirectInput) {
|
||||
payload.acdntNm = incidentName.trim();
|
||||
payload.spillUnit = spillUnit;
|
||||
payload.spillTypeCd = spillType;
|
||||
}
|
||||
|
||||
const { data } = await api.post<SimulationRunResponse>('/simulation/run', payload);
|
||||
setCurrentExecSn(data.execSn);
|
||||
|
||||
// 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화
|
||||
if (data.acdntSn && isDirectInput) {
|
||||
setSelectedAnalysis({
|
||||
acdntSn: data.acdntSn,
|
||||
acdntNm: incidentName.trim(),
|
||||
occurredAt: accidentTime ? `${accidentTime}:00` : '',
|
||||
analysisDate: new Date().toISOString(),
|
||||
requestor: '',
|
||||
duration: String(predictionTime),
|
||||
oilType,
|
||||
volume: spillAmount,
|
||||
location: '',
|
||||
lat: incidentCoord.lat,
|
||||
lon: incidentCoord.lon,
|
||||
kospsStatus: 'pending',
|
||||
poseidonStatus: 'pending',
|
||||
opendriftStatus: 'pending',
|
||||
backtrackStatus: 'pending',
|
||||
analyst: '',
|
||||
officeName: '',
|
||||
} as Analysis);
|
||||
// 다음 실행 시 동일 사고 재생성 방지 — 이후에는 selectedAnalysis.acdntSn 사용
|
||||
setIncidentName('');
|
||||
}
|
||||
// setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리
|
||||
} catch {
|
||||
// 백엔드 미구현 — 클라이언트 데모 궤적 fallback
|
||||
console.info('[prediction] 서버 시뮬레이션 미구현, 데모 궤적 생성')
|
||||
const models = Array.from(selectedModels)
|
||||
const demoTrajectory = generateDemoTrajectory(incidentCoord, models, predictionTime)
|
||||
setOilTrajectory(demoTrajectory)
|
||||
|
||||
// AI 방어선 자동 생성
|
||||
const demoBooms = generateAIBoomLines(demoTrajectory, incidentCoord, algorithmSettings)
|
||||
setBoomLines(demoBooms)
|
||||
|
||||
// 민감자원 로드
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
} finally {
|
||||
setIsRunningSimulation(false)
|
||||
setIsRunningSimulation(false);
|
||||
// 503 등 에러 시 상태 복원 (에러 메시지 표시는 향후 토스트로 처리)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<LeftPanel
|
||||
selectedAnalysis={selectedAnalysis}
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={handleToggleLayer}
|
||||
accidentTime={accidentTime}
|
||||
onAccidentTimeChange={setAccidentTime}
|
||||
incidentCoord={incidentCoord}
|
||||
onCoordChange={setIncidentCoord}
|
||||
onMapSelectClick={() => setIsSelectingLocation(true)}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapSelectClick={() => setIsSelectingLocation(prev => !prev)}
|
||||
onRunSimulation={handleRunSimulation}
|
||||
isRunningSimulation={isRunningSimulation}
|
||||
selectedModels={selectedModels}
|
||||
@ -420,6 +555,10 @@ export function OilSpillView() {
|
||||
onOilTypeChange={setOilType}
|
||||
spillAmount={spillAmount}
|
||||
onSpillAmountChange={setSpillAmount}
|
||||
incidentName={incidentName}
|
||||
onIncidentNameChange={setIncidentName}
|
||||
spillUnit={spillUnit}
|
||||
onSpillUnitChange={setSpillUnit}
|
||||
boomLines={boomLines}
|
||||
onBoomLinesChange={setBoomLines}
|
||||
oilTrajectory={oilTrajectory}
|
||||
@ -450,7 +589,7 @@ export function OilSpillView() {
|
||||
<>
|
||||
<MapView
|
||||
enabledLayers={enabledLayers}
|
||||
incidentCoord={incidentCoord}
|
||||
incidentCoord={incidentCoord ?? undefined}
|
||||
isSelectingLocation={isSelectingLocation || isDrawingBoom}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={oilTrajectory}
|
||||
@ -461,10 +600,16 @@ export function OilSpillView() {
|
||||
layerOpacity={layerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
sensitiveResources={sensitiveResources}
|
||||
backtrackReplay={isReplayActive && replayShips.length > 0 ? {
|
||||
centerPoints={centerPoints}
|
||||
windData={windData}
|
||||
hydrData={hydrData}
|
||||
flyToTarget={flyToTarget}
|
||||
fitBoundsTarget={fitBoundsTarget}
|
||||
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
|
||||
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
|
||||
isActive: true,
|
||||
ships: replayShips,
|
||||
collisionEvent: collisionEvent || undefined,
|
||||
collisionEvent: collisionEvent ?? null,
|
||||
replayFrame,
|
||||
totalFrames: TOTAL_REPLAY_FRAMES,
|
||||
incidentCoord,
|
||||
@ -472,148 +617,166 @@ export function OilSpillView() {
|
||||
/>
|
||||
|
||||
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
||||
{!isReplayActive && <div className="absolute bottom-0 left-0 right-0 h-[72px] flex items-center px-5 gap-4" style={{
|
||||
background: 'rgba(15,21,36,0.95)', backdropFilter: 'blur(16px)',
|
||||
borderTop: '1px solid var(--bd)',
|
||||
zIndex: 1100
|
||||
}}>
|
||||
{/* 컨트롤 버튼 */}
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{[
|
||||
{ icon: '⏮', action: () => setTimelinePosition(0) },
|
||||
{ icon: '◀', action: () => setTimelinePosition(Math.max(0, timelinePosition - 100 / 12)) },
|
||||
].map((btn, i) => (
|
||||
<button key={i} onClick={btn.action} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
|
||||
}}>{btn.icon}</button>
|
||||
))}
|
||||
<button onClick={() => setIsPlaying(!isPlaying)} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: isPlaying ? '1px solid var(--cyan)' : '1px solid var(--bd)',
|
||||
background: isPlaying ? 'var(--cyan)' : 'var(--bg3)',
|
||||
color: isPlaying ? 'var(--bg0)' : 'var(--t2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
|
||||
}}>{isPlaying ? '⏸' : '▶'}</button>
|
||||
{[
|
||||
{ icon: '▶▶', action: () => setTimelinePosition(Math.min(100, timelinePosition + 100 / 12)) },
|
||||
{ icon: '⏭', action: () => setTimelinePosition(100) },
|
||||
].map((btn, i) => (
|
||||
<button key={i} onClick={btn.action} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '12px', transition: '0.2s'
|
||||
}}>{btn.icon}</button>
|
||||
))}
|
||||
<div className="w-2" />
|
||||
<button onClick={() => setPlaySpeed(playSpeed >= 4 ? 1 : playSpeed * 2)} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fM)', transition: '0.2s'
|
||||
}}>{playSpeed}×</button>
|
||||
</div>
|
||||
|
||||
{/* 타임라인 슬라이더 */}
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
{/* 시간 라벨 */}
|
||||
<div className="flex justify-between px-1">
|
||||
{['0h', '6h', '12h', '18h', '24h', '36h', '48h', '60h', '72h'].map((label, i) => {
|
||||
const pos = [0, 8.33, 16.67, 25, 33.33, 50, 66.67, 83.33, 100][i]
|
||||
const isActive = Math.abs(timelinePosition - pos) < 5
|
||||
return (
|
||||
<span key={label} style={{
|
||||
fontSize: '10px', fontFamily: 'var(--fM)',
|
||||
color: isActive ? 'var(--cyan)' : 'var(--t3)',
|
||||
fontWeight: isActive ? 600 : 400, cursor: 'pointer'
|
||||
}} onClick={() => setTimelinePosition(pos)}>{label}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 슬라이더 트랙 */}
|
||||
<div className="relative h-6 flex items-center">
|
||||
{/* 트랙 레일 */}
|
||||
<div
|
||||
style={{ width: '100%', height: '4px', background: 'var(--bd)', borderRadius: '2px', position: 'relative', cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setTimelinePosition(Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)))
|
||||
}}
|
||||
>
|
||||
{/* 진행 바 */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
width: `${timelinePosition}%`, height: '100%',
|
||||
background: 'linear-gradient(90deg, var(--cyan), var(--blue))',
|
||||
borderRadius: '2px', transition: 'width 0.15s'
|
||||
}} />
|
||||
{/* 주요 마커 */}
|
||||
{[0, 16.67, 33.33, 50, 66.67, 83.33, 100].map((pos) => (
|
||||
<div key={`mj-${pos}`} style={{
|
||||
position: 'absolute', width: '2px', height: '14px',
|
||||
background: 'var(--t3)', top: '-5px', left: `${pos}%`
|
||||
}} />
|
||||
{!isReplayActive && (() => {
|
||||
const progressPct = maxTime > 0 ? (currentStep / maxTime) * 100 : 0;
|
||||
// 동적 라벨: 스텝 수에 따라 균등 분배
|
||||
const visibleLabels: number[] = (() => {
|
||||
if (timeSteps.length === 0) return [0];
|
||||
if (timeSteps.length <= 8) return timeSteps;
|
||||
const interval = Math.ceil(timeSteps.length / 7);
|
||||
return timeSteps.filter((_, i) => i % interval === 0 || i === timeSteps.length - 1);
|
||||
})();
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[72px] flex items-center px-5 gap-4" style={{
|
||||
background: 'rgba(15,21,36,0.95)', backdropFilter: 'blur(16px)',
|
||||
borderTop: '1px solid var(--bd)',
|
||||
zIndex: 1100
|
||||
}}>
|
||||
{/* 컨트롤 버튼 */}
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{[
|
||||
{ icon: '⏮', action: () => { setCurrentStep(timeSteps[0] ?? 0); setIsPlaying(false); } },
|
||||
{ icon: '◀', action: () => { const idx = timeSteps.indexOf(currentStep); if (idx > 0) setCurrentStep(timeSteps[idx - 1]); } },
|
||||
].map((btn, i) => (
|
||||
<button key={i} onClick={btn.action} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
|
||||
}}>{btn.icon}</button>
|
||||
))}
|
||||
{/* 보조 마커 */}
|
||||
{[8.33, 25].map((pos) => (
|
||||
<div key={`mn-${pos}`} style={{
|
||||
position: 'absolute', width: '2px', height: '10px',
|
||||
background: 'var(--bdL)', top: '-3px', left: `${pos}%`
|
||||
}} />
|
||||
))}
|
||||
{/* 방어선 설치 이벤트 마커 */}
|
||||
{boomLines.length > 0 && [
|
||||
{ pos: 4.2, label: '1차 방어선 설치 (+3h)' },
|
||||
{ pos: 8.3, label: '2차 방어선 설치 (+6h)' },
|
||||
{ pos: 12.5, label: '3차 방어선 설치 (+9h)' },
|
||||
].slice(0, boomLines.length).map((bm, i) => (
|
||||
<div key={`bm-${i}`} title={bm.label} style={{
|
||||
position: 'absolute', top: '-18px', left: `${bm.pos}%`,
|
||||
transform: 'translateX(-50%)', fontSize: '12px', cursor: 'pointer',
|
||||
filter: 'drop-shadow(0 0 4px rgba(245,158,11,0.5))'
|
||||
}}>🛡</div>
|
||||
<button onClick={() => {
|
||||
if (!isPlaying && currentStep >= maxTime) setCurrentStep(timeSteps[0] ?? 0);
|
||||
setIsPlaying(p => !p);
|
||||
}} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: isPlaying ? '1px solid var(--cyan)' : '1px solid var(--bd)',
|
||||
background: isPlaying ? 'var(--cyan)' : 'var(--bg3)',
|
||||
color: isPlaying ? 'var(--bg0)' : 'var(--t2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '14px', transition: '0.2s'
|
||||
}}>{isPlaying ? '⏸' : '▶'}</button>
|
||||
{[
|
||||
{ icon: '▶▶', action: () => { const idx = timeSteps.indexOf(currentStep); if (idx < timeSteps.length - 1) setCurrentStep(timeSteps[idx + 1]); } },
|
||||
{ icon: '⏭', action: () => { setCurrentStep(maxTime); setIsPlaying(false); } },
|
||||
].map((btn, i) => (
|
||||
<button key={i} onClick={btn.action} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '12px', transition: '0.2s'
|
||||
}}>{btn.icon}</button>
|
||||
))}
|
||||
<div className="w-2" />
|
||||
<button onClick={() => setPlaySpeed(playSpeed >= 4 ? 1 : playSpeed * 2)} style={{
|
||||
width: '34px', height: '34px', borderRadius: 'var(--rS, 4px)',
|
||||
border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fM)', transition: '0.2s'
|
||||
}}>{playSpeed}×</button>
|
||||
</div>
|
||||
{/* 드래그 핸들 */}
|
||||
<div style={{
|
||||
position: 'absolute', left: `${timelinePosition}%`, top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '16px', height: '16px',
|
||||
background: 'var(--cyan)', border: '3px solid var(--bg0)',
|
||||
borderRadius: '50%', cursor: 'grab',
|
||||
boxShadow: '0 0 10px rgba(6,182,212,0.4)', zIndex: 2,
|
||||
transition: 'left 0.15s'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시간 정보 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px', flexShrink: 0, minWidth: '200px' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>
|
||||
+{Math.round(timelinePosition * 72 / 100)}h — {(() => {
|
||||
const d = new Date(); d.setHours(d.getHours() + Math.round(timelinePosition * 72 / 100))
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`
|
||||
})()}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '14px' }}>
|
||||
{[
|
||||
{ label: '풍화율', value: `${Math.min(99, Math.round(timelinePosition * 0.4))}%` },
|
||||
{ label: '면적', value: `${(timelinePosition * 0.08).toFixed(1)} km²` },
|
||||
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(timelinePosition * 0.2))}%` : '—', color: 'var(--boom)' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
|
||||
<span className="text-text-3">{s.label}</span>
|
||||
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--fM)' }}>{s.value}</span>
|
||||
{/* 타임라인 슬라이더 */}
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
{/* 동적 시간 라벨 */}
|
||||
<div className="relative h-4">
|
||||
{visibleLabels.map(t => {
|
||||
const pos = maxTime > 0 ? (t / maxTime) * 100 : 0;
|
||||
const isActive = t === currentStep;
|
||||
return (
|
||||
<span key={t} style={{
|
||||
position: 'absolute', left: `${pos}%`, transform: 'translateX(-50%)',
|
||||
fontSize: '10px', fontFamily: 'var(--fM)',
|
||||
color: isActive ? 'var(--cyan)' : 'var(--t3)',
|
||||
fontWeight: isActive ? 600 : 400, cursor: 'pointer', whiteSpace: 'nowrap'
|
||||
}} onClick={() => setCurrentStep(t)}>{t}h</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 슬라이더 트랙 */}
|
||||
<div className="relative h-6 flex items-center">
|
||||
<div
|
||||
style={{ width: '100%', height: '4px', background: 'var(--bd)', borderRadius: '2px', position: 'relative', cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
if (timeSteps.length === 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
const targetTime = pct * maxTime;
|
||||
const closest = timeSteps.reduce((a, b) =>
|
||||
Math.abs(b - targetTime) < Math.abs(a - targetTime) ? b : a
|
||||
);
|
||||
setCurrentStep(closest);
|
||||
}}
|
||||
>
|
||||
{/* 진행 바 */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
width: `${progressPct}%`, height: '100%',
|
||||
background: 'linear-gradient(90deg, var(--cyan), var(--blue))',
|
||||
borderRadius: '2px', transition: 'width 0.15s'
|
||||
}} />
|
||||
{/* 스텝 마커 (각 타임스텝 위치에 틱 표시) */}
|
||||
{timeSteps.map(t => {
|
||||
const pos = maxTime > 0 ? (t / maxTime) * 100 : 0;
|
||||
return (
|
||||
<div key={`tick-${t}`} style={{
|
||||
position: 'absolute', width: '2px', height: '10px',
|
||||
background: t <= currentStep ? 'var(--cyan)' : 'var(--t3)',
|
||||
top: '-3px', left: `${pos}%`, opacity: 0.6
|
||||
}} />
|
||||
);
|
||||
})}
|
||||
{/* 방어선 설치 이벤트 마커 */}
|
||||
{boomLines.length > 0 && [
|
||||
{ pos: 4.2, label: '1차 방어선 설치 (+3h)' },
|
||||
{ pos: 8.3, label: '2차 방어선 설치 (+6h)' },
|
||||
{ pos: 12.5, label: '3차 방어선 설치 (+9h)' },
|
||||
].slice(0, boomLines.length).map((bm, i) => (
|
||||
<div key={`bm-${i}`} title={bm.label} style={{
|
||||
position: 'absolute', top: '-18px', left: `${bm.pos}%`,
|
||||
transform: 'translateX(-50%)', fontSize: '12px', cursor: 'pointer',
|
||||
filter: 'drop-shadow(0 0 4px rgba(245,158,11,0.5))'
|
||||
}}>🛡</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 드래그 핸들 */}
|
||||
<div style={{
|
||||
position: 'absolute', left: `${progressPct}%`, top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '16px', height: '16px',
|
||||
background: 'var(--cyan)', border: '3px solid var(--bg0)',
|
||||
borderRadius: '50%', cursor: 'grab',
|
||||
boxShadow: '0 0 10px rgba(6,182,212,0.4)', zIndex: 2,
|
||||
transition: 'left 0.15s'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시간 정보 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px', flexShrink: 0, minWidth: '200px' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>
|
||||
+{currentStep}h — {(() => {
|
||||
const d = new Date(); d.setHours(d.getHours() + currentStep);
|
||||
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 style={{ display: 'flex', gap: '14px' }}>
|
||||
{[
|
||||
{ label: '풍화율', value: `${Math.min(99, Math.round(progressPct * 0.4))}%` },
|
||||
{ label: '면적', value: `${(progressPct * 0.08).toFixed(1)} km²` },
|
||||
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--boom)' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
|
||||
<span className="text-text-3">{s.label}</span>
|
||||
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--fM)' }}>{s.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 역추적 리플레이 바 */}
|
||||
{isReplayActive && (
|
||||
@ -627,7 +790,7 @@ export function OilSpillView() {
|
||||
onSpeedChange={setReplaySpeed}
|
||||
onClose={handleCloseReplay}
|
||||
replayShips={replayShips}
|
||||
collisionEvent={collisionEvent || undefined}
|
||||
collisionEvent={collisionEvent}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -635,7 +798,15 @@ export function OilSpillView() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />}
|
||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} summary={simulationSummary} />}
|
||||
|
||||
{/* 확산 예측 실행 중 로딩 오버레이 */}
|
||||
{isRunningSimulation && (
|
||||
<SimulationLoadingOverlay
|
||||
status={simStatus?.status === 'RUNNING' ? 'RUNNING' : 'PENDING'}
|
||||
progress={simStatus?.progress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 재계산 모달 */}
|
||||
<RecalcModal
|
||||
@ -645,7 +816,7 @@ export function OilSpillView() {
|
||||
spillAmount={spillAmount}
|
||||
spillType={spillType}
|
||||
predictionTime={predictionTime}
|
||||
incidentCoord={incidentCoord}
|
||||
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
|
||||
selectedModels={selectedModels}
|
||||
onSubmit={(params) => {
|
||||
setOilType(params.oilType)
|
||||
|
||||
@ -7,8 +7,11 @@ import type { PredictionModel } from './OilSpillView'
|
||||
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
|
||||
@ -22,13 +25,20 @@ interface PredictionInputSectionProps {
|
||||
onOilTypeChange: (type: string) => void
|
||||
spillAmount: number
|
||||
onSpillAmountChange: (amount: number) => void
|
||||
incidentName: string
|
||||
onIncidentNameChange: (name: string) => void
|
||||
spillUnit: string
|
||||
onSpillUnitChange: (unit: string) => void
|
||||
}
|
||||
|
||||
const PredictionInputSection = ({
|
||||
expanded,
|
||||
onToggle,
|
||||
accidentTime,
|
||||
onAccidentTimeChange,
|
||||
incidentCoord,
|
||||
onCoordChange,
|
||||
isSelectingLocation,
|
||||
onMapSelectClick,
|
||||
onRunSimulation,
|
||||
isRunningSimulation,
|
||||
@ -42,6 +52,10 @@ const PredictionInputSection = ({
|
||||
onOilTypeChange,
|
||||
spillAmount,
|
||||
onSpillAmountChange,
|
||||
incidentName,
|
||||
onIncidentNameChange,
|
||||
spillUnit,
|
||||
onSpillUnitChange,
|
||||
}: PredictionInputSectionProps) => {
|
||||
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct')
|
||||
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
||||
@ -109,8 +123,13 @@ 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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -220,6 +239,18 @@ const PredictionInputSection = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 사고 발생 시각 */}
|
||||
<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' }}>
|
||||
@ -230,7 +261,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="위도°"
|
||||
/>
|
||||
@ -241,11 +272,14 @@ 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) && (
|
||||
@ -299,8 +333,8 @@ const PredictionInputSection = ({
|
||||
/>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value="kL"
|
||||
onChange={() => {}}
|
||||
value={spillUnit}
|
||||
onChange={onSpillUnitChange}
|
||||
options={[
|
||||
{ value: 'kL', label: 'kL' },
|
||||
{ value: 'ton', label: 'Ton' },
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { PredictionDetail } from '../services/predictionApi'
|
||||
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
||||
|
||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null }) {
|
||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null }) {
|
||||
const vessel = detail?.vessels?.[0]
|
||||
const vessel2 = detail?.vessels?.[1]
|
||||
const spill = detail?.spill
|
||||
@ -44,11 +44,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>
|
||||
|
||||
@ -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;
|
||||
@ -6,8 +6,11 @@ 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
|
||||
@ -21,6 +24,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
|
||||
|
||||
16
frontend/src/tabs/prediction/hooks/useSimulationStatus.ts
Normal file
16
frontend/src/tabs/prediction/hooks/useSimulationStatus.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@common/services/api';
|
||||
import type { SimulationStatusResponse } from '../services/predictionApi';
|
||||
|
||||
export const useSimulationStatus = (execSn: number | null) => {
|
||||
return useQuery<SimulationStatusResponse>({
|
||||
queryKey: ['simulationStatus', execSn],
|
||||
queryFn: () => api.get<SimulationStatusResponse>(`/simulation/status/${execSn}`).then(r => r.data),
|
||||
enabled: execSn !== null,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (status === 'DONE' || status === 'ERROR') return false;
|
||||
return 3000;
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -115,3 +115,80 @@ 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;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface OilParticle {
|
||||
lat: number;
|
||||
lon: number;
|
||||
time: number;
|
||||
particle?: number;
|
||||
stranded?: 0 | 1;
|
||||
}
|
||||
|
||||
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 TrajectoryResponse {
|
||||
trajectory: OilParticle[] | null;
|
||||
summary: SimulationSummary | null;
|
||||
centerPoints?: CenterPoint[];
|
||||
windData?: WindPoint[][];
|
||||
hydrData?: (HydrDataStep | null)[];
|
||||
}
|
||||
|
||||
export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<TrajectoryResponse> => {
|
||||
const response = await api.get<TrajectoryResponse>(`/prediction/analyses/${acdntSn}/trajectory`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
116
prediction/opendrift/CLAUDE.md
Normal file
116
prediction/opendrift/CLAUDE.md
Normal file
@ -0,0 +1,116 @@
|
||||
# CLAUDE.md
|
||||
|
||||
이 파일은 Claude Code (claude.ai/code)가 이 저장소의 코드를 작업할 때 참고할 수 있는 가이드를 제공합니다.
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
이 프로젝트는 OpenDrift 기반의 **기름 유출 모델링 및 예측 시스템**입니다. OpenDrift는 라그랑지안 입자 기반 해양 표류 모델링 프레임워크입니다. 이 시스템은 기상(GDAPS) 및 해양(MOHID) 예보 데이터를 활용하여 기름 유출 궤적, 풍화 과정(증발, 유화), 환경 영향을 시뮬레이션합니다.
|
||||
|
||||
## API 서버 실행
|
||||
|
||||
```bash
|
||||
# 서버 시작 (uvicorn 4 workers, 포트 5003)
|
||||
./startup.sh
|
||||
|
||||
# 서버 중지
|
||||
./shutdown.sh
|
||||
|
||||
# 로그 파일: uvicorn.log
|
||||
# PID 파일: server.pid
|
||||
```
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
- `GET /get-received-date` - 최신 예보 수신 가능 날짜 조회
|
||||
- `GET /get-uv/{datetime}/{category}` - 바람/해류 시각화 데이터 (category: "wind" 또는 "hydr")
|
||||
- `GET /get-base64/{datetime}/{img_type}` - Base64 인코딩된 지도 이미지
|
||||
- `POST /check-nc` - 특정 시작 시간에 대한 NetCDF 파일 존재 여부 확인
|
||||
- `POST /run-model` - 기름 유출 시뮬레이션 실행
|
||||
|
||||
### run-model 요청 본문
|
||||
```json
|
||||
{
|
||||
"startTime": "2025-01-15 12:00:00",
|
||||
"runTime": 72,
|
||||
"matTy": "CRUDE OIL",
|
||||
"matVol": 100.0,
|
||||
"lon": 126.1,
|
||||
"lat": 36.6,
|
||||
"spillTime": 12,
|
||||
"name": "simulation_id"
|
||||
}
|
||||
```
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 핵심 흐름
|
||||
1. **api.py** - FastAPI 진입점, 요청 처리 및 OpenOil 시뮬레이션 실행
|
||||
2. **createJsonResult.py** - 시뮬레이션 NetCDF 출력 처리, 시계열 데이터 추출(위치, 부피, 풍화 지표), 컨벡스 헐 및 해안 오염 계산
|
||||
3. 결과는 시간 단계별 입자 위치, 유류 부피, 환경 조건을 포함한 JSON으로 반환
|
||||
|
||||
### 주요 모듈
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| `calcCostlineLength.py` | `OilSpillCoastlineAnalyzer` 클래스 - 한국 해안선 shapefile에 KD-tree 공간 인덱싱을 사용하여 오염 해안선 길이 계산 |
|
||||
| `convex_hull.py` | 입자 위치로부터 WKT 폴리곤 생성 |
|
||||
| `extractUvFull.py` / `extractUvWithinBox.py` | 시각화를 위한 NetCDF에서 UV 바람/해류 벡터 추출 |
|
||||
| `createWindJson.py` | 특정 지점의 바람 데이터 추출 |
|
||||
| `latestForecastDate.py` | 예보 데이터 가용성 확인 |
|
||||
| `weatherData.py` | 조석, 파고, 기상 데이터용 PostgreSQL/PostGIS 쿼리 |
|
||||
| `findFile.py` | 폴백 로직이 포함된 시간 기반 파일 탐색기 |
|
||||
|
||||
### 데이터 소스
|
||||
- **바람**: `/storage/pos_wind/` 또는 `/storage/wind/` - KMA GDAPS 파일 (`KO108_GDAPS_ATMO_SURF_YYYYMMDDhh.nc`)
|
||||
- **해양**: `/storage/pos_hydr/` 또는 `/storage/hydr/` - MOHID 해양역학 파일 (`KO108_MOHID_HYDR_SURF_YYYYMMDDhh.nc`)
|
||||
- **해안선**: `coastline/TN_SHORLINE.shp` (EPSG:5179, EPSG:4326으로 변환)
|
||||
|
||||
### 파일 폴백 로직
|
||||
특정 날짜의 데이터 요청 시 파일이 없으면 최대 3일 전까지 순차적으로 확인합니다.
|
||||
|
||||
## 주요 의존성
|
||||
|
||||
- **opendrift** - 핵심 기름 표류 시뮬레이션 엔진 (`opendrift.models.openoil.OpenOil`)
|
||||
- **xarray** - NetCDF 파일 처리
|
||||
- **geopandas, shapely** - GIS 연산 및 지오메트리
|
||||
- **scipy.spatial.cKDTree** - 해안선 분석용 공간 인덱싱
|
||||
- **psycopg2** - PostgreSQL 데이터베이스 연결
|
||||
- **FastAPI/uvicorn** - 웹 API 프레임워크
|
||||
|
||||
## OpenOil 시뮬레이션 설정
|
||||
|
||||
```python
|
||||
o.set_config('processes:evaporation', True)
|
||||
o.set_config('processes:emulsification', True)
|
||||
o.set_config('drift:vertical_mixing', True)
|
||||
o.set_config('vertical_mixing:timestep', 5)
|
||||
o.set_config('seed:m3_per_hour', matVol)
|
||||
# 시간 간격: 900초, 출력 간격: 3600초
|
||||
```
|
||||
|
||||
## 오류 코드
|
||||
|
||||
| 코드 | 의미 |
|
||||
|------|------|
|
||||
| 5001 | FILE_NOT_FOUND - NetCDF 예보 파일 없음 |
|
||||
| 5002 | PARSE_ERROR - JSON 추출 실패 |
|
||||
| 5003 | MODELING_ERROR - OpenOil 시뮬레이션 실패 |
|
||||
| 5004 | SYSTEM_ERROR - 일반 예외 |
|
||||
|
||||
## 한국 해역 범위
|
||||
|
||||
```python
|
||||
lon_range: (124.21, 129.96)
|
||||
lat_range: (32.79, 38.96)
|
||||
```
|
||||
|
||||
## 동시성
|
||||
|
||||
- API: uvicorn 4 workers
|
||||
- 결과 처리: 병렬 시간 단계 계산을 위한 ThreadPoolExecutor 16 workers
|
||||
- `OilSpillCoastlineAnalyzer`는 스레드 세이프
|
||||
|
||||
## 참고 사항
|
||||
|
||||
- 모든 시간은 시뮬레이션 전에 KST(UTC+9)에서 UTC로 변환됨
|
||||
- 시뮬레이션 결과는 `result/{name}.nc`에 저장됨
|
||||
- 해양 데이터에서 100도 이상의 온도 값은 NaN으로 마스킹됨
|
||||
267
prediction/opendrift/api.py
Normal file
267
prediction/opendrift/api.py
Normal file
@ -0,0 +1,267 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
import uuid
|
||||
import os
|
||||
import numpy as np
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from enum import Enum
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from opendrift.readers import reader_netCDF_CF_generic
|
||||
from opendrift.models.openoil import OpenOil
|
||||
|
||||
from config import STORAGE, COORDS, SIM
|
||||
from logger import get_logger
|
||||
from utils import check_nc_file_by_date, check_nc_files_for_date, check_img_file_by_date, kst_to_utc
|
||||
from createJsonResult import extract_and_save_data_to_json as extract_json
|
||||
from findFile import find_nearest_earlier_file as find_file
|
||||
from extractUvFull import extract_uv_full
|
||||
from latestForecastDate import get_earliest_latest_forecast_date
|
||||
|
||||
logger = get_logger("api")
|
||||
app = FastAPI()
|
||||
|
||||
# ============================================================
|
||||
# Workers 포화 관리 (단일 프로세스 기준 — startup.sh: --workers 1)
|
||||
# ============================================================
|
||||
MAX_CONCURRENT = int(os.getenv('MAX_CONCURRENT_JOBS', '4'))
|
||||
jobs: dict[str, dict] = {}
|
||||
_thread_pool = ThreadPoolExecutor(max_workers=MAX_CONCURRENT)
|
||||
|
||||
# ============================================================
|
||||
# Parcels 선택적 로드 (없어도 동작)
|
||||
# ============================================================
|
||||
try:
|
||||
sys.path.insert(0, str(STORAGE.PARCELS_PATH))
|
||||
from parcels_api import router as parcels_router # type: ignore
|
||||
app.include_router(parcels_router)
|
||||
logger.info("Parcels router 로드 완료")
|
||||
except Exception as _e:
|
||||
logger.warning(f"Parcels router 로드 건너뜀 (정상): {_e}")
|
||||
|
||||
|
||||
class CustomErrorCode(Enum):
|
||||
FILE_NOT_FOUND = 5001
|
||||
PARSE_ERROR = 5002
|
||||
MODELING_ERROR = 5003
|
||||
SYSTEM_ERROR = 5004
|
||||
|
||||
|
||||
def _parse_datetime(dt_str: Optional[str]) -> Optional[datetime]:
|
||||
"""다양한 형식의 날짜 문자열을 datetime으로 변환 (KST 기준)"""
|
||||
if not dt_str:
|
||||
return None
|
||||
formats = [
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ",
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y%m%d%H",
|
||||
]
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(dt_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 기존 API 엔드포인트 (변경 없음)
|
||||
# ============================================================
|
||||
|
||||
@app.get("/get-received-date")
|
||||
async def get_received_date():
|
||||
"""예보 수신일 및 가능일 확인"""
|
||||
result = get_earliest_latest_forecast_date()
|
||||
if result:
|
||||
return JSONResponse(content=result, status_code=200)
|
||||
return JSONResponse(content={
|
||||
"error_code": CustomErrorCode.FILE_NOT_FOUND.value,
|
||||
"message": "File not found error"
|
||||
}, status_code=200)
|
||||
|
||||
|
||||
@app.get("/get-uv/{datetime_str}/{category}")
|
||||
async def get_uv(datetime_str: str, category: str):
|
||||
"""바람, 해수 시각화용 데이터 리턴"""
|
||||
date_obj = kst_to_utc(datetime.strptime(datetime_str, "%Y%m%d%H"))
|
||||
if category == "wind":
|
||||
nc_path, date = check_nc_file_by_date(str(STORAGE.WIND), date_obj)
|
||||
else:
|
||||
nc_path, date = check_nc_file_by_date(str(STORAGE.HYDR), date_obj)
|
||||
result = extract_uv_full(
|
||||
nc_path,
|
||||
date_obj.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
category,
|
||||
skip=1,
|
||||
lon_range=COORDS.lon_range,
|
||||
lat_range=COORDS.lat_range
|
||||
)
|
||||
return JSONResponse(content={"result": result}, status_code=200)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# NC 파일 확인 (수정: 404 반환으로 Node.js !checkRes.ok 연동)
|
||||
# ============================================================
|
||||
|
||||
@app.post("/check-nc")
|
||||
async def check_nc(request: Request):
|
||||
"""기상 데이터 존재 여부 확인. startTime(KST) 기준으로 NC 파일 조회."""
|
||||
body = await request.json()
|
||||
start_time_str = body.get('startTime') or body.get('start_time')
|
||||
|
||||
try:
|
||||
date_obj = _parse_datetime(start_time_str)
|
||||
if date_obj is None:
|
||||
date_obj = datetime.now()
|
||||
|
||||
date_utc = kst_to_utc(date_obj)
|
||||
wind_nc_path, ocean_nc_path, _, _ = check_nc_files_for_date(date_utc)
|
||||
|
||||
if not wind_nc_path or not ocean_nc_path:
|
||||
return JSONResponse(content={"message": "not exist"}, status_code=404)
|
||||
|
||||
return JSONResponse(content={"message": "exist"}, status_code=200)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error checking NC files")
|
||||
return JSONResponse(content={
|
||||
"error_code": CustomErrorCode.SYSTEM_ERROR.value,
|
||||
"message": "System Error"
|
||||
}, status_code=500)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 비동기 시뮬레이션 실행 (Workers 포화 제어)
|
||||
# ============================================================
|
||||
|
||||
@app.post("/run-model")
|
||||
async def run_model(request: Request):
|
||||
"""기름 유출 시뮬레이션 비동기 실행. job_id를 즉시 반환하고 백그라운드에서 처리."""
|
||||
running = sum(1 for j in jobs.values() if j['status'] == 'RUNNING')
|
||||
if running >= MAX_CONCURRENT:
|
||||
return JSONResponse(status_code=503, content={
|
||||
'success': False,
|
||||
'error': '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.',
|
||||
'running': running,
|
||||
'max': MAX_CONCURRENT,
|
||||
})
|
||||
|
||||
body = await request.json()
|
||||
job_id = str(uuid.uuid4())
|
||||
jobs[job_id] = {'status': 'RUNNING', 'result': None, 'error': None}
|
||||
asyncio.create_task(_run_simulation(job_id, body))
|
||||
|
||||
return JSONResponse(content={'success': True, 'job_id': job_id, 'status': 'RUNNING'}, status_code=200)
|
||||
|
||||
|
||||
@app.get("/status/{job_id}")
|
||||
async def get_job_status(job_id: str):
|
||||
"""시뮬레이션 작업 상태 조회"""
|
||||
if job_id not in jobs:
|
||||
return JSONResponse(content={'error': 'Job not found'}, status_code=404)
|
||||
|
||||
job = jobs[job_id]
|
||||
if job['status'] == 'DONE':
|
||||
return JSONResponse(content={'status': 'DONE', 'result': job['result']})
|
||||
if job['status'] == 'ERROR':
|
||||
return JSONResponse(content={'status': 'ERROR', 'error': job['error']})
|
||||
return JSONResponse(content={'status': 'RUNNING'})
|
||||
|
||||
|
||||
async def _run_simulation(job_id: str, body: dict) -> None:
|
||||
"""시뮬레이션을 ThreadPoolExecutor에서 실행하고 결과를 jobs 딕셔너리에 저장"""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(_thread_pool, _simulate_sync, body, job_id)
|
||||
jobs[job_id] = {'status': 'DONE', 'result': result, 'error': None}
|
||||
except Exception as e:
|
||||
logger.exception(f"시뮬레이션 오류 (job_id={job_id})")
|
||||
jobs[job_id] = {'status': 'ERROR', 'result': None, 'error': str(e)}
|
||||
|
||||
|
||||
def _simulate_sync(body: dict, job_id: str):
|
||||
"""동기 시뮬레이션 로직 (ThreadPoolExecutor에서 실행)"""
|
||||
start_time_str = body.get('startTime') or body.get('start_time')
|
||||
run_time = body.get('runTime') or body.get('run_time')
|
||||
mat_ty = body.get('matTy') or body.get('mat_ty')
|
||||
mat_vol = body.get('matVol') or body.get('mat_vol')
|
||||
lon = body.get('lon')
|
||||
lat = body.get('lat')
|
||||
spill_time = body.get('spillTime') or body.get('spill_time')
|
||||
name = body.get('name') or job_id # name 없으면 job_id 사용
|
||||
|
||||
start_time_measure = datetime.now()
|
||||
o = OpenOil(loglevel=20)
|
||||
|
||||
date_obj = _parse_datetime(start_time_str) or datetime.now()
|
||||
date_utc = kst_to_utc(date_obj)
|
||||
|
||||
wind_nc_path, ocean_nc_path, _, _ = check_nc_files_for_date(date_utc)
|
||||
if not wind_nc_path:
|
||||
raise FileNotFoundError("바람 NC 파일을 찾을 수 없습니다.")
|
||||
if not ocean_nc_path:
|
||||
raise FileNotFoundError("해양 NC 파일을 찾을 수 없습니다.")
|
||||
|
||||
logger.info(f"[job:{job_id}] wind_nc_path: {wind_nc_path}")
|
||||
logger.info(f"[job:{job_id}] ocean_nc_path: {ocean_nc_path}")
|
||||
|
||||
reader_wind = reader_netCDF_CF_generic.Reader(
|
||||
wind_nc_path,
|
||||
standard_name_mapping={'x_wind': 'x_wind', 'y_wind': 'y_wind'}
|
||||
)
|
||||
reader_ocean = reader_netCDF_CF_generic.Reader(ocean_nc_path)
|
||||
if 'temperature' in reader_ocean.Dataset.variables:
|
||||
temp = reader_ocean.Dataset['temperature']
|
||||
temp_values = temp.values
|
||||
mask = temp_values > SIM.TEMPERATURE_THRESHOLD
|
||||
temp_values[mask] = np.nan
|
||||
reader_ocean.Dataset['temperature'].values = temp_values
|
||||
|
||||
o.add_reader([reader_ocean, reader_wind])
|
||||
|
||||
o.set_config('processes:evaporation', True)
|
||||
o.set_config('processes:emulsification', True)
|
||||
o.set_config('drift:vertical_mixing', True)
|
||||
o.set_config('vertical_mixing:timestep', SIM.VERTICAL_MIXING_TIMESTEP)
|
||||
o.set_config('seed:m3_per_hour', mat_vol)
|
||||
|
||||
if spill_time == 0 or spill_time is None:
|
||||
o.seed_elements(lon=lon, lat=lat, number=100,
|
||||
time=date_utc, z=0, oil_type=mat_ty)
|
||||
else:
|
||||
release_duration = timedelta(hours=spill_time)
|
||||
end_t = date_utc + release_duration
|
||||
o.seed_elements(lon=lon, lat=lat, number=100,
|
||||
time=[date_utc, end_t], z=0, oil_type=mat_ty)
|
||||
|
||||
ncfile = f"{STORAGE.RESULT}/{name}.nc"
|
||||
try:
|
||||
o.run(duration=timedelta(hours=run_time), time_step=900, time_step_output=3600, outfile=ncfile)
|
||||
except Exception as e:
|
||||
logger.error(f"[job:{job_id}] 시뮬레이션 실행 오류: {e}")
|
||||
raise
|
||||
|
||||
json_data = extract_json(ncfile, wind_nc_path, ocean_nc_path, name, lon, lat)
|
||||
if not json_data:
|
||||
raise ValueError("시뮬레이션 결과 변환 실패")
|
||||
|
||||
elapsed = (datetime.now() - start_time_measure).total_seconds()
|
||||
logger.info(f"[job:{job_id}] 완료: {int(elapsed//60)}m {int(elapsed%60)}s")
|
||||
return json_data
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
# 서버 설정 (호스트와 포트는 필요에 따라 수정하세요)
|
||||
# log_level="info"를 통해 FastAPI와 uvicorn의 로그를 확인할 수 있습니다.
|
||||
uvicorn.run(
|
||||
"api:app",
|
||||
host="0.0.0.0",
|
||||
port=5003,
|
||||
reload=True # 코드 변경 시 자동으로 서버를 재시작하는 모드 (개발용)
|
||||
)
|
||||
252
prediction/opendrift/calcCostlineLength.py
Normal file
252
prediction/opendrift/calcCostlineLength.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""
|
||||
calcCostlineLength.py
|
||||
|
||||
기름 유출로 오염된 해안선 길이를 계산하는 모듈
|
||||
Thread-safe하며 다른 스크립트에서 import하여 사용 가능
|
||||
|
||||
사용 예시:
|
||||
from calcCostlineLength import OilSpillCoastlineAnalyzer
|
||||
|
||||
analyzer = OilSpillCoastlineAnalyzer("coastline.shp")
|
||||
length, info = analyzer.calculate_polluted_length(particles)
|
||||
"""
|
||||
|
||||
import geopandas as gpd
|
||||
import numpy as np
|
||||
from scipy.spatial import cKDTree
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import os
|
||||
|
||||
from logger import get_logger
|
||||
from utils import haversine_distance
|
||||
|
||||
logger = get_logger("calcCostlineLength")
|
||||
|
||||
|
||||
class OilSpillCoastlineAnalyzer:
|
||||
"""
|
||||
기름 유출로 오염된 해안선 길이를 계산하는 클래스 (Thread-Safe)
|
||||
|
||||
Attributes:
|
||||
coastline_gdf: 해안선 GeoDataFrame
|
||||
buffer_distance: 입자 매칭 버퍼 거리 (도 단위)
|
||||
coastline_points: 해안선 점들의 NumPy 배열
|
||||
kdtree: 공간 검색을 위한 KD-Tree
|
||||
segment_info: 세그먼트 정보 튜플
|
||||
segment_lengths: 세그먼트 길이 배열 (미터)
|
||||
"""
|
||||
|
||||
def __init__(self, coastline_shp_path: str, buffer_distance: float = 0.001,
|
||||
simplify_tolerance: float = 0.001,
|
||||
bbox: Optional[Tuple[float, float, float, float]] = None,
|
||||
center_point: Optional[Tuple[float, float]] = None,
|
||||
radius: Optional[float] = None):
|
||||
|
||||
if not os.path.exists(coastline_shp_path):
|
||||
raise FileNotFoundError(f"Coastline file not found: {coastline_shp_path}")
|
||||
|
||||
self.coastline_gdf = gpd.read_file(coastline_shp_path)
|
||||
|
||||
if self.coastline_gdf.crs and self.coastline_gdf.crs != 'EPSG:4326':
|
||||
self.coastline_gdf = self.coastline_gdf.to_crs('EPSG:4326')
|
||||
logger.info(f"Original coastline features: {len(self.coastline_gdf):,}")
|
||||
|
||||
if bbox is not None:
|
||||
self._filter_by_bbox(bbox)
|
||||
elif center_point is not None and radius is not None:
|
||||
self._filter_by_center(center_point, radius)
|
||||
|
||||
self.buffer_distance = buffer_distance
|
||||
self.simplify_tolerance = simplify_tolerance
|
||||
self._build_spatial_index()
|
||||
|
||||
def _filter_by_bbox(self, bbox: Tuple[float, float, float, float]):
|
||||
"""경계 상자로 해안선 필터링"""
|
||||
minx, miny, maxx, maxy = bbox
|
||||
|
||||
bounds = self.coastline_gdf.bounds
|
||||
mask = (
|
||||
(bounds['minx'] <= maxx) & (bounds['maxx'] >= minx) &
|
||||
(bounds['miny'] <= maxy) & (bounds['maxy'] >= miny)
|
||||
)
|
||||
|
||||
self.coastline_gdf = self.coastline_gdf[mask].copy()
|
||||
logger.info(f"Filtered features: {len(self.coastline_gdf):,} "
|
||||
f"({len(self.coastline_gdf) / len(mask) * 100:.1f}% retained)")
|
||||
|
||||
def _filter_by_center(self, center_point: Tuple[float, float], radius: float):
|
||||
"""중심점과 반경으로 해안선 필터링"""
|
||||
lon, lat = center_point
|
||||
bbox = (lon - radius, lat - radius, lon + radius, lat + radius)
|
||||
self._filter_by_bbox(bbox)
|
||||
|
||||
def _build_spatial_index(self):
|
||||
"""해안선의 공간 인덱스 구축 (KD-Tree 사용)"""
|
||||
|
||||
if len(self.coastline_gdf) == 0:
|
||||
logger.warning("No coastline after filtering!")
|
||||
self.coastline_points = np.array([]).reshape(0, 2)
|
||||
self.segment_info = tuple()
|
||||
self.segment_lengths = {}
|
||||
self.kdtree = None
|
||||
return
|
||||
|
||||
coastline_points = []
|
||||
segment_info = []
|
||||
segment_lengths = {}
|
||||
|
||||
if self.simplify_tolerance > 0:
|
||||
self.coastline_gdf.geometry = self.coastline_gdf.geometry.simplify(
|
||||
self.simplify_tolerance, preserve_topology=False
|
||||
)
|
||||
|
||||
for idx, geom in enumerate(self.coastline_gdf.geometry):
|
||||
if geom.is_empty:
|
||||
continue
|
||||
|
||||
if geom.geom_type == 'LineString':
|
||||
coords = np.array(geom.coords)
|
||||
for i in range(len(coords) - 1):
|
||||
p1 = coords[i]
|
||||
p2 = coords[i + 1]
|
||||
|
||||
seg_key = (idx, i)
|
||||
|
||||
if seg_key not in segment_lengths:
|
||||
length_m = haversine_distance(p1[0], p1[1], p2[0], p2[1], return_km=False)
|
||||
segment_lengths[seg_key] = length_m
|
||||
|
||||
coastline_points.append(p1)
|
||||
segment_info.append(seg_key)
|
||||
|
||||
coastline_points.append(p2)
|
||||
segment_info.append(seg_key)
|
||||
|
||||
elif geom.geom_type == 'MultiLineString':
|
||||
for line_idx, line in enumerate(geom.geoms):
|
||||
if line.is_empty:
|
||||
continue
|
||||
coords = np.array(line.coords)
|
||||
for i in range(len(coords) - 1):
|
||||
p1 = coords[i]
|
||||
p2 = coords[i + 1]
|
||||
|
||||
seg_key = (idx, i, line_idx)
|
||||
|
||||
if seg_key not in segment_lengths:
|
||||
length_m = haversine_distance(p1[0], p1[1], p2[0], p2[1], return_km=False)
|
||||
segment_lengths[seg_key] = length_m
|
||||
|
||||
coastline_points.append(p1)
|
||||
segment_info.append(seg_key)
|
||||
|
||||
coastline_points.append(p2)
|
||||
segment_info.append(seg_key)
|
||||
|
||||
self.coastline_points = np.array(coastline_points)
|
||||
self.segment_info = tuple(segment_info)
|
||||
self.segment_lengths = segment_lengths
|
||||
self.kdtree = cKDTree(self.coastline_points)
|
||||
|
||||
def calculate_polluted_length(self, particles: List[Dict]) -> Tuple[float, Dict]:
|
||||
"""
|
||||
오염된 해안선 길이 계산 (완전 Thread-Safe)
|
||||
|
||||
Args:
|
||||
particles: 입자 정보 리스트
|
||||
각 입자는 {"lon": float, "lat": float, "stranded": int} 형태
|
||||
|
||||
Returns:
|
||||
tuple: (오염된 해안선 총 길이(m), 상세 정보 dict)
|
||||
"""
|
||||
stranded_particles = [p for p in particles if p.get('stranded', 0) == 1]
|
||||
|
||||
if not stranded_particles:
|
||||
return 0.0, {
|
||||
"polluted_segments": 0,
|
||||
"total_particles": len(particles),
|
||||
"stranded_particles": 0,
|
||||
"affected_particles_in_buffer": 0
|
||||
}
|
||||
|
||||
if self.kdtree is None or len(self.coastline_points) == 0:
|
||||
return 0.0, {
|
||||
"polluted_segments": 0,
|
||||
"total_particles": len(particles),
|
||||
"stranded_particles": len(stranded_particles),
|
||||
"affected_particles_in_buffer": 0
|
||||
}
|
||||
|
||||
particle_coords = np.array([[p['lon'], p['lat']] for p in stranded_particles])
|
||||
|
||||
distances, indices = self.kdtree.query(particle_coords, k=1)
|
||||
|
||||
valid_mask = distances < self.buffer_distance
|
||||
valid_indices = indices[valid_mask]
|
||||
|
||||
if len(valid_indices) == 0:
|
||||
return 0.0, {
|
||||
"polluted_segments": 0,
|
||||
"total_particles": len(particles),
|
||||
"stranded_particles": len(stranded_particles),
|
||||
"affected_particles_in_buffer": 0
|
||||
}
|
||||
|
||||
polluted_segments = set()
|
||||
for idx in valid_indices:
|
||||
seg_info = self.segment_info[idx]
|
||||
polluted_segments.add(seg_info)
|
||||
|
||||
total_length = sum(self.segment_lengths[seg] for seg in polluted_segments)
|
||||
|
||||
detail_info = {
|
||||
"polluted_segments": len(polluted_segments),
|
||||
"total_particles": len(particles),
|
||||
"stranded_particles": len(stranded_particles),
|
||||
"affected_particles_in_buffer": int(valid_mask.sum())
|
||||
}
|
||||
|
||||
return total_length, detail_info
|
||||
|
||||
def calculate_polluted_length_batch(self,
|
||||
particle_batches: List[List[Dict]],
|
||||
max_workers: Optional[int] = None) -> List[Tuple[float, Dict]]:
|
||||
"""여러 입자 배치를 병렬로 처리"""
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {executor.submit(self.calculate_polluted_length, batch): i
|
||||
for i, batch in enumerate(particle_batches)}
|
||||
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
|
||||
return results
|
||||
|
||||
def get_info(self) -> Dict:
|
||||
"""분석기의 정보 반환"""
|
||||
return {
|
||||
"buffer_distance": self.buffer_distance,
|
||||
"total_coastline_segments": len(set(self.segment_info)),
|
||||
"total_coastline_points": len(self.coastline_points),
|
||||
"coastline_features": len(self.coastline_gdf)
|
||||
}
|
||||
|
||||
|
||||
def create_analyzer(coastline_shp_path: str,
|
||||
buffer_distance: float = 0.001,
|
||||
simplify_tolerance: float = 0.001,
|
||||
bbox: Optional[Tuple[float, float, float, float]] = None,
|
||||
center_point: Optional[Tuple[float, float]] = None,
|
||||
radius: Optional[float] = None) -> OilSpillCoastlineAnalyzer:
|
||||
"""분석기 인스턴스 생성 (편의 함수)"""
|
||||
return OilSpillCoastlineAnalyzer(coastline_shp_path, buffer_distance,
|
||||
simplify_tolerance, bbox, center_point, radius)
|
||||
|
||||
|
||||
def calculate_single(coastline_shp_path: str,
|
||||
particles: List[Dict],
|
||||
buffer_distance: float = 0.001) -> Tuple[float, Dict]:
|
||||
"""한 번만 계산하는 경우 사용하는 편의 함수"""
|
||||
analyzer = OilSpillCoastlineAnalyzer(coastline_shp_path, buffer_distance)
|
||||
return analyzer.calculate_polluted_length(particles)
|
||||
1
prediction/opendrift/coastline/TN_SHORLINE.cpg
Normal file
1
prediction/opendrift/coastline/TN_SHORLINE.cpg
Normal file
@ -0,0 +1 @@
|
||||
CP949
|
||||
BIN
prediction/opendrift/coastline/TN_SHORLINE.dbf
Normal file
BIN
prediction/opendrift/coastline/TN_SHORLINE.dbf
Normal file
Binary file not shown.
1
prediction/opendrift/coastline/TN_SHORLINE.prj
Normal file
1
prediction/opendrift/coastline/TN_SHORLINE.prj
Normal file
@ -0,0 +1 @@
|
||||
PROJCS["Korea_2000_Korea_Unified_Coordinate_System",GEOGCS["GCS_Korea_2000",DATUM["D_Korea_2000",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",1000000.0],PARAMETER["False_Northing",2000000.0],PARAMETER["Central_Meridian",127.5],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",38.0],UNIT["Meter",1.0]]
|
||||
BIN
prediction/opendrift/coastline/TN_SHORLINE.shp
Normal file
BIN
prediction/opendrift/coastline/TN_SHORLINE.shp
Normal file
Binary file not shown.
BIN
prediction/opendrift/coastline/TN_SHORLINE.shx
Normal file
BIN
prediction/opendrift/coastline/TN_SHORLINE.shx
Normal file
Binary file not shown.
100
prediction/opendrift/config.py
Normal file
100
prediction/opendrift/config.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""
|
||||
config.py
|
||||
|
||||
중앙집중화된 설정 모듈
|
||||
모든 경로, 좌표 범위, 시뮬레이션 상수를 한 곳에서 관리합니다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Tuple
|
||||
|
||||
_BASE_STR = "C:/upload"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoragePaths:
|
||||
"""파일 저장 경로 설정"""
|
||||
BASE: Path = Path(_BASE_STR)
|
||||
WIND: Path = Path(_BASE_STR) / "wind"
|
||||
POS_WIND: Path = Path(_BASE_STR) / "pos_wind"
|
||||
HYDR: Path = Path(_BASE_STR) / "hydr"
|
||||
POS_HYDR: Path = Path(_BASE_STR) / "pos_hydr"
|
||||
RESULT: Path = Path("result")
|
||||
COASTLINE: Path = Path("coastline/TN_SHORLINE.shp")
|
||||
PARCELS_PATH: str = "/home/gcrnd/apps/parcels"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoordinateBounds:
|
||||
"""한국 해역 좌표 범위"""
|
||||
LON_MIN: float = 124.2083983267507250
|
||||
LON_MAX: float = 129.9583954964914767
|
||||
LAT_MIN: float = 32.7916655404227129
|
||||
LAT_MAX: float = 38.9583268301827559
|
||||
|
||||
@property
|
||||
def lon_range(self) -> Tuple[float, float]:
|
||||
return (self.LON_MIN, self.LON_MAX)
|
||||
|
||||
@property
|
||||
def lat_range(self) -> Tuple[float, float]:
|
||||
return (self.LAT_MIN, self.LAT_MAX)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationConstants:
|
||||
"""시뮬레이션 관련 상수"""
|
||||
TEMPERATURE_THRESHOLD: float = 100.0 # 온도 이상값 필터링 임계값
|
||||
POLLUTION_GRID_BINS: int = 200 # 오염 면적 계산용 격자 해상도
|
||||
KM_PER_DEG_LAT: float = 111.0 # 위도 1도당 km
|
||||
KM_PER_DEG_LON: float = 91.0 # 경도 1도당 km (위도 35도 기준)
|
||||
EARTH_RADIUS_M: float = 6371000.0 # 지구 반경 (미터)
|
||||
EARTH_RADIUS_KM: float = 6371.0 # 지구 반경 (km)
|
||||
VERTICAL_MIXING_TIMESTEP: int = 5 # 수직 혼합 타임스텝
|
||||
FILE_FALLBACK_DAYS: int = 3 # 파일 폴백 시도 일수
|
||||
TIMEZONE_OFFSET_HOURS: int = 9 # KST-UTC 시간차
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindJsonConfig:
|
||||
"""바람 데이터 추출 설정"""
|
||||
RANGE_KM: float = 24.0 # 중심점으로부터의 추출 범위 (km)
|
||||
GRID_SPACING_KM: float = 4.0 # 격자 간격 (km)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoastlineAnalyzerConfig:
|
||||
"""해안선 분석 설정"""
|
||||
BUFFER_DISTANCE: float = 0.001 # 입자 매칭 버퍼 거리 (도)
|
||||
SIMPLIFY_TOLERANCE: float = 0.0 # 해안선 단순화 허용오차
|
||||
DEFAULT_RADIUS: float = 0.2 # 기본 검색 반경 (도, ~50km)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThreadPoolConfig:
|
||||
"""스레드 풀 설정"""
|
||||
MAX_WORKERS: int = 16 # 최대 워커 수
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
STORAGE = StoragePaths()
|
||||
COORDS = CoordinateBounds()
|
||||
SIM = SimulationConstants()
|
||||
WIND_JSON = WindJsonConfig()
|
||||
COASTLINE = CoastlineAnalyzerConfig()
|
||||
THREAD_POOL = ThreadPoolConfig()
|
||||
|
||||
|
||||
# 파일 패턴 템플릿
|
||||
class FilePatterns:
|
||||
"""NC 파일명 패턴"""
|
||||
WIND_FILE = "KO108_GDAPS_ATMO_SURF_{date}00.nc"
|
||||
HYDR_FILE = "KO108_MOHID_HYDR_SURF_{date}00.nc"
|
||||
|
||||
@staticmethod
|
||||
def get_wind_filename(date_str: str) -> str:
|
||||
return FilePatterns.WIND_FILE.format(date=date_str)
|
||||
|
||||
@staticmethod
|
||||
def get_hydr_filename(date_str: str) -> str:
|
||||
return FilePatterns.HYDR_FILE.format(date=date_str)
|
||||
34
prediction/opendrift/convex_hull.py
Normal file
34
prediction/opendrift/convex_hull.py
Normal file
@ -0,0 +1,34 @@
|
||||
import json
|
||||
from shapely.geometry import MultiPoint, Point
|
||||
|
||||
def get_convex_hull_from_json(json_data):
|
||||
"""
|
||||
JSON 형식의 위경도 데이터로 Convex Hull을 계산합니다.
|
||||
|
||||
:param json_data: 위경도 데이터가 포함된 JSON 리스트
|
||||
:return: Convex Hull의 좌표 리스트 (폴리곤 또는 포인트)
|
||||
"""
|
||||
# JSON 데이터를 파싱하여 [longitude, latitude] 형태로 변환
|
||||
points = [(point["lon"], point["lat"]) for point in json_data]
|
||||
|
||||
# 중복 제거
|
||||
unique_points = list(set(points))
|
||||
|
||||
if len(unique_points) < 3:
|
||||
if len(unique_points) == 1:
|
||||
return unique_points # 단일 포인트 반환
|
||||
elif len(unique_points) == 2:
|
||||
return unique_points + [unique_points[0]] # 두 점은 선분으로 처리
|
||||
else:
|
||||
raise ValueError("Convex Hull을 계산하려면 최소 3개의 고유한 포인트가 필요합니다.")
|
||||
|
||||
# MultiPoint로 Convex Hull 계산
|
||||
multi_point = MultiPoint(unique_points)
|
||||
hull = multi_point.convex_hull
|
||||
|
||||
# 결과가 폴리곤일 경우 좌표 리스트 반환, 단일 포인트일 경우 포인트 반환
|
||||
if isinstance(hull, Point):
|
||||
return [list(hull.coords)[0]]
|
||||
else:
|
||||
# 폴리곤의 외곽 좌표 (폐쇄된 형태로 첫 포인트 반복)
|
||||
return list(hull.exterior.coords)
|
||||
103
prediction/opendrift/createImage.py
Normal file
103
prediction/opendrift/createImage.py
Normal file
@ -0,0 +1,103 @@
|
||||
from PIL import Image
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
def crop_and_encode_geographic_image(
|
||||
image_path: str,
|
||||
center_point: tuple[float, float] # (center_lon, center_lat)
|
||||
) -> str:
|
||||
"""
|
||||
지정된 PNG 이미지에서 특정 위경도 중심을 기준으로
|
||||
주변 crop_radius_km 영역을 잘라내고 Base64 문자열로 인코딩합니다.
|
||||
|
||||
:param image_path: 입력 PNG 파일 경로.
|
||||
:param image_bounds: 이미지 전체가 나타내는 영역의 (min_lon, min_lat, max_lon, max_lat)
|
||||
좌표. (경도 최소, 위도 최소, 경도 최대, 위도 최대)
|
||||
:param center_point: 자르기 영역의 중심이 될 (lon, lat) 좌표.
|
||||
:param crop_radius_km: 중심에서 상하좌우로 자를 거리 (km).
|
||||
:return: 잘린 이미지의 PNG Base64 문자열.
|
||||
"""
|
||||
image_bounds = (124.2083983267507250, 32.7916655404227129, 129.9583954964914767, 38.9583268301827559)
|
||||
crop_radius_km = 25.0
|
||||
|
||||
# 1. 이미지 로드
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
except FileNotFoundError:
|
||||
return f"Error: File not found at {image_path}"
|
||||
except Exception as e:
|
||||
return f"Error opening image: {e}"
|
||||
|
||||
width, height = img.size
|
||||
min_lon, min_lat, max_lon, max_lat = image_bounds
|
||||
center_lon, center_lat = center_point
|
||||
|
||||
# 2. 위경도 경계 계산 (25km 반경)
|
||||
|
||||
# 1도당 근사적인 거리 (대한민국 지역 기준)
|
||||
# 위도 1도: 약 111 km (거의 일정)
|
||||
# 경도 1도: 위도에 따라 달라지지만, 한국의 위도(약 33~38도)에서 약 88~93 km 정도.
|
||||
# 안전을 위해 WGS84 타원체 기준 위도 35도에서 경도 1도당 약 91.2km 가정
|
||||
# 더 정확한 계산을 위해선 `pyproj` 등의 라이브러리 사용이 권장되나, 여기선 근사치 사용
|
||||
|
||||
KM_PER_DEG_LAT = 111.0 # 위도 1도당 km (근사치)
|
||||
KM_PER_DEG_LON_AT_35 = 91.2 # 위도 35도에서 경도 1도당 km (근사치)
|
||||
|
||||
# 위도/경도 1도에 해당하는 픽셀 수 계산
|
||||
deg_lat_span = max_lat - min_lat
|
||||
deg_lon_span = max_lon - min_lon
|
||||
|
||||
# KM_PER_DEG_LON을 중심 위도에 맞게 조정 (단순화를 위해 상수 사용을 유지)
|
||||
|
||||
# 25km에 해당하는 위도/경도 변화량 계산
|
||||
delta_lat = crop_radius_km / KM_PER_DEG_LAT
|
||||
delta_lon = crop_radius_km / KM_PER_DEG_LON_AT_35 # 근사치 사용
|
||||
|
||||
# 자를 영역의 위경도 바운딩 박스 (Bounding Box)
|
||||
crop_min_lon = center_lon - delta_lon
|
||||
crop_max_lon = center_lon + delta_lon
|
||||
crop_min_lat = center_lat - delta_lat
|
||||
crop_max_lat = center_lat + delta_lat
|
||||
bounds = {
|
||||
"min_lon": float(crop_min_lon),
|
||||
"max_lon": float(crop_max_lon),
|
||||
"min_lat": float(crop_min_lat),
|
||||
"max_lat": float(crop_max_lat)
|
||||
}
|
||||
|
||||
# 3. 위경도 좌표를 픽셀 좌표로 변환 (선형 매핑 가정)
|
||||
|
||||
# 픽셀 좌표 x: min_lon -> 0, max_lon -> width
|
||||
# 픽셀 좌표 y: max_lat -> 0, min_lat -> height (GIS 이미지는 보통 북쪽(위도 최대)이 0에 해당)
|
||||
|
||||
def lon_to_pixel_x(lon):
|
||||
return int(width * (lon - min_lon) / deg_lon_span)
|
||||
|
||||
def lat_to_pixel_y(lat):
|
||||
# Y축은 위도에 반비례 (큰 위도가 작은 Y 픽셀)
|
||||
return int(height * (max_lat - lat) / deg_lat_span)
|
||||
|
||||
# 자를 영역의 픽셀 좌표 계산
|
||||
pixel_x_min = max(0, lon_to_pixel_x(crop_min_lon))
|
||||
pixel_y_min = max(0, lat_to_pixel_y(crop_max_lat)) # 위도 최대가 y_min (상단)
|
||||
pixel_x_max = min(width, lon_to_pixel_x(crop_max_lon))
|
||||
pixel_y_max = min(height, lat_to_pixel_y(crop_min_lat)) # 위도 최소가 y_max (하단)
|
||||
|
||||
# PIL의 crop 함수는 (left, top, right, bottom) 순서의 픽셀 좌표를 사용
|
||||
crop_box = (pixel_x_min, pixel_y_min, pixel_x_max, pixel_y_max)
|
||||
|
||||
# 4. 이미지 자르기
|
||||
if pixel_x_min >= pixel_x_max or pixel_y_min >= pixel_y_max:
|
||||
return "Error: Crop area is outside the image bounds or zero size."
|
||||
|
||||
cropped_img = img.crop(crop_box)
|
||||
|
||||
# 5. Base64 문자열로 인코딩
|
||||
buffer = BytesIO()
|
||||
cropped_img.save(buffer, format="PNG")
|
||||
base64_encoded_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
|
||||
# Base64 문자열 앞에 MIME 타입 정보 추가
|
||||
# base64_string = f"data:image/png;base64,{base64_encoded_data}"
|
||||
|
||||
return base64_encoded_data, bounds
|
||||
325
prediction/opendrift/createJsonResult.py
Normal file
325
prediction/opendrift/createJsonResult.py
Normal file
@ -0,0 +1,325 @@
|
||||
import xarray as xr
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from shapely.geometry import Polygon, Point
|
||||
from convex_hull import get_convex_hull_from_json as get_convex_hull
|
||||
from createWindJson import extract_wind_data_json as create_wind_json
|
||||
from calcCostlineLength import OilSpillCoastlineAnalyzer
|
||||
from extractUvWithinBox import _compute_hydr_region, _extract_uv_at_time
|
||||
import concurrent.futures
|
||||
|
||||
from config import STORAGE, SIM, COASTLINE, THREAD_POOL
|
||||
from logger import get_logger
|
||||
from utils import check_img_file_by_date, find_time_index
|
||||
|
||||
logger = get_logger("createJsonResult")
|
||||
|
||||
|
||||
def extract_and_save_data_to_json(ncfile_path, wind_ncfile_path, ocean_ncfile_path, name, ac_lon, ac_lat):
|
||||
"""
|
||||
NetCDF 파일에서 시간별 위치, 잔존량, 풍화량, 오염 면적을 추출하여 JSON 파일로 저장합니다.
|
||||
"""
|
||||
logger.info(f"Processing: {ncfile_path}, {wind_ncfile_path}, {ocean_ncfile_path}")
|
||||
try:
|
||||
if not os.path.exists(ncfile_path):
|
||||
raise FileNotFoundError(f"Simulation result file not found: {ncfile_path}")
|
||||
if not os.path.exists(wind_ncfile_path):
|
||||
raise FileNotFoundError(f"Wind data file not found: {wind_ncfile_path}")
|
||||
if not os.path.exists(ocean_ncfile_path):
|
||||
raise FileNotFoundError(f"Ocean data file not found: {ocean_ncfile_path}")
|
||||
|
||||
analyzer = OilSpillCoastlineAnalyzer(
|
||||
str(STORAGE.COASTLINE),
|
||||
buffer_distance=COASTLINE.BUFFER_DISTANCE,
|
||||
simplify_tolerance=COASTLINE.SIMPLIFY_TOLERANCE,
|
||||
center_point=(ac_lon, ac_lat),
|
||||
radius=COASTLINE.DEFAULT_RADIUS
|
||||
)
|
||||
|
||||
with xr.open_dataset(ncfile_path) as ds, \
|
||||
xr.open_dataset(wind_ncfile_path) as wind_ds, \
|
||||
xr.open_dataset(ocean_ncfile_path) as ocean_ds:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ① 시뮬레이션 변수 일괄 로드 — xarray → NumPy 1회 변환
|
||||
# ------------------------------------------------------------------
|
||||
total_steps = len(ds.time)
|
||||
|
||||
# OpenDrift NetCDF 차원 순서: (trajectory, time) = (N, T)
|
||||
# .T 전치로 (T, N)으로 통일하여 이후 모든 연산이 axis=0=시간, axis=1=입자 기준이 되도록 함
|
||||
status_all = ds.status.values.T if 'status' in ds else None # (T, N)
|
||||
lon_all = ds.lon.values.T if 'lon' in ds else None
|
||||
lat_all = ds.lat.values.T if 'lat' in ds else None
|
||||
mass_all = ds.mass_oil.values.T if 'mass_oil' in ds else None
|
||||
density_all = ds.density.values.T if 'density' in ds else None
|
||||
evap_all = ds.mass_evaporated.values.T if 'mass_evaporated' in ds else None
|
||||
moving_all = ds.moving.values.T if 'moving' in ds else None
|
||||
viscosity_all = ds.viscosity.values.T if 'viscosity' in ds else None
|
||||
watertemp_all = ds.sea_water_temperature.values.T if 'sea_water_temperature' in ds else None
|
||||
oil_film_thick = (float(ds.oil_film_thickness.isel(time=0).values[0])
|
||||
if 'oil_film_thickness' in ds else None)
|
||||
|
||||
initial_mass_total = None
|
||||
if mass_all is not None:
|
||||
try:
|
||||
initial_mass_total = float(mass_all[0].sum())
|
||||
logger.info(f"Initial oil mass: {initial_mass_total:.2f} kg")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing mass_oil: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ② 타임스탬프 사전 계산
|
||||
# ------------------------------------------------------------------
|
||||
time_values_pd = pd.to_datetime(ds.time.values)
|
||||
formatted_times = [t.strftime('%Y-%m-%d %H:%M:%S') for t in time_values_pd]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ③ 전처리 루프 벡터화 (기존 lines 77–175 대체)
|
||||
# ------------------------------------------------------------------
|
||||
cumulative_evaporated_arr = np.zeros(total_steps)
|
||||
cumulative_beached_arr = np.zeros(total_steps)
|
||||
cumulative_pollution_area = {}
|
||||
|
||||
first_strand_step = None
|
||||
last_valid_lon = None
|
||||
last_valid_lat = None
|
||||
|
||||
if status_all is not None and lon_all is not None:
|
||||
N = status_all.shape[1]
|
||||
|
||||
# 입자별 최초 stranded 스텝
|
||||
stranded_mask = (status_all == 1) # (T, N)
|
||||
has_stranded = stranded_mask.any(axis=0) # (N,)
|
||||
first_strand_step = np.where(has_stranded,
|
||||
stranded_mask.argmax(axis=0),
|
||||
-1).astype(np.int32) # (N,)
|
||||
|
||||
# 입자별 마지막 유효 위치
|
||||
valid_lon_mask = ~np.isnan(lon_all) # (T, N)
|
||||
has_any_valid = valid_lon_mask.any(axis=0) # (N,)
|
||||
last_valid_t = (total_steps - 1
|
||||
- np.flip(valid_lon_mask, axis=0).argmax(axis=0)) # (N,)
|
||||
pix = np.arange(N)
|
||||
last_valid_lon = np.where(has_any_valid, lon_all[last_valid_t, pix], np.nan)
|
||||
last_valid_lat = np.where(has_any_valid, lat_all[last_valid_t, pix], np.nan)
|
||||
|
||||
# 누적 증발량 — expanding max per particle
|
||||
if evap_all is not None:
|
||||
evap_clean = np.where(np.isnan(evap_all), -np.inf, evap_all)
|
||||
run_evap = np.maximum.accumulate(evap_clean, axis=0) # (T, N)
|
||||
run_evap = np.clip(run_evap, 0, None)
|
||||
cumulative_evaporated_arr = run_evap.sum(axis=1) # (T,)
|
||||
|
||||
# 누적 부착량 — expanding max per stranded particle
|
||||
if mass_all is not None and density_all is not None:
|
||||
safe_den = np.where((density_all > 0) & ~np.isnan(density_all),
|
||||
density_all, np.inf)
|
||||
beach_vol = np.where(
|
||||
stranded_mask & ~np.isnan(mass_all) & (mass_all > 0),
|
||||
mass_all / safe_den, 0.0) # (T, N)
|
||||
run_beach = np.maximum.accumulate(beach_vol, axis=0) # (T, N)
|
||||
|
||||
strand_threshold = np.where(first_strand_step >= 0,
|
||||
first_strand_step,
|
||||
total_steps)
|
||||
active_matrix = (np.arange(total_steps)[:, None]
|
||||
>= strand_threshold[None, :]) # (T, N)
|
||||
cumulative_beached_arr = (run_beach * active_matrix).sum(axis=1) # (T,)
|
||||
|
||||
# 누적 오염 면적 — 순차 루프 유지 (set union 의존), 내부 연산은 vectorized
|
||||
all_polluted_cells = set()
|
||||
grid_config = None
|
||||
|
||||
for i in range(total_steps):
|
||||
if mass_all is not None:
|
||||
lon_i = lon_all[i]
|
||||
lat_i = lat_all[i]
|
||||
mass_i = mass_all[i]
|
||||
valid_mask = (~np.isnan(lon_i)) & (~np.isnan(lat_i)) & (mass_i > 0)
|
||||
|
||||
if np.any(valid_mask):
|
||||
lon_active = lon_i[valid_mask]
|
||||
lat_active = lat_i[valid_mask]
|
||||
|
||||
if grid_config is None:
|
||||
grid_config = {
|
||||
'min_lon': lon_active.min() - 0.01,
|
||||
'max_lon': lon_active.max() + 0.01,
|
||||
'min_lat': lat_active.min() - 0.01,
|
||||
'max_lat': lat_active.max() + 0.01,
|
||||
'num_lon_bins': SIM.POLLUTION_GRID_BINS,
|
||||
'num_lat_bins': SIM.POLLUTION_GRID_BINS,
|
||||
}
|
||||
else:
|
||||
grid_config['min_lon'] = min(grid_config['min_lon'], lon_active.min() - 0.01)
|
||||
grid_config['max_lon'] = max(grid_config['max_lon'], lon_active.max() + 0.01)
|
||||
grid_config['min_lat'] = min(grid_config['min_lat'], lat_active.min() - 0.01)
|
||||
grid_config['max_lat'] = max(grid_config['max_lat'], lat_active.max() + 0.01)
|
||||
|
||||
lon_bins = np.linspace(grid_config['min_lon'], grid_config['max_lon'],
|
||||
grid_config['num_lon_bins'] + 1)
|
||||
lat_bins = np.linspace(grid_config['min_lat'], grid_config['max_lat'],
|
||||
grid_config['num_lat_bins'] + 1)
|
||||
|
||||
lon_indices = np.digitize(lon_active, lon_bins) - 1
|
||||
lat_indices = np.digitize(lat_active, lat_bins) - 1
|
||||
|
||||
for lon_idx, lat_idx in zip(lon_indices, lat_indices):
|
||||
if (0 <= lon_idx < grid_config['num_lon_bins']
|
||||
and 0 <= lat_idx < grid_config['num_lat_bins']):
|
||||
all_polluted_cells.add((lon_idx, lat_idx))
|
||||
|
||||
delta_lon_km = ((grid_config['max_lon'] - grid_config['min_lon'])
|
||||
* SIM.KM_PER_DEG_LON)
|
||||
delta_lat_km = ((grid_config['max_lat'] - grid_config['min_lat'])
|
||||
* SIM.KM_PER_DEG_LAT)
|
||||
area_of_cell_km2 = ((delta_lon_km / grid_config['num_lon_bins'])
|
||||
* (delta_lat_km / grid_config['num_lat_bins']))
|
||||
|
||||
cumulative_pollution_area[i] = len(all_polluted_cells) * area_of_cell_km2
|
||||
else:
|
||||
cumulative_pollution_area[i] = cumulative_pollution_area.get(i - 1, 0.0) if i > 0 else 0.0
|
||||
else:
|
||||
cumulative_pollution_area[i] = cumulative_pollution_area.get(i - 1, 0.0) if i > 0 else 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ④ wind/hydr 전체 타임스텝 사전 추출 (파일 재오픈 없음)
|
||||
# ------------------------------------------------------------------
|
||||
logger.info("Pre-extracting hydr data for all timesteps...")
|
||||
hydr_region = _compute_hydr_region(ocean_ds, ac_lon, ac_lat)
|
||||
hydr_time_indices = [find_time_index(ocean_ds, t)[0] for t in formatted_times]
|
||||
hydr_cache = [_extract_uv_at_time(ocean_ds, idx, hydr_region)
|
||||
for idx in hydr_time_indices]
|
||||
|
||||
logger.info("Pre-extracting wind data for all timesteps...")
|
||||
wind_cache = [create_wind_json(wind_ds, time_values_pd[i], ac_lon, ac_lat)
|
||||
for i in range(total_steps)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ⑤ process_time_step — pre-loaded 배열 사용, 중복 xarray 호출 없음
|
||||
# ------------------------------------------------------------------
|
||||
def process_time_step(i):
|
||||
formatted_time = formatted_times[i]
|
||||
logger.debug(f"Processing time step: {formatted_time}")
|
||||
|
||||
lon_t = lon_all[i] if lon_all is not None else None
|
||||
lat_t = lat_all[i] if lat_all is not None else None
|
||||
mass_t = mass_all[i] if mass_all is not None else None
|
||||
density_t = density_all[i] if density_all is not None else None
|
||||
status_t = status_all[i] if status_all is not None else None
|
||||
moving_t = moving_all[i] if moving_all is not None else None
|
||||
viscosity_t = viscosity_all[i] if viscosity_all is not None else None
|
||||
watertemp_t = watertemp_all[i] if watertemp_all is not None else None
|
||||
|
||||
# 활성 입자 처리
|
||||
active_mask = moving_t > 0 if moving_t is not None else None
|
||||
active_lon = lon_t[active_mask] if (active_mask is not None and lon_t is not None) else []
|
||||
active_lat = lat_t[active_mask] if (active_mask is not None and lat_t is not None) else []
|
||||
|
||||
center_lon, center_lat = None, None
|
||||
if len(active_lon) > 0 and len(active_lat) > 0:
|
||||
center_lon = float(np.mean(active_lon))
|
||||
center_lat = float(np.mean(active_lat))
|
||||
|
||||
# 입자 목록 구성 (stranded 처리 + last valid position 사용)
|
||||
particles = []
|
||||
if lon_t is not None and lat_t is not None and first_strand_step is not None:
|
||||
stranded_flags = (first_strand_step >= 0) & (i >= first_strand_step) # (N,)
|
||||
|
||||
for idx in range(len(lon_t)):
|
||||
lon_val = lon_t[idx]
|
||||
lat_val = lat_t[idx]
|
||||
stranded_value = int(stranded_flags[idx])
|
||||
|
||||
if stranded_value == 1 and (np.isnan(lon_val) or np.isnan(lat_val)):
|
||||
if last_valid_lon is not None and not np.isnan(last_valid_lon[idx]):
|
||||
lon_val = last_valid_lon[idx]
|
||||
lat_val = last_valid_lat[idx]
|
||||
|
||||
if np.isnan(lon_val) or np.isnan(lat_val):
|
||||
continue
|
||||
|
||||
particles.append({
|
||||
"lon": float(lon_val),
|
||||
"lat": float(lat_val),
|
||||
"stranded": stranded_value,
|
||||
})
|
||||
|
||||
# 오염 해안 길이
|
||||
length, info = analyzer.calculate_polluted_length(particles)
|
||||
|
||||
# Convex hull
|
||||
try:
|
||||
hull_coords = get_convex_hull(particles)
|
||||
wkt = Point(hull_coords[0]).wkt if len(hull_coords) == 1 else Polygon(hull_coords).wkt
|
||||
except ValueError as e:
|
||||
logger.warning(f"Convex hull error: {e}")
|
||||
wkt = ""
|
||||
|
||||
# 잔존량 (status == 0 해상 입자)
|
||||
remaining_volume_m3 = 0.0
|
||||
if mass_t is not None and density_t is not None and status_t is not None:
|
||||
sea_mask = (status_t == 0)
|
||||
sea_masses = mass_t[sea_mask]
|
||||
sea_densities = density_t[sea_mask]
|
||||
valid_sea = ~np.isnan(sea_masses) & (sea_masses > 0) & ~np.isnan(sea_densities) & (sea_densities > 0)
|
||||
if np.any(valid_sea):
|
||||
avg_density = np.mean(sea_densities[valid_sea])
|
||||
if avg_density > 0:
|
||||
remaining_volume_m3 = float(np.sum(sea_masses[valid_sea]) / avg_density)
|
||||
|
||||
# 누적 증발량 → 부피
|
||||
evaporated_mass = float(cumulative_evaporated_arr[i])
|
||||
weathered_volume_m3 = 0.0
|
||||
if evaporated_mass > 0 and density_t is not None:
|
||||
valid_den = ~np.isnan(density_t) & (density_t > 0)
|
||||
if np.any(valid_den):
|
||||
avg_density = float(np.mean(density_t[valid_den]))
|
||||
if avg_density > 0:
|
||||
weathered_volume_m3 = evaporated_mass / avg_density
|
||||
|
||||
beached_volume_m3 = float(cumulative_beached_arr[i])
|
||||
pollution_area = cumulative_pollution_area.get(i, 0.0)
|
||||
|
||||
average_viscosity = float(np.nanmean(viscosity_t)) if (viscosity_t is not None and len(viscosity_t) > 0) else None
|
||||
average_water_temp = float(np.nanmean(watertemp_t)) if (watertemp_t is not None and len(watertemp_t) > 0) else None
|
||||
|
||||
hydr_data = hydr_cache[i]
|
||||
|
||||
return {
|
||||
"time": formatted_time,
|
||||
"center_lon": float(center_lon) if center_lon is not None else None,
|
||||
"center_lat": float(center_lat) if center_lat is not None else None,
|
||||
"remaining_volume_m3": float(remaining_volume_m3),
|
||||
"weathered_volume_m3": float(weathered_volume_m3),
|
||||
"pollution_area_km2": float(pollution_area),
|
||||
"beached_volume_m3": float(beached_volume_m3),
|
||||
"particles": particles,
|
||||
"wkt": wkt,
|
||||
"viscosity": average_viscosity,
|
||||
"thickness": oil_film_thick,
|
||||
"temperature": average_water_temp,
|
||||
"wind_data": wind_cache[i],
|
||||
"hydr_data": hydr_data['value'],
|
||||
"hydr_grid": hydr_data['grid'],
|
||||
"pollution_coast_length_m": length,
|
||||
}
|
||||
|
||||
# ThreadPoolExecutor — wind/hydr I/O 없으므로 CPU 위주 작업만
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=THREAD_POOL.MAX_WORKERS) as executor:
|
||||
futures = {executor.submit(process_time_step, i): i for i in range(total_steps)}
|
||||
results = [None] * total_steps
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
idx = futures[future]
|
||||
results[idx] = future.result()
|
||||
|
||||
return results
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"File not found: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"An unexpected error occurred: {e}")
|
||||
return None
|
||||
83
prediction/opendrift/createKmaImage.py
Normal file
83
prediction/opendrift/createKmaImage.py
Normal file
@ -0,0 +1,83 @@
|
||||
from PIL import Image
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
def crop_and_encode_geographic_kma_image(
|
||||
image_path: str,
|
||||
type: str
|
||||
) -> str:
|
||||
"""
|
||||
지정된 PNG 이미지에서 특정 위경도 중심을 기준으로
|
||||
주변 crop_radius_km 영역을 잘라내고 Base64 문자열로 인코딩합니다.
|
||||
|
||||
:param image_path: 입력 PNG 파일 경로.
|
||||
:param image_bounds: 이미지 전체가 나타내는 영역의 (min_lon, min_lat, max_lon, max_lat)
|
||||
좌표. (경도 최소, 위도 최소, 경도 최대, 위도 최대)
|
||||
:param center_point: 자르기 영역의 중심이 될 (lon, lat) 좌표.
|
||||
:param crop_radius_km: 중심에서 상하좌우로 자를 거리 (km).
|
||||
:return: 잘린 이미지의 PNG Base64 문자열.
|
||||
"""
|
||||
|
||||
if type == 'wind':
|
||||
full_image_bounds = (78.0993797274984161,12.1585396012363489, 173.8566763130032484,61.1726557651764793)
|
||||
elif type == 'hydr':
|
||||
full_image_bounds = (101.57732, 12.21703, 155.62642, 57.32893)
|
||||
image_bounds = (121.8399658203125, 32.2400016784668, 131.679931640625, 42.79999923706055)
|
||||
|
||||
TARGET_WIDTH = 50
|
||||
TARGET_HEIGHT = 90
|
||||
|
||||
# 1. 이미지 로드
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
except FileNotFoundError:
|
||||
return f"Error: File not found at {image_path}"
|
||||
except Exception as e:
|
||||
return f"Error opening image: {e}"
|
||||
|
||||
width, height = img.size
|
||||
|
||||
full_min_lon, full_min_lat, full_max_lon, full_max_lat = full_image_bounds
|
||||
full_deg_lat_span = full_max_lat - full_min_lat
|
||||
full_deg_lon_span = full_max_lon - full_min_lon
|
||||
|
||||
min_lon, min_lat, max_lon, max_lat = image_bounds
|
||||
|
||||
def lon_to_pixel_x(lon):
|
||||
return int(width * (lon - full_min_lon) / full_deg_lon_span)
|
||||
|
||||
def lat_to_pixel_y(lat):
|
||||
# Y축은 위도에 반비례 (큰 위도가 작은 Y 픽셀)
|
||||
return int(height * (full_max_lat - lat) / full_deg_lat_span)
|
||||
|
||||
# 자를 영역의 픽셀 좌표 계산
|
||||
pixel_x_min = max(0, lon_to_pixel_x(min_lon))
|
||||
pixel_y_min = max(0, lat_to_pixel_y(max_lat)) # 위도 최대가 y_min (상단)
|
||||
pixel_x_max = min(width, lon_to_pixel_x(max_lon))
|
||||
pixel_y_max = min(height, lat_to_pixel_y(min_lat)) # 위도 최소가 y_max (하단)
|
||||
|
||||
# PIL의 crop 함수는 (left, top, right, bottom) 순서의 픽셀 좌표를 사용
|
||||
crop_box = (pixel_x_min, pixel_y_min, pixel_x_max, pixel_y_max)
|
||||
|
||||
# 4. 이미지 자르기
|
||||
if pixel_x_min >= pixel_x_max or pixel_y_min >= pixel_y_max:
|
||||
return "Error: Crop area is outside the image bounds or zero size."
|
||||
|
||||
cropped_img = img.crop(crop_box)
|
||||
|
||||
if cropped_img.size != (TARGET_WIDTH, TARGET_HEIGHT):
|
||||
cropped_img = cropped_img.resize(
|
||||
(TARGET_WIDTH, TARGET_HEIGHT),
|
||||
Image.LANCZOS
|
||||
)
|
||||
|
||||
# 5. Base64 문자열로 인코딩
|
||||
buffer = BytesIO()
|
||||
cropped_img.save(buffer, format="PNG")
|
||||
base64_encoded_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
|
||||
# Base64 문자열 앞에 MIME 타입 정보 추가
|
||||
# base64_string = f"data:image/png;base64,{base64_encoded_data}"
|
||||
|
||||
return base64_encoded_data
|
||||
|
||||
95
prediction/opendrift/createWindJson.py
Normal file
95
prediction/opendrift/createWindJson.py
Normal file
@ -0,0 +1,95 @@
|
||||
import xarray as xr
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
from typing import Union
|
||||
|
||||
from config import WIND_JSON, SIM
|
||||
from logger import get_logger
|
||||
|
||||
logger = get_logger("createWindJson")
|
||||
|
||||
|
||||
def extract_wind_data_json(wind_ds_or_path: Union[str, xr.Dataset],
|
||||
target_time, center_lon: float, center_lat: float) -> list:
|
||||
"""
|
||||
NetCDF 파일 또는 이미 열린 Dataset에서 특정 시간의 바람 데이터를 추출하고 JSON 형식으로 반환
|
||||
|
||||
Parameters
|
||||
----------
|
||||
wind_ds_or_path : str or xr.Dataset
|
||||
NetCDF 파일 경로(str) 또는 이미 열린 xr.Dataset 객체.
|
||||
Dataset 객체가 전달되면 내부에서 닫지 않습니다.
|
||||
target_time : str or datetime or pd.Timestamp
|
||||
추출할 시간 (예: '2024-01-15 12:00:00' 또는 datetime 객체)
|
||||
center_lon : float
|
||||
중심 경도
|
||||
center_lat : float
|
||||
중심 위도
|
||||
|
||||
Returns
|
||||
-------
|
||||
list
|
||||
위도, 경도, 풍향, 풍속 정보가 포함된 리스트
|
||||
"""
|
||||
range_km = WIND_JSON.RANGE_KM
|
||||
grid_spacing_km = WIND_JSON.GRID_SPACING_KM
|
||||
|
||||
own_ds = isinstance(wind_ds_or_path, str)
|
||||
if own_ds:
|
||||
ds = xr.open_dataset(wind_ds_or_path)
|
||||
else:
|
||||
ds = wind_ds_or_path
|
||||
|
||||
try:
|
||||
ds_time = ds.sel(time=target_time, method='nearest')
|
||||
|
||||
lat_per_km = 1 / SIM.KM_PER_DEG_LAT
|
||||
lon_per_km = 1 / (SIM.KM_PER_DEG_LAT * np.cos(np.radians(center_lat)))
|
||||
|
||||
lat_range = range_km * lat_per_km
|
||||
lon_range = range_km * lon_per_km
|
||||
|
||||
lat_min = center_lat - lat_range
|
||||
lat_max = center_lat + lat_range
|
||||
lon_min = center_lon - lon_range
|
||||
lon_max = center_lon + lon_range
|
||||
|
||||
ds_subset = ds_time.sel(
|
||||
lat=slice(lat_min, lat_max),
|
||||
lon=slice(lon_min, lon_max)
|
||||
)
|
||||
|
||||
n_points = int(2 * range_km / grid_spacing_km) + 1
|
||||
new_lats = np.linspace(lat_min, lat_max, n_points)
|
||||
new_lons = np.linspace(lon_min, lon_max, n_points)
|
||||
|
||||
ds_interp = ds_subset.interp(
|
||||
lat=new_lats,
|
||||
lon=new_lons,
|
||||
method='linear'
|
||||
)
|
||||
|
||||
u = ds_interp['x_wind'].values
|
||||
v = ds_interp['y_wind'].values
|
||||
|
||||
wind_speed = np.sqrt(u**2 + v**2)
|
||||
wind_direction = (270 - np.arctan2(v, u) * 180 / np.pi) % 360
|
||||
|
||||
data = []
|
||||
for i, lat in enumerate(new_lats):
|
||||
for j, lon in enumerate(new_lons):
|
||||
ws = float(wind_speed[i, j]) if not np.isnan(wind_speed[i, j]) else None
|
||||
wd = float(wind_direction[i, j]) if not np.isnan(wind_direction[i, j]) else None
|
||||
data.append({
|
||||
"lat": round(float(lat), 6),
|
||||
"lon": round(float(lon), 6),
|
||||
"wind_speed": round(ws, 2) if ws is not None else None,
|
||||
"wind_direction": round(wd, 1) if wd is not None else None,
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
finally:
|
||||
if own_ds:
|
||||
ds.close()
|
||||
120
prediction/opendrift/extractUvFull.py
Normal file
120
prediction/opendrift/extractUvFull.py
Normal file
@ -0,0 +1,120 @@
|
||||
import numpy as np
|
||||
import xarray as xr
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
|
||||
from logger import get_logger
|
||||
from utils import find_time_index, convert_and_round
|
||||
|
||||
logger = get_logger("extractUvFull")
|
||||
|
||||
|
||||
def extract_uv_full(nc_file, target_time, category, skip=5, lon_range=None, lat_range=None):
|
||||
"""
|
||||
NetCDF 파일 전체에서 선택한 시간의 u, v 데이터 추출 (일정 간격으로 샘플링)
|
||||
"""
|
||||
ds = xr.open_dataset(nc_file)
|
||||
|
||||
time_idx, selected_time = find_time_index(ds, target_time)
|
||||
|
||||
lon = ds['lon'].values
|
||||
lat = ds['lat'].values
|
||||
|
||||
if lon.ndim == 1 and lat.ndim == 1:
|
||||
lon_2d, lat_2d = np.meshgrid(lon, lat)
|
||||
else:
|
||||
lon_2d = lon
|
||||
lat_2d = lat
|
||||
|
||||
if category == "wind":
|
||||
u_data = ds['x_wind'].values
|
||||
v_data = ds['y_wind'].values
|
||||
else:
|
||||
u_data = ds['ssu'].values
|
||||
v_data = ds['ssv'].values
|
||||
|
||||
if u_data.ndim == 3:
|
||||
u_full = u_data[time_idx]
|
||||
v_full = v_data[time_idx]
|
||||
elif u_data.ndim == 4:
|
||||
u_full = u_data[time_idx, 0]
|
||||
v_full = v_data[time_idx, 0]
|
||||
else:
|
||||
u_full = u_data
|
||||
v_full = v_data
|
||||
|
||||
if lon_range is not None or lat_range is not None:
|
||||
mask = np.ones(lon_2d.shape, dtype=bool)
|
||||
|
||||
if lon_range is not None:
|
||||
min_lon, max_lon = lon_range
|
||||
mask = mask & (lon_2d >= min_lon) & (lon_2d <= max_lon)
|
||||
|
||||
if lat_range is not None:
|
||||
min_lat, max_lat = lat_range
|
||||
mask = mask & (lat_2d >= min_lat) & (lat_2d <= max_lat)
|
||||
|
||||
rows, cols = np.where(mask)
|
||||
if len(rows) == 0:
|
||||
raise ValueError("No data within specified range")
|
||||
|
||||
min_row, max_row = rows.min(), rows.max()
|
||||
min_col, max_col = cols.min(), cols.max()
|
||||
|
||||
u_full = u_full[min_row:max_row+1, min_col:max_col+1]
|
||||
v_full = v_full[min_row:max_row+1, min_col:max_col+1]
|
||||
lon_2d = lon_2d[min_row:max_row+1, min_col:max_col+1]
|
||||
lat_2d = lat_2d[min_row:max_row+1, min_col:max_col+1]
|
||||
|
||||
u_region = u_full[::skip, ::skip]
|
||||
v_region = v_full[::skip, ::skip]
|
||||
lon_region = lon_2d[::skip, ::skip]
|
||||
lat_region = lat_2d[::skip, ::skip]
|
||||
|
||||
logger.debug(f"Original size: {u_full.shape}")
|
||||
logger.debug(f"Sampled size: {u_region.shape}")
|
||||
|
||||
land_mask = (u_region == 0) & (v_region == 0)
|
||||
|
||||
u_list = convert_and_round(u_region, land_mask)
|
||||
v_list = convert_and_round(v_region, land_mask)
|
||||
|
||||
lon_intervals = []
|
||||
for i in range(lon_region.shape[1] - 1):
|
||||
interval = lon_region[0, i+1] - lon_region[0, i]
|
||||
lon_intervals.append(float(interval))
|
||||
|
||||
lat_intervals = []
|
||||
for i in range(lat_region.shape[0] - 1):
|
||||
interval = lat_region[i+1, 0] - lat_region[i, 0]
|
||||
lat_intervals.append(float(interval))
|
||||
|
||||
bound_lon_lat = {
|
||||
"top": float(lat_region.max()),
|
||||
"bottom": float(lat_region.min()),
|
||||
"left": float(lon_region.min()),
|
||||
"right": float(lon_region.max())
|
||||
}
|
||||
|
||||
model_fcst_dt = selected_time.strftime("%Y%m%d%H")
|
||||
|
||||
result = {
|
||||
"data": {
|
||||
"modelFcstDt": model_fcst_dt,
|
||||
"values": [
|
||||
u_list,
|
||||
v_list
|
||||
]
|
||||
},
|
||||
"grid": {
|
||||
"lonInterval": lon_intervals,
|
||||
"boundLonLat": bound_lon_lat,
|
||||
"rows": int(u_region.shape[0]),
|
||||
"cols": int(u_region.shape[1]),
|
||||
"latInterval": lat_intervals
|
||||
}
|
||||
}
|
||||
|
||||
ds.close()
|
||||
|
||||
return result
|
||||
164
prediction/opendrift/extractUvWithinBox.py
Normal file
164
prediction/opendrift/extractUvWithinBox.py
Normal file
@ -0,0 +1,164 @@
|
||||
import numpy as np
|
||||
import xarray as xr
|
||||
from datetime import datetime
|
||||
import pandas as pd
|
||||
|
||||
from logger import get_logger
|
||||
from utils import haversine_distance, find_time_index, convert_and_round
|
||||
|
||||
logger = get_logger("extractUvWithinBox")
|
||||
|
||||
|
||||
def _compute_hydr_region(ocean_ds: xr.Dataset, center_lon: float, center_lat: float,
|
||||
box_size_km: float = 25) -> dict:
|
||||
"""
|
||||
해양 데이터셋에서 중심점 기준 공간 영역을 계산합니다. 시뮬레이션당 1회 호출.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ocean_ds : xr.Dataset
|
||||
이미 열린 해양 NetCDF 데이터셋
|
||||
center_lon, center_lat : float
|
||||
중심점 경도, 위도
|
||||
box_size_km : float
|
||||
상하좌우 범위 (km)
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
min_row, max_row, min_col, max_col, lon_region, lat_region,
|
||||
lon_intervals, lat_intervals, bound_lon_lat, u_ndim
|
||||
"""
|
||||
lon = ocean_ds['lon'].values
|
||||
lat = ocean_ds['lat'].values
|
||||
|
||||
if lon.ndim == 1 and lat.ndim == 1:
|
||||
lon_2d, lat_2d = np.meshgrid(lon, lat)
|
||||
else:
|
||||
lon_2d = lon
|
||||
lat_2d = lat
|
||||
|
||||
# 동서/남북 거리 계산
|
||||
dx = haversine_distance(center_lon, center_lat, lon_2d, center_lat, return_km=True)
|
||||
dx = dx * np.sign(lon_2d - center_lon)
|
||||
dy = haversine_distance(center_lon, center_lat, center_lon, lat_2d, return_km=True)
|
||||
dy = dy * np.sign(lat_2d - center_lat)
|
||||
|
||||
mask = (np.abs(dx) <= box_size_km) & (np.abs(dy) <= box_size_km)
|
||||
|
||||
rows, cols = np.where(mask)
|
||||
if len(rows) == 0:
|
||||
raise ValueError("No data within specified range")
|
||||
|
||||
min_row, max_row = int(rows.min()), int(rows.max())
|
||||
min_col, max_col = int(cols.min()), int(cols.max())
|
||||
|
||||
lon_region = lon_2d[min_row:max_row + 1, min_col:max_col + 1]
|
||||
lat_region = lat_2d[min_row:max_row + 1, min_col:max_col + 1]
|
||||
|
||||
lon_intervals = [float(lon_region[0, i + 1] - lon_region[0, i])
|
||||
for i in range(lon_region.shape[1] - 1)]
|
||||
lat_intervals = [float(lat_region[i + 1, 0] - lat_region[i, 0])
|
||||
for i in range(lat_region.shape[0] - 1)]
|
||||
|
||||
bound_lon_lat = {
|
||||
"top": float(lat_region.max()),
|
||||
"bottom": float(lat_region.min()),
|
||||
"left": float(lon_region.min()),
|
||||
"right": float(lon_region.max()),
|
||||
}
|
||||
|
||||
# u 변수 차원 수 미리 파악
|
||||
u_data = ocean_ds.get('u', ocean_ds.get('ssu'))
|
||||
u_ndim = u_data.ndim
|
||||
|
||||
return {
|
||||
"min_row": min_row, "max_row": max_row,
|
||||
"min_col": min_col, "max_col": max_col,
|
||||
"rows": int(max_row - min_row + 1),
|
||||
"cols": int(max_col - min_col + 1),
|
||||
"lon_intervals": lon_intervals,
|
||||
"lat_intervals": lat_intervals,
|
||||
"bound_lon_lat": bound_lon_lat,
|
||||
"u_ndim": u_ndim,
|
||||
}
|
||||
|
||||
|
||||
def _extract_uv_at_time(ocean_ds: xr.Dataset, time_idx: int, region: dict) -> dict:
|
||||
"""
|
||||
사전 계산된 공간 영역에서 특정 timestep의 u/v 데이터를 추출합니다.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ocean_ds : xr.Dataset
|
||||
이미 열린 해양 NetCDF 데이터셋
|
||||
time_idx : int
|
||||
시간 인덱스
|
||||
region : dict
|
||||
_compute_hydr_region()이 반환한 영역 정보
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
{"value": [u_list, v_list], "grid": {...}}
|
||||
"""
|
||||
u_data = ocean_ds.get('u', ocean_ds.get('ssu'))
|
||||
v_data = ocean_ds.get('v', ocean_ds.get('ssv'))
|
||||
|
||||
u_ndim = region["u_ndim"]
|
||||
if u_ndim == 3:
|
||||
u_2d = u_data[time_idx].values
|
||||
v_2d = v_data[time_idx].values
|
||||
elif u_ndim == 4:
|
||||
u_2d = u_data[time_idx, 0].values
|
||||
v_2d = v_data[time_idx, 0].values
|
||||
else:
|
||||
u_2d = u_data.values
|
||||
v_2d = v_data.values
|
||||
|
||||
r = region
|
||||
u_region = u_2d[r["min_row"]:r["max_row"] + 1, r["min_col"]:r["max_col"] + 1]
|
||||
v_region = v_2d[r["min_row"]:r["max_row"] + 1, r["min_col"]:r["max_col"] + 1]
|
||||
|
||||
land_mask = (u_region == 0) & (v_region == 0)
|
||||
u_list = convert_and_round(u_region, land_mask)
|
||||
v_list = convert_and_round(v_region, land_mask)
|
||||
|
||||
return {
|
||||
"value": [u_list, v_list],
|
||||
"grid": {
|
||||
"lonInterval": r["lon_intervals"],
|
||||
"boundLonLat": r["bound_lon_lat"],
|
||||
"rows": r["rows"],
|
||||
"cols": r["cols"],
|
||||
"latInterval": r["lat_intervals"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def extract_uv_within_box(nc_file, center_lon, center_lat, target_time, box_size_km=25):
|
||||
"""
|
||||
선택한 포인트와 시간으로부터 상하좌우 정사각형 범위 내의 u, v 데이터 추출
|
||||
|
||||
Parameters
|
||||
----------
|
||||
nc_file : str
|
||||
NetCDF 파일 경로
|
||||
center_lon : float
|
||||
중심점 경도
|
||||
center_lat : float
|
||||
중심점 위도
|
||||
target_time : str or datetime
|
||||
목표 시간 (예: '2024-01-15 12:00:00')
|
||||
box_size_km : float
|
||||
상하좌우 범위 (km), 기본값 25km
|
||||
|
||||
Returns
|
||||
-------
|
||||
result : dict
|
||||
추출된 데이터를 담은 딕셔너리
|
||||
"""
|
||||
with xr.open_dataset(nc_file) as ds:
|
||||
region = _compute_hydr_region(ds, center_lon, center_lat, box_size_km)
|
||||
time_idx, _ = find_time_index(ds, target_time)
|
||||
return _extract_uv_at_time(ds, time_idx, region)
|
||||
58
prediction/opendrift/findFile.py
Normal file
58
prediction/opendrift/findFile.py
Normal file
@ -0,0 +1,58 @@
|
||||
import os
|
||||
import glob
|
||||
from datetime import datetime
|
||||
|
||||
def find_nearest_earlier_file(folder_path, target_time_str):
|
||||
"""
|
||||
주어진 폴더에서 target_time_str (yyyymmddhhmmss)보다 빠르면서
|
||||
가장 가까운 시간의 파일을 찾습니다.
|
||||
|
||||
:param folder_path: 파일을 검색할 폴더 경로
|
||||
:param target_time_str: 기준 시간 문자열 (yyyymmddhhmmss 형식)
|
||||
:return: 찾은 파일의 전체 경로 (없으면 None)
|
||||
"""
|
||||
# 1. 대상 시간을 datetime 객체로 변환
|
||||
try:
|
||||
# 파일명 형식 (yyyy-mm-dd hh:mm:ss)과 일치하는 포맷
|
||||
target_time = datetime.strptime(target_time_str, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
return f"오류: 입력된 기준 시간 '{target_time_str}'의 형식이 'yyyymmddhhmmss'와 일치하지 않습니다."
|
||||
|
||||
# 2. 폴더 내 파일 목록 가져오기 및 시간 정보 파싱
|
||||
# '??????????????.png' 패턴으로 파일명 필터링
|
||||
file_pattern = os.path.join(folder_path, '*.png')
|
||||
all_files = glob.glob(file_pattern)
|
||||
|
||||
# 시간 차이와 파일 경로를 저장할 리스트
|
||||
earlier_files = []
|
||||
|
||||
for file_path in all_files:
|
||||
# 파일명에서 확장자를 제외한 부분 (시간 문자열) 추출
|
||||
base_name = os.path.basename(file_path)
|
||||
file_time_str = base_name.split('.')[0]
|
||||
|
||||
# 파일명 길이가 yyyymmddhhmmss (14자리)인지 확인
|
||||
if len(file_time_str) == 14:
|
||||
try:
|
||||
# 파일 시간을 datetime 객체로 변환
|
||||
file_time = datetime.strptime(file_time_str, '%Y%m%d%H%M%S')
|
||||
|
||||
# 3. 기준 시간보다 **이전**인 파일만 필터링
|
||||
if file_time <= target_time:
|
||||
# 기준 시간과의 차이 (양수)를 계산하고 저장
|
||||
time_difference = target_time - file_time
|
||||
earlier_files.append((time_difference, file_path))
|
||||
|
||||
except ValueError:
|
||||
# 파일명이 14자리여도 datetime 변환에 실패하면 건너뜀
|
||||
continue
|
||||
|
||||
# 4. 필터링된 파일 중 시간 차이가 가장 작은 (가장 가까운) 파일 찾기
|
||||
if not earlier_files:
|
||||
return None # 기준 시간보다 이전인 파일이 없는 경우
|
||||
|
||||
# 시간 차이를 기준으로 오름차순 정렬 (가장 작은 차이가 첫 번째 요소)
|
||||
earlier_files.sort(key=lambda x: x[0])
|
||||
|
||||
# 가장 가까운 파일의 경로 반환
|
||||
return earlier_files[0][1]
|
||||
203
prediction/opendrift/latestForecastDate.py
Normal file
203
prediction/opendrift/latestForecastDate.py
Normal file
@ -0,0 +1,203 @@
|
||||
import os
|
||||
import xarray as xr
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
from config import STORAGE
|
||||
from logger import get_logger
|
||||
|
||||
logger = get_logger("latestForecastDate")
|
||||
|
||||
|
||||
def check_file_size(file_path: str, min_size_bytes: int = 1024) -> bool:
|
||||
"""파일 크기 확인"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
return False
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size < min_size_bytes:
|
||||
logger.debug(f"File size insufficient: {os.path.basename(file_path)} ({file_size} bytes)")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"File check error: {os.path.basename(file_path)} - {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_folder_completeness(folder_path: str,
|
||||
required_patterns: Optional[List[str]] = None,
|
||||
min_file_size: int = 1024) -> bool:
|
||||
"""폴더의 다운로드 완전성 확인"""
|
||||
try:
|
||||
all_files = os.listdir(folder_path)
|
||||
if required_patterns:
|
||||
for pattern in required_patterns:
|
||||
matching_files = [f for f in all_files if f.startswith(pattern)]
|
||||
if not matching_files:
|
||||
return False
|
||||
for matched_file in matching_files:
|
||||
file_path = os.path.join(folder_path, matched_file)
|
||||
if not check_file_size(file_path, min_file_size):
|
||||
return False
|
||||
return True
|
||||
|
||||
nc_files = [f for f in os.listdir(folder_path) if f.endswith('.nc')]
|
||||
|
||||
if not nc_files:
|
||||
logger.debug("No NC files found")
|
||||
return False
|
||||
|
||||
invalid_count = 0
|
||||
for nc_file in nc_files:
|
||||
file_path = os.path.join(folder_path, nc_file)
|
||||
if not check_file_size(file_path, min_file_size):
|
||||
invalid_count += 1
|
||||
|
||||
if invalid_count > 0:
|
||||
logger.debug(f"{invalid_count} files with insufficient size")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Validation error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_latest_forecast_date(base_path: str,
|
||||
max_folders_to_check: int = 7,
|
||||
required_patterns: Optional[List[str]] = None,
|
||||
min_file_size: int = 1024) -> Optional[str]:
|
||||
"""
|
||||
YYYYMMDD 폴더 중 내림차순 상위 N개에서 완전한 최신 폴더의 생성 시간 반환
|
||||
|
||||
Args:
|
||||
base_path: 예보 파일 저장 경로
|
||||
max_folders_to_check: 확인할 최신 폴더 개수
|
||||
required_patterns: 필수 파일 목록
|
||||
min_file_size: 파일 최소 크기 (bytes)
|
||||
|
||||
Returns:
|
||||
YYYYMMDDHHmm 형식 또는 None
|
||||
"""
|
||||
if not os.path.exists(base_path):
|
||||
logger.warning(f"Path not found: {base_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
subdirs = [d for d in os.listdir(base_path)
|
||||
if os.path.isdir(os.path.join(base_path, d))]
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied: {base_path}")
|
||||
return None
|
||||
|
||||
valid_folders = []
|
||||
for subdir in subdirs:
|
||||
if len(subdir) == 8 and subdir.isdigit():
|
||||
try:
|
||||
datetime.strptime(subdir, '%Y%m%d')
|
||||
folder_path = os.path.join(base_path, subdir)
|
||||
creation_time = os.path.getctime(folder_path)
|
||||
valid_folders.append((subdir, creation_time, folder_path))
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
|
||||
if not valid_folders:
|
||||
logger.warning(f"No valid date folders: {base_path}")
|
||||
return None
|
||||
|
||||
valid_folders.sort(key=lambda x: x[0], reverse=True)
|
||||
folders_to_check = valid_folders[:max_folders_to_check]
|
||||
|
||||
folders_to_check.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
for folder_name, creation_timestamp, folder_path in folders_to_check:
|
||||
is_complete = check_folder_completeness(
|
||||
folder_path,
|
||||
required_patterns=required_patterns,
|
||||
min_file_size=min_file_size
|
||||
)
|
||||
|
||||
if is_complete:
|
||||
return folder_name
|
||||
|
||||
logger.warning(f"No complete folder: {base_path}")
|
||||
return None
|
||||
|
||||
|
||||
def get_earliest_latest_forecast_date(wind_path: str = None,
|
||||
hydr_path: str = None,
|
||||
max_folders_to_check: int = 7,
|
||||
required_patterns: Optional[List[str]] = None,
|
||||
min_file_size: int = 1024) -> Optional[dict]:
|
||||
"""
|
||||
바람과 해수 예보의 완전한 최신 폴더 생성 시간 중 더 과거 시간 반환
|
||||
|
||||
Args:
|
||||
wind_path: 바람 예보 경로
|
||||
hydr_path: 해수 예보 경로
|
||||
max_folders_to_check: 확인할 최신 폴더 개수
|
||||
required_patterns: 필수 파일 목록
|
||||
min_file_size: 파일 최소 크기 (bytes)
|
||||
|
||||
Returns:
|
||||
예보 정보 딕셔너리 또는 None
|
||||
"""
|
||||
if wind_path is None:
|
||||
wind_path = str(STORAGE.POS_WIND)
|
||||
if hydr_path is None:
|
||||
hydr_path = str(STORAGE.POS_HYDR)
|
||||
if required_patterns is None:
|
||||
required_patterns = ["EA012", "KO108"]
|
||||
|
||||
wind_latest = get_latest_forecast_date(
|
||||
wind_path,
|
||||
max_folders_to_check=max_folders_to_check,
|
||||
required_patterns=required_patterns,
|
||||
min_file_size=min_file_size
|
||||
)
|
||||
|
||||
hydr_latest = get_latest_forecast_date(
|
||||
hydr_path,
|
||||
max_folders_to_check=max_folders_to_check,
|
||||
required_patterns=required_patterns,
|
||||
min_file_size=min_file_size
|
||||
)
|
||||
|
||||
if wind_latest is None or hydr_latest is None:
|
||||
logger.warning(f"Warning: No forecast received in the last {max_folders_to_check} days. Contact administrator.")
|
||||
return None
|
||||
|
||||
latest_folder_name = min(wind_latest, hydr_latest)
|
||||
latest_folder_name_formatted = datetime.strptime(latest_folder_name, "%Y%m%d").strftime("%Y-%m-%d")
|
||||
latest_receive_date = (datetime.strptime(latest_folder_name, "%Y%m%d") + timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
hydr_file_path = os.path.join(hydr_path, hydr_latest, f"KO108_MOHID_HYDR_SURF_{hydr_latest}00.nc")
|
||||
with xr.open_dataset(hydr_file_path) as ds:
|
||||
start_date = ds['time'].values[0]
|
||||
end_date = ds['time'].values[-1]
|
||||
diff = end_date - start_date
|
||||
hour_diff = diff / np.timedelta64(1, 'h')
|
||||
hour_diff_string = f"{int(hour_diff)}h"
|
||||
|
||||
return_json = {
|
||||
"date": latest_folder_name_formatted,
|
||||
"receivedDate": latest_receive_date + " 12:00",
|
||||
"startDate": pd.Timestamp(start_date).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"endDate": pd.Timestamp(end_date).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"diff": hour_diff_string
|
||||
}
|
||||
|
||||
return return_json
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = get_earliest_latest_forecast_date()
|
||||
|
||||
if result:
|
||||
logger.info(f"Result: {result}")
|
||||
else:
|
||||
logger.info("No complete forecast folder found")
|
||||
68
prediction/opendrift/logger.py
Normal file
68
prediction/opendrift/logger.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""
|
||||
logger.py
|
||||
|
||||
로깅 설정 모듈
|
||||
print() 대신 logging 모듈을 사용하여 일관된 로그 출력을 제공합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def setup_logger(name: str = "opendrift", level: int = logging.INFO,
|
||||
log_format: Optional[str] = None) -> logging.Logger:
|
||||
"""
|
||||
로거를 설정하고 반환합니다.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
로거 이름 (기본값: "opendrift")
|
||||
level : int
|
||||
로그 레벨 (기본값: logging.INFO)
|
||||
log_format : str, optional
|
||||
로그 포맷 (기본값: 표준 포맷)
|
||||
|
||||
Returns
|
||||
-------
|
||||
logging.Logger
|
||||
설정된 로거 인스턴스
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
if not logger.handlers:
|
||||
logger.setLevel(level)
|
||||
|
||||
if log_format is None:
|
||||
log_format = '[%(asctime)s] %(levelname)s - %(message)s'
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(logging.Formatter(log_format, datefmt='%Y-%m-%d %H:%M:%S'))
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# 기본 로거 인스턴스
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def get_logger(name: str = None) -> logging.Logger:
|
||||
"""
|
||||
모듈별 로거를 가져옵니다.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str, optional
|
||||
로거 이름. None이면 기본 로거 반환
|
||||
|
||||
Returns
|
||||
-------
|
||||
logging.Logger
|
||||
로거 인스턴스
|
||||
"""
|
||||
if name is None:
|
||||
return logger
|
||||
return logging.getLogger(f"opendrift.{name}")
|
||||
52
prediction/opendrift/shutdown.sh
Normal file
52
prediction/opendrift/shutdown.sh
Normal file
@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# stop_server.sh - 백그라운드 FastAPI 서버를 종료하는 스크립트
|
||||
|
||||
# 사용할 PID 파일 및 로그 파일 이름 정의
|
||||
PID_FILE="server.pid"
|
||||
LOG_FILE="uvicorn.log"
|
||||
|
||||
# ----------------------------------------------------
|
||||
|
||||
echo "--- FastAPI 서버 종료 스크립트 ---"
|
||||
|
||||
# PID 파일이 존재하는지 확인
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "오류: PID 파일 ($PID_FILE)을 찾을 수 없습니다. 서버가 실행 중이 아닐 수 있습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# PID 파일에서 PID 읽기
|
||||
PID=$(cat "$PID_FILE")
|
||||
|
||||
# 해당 PID를 가진 프로세스가 실제로 실행 중인지 확인
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "PID $PID 를 가진 서버 프로세스를 종료합니다..."
|
||||
# -TERM 시그널을 보내 프로세스를 우아하게 종료 시도
|
||||
kill -TERM "$PID"
|
||||
|
||||
# 프로세스가 종료될 때까지 최대 10초 대기
|
||||
for i in {1..10}; do
|
||||
if ! kill -0 "$PID" 2>/dev/null; then
|
||||
echo "서버 (PID $PID)가 성공적으로 종료되었습니다."
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 10초 후에도 종료되지 않았다면 강제 종료
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "경고: 서버가 정상 종료되지 않아 강제 종료 (kill -9) 합니다."
|
||||
kill -9 "$PID"
|
||||
fi
|
||||
else
|
||||
echo "PID $PID 를 가진 프로세스가 실행 중이지 않습니다."
|
||||
fi
|
||||
|
||||
# PID 파일 및 로그 파일 정리
|
||||
rm -f "$PID_FILE"
|
||||
echo "PID 파일($PID_FILE)이 삭제되었습니다."
|
||||
# 로그 파일도 필요하면 삭제하거나 보관할 수 있습니다.
|
||||
# rm -f "$LOG_FILE"
|
||||
|
||||
echo "종료 프로세스 완료."
|
||||
|
||||
47
prediction/opendrift/startup.sh
Normal file
47
prediction/opendrift/startup.sh
Normal file
@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# start_server.sh - FastAPI 서버를 백그라운드에서 시작하는 스크립트
|
||||
|
||||
# 사용할 PID 파일 및 로그 파일 이름 정의
|
||||
PID_FILE="server.pid"
|
||||
LOG_FILE="uvicorn.log"
|
||||
|
||||
# uvicorn 명령 (가상환경이 활성화되어 있어야 함, 또는 venv 경로를 명시해야 함)
|
||||
# 예를 들어, venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
UVICORN_CMD="uvicorn api:app --host 0.0.0.0 --port 5003 --workers 1"
|
||||
|
||||
# ----------------------------------------------------
|
||||
|
||||
echo "--- FastAPI 서버 시작 스크립트 ---"
|
||||
|
||||
# 서버가 이미 실행 중인지 확인 (PID 파일 존재 여부)
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
# PID가 실제로 실행 중인 프로세스인지 확인
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "오류: 서버가 이미 PID $PID 로 실행 중입니다. 먼저 종료해주세요."
|
||||
exit 1
|
||||
else
|
||||
echo "경고: 오래된 PID 파일($PID_FILE)을 찾았습니다. 삭제하고 새로 시작합니다."
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "PID 파일 경로: $PID_FILE"
|
||||
echo "로그 파일 경로: $LOG_FILE"
|
||||
echo "명령어 실행: $UVICORN_CMD"
|
||||
|
||||
# uvicorn을 nohup을 사용하여 백그라운드 실행. 모든 출력을 로그 파일로 리다이렉트.
|
||||
# $!는 백그라운드에서 실행된 명령어의 PID를 반환합니다.
|
||||
nohup $UVICORN_CMD > "$LOG_FILE" 2>&1 &
|
||||
|
||||
# 백그라운드 프로세스의 PID를 파일에 저장
|
||||
echo $! > "$PID_FILE"
|
||||
NEW_PID=$(cat "$PID_FILE")
|
||||
|
||||
echo "====================================="
|
||||
echo "서버가 성공적으로 백그라운드에서 시작되었습니다."
|
||||
echo "새로운 PID: $NEW_PID"
|
||||
echo "로그 확인: tail -f $LOG_FILE"
|
||||
echo "종료 명령: ./stop_server.sh"
|
||||
echo "====================================="
|
||||
|
||||
267
prediction/opendrift/utils.py
Normal file
267
prediction/opendrift/utils.py
Normal file
@ -0,0 +1,267 @@
|
||||
"""
|
||||
utils.py
|
||||
|
||||
공통 유틸리티 함수 모듈
|
||||
여러 모듈에서 중복 사용되는 함수들을 통합합니다.
|
||||
"""
|
||||
|
||||
import os
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
from config import STORAGE, SIM, FilePatterns
|
||||
|
||||
|
||||
def haversine_distance(lon1: float, lat1: float, lon2: float, lat2: float,
|
||||
return_km: bool = True) -> float:
|
||||
"""
|
||||
두 지점 간의 Haversine 거리를 계산합니다.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
lon1, lat1 : float
|
||||
첫 번째 지점의 경도, 위도
|
||||
lon2, lat2 : float
|
||||
두 번째 지점의 경도, 위도
|
||||
return_km : bool
|
||||
True이면 km 단위, False이면 m 단위로 반환
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
두 지점 간의 거리
|
||||
"""
|
||||
lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
|
||||
|
||||
dlon = lon2 - lon1
|
||||
dlat = lat2 - lat1
|
||||
a = np.sin(dlat/2)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2)**2
|
||||
c = 2 * np.arcsin(np.sqrt(a))
|
||||
|
||||
if return_km:
|
||||
return c * SIM.EARTH_RADIUS_KM
|
||||
else:
|
||||
return c * SIM.EARTH_RADIUS_M
|
||||
|
||||
|
||||
def find_time_index(ds, target_time) -> Tuple[int, datetime]:
|
||||
"""
|
||||
입력된 시간과 동일하거나 과거이면서 가장 가까운 시간의 인덱스를 찾습니다.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ds : xarray.Dataset
|
||||
NetCDF 데이터셋
|
||||
target_time : str or datetime
|
||||
목표 시간 (예: '2024-01-15 12:00:00' 또는 datetime 객체)
|
||||
|
||||
Returns
|
||||
-------
|
||||
time_idx : int
|
||||
선택된 시간의 인덱스
|
||||
selected_time : datetime
|
||||
선택된 시간
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
목표 시간 이전의 데이터가 없는 경우
|
||||
"""
|
||||
if isinstance(target_time, str):
|
||||
target_time = pd.to_datetime(target_time)
|
||||
|
||||
time_var = ds['time'].values
|
||||
times = pd.to_datetime(time_var)
|
||||
|
||||
valid_times = times[times <= target_time]
|
||||
|
||||
if len(valid_times) == 0:
|
||||
raise ValueError(f"목표 시간 {target_time} 이전의 데이터가 없습니다. "
|
||||
f"데이터의 시작 시간: {times[0]}")
|
||||
|
||||
selected_time = valid_times.max()
|
||||
time_idx = np.where(times == selected_time)[0][0]
|
||||
|
||||
return time_idx, selected_time
|
||||
|
||||
|
||||
def convert_and_round(arr: np.ndarray, land_mask: np.ndarray,
|
||||
land_value: int = 0, decimals: int = 3) -> List[List]:
|
||||
"""
|
||||
2D numpy 배열을 리스트로 변환하면서 반올림 및 육지 마스킹을 처리합니다.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
arr : np.ndarray
|
||||
변환할 2D 배열
|
||||
land_mask : np.ndarray
|
||||
육지 마스크 (True인 위치는 육지)
|
||||
land_value : int
|
||||
육지 값 (기본값: 0)
|
||||
decimals : int
|
||||
반올림 소수점 자릿수 (기본값: 3)
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[List]
|
||||
변환된 2D 리스트
|
||||
"""
|
||||
result = np.where(
|
||||
land_mask | np.isnan(arr),
|
||||
float(land_value),
|
||||
np.round(arr.astype(float), decimals)
|
||||
)
|
||||
return result.tolist()
|
||||
|
||||
|
||||
def check_nc_file_by_date(base_path: str, date_obj: datetime,
|
||||
max_attempts: int = None) -> Tuple[Optional[str], Optional[datetime]]:
|
||||
"""
|
||||
주어진 날짜로 NC 파일이 존재하는지 확인하고, 없으면 이전 날짜로 재시도합니다.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
base_path : str
|
||||
기본 저장 경로
|
||||
date_obj : datetime
|
||||
시작 날짜
|
||||
max_attempts : int, optional
|
||||
최대 시도 횟수 (기본값: FILE_FALLBACK_DAYS)
|
||||
|
||||
Returns
|
||||
-------
|
||||
file_path : str or None
|
||||
찾은 파일 경로, 없으면 None
|
||||
final_date : datetime or None
|
||||
최종 사용된 날짜, 없으면 None
|
||||
"""
|
||||
if max_attempts is None:
|
||||
max_attempts = SIM.FILE_FALLBACK_DAYS
|
||||
|
||||
is_wind = "wind" in base_path.lower()
|
||||
|
||||
for _ in range(max_attempts):
|
||||
date_str = date_obj.strftime("%Y%m%d")
|
||||
dir_path = os.path.join(base_path, date_str)
|
||||
|
||||
if not os.path.exists(dir_path):
|
||||
date_obj -= timedelta(days=1)
|
||||
continue
|
||||
|
||||
if is_wind:
|
||||
file_name = FilePatterns.get_wind_filename(date_str)
|
||||
else:
|
||||
file_name = FilePatterns.get_hydr_filename(date_str)
|
||||
|
||||
file_path = os.path.join(dir_path, file_name)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
return file_path, date_obj
|
||||
|
||||
date_obj -= timedelta(days=1)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def check_nc_files_for_date(date_obj: datetime,
|
||||
primary_wind_path: str = None,
|
||||
fallback_wind_path: str = None,
|
||||
primary_hydr_path: str = None,
|
||||
fallback_hydr_path: str = None) -> Tuple[Optional[str], Optional[str], Optional[datetime], Optional[datetime]]:
|
||||
"""
|
||||
바람과 해양 NC 파일을 모두 확인합니다. (primary 경로 우선, fallback 경로 대체)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date_obj : datetime
|
||||
시작 날짜
|
||||
primary_wind_path : str, optional
|
||||
바람 데이터 기본 경로 (기본값: /storage/pos_wind)
|
||||
fallback_wind_path : str, optional
|
||||
바람 데이터 대체 경로 (기본값: /storage/wind)
|
||||
primary_hydr_path : str, optional
|
||||
해양 데이터 기본 경로 (기본값: /storage/pos_hydr)
|
||||
fallback_hydr_path : str, optional
|
||||
해양 데이터 대체 경로 (기본값: /storage/hydr)
|
||||
|
||||
Returns
|
||||
-------
|
||||
wind_nc_path : str or None
|
||||
ocean_nc_path : str or None
|
||||
wind_date : datetime or None
|
||||
ocean_date : datetime or None
|
||||
"""
|
||||
if primary_wind_path is None:
|
||||
primary_wind_path = str(STORAGE.POS_WIND)
|
||||
if fallback_wind_path is None:
|
||||
fallback_wind_path = str(STORAGE.WIND)
|
||||
if primary_hydr_path is None:
|
||||
primary_hydr_path = str(STORAGE.POS_HYDR)
|
||||
if fallback_hydr_path is None:
|
||||
fallback_hydr_path = str(STORAGE.HYDR)
|
||||
|
||||
# 바람 파일 확인
|
||||
wind_nc_path, wind_date = check_nc_file_by_date(primary_wind_path, date_obj)
|
||||
if not wind_nc_path:
|
||||
wind_nc_path, wind_date = check_nc_file_by_date(fallback_wind_path, date_obj)
|
||||
|
||||
# 해양 파일 확인
|
||||
ocean_nc_path, ocean_date = check_nc_file_by_date(primary_hydr_path, date_obj)
|
||||
if not ocean_nc_path:
|
||||
ocean_nc_path, ocean_date = check_nc_file_by_date(fallback_hydr_path, date_obj)
|
||||
|
||||
return wind_nc_path, ocean_nc_path, wind_date, ocean_date
|
||||
|
||||
|
||||
def check_img_file_by_date(date_obj: datetime, img_type: str,
|
||||
max_attempts: int = None) -> Tuple[Optional[str], Optional[datetime]]:
|
||||
"""
|
||||
주어진 날짜로 이미지 폴더가 존재하는지 확인하고, 없으면 이전 날짜로 재시도합니다.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date_obj : datetime
|
||||
시작 날짜
|
||||
img_type : str
|
||||
이미지 타입 (예: "wind", "hydr", "pos_wind", "pos_hydr")
|
||||
max_attempts : int, optional
|
||||
최대 시도 횟수 (기본값: FILE_FALLBACK_DAYS)
|
||||
|
||||
Returns
|
||||
-------
|
||||
folder_path : str or None
|
||||
찾은 폴더 경로, 없으면 None
|
||||
final_date : datetime or None
|
||||
최종 사용된 날짜, 없으면 None
|
||||
"""
|
||||
if max_attempts is None:
|
||||
max_attempts = SIM.FILE_FALLBACK_DAYS
|
||||
|
||||
for _ in range(max_attempts):
|
||||
date_str = date_obj.strftime("%Y%m%d")
|
||||
dir_path = os.path.join(f"/storage/{img_type}", date_str)
|
||||
|
||||
if not os.path.exists(dir_path):
|
||||
date_obj -= timedelta(days=1)
|
||||
continue
|
||||
|
||||
file_path = os.path.join(dir_path, "visual_image")
|
||||
|
||||
if os.path.exists(file_path):
|
||||
return file_path, date_obj
|
||||
|
||||
date_obj -= timedelta(days=1)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def kst_to_utc(dt: datetime) -> datetime:
|
||||
"""KST 시간을 UTC로 변환합니다."""
|
||||
return dt - timedelta(hours=SIM.TIMEZONE_OFFSET_HOURS)
|
||||
|
||||
|
||||
def utc_to_kst(dt: datetime) -> datetime:
|
||||
"""UTC 시간을 KST로 변환합니다."""
|
||||
return dt + timedelta(hours=SIM.TIMEZONE_OFFSET_HOURS)
|
||||
불러오는 중...
Reference in New Issue
Block a user