release: 2026-03-11.2 (12건 커밋) #85

병합
jhkang develop 에서 main 로 12 commits 를 머지했습니다 2026-03-11 18:37:40 +09:00
74개의 변경된 파일8869개의 추가작업 그리고 964개의 파일을 삭제
Showing only changes of commit 88eb6b121a - Show all commits

파일 보기

@ -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
파일 보기

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

파일 보기

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

파일 보기

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

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

파일 보기

@ -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
파일 보기

@ -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) — 인증·상태관리 공통 로직

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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"
>
&lt;
</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"
>
&gt;
</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

파일 보기

@ -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)',

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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
파일 보기

@ -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 # 코드 변경 시 자동으로 서버를 재시작하는 모드 (개발용)
)

파일 보기

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

파일 보기

@ -0,0 +1 @@
CP949

Binary file not shown.

파일 보기

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

Binary file not shown.

Binary file not shown.

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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 77175 대체)
# ------------------------------------------------------------------
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

파일 보기

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

파일 보기

@ -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()

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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")

파일 보기

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

파일 보기

@ -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 "종료 프로세스 완료."

파일 보기

@ -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 "====================================="

파일 보기

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