feat(weather): 기상 정보 기상 레이어 업데이트 #78
2
backend/package-lock.json
generated
2
backend/package-lock.json
generated
@ -558,7 +558,6 @@
|
|||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@ -1992,7 +1991,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.11.0",
|
"pg-connection-string": "^2.11.0",
|
||||||
"pg-pool": "^3.12.0",
|
"pg-pool": "^3.12.0",
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
createSatRequest,
|
createSatRequest,
|
||||||
updateSatRequestStatus,
|
updateSatRequestStatus,
|
||||||
isValidSatStatus,
|
isValidSatStatus,
|
||||||
|
requestOilInference,
|
||||||
|
checkInferenceHealth,
|
||||||
} from './aerialService.js';
|
} from './aerialService.js';
|
||||||
import { isValidNumber } from '../middleware/security.js';
|
import { isValidNumber } from '../middleware/security.js';
|
||||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.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;
|
export default router;
|
||||||
|
|||||||
@ -339,3 +339,62 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
|||||||
[sttsCd, sn]
|
[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
|
req.resolvedPermissions = userInfo.permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedOps = req.resolvedPermissions[resource]
|
// 정확한 리소스 매칭 → 부모 리소스 fallback (board:notice → board)
|
||||||
if (allowedOps && allowedOps.includes(operation)) {
|
let cursor: string | undefined = resource
|
||||||
next()
|
while (cursor) {
|
||||||
return
|
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: '접근 권한이 없습니다.' })
|
res.status(403).json({ error: '접근 권한이 없습니다.' })
|
||||||
|
|||||||
@ -112,6 +112,23 @@ export async function login(
|
|||||||
return userInfo
|
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> {
|
export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
|
||||||
const userResult = await authPool.query(
|
const userResult = await authPool.query(
|
||||||
`SELECT u.USER_ID as user_id, u.USER_ACNT as user_acnt, u.USER_NM as user_nm,
|
`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)
|
permissions = grantedSetToRecord(granted)
|
||||||
} else {
|
} else {
|
||||||
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
|
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
|
||||||
const permsResult = await authPool.query(
|
permissions = await flatPermissionsFallback(userId)
|
||||||
`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']
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// AUTH_PERM_TREE 테이블 미존재 시 fallback
|
// AUTH_PERM_TREE 테이블 미존재 시 fallback
|
||||||
const permsResult = await authPool.query(
|
try {
|
||||||
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
|
permissions = await flatPermissionsFallback(userId)
|
||||||
FROM AUTH_PERM p
|
} catch {
|
||||||
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
|
console.error('[auth] 권한 조회 fallback 실패, 빈 권한 반환')
|
||||||
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
|
permissions = {}
|
||||||
[userId]
|
|
||||||
)
|
|
||||||
permissions = {}
|
|
||||||
for (const p of permsResult.rows) {
|
|
||||||
permissions[p.rsrc_cd] = ['READ']
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Router } from 'express'
|
|||||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||||
import { AuthError } from '../auth/authService.js'
|
import { AuthError } from '../auth/authService.js'
|
||||||
import {
|
import {
|
||||||
listPosts, getPost, createPost, updatePost, deletePost,
|
listPosts, getPost, createPost, updatePost, deletePost, adminDeletePost,
|
||||||
listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
|
listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
|
||||||
} from './boardService.js'
|
} 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
|
export default router
|
||||||
|
|||||||
@ -398,3 +398,18 @@ export async function deletePost(postSn: number, requesterId: string): Promise<v
|
|||||||
[postSn]
|
[postSn]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 관리자 전용 삭제 — 소유자 검증 없이 논리 삭제 */
|
||||||
|
export async function adminDeletePost(postSn: number): Promise<void> {
|
||||||
|
const existing = await wingPool.query(
|
||||||
|
`SELECT POST_SN FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
|
||||||
|
[postSn]
|
||||||
|
)
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
|
||||||
|
}
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
|
||||||
|
[postSn]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
185
backend/src/inference/oil_inference_server.py
Normal file
185
backend/src/inference/oil_inference_server.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
오일 유출 감지 추론 서버 (GPU)
|
||||||
|
시립대 starsafire ResNet101+DANet 모델 기반
|
||||||
|
|
||||||
|
실행: uvicorn oil_inference_server:app --host 0.0.0.0 --port 8090
|
||||||
|
모델 파일 필요: ./V7_SPECIAL.py, ./epoch_165.pth (같은 디렉토리)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# ── MMSegmentation (지연 임포트 — 서버 시작 시 로드) ─────────────────────────
|
||||||
|
model = None
|
||||||
|
DEVICE = os.getenv("INFERENCE_DEVICE", "cuda:0")
|
||||||
|
|
||||||
|
CLASSES = ("background", "black", "brown", "rainbow", "silver")
|
||||||
|
PALETTE = [
|
||||||
|
[0, 0, 0], # 0: background
|
||||||
|
[0, 0, 204], # 1: black oil (에멀전)
|
||||||
|
[180, 180, 180], # 2: brown oil (원유)
|
||||||
|
[255, 255, 0], # 3: rainbow oil (박막)
|
||||||
|
[178, 102, 255], # 4: silver oil (극박막)
|
||||||
|
]
|
||||||
|
THICKNESS_MM = {
|
||||||
|
1: 1.0, # black
|
||||||
|
2: 0.1, # brown
|
||||||
|
3: 0.0003, # rainbow
|
||||||
|
4: 0.0001, # silver
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("oil-inference")
|
||||||
|
|
||||||
|
# ── FastAPI App ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app = FastAPI(title="Oil Spill Inference Server", version="1.0.0")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InferenceRequest(BaseModel):
|
||||||
|
image: str # base64 encoded JPEG/PNG
|
||||||
|
|
||||||
|
|
||||||
|
class OilRegionResult(BaseModel):
|
||||||
|
classId: int
|
||||||
|
className: str
|
||||||
|
pixelCount: int
|
||||||
|
percentage: float
|
||||||
|
thicknessMm: float
|
||||||
|
|
||||||
|
|
||||||
|
class InferenceResponse(BaseModel):
|
||||||
|
mask: str # base64 encoded uint8 array (values 0-4)
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
regions: list[OilRegionResult]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Model Loading ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_model():
|
||||||
|
"""모델을 로드한다. 서버 시작 시 1회 호출."""
|
||||||
|
global model
|
||||||
|
try:
|
||||||
|
from mmseg.apis import init_segmentor
|
||||||
|
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
config_path = os.path.join(script_dir, "V7_SPECIAL.py")
|
||||||
|
checkpoint_path = os.path.join(script_dir, "epoch_165.pth")
|
||||||
|
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
logger.error(f"Config not found: {config_path}")
|
||||||
|
return False
|
||||||
|
if not os.path.exists(checkpoint_path):
|
||||||
|
logger.error(f"Checkpoint not found: {checkpoint_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Loading model on {DEVICE}...")
|
||||||
|
model = init_segmentor(config_path, checkpoint_path, device=DEVICE)
|
||||||
|
model.PALETTE = PALETTE
|
||||||
|
logger.info("Model loaded successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Model loading failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
success = load_model()
|
||||||
|
if not success:
|
||||||
|
logger.warning("Model not loaded — inference will be unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"status": "ok" if model is not None else "model_not_loaded",
|
||||||
|
"device": DEVICE,
|
||||||
|
"classes": list(CLASSES),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/inference", response_model=InferenceResponse)
|
||||||
|
async def inference(req: InferenceRequest):
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Base64 → numpy array
|
||||||
|
img_bytes = base64.b64decode(req.image)
|
||||||
|
img_pil = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||||
|
img_np = np.array(img_pil)
|
||||||
|
|
||||||
|
# 2. 임시 파일로 저장 (mmseg inference_segmentor는 파일 경로 필요)
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
cv2.imwrite(tmp_path, cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR))
|
||||||
|
|
||||||
|
# 3. 추론
|
||||||
|
from mmseg.apis import inference_segmentor
|
||||||
|
result = inference_segmentor(model, tmp_path)
|
||||||
|
seg_map = result[0] # (H, W) uint8, values 0-4
|
||||||
|
|
||||||
|
# 임시 파일 삭제
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
h, w = seg_map.shape
|
||||||
|
total_pixels = h * w
|
||||||
|
|
||||||
|
# 4. 클래스별 통계
|
||||||
|
counter = Counter(seg_map.flatten().tolist())
|
||||||
|
regions = []
|
||||||
|
for class_id in range(1, 5): # 1-4 (skip background)
|
||||||
|
count = counter.get(class_id, 0)
|
||||||
|
if count > 0:
|
||||||
|
regions.append(OilRegionResult(
|
||||||
|
classId=class_id,
|
||||||
|
className=CLASSES[class_id],
|
||||||
|
pixelCount=count,
|
||||||
|
percentage=round(count / total_pixels * 100, 2),
|
||||||
|
thicknessMm=THICKNESS_MM[class_id],
|
||||||
|
))
|
||||||
|
|
||||||
|
# 5. 마스크를 base64로 인코딩
|
||||||
|
mask_bytes = seg_map.astype(np.uint8).tobytes()
|
||||||
|
mask_b64 = base64.b64encode(mask_bytes).decode("ascii")
|
||||||
|
|
||||||
|
return InferenceResponse(
|
||||||
|
mask=mask_b64,
|
||||||
|
width=w,
|
||||||
|
height=h,
|
||||||
|
regions=regions,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Inference error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8090)
|
||||||
8
backend/src/inference/requirements.txt
Normal file
8
backend/src/inference/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn==0.24.0
|
||||||
|
torch>=1.13.0
|
||||||
|
mmcv-full>=1.7.0
|
||||||
|
mmsegmentation>=0.30.0
|
||||||
|
opencv-python-headless>=4.8.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
Pillow>=10.0.0
|
||||||
@ -97,9 +97,13 @@ app.use(cors({
|
|||||||
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
|
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
|
||||||
const generalLimiter = rateLimit({
|
const generalLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15분
|
windowMs: 15 * 60 * 1000, // 15분
|
||||||
max: 200, // IP당 최대 200요청
|
max: 500, // IP당 최대 500요청 (HLS 스트리밍 고려)
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: (req) => {
|
||||||
|
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
|
||||||
|
return req.path.startsWith('/api/aerial/cctv/stream-proxy');
|
||||||
|
},
|
||||||
message: {
|
message: {
|
||||||
error: '요청 횟수 초과',
|
error: '요청 횟수 초과',
|
||||||
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'
|
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
assignRoles,
|
assignRoles,
|
||||||
approveUser,
|
approveUser,
|
||||||
rejectUser,
|
rejectUser,
|
||||||
|
listOrgs,
|
||||||
} from './userService.js'
|
} from './userService.js'
|
||||||
|
|
||||||
const router = Router()
|
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
|
// GET /api/users/:id
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
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> {
|
export async function assignRoles(userId: string, roleSns: number[]): Promise<void> {
|
||||||
await authPool.query('DELETE FROM AUTH_USER_ROLE WHERE USER_ID = $1', [userId])
|
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. 초기 데이터: 역할
|
-- 10. 초기 데이터: 역할
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
|
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
|
||||||
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
|
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
|
||||||
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
|
('HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'),
|
||||||
('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'),
|
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
|
||||||
('VIEWER', '뷰어', '조회 전용 접근 권한', '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, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
|
||||||
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
|
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
|
||||||
|
|
||||||
-- 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
|
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, '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'),
|
(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, '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, '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, '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, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'),
|
||||||
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', 'DELETE', 'Y'),
|
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'),
|
||||||
(2, 'admin', 'READ', 'N');
|
(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
|
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, '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', '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', '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', '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', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'), (3, 'aerial', 'DELETE', 'Y'),
|
||||||
(3, 'assets', 'READ', 'N'),
|
(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', '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', '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', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
|
||||||
(3, 'weather', 'READ', 'Y'),
|
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
|
||||||
(3, 'admin', 'READ', 'N');
|
(3, 'admin', 'READ', 'N');
|
||||||
|
|
||||||
-- 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
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
(4, 'prediction', 'READ', 'Y'),
|
(4, 'prediction', 'READ', 'Y'), (4, 'prediction', 'CREATE', 'Y'), (4, 'prediction', 'UPDATE', 'Y'),
|
||||||
(4, 'hns', 'READ', 'Y'),
|
(4, 'hns', 'READ', 'Y'), (4, 'hns', 'CREATE', 'Y'), (4, 'hns', 'UPDATE', 'Y'),
|
||||||
(4, 'rescue', 'READ', 'Y'),
|
(4, 'rescue', 'READ', 'Y'), (4, 'rescue', 'CREATE', 'Y'), (4, 'rescue', 'UPDATE', 'Y'),
|
||||||
(4, 'reports', 'READ', 'N'),
|
(4, 'reports', 'READ', 'Y'), (4, 'reports', 'CREATE', 'Y'), (4, 'reports', 'UPDATE', 'Y'),
|
||||||
(4, 'aerial', 'READ', 'Y'),
|
(4, 'aerial', 'READ', 'Y'), (4, 'aerial', 'CREATE', 'Y'), (4, 'aerial', 'UPDATE', 'Y'),
|
||||||
(4, 'assets', 'READ', 'N'),
|
(4, 'assets', 'READ', 'N'),
|
||||||
(4, 'scat', 'READ', 'N'),
|
(4, 'scat', 'READ', 'Y'), (4, 'scat', 'CREATE', 'Y'), (4, 'scat', 'UPDATE', 'Y'),
|
||||||
(4, 'incidents', 'READ', 'Y'),
|
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
|
||||||
(4, 'board', 'READ', 'Y'),
|
(4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
|
||||||
(4, 'weather', 'READ', 'Y'),
|
(4, 'weather', 'READ', 'Y'),
|
||||||
(4, 'admin', 'READ', 'N');
|
(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. 초기 데이터: 조직
|
-- 12. 초기 데이터: 조직
|
||||||
|
|||||||
1396
database/migration/019_ship_insurance_seed.sql
Normal file
1396
database/migration/019_ship_insurance_seed.sql
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
32
database/migration/020_add_hq_cleanup_role.sql
Normal file
32
database/migration/020_add_hq_cleanup_role.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 020: 본청방제과 역할 추가
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 역할 추가 (이미 존재하면 무시)
|
||||||
|
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN)
|
||||||
|
SELECT 'HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP');
|
||||||
|
|
||||||
|
-- 본청방제과 권한 설정: 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_role_sn INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT ROLE_SN INTO v_role_sn FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP';
|
||||||
|
|
||||||
|
-- 기존 권한 초기화 (재실행 안전)
|
||||||
|
DELETE FROM AUTH_PERM WHERE ROLE_SN = v_role_sn;
|
||||||
|
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
|
||||||
|
(v_role_sn, 'prediction', 'READ', 'Y'), (v_role_sn, 'prediction', 'CREATE', 'Y'), (v_role_sn, 'prediction', 'UPDATE', 'Y'), (v_role_sn, 'prediction', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'hns', 'READ', 'Y'), (v_role_sn, 'hns', 'CREATE', 'Y'), (v_role_sn, 'hns', 'UPDATE', 'Y'), (v_role_sn, 'hns', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'rescue', 'READ', 'Y'), (v_role_sn, 'rescue', 'CREATE', 'Y'), (v_role_sn, 'rescue', 'UPDATE', 'Y'), (v_role_sn, 'rescue', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'reports', 'READ', 'Y'), (v_role_sn, 'reports', 'CREATE', 'Y'), (v_role_sn, 'reports', 'UPDATE', 'Y'), (v_role_sn, 'reports', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'aerial', 'READ', 'Y'), (v_role_sn, 'aerial', 'CREATE', 'Y'), (v_role_sn, 'aerial', 'UPDATE', 'Y'), (v_role_sn, 'aerial', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'assets', 'READ', 'Y'), (v_role_sn, 'assets', 'CREATE', 'Y'), (v_role_sn, 'assets', 'UPDATE', 'Y'), (v_role_sn, 'assets', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'scat', 'READ', 'Y'), (v_role_sn, 'scat', 'CREATE', 'Y'), (v_role_sn, 'scat', 'UPDATE', 'Y'), (v_role_sn, 'scat', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'incidents', 'READ', 'Y'), (v_role_sn, 'incidents', 'CREATE', 'Y'), (v_role_sn, 'incidents', 'UPDATE', 'Y'), (v_role_sn, 'incidents', 'DELETE', 'Y'),
|
||||||
|
(v_role_sn, 'board', 'READ', 'Y'), (v_role_sn, 'board', 'CREATE', 'Y'), (v_role_sn, 'board', 'UPDATE', 'Y'),
|
||||||
|
(v_role_sn, 'weather', 'READ', 'Y'), (v_role_sn, 'weather', 'CREATE', 'Y'),
|
||||||
|
(v_role_sn, 'admin', 'READ', 'N');
|
||||||
|
END $$;
|
||||||
@ -60,12 +60,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
|||||||
{ id: 'manual', label: '해경매뉴얼', icon: '📘' }
|
{ id: 'manual', label: '해경매뉴얼', icon: '📘' }
|
||||||
],
|
],
|
||||||
weather: null,
|
weather: null,
|
||||||
admin: [
|
admin: null // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
||||||
{ id: 'users', label: '사용자 관리', icon: '👥' },
|
|
||||||
{ id: 'permissions', label: '사용자 권한 관리', icon: '🔐' },
|
|
||||||
{ id: 'menus', label: '메뉴 관리', icon: '📑' },
|
|
||||||
{ id: 'settings', label: '시스템 설정', icon: '⚙️' }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전역 상태 관리 (간단한 방식)
|
// 전역 상태 관리 (간단한 방식)
|
||||||
|
|||||||
@ -107,6 +107,20 @@ export async function assignRolesApi(id: string, roleSns: number[]): Promise<voi
|
|||||||
await api.put(`/users/${id}/roles`, { roleSns })
|
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 전용)
|
// 역할/권한 API (ADMIN 전용)
|
||||||
export interface RoleWithPermissions {
|
export interface RoleWithPermissions {
|
||||||
sn: number
|
sn: number
|
||||||
|
|||||||
14
frontend/src/tabs/admin/components/AdminPlaceholder.tsx
Normal file
14
frontend/src/tabs/admin/components/AdminPlaceholder.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
interface AdminPlaceholderProps {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미구현 관리자 메뉴 placeholder */
|
||||||
|
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
|
<div className="text-4xl opacity-20">🚧</div>
|
||||||
|
<div className="text-sm font-korean text-text-2 font-semibold">{label}</div>
|
||||||
|
<div className="text-[11px] font-korean text-text-3">해당 기능은 준비 중입니다.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdminPlaceholder;
|
||||||
156
frontend/src/tabs/admin/components/AdminSidebar.tsx
Normal file
156
frontend/src/tabs/admin/components/AdminSidebar.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ADMIN_MENU } from './adminMenuConfig';
|
||||||
|
import type { AdminMenuItem } from './adminMenuConfig';
|
||||||
|
|
||||||
|
interface AdminSidebarProps {
|
||||||
|
activeMenu: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관리자 좌측 사이드바 — 9-섹션 아코디언 */
|
||||||
|
const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||||
|
// 초기: 첫 번째 섹션 열기
|
||||||
|
const init = new Set<string>();
|
||||||
|
if (ADMIN_MENU.length > 0) init.add(ADMIN_MENU[0].id);
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 재귀적으로 메뉴 아이템이 activeMenu를 포함하는지 확인 */
|
||||||
|
const containsActive = (item: AdminMenuItem): boolean => {
|
||||||
|
if (item.id === activeMenu) return true;
|
||||||
|
return item.children?.some(c => containsActive(c)) ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLeaf = (item: AdminMenuItem, depth: number) => {
|
||||||
|
const isActive = item.id === activeMenu;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${12 + depth * 14}px`,
|
||||||
|
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
|
||||||
|
color: isActive ? 'var(--cyan)' : 'var(--t2)',
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGroup = (item: AdminMenuItem, depth: number) => {
|
||||||
|
const isOpen = expanded.has(item.id);
|
||||||
|
const hasActiveChild = containsActive(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
toggle(item.id);
|
||||||
|
// 그룹 자체에 children의 첫 leaf가 있으면 자동 선택
|
||||||
|
if (!isOpen && item.children) {
|
||||||
|
const firstLeaf = findFirstLeaf(item.children);
|
||||||
|
if (firstLeaf) onSelect(firstLeaf.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${12 + depth * 14}px`,
|
||||||
|
color: hasActiveChild ? 'var(--cyan)' : 'var(--t2)',
|
||||||
|
fontWeight: hasActiveChild ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && item.children && (
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
{item.children.map(child => renderItem(child, depth + 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = (item: AdminMenuItem, depth: number) => {
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
return renderGroup(item, depth);
|
||||||
|
}
|
||||||
|
return renderLeaf(item, depth);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col bg-bg-1 border-r border-border overflow-y-auto shrink-0"
|
||||||
|
style={{ width: 240, scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-bg-2 shrink-0">
|
||||||
|
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
|
||||||
|
<span>⚙️</span> 관리자 설정
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메뉴 목록 */}
|
||||||
|
<div className="flex flex-col gap-0.5 p-2">
|
||||||
|
{ADMIN_MENU.map(section => {
|
||||||
|
const isOpen = expanded.has(section.id);
|
||||||
|
const hasActiveChild = containsActive(section);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={section.id} className="mb-0.5">
|
||||||
|
{/* 섹션 헤더 */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(section.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-[11px] font-bold font-korean transition-colors cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||||
|
color: hasActiveChild ? 'var(--cyan)' : 'var(--t1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{section.icon}</span>
|
||||||
|
<span className="flex-1 text-left">{section.label}</span>
|
||||||
|
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 하위 메뉴 */}
|
||||||
|
{isOpen && section.children && (
|
||||||
|
<div className="flex flex-col gap-px mt-0.5 ml-1">
|
||||||
|
{section.children.map(child => renderItem(child, 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** children 중 첫 번째 leaf 노드를 찾는다 */
|
||||||
|
function findFirstLeaf(items: AdminMenuItem[]): AdminMenuItem | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.children || item.children.length === 0) return item;
|
||||||
|
const found = findFirstLeaf(item.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminSidebar;
|
||||||
@ -1,21 +1,42 @@
|
|||||||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
import { useState } from 'react';
|
||||||
import UsersPanel from './UsersPanel'
|
import AdminSidebar from './AdminSidebar';
|
||||||
import PermissionsPanel from './PermissionsPanel'
|
import AdminPlaceholder from './AdminPlaceholder';
|
||||||
import MenusPanel from './MenusPanel'
|
import { findMenuLabel } from './adminMenuConfig';
|
||||||
import SettingsPanel from './SettingsPanel'
|
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() {
|
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 (
|
return (
|
||||||
<div className="flex flex-1 overflow-hidden bg-bg-0">
|
<div className="flex flex-1 overflow-hidden bg-bg-0">
|
||||||
|
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{activeSubTab === 'users' && <UsersPanel />}
|
{renderContent()}
|
||||||
{activeSubTab === 'permissions' && <PermissionsPanel />}
|
|
||||||
{activeSubTab === 'menus' && <MenusPanel />}
|
|
||||||
{activeSubTab === 'settings' && <SettingsPanel />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
293
frontend/src/tabs/admin/components/BoardMgmtPanel.tsx
Normal file
293
frontend/src/tabs/admin/components/BoardMgmtPanel.tsx
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
fetchBoardPosts,
|
||||||
|
adminDeleteBoardPost,
|
||||||
|
type BoardPostItem,
|
||||||
|
type BoardListResponse,
|
||||||
|
} from '@tabs/board/services/boardApi';
|
||||||
|
|
||||||
|
// ─── 상수 ──────────────────────────────────────────────────
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const CATEGORY_TABS = [
|
||||||
|
{ code: '', label: '전체' },
|
||||||
|
{ code: 'NOTICE', label: '공지사항' },
|
||||||
|
{ code: 'DATA', label: '게시판' },
|
||||||
|
{ code: 'QNA', label: 'Q&A' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
NOTICE: '공지사항',
|
||||||
|
DATA: '게시판',
|
||||||
|
QNA: 'Q&A',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 패널 ─────────────────────────────────────────────
|
||||||
|
interface BoardMgmtPanelProps {
|
||||||
|
initialCategory?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelProps) {
|
||||||
|
const [activeCategory, setActiveCategory] = useState(initialCategory);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [data, setData] = useState<BoardListResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchBoardPosts({
|
||||||
|
categoryCd: activeCategory || undefined,
|
||||||
|
search: search || undefined,
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
setData(result);
|
||||||
|
setSelected(new Set());
|
||||||
|
} catch {
|
||||||
|
console.error('게시글 목록 로드 실패');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeCategory, search, page]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.totalCount / PAGE_SIZE) : 0;
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
const toggleSelect = (sn: number) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(sn)) next.delete(sn);
|
||||||
|
else next.add(sn);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selected.size === items.length) {
|
||||||
|
setSelected(new Set());
|
||||||
|
} else {
|
||||||
|
setSelected(new Set(items.map(i => i.sn)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
if (!confirm(`선택한 ${selected.size}건의 게시글을 삭제하시겠습니까?`)) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([...selected].map(sn => adminDeleteBoardPost(sn)));
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
alert('삭제 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryChange = (code: string) => {
|
||||||
|
setActiveCategory(code);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1">
|
||||||
|
<h2 className="text-sm font-semibold text-text-1">게시판 관리</h2>
|
||||||
|
<span className="text-xs text-text-3">
|
||||||
|
총 {data?.totalCount ?? 0}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 탭 + 검색 */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-2 border-b border-border-1">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{CATEGORY_TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.code}
|
||||||
|
onClick={() => handleCategoryChange(tab.code)}
|
||||||
|
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||||
|
activeCategory === tab.code
|
||||||
|
? 'bg-blue-500/20 text-blue-400 font-medium'
|
||||||
|
: 'text-text-3 hover:text-text-2 hover:bg-bg-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-1 ml-auto">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
|
placeholder="제목/작성자 검색"
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1 placeholder:text-text-4 w-48"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 바 */}
|
||||||
|
<div className="flex items-center gap-2 px-5 py-2 border-b border-border-1">
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={selected.size === 0 || deleting}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-bg-1 z-10">
|
||||||
|
<tr className="border-b border-border-1 text-text-3">
|
||||||
|
<th className="w-8 py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={items.length > 0 && selected.size === items.length}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="accent-blue-500"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="w-12 py-2 text-center">번호</th>
|
||||||
|
<th className="w-20 py-2 text-center">분류</th>
|
||||||
|
<th className="py-2 text-left pl-3">제목</th>
|
||||||
|
<th className="w-24 py-2 text-center">작성자</th>
|
||||||
|
<th className="w-16 py-2 text-center">조회</th>
|
||||||
|
<th className="w-36 py-2 text-center">등록일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-text-3">로딩 중...</td>
|
||||||
|
</tr>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-text-3">게시글이 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
items.map(post => (
|
||||||
|
<PostRow
|
||||||
|
key={post.sn}
|
||||||
|
post={post}
|
||||||
|
checked={selected.has(post.sn)}
|
||||||
|
onToggle={() => toggleSelect(post.sn)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-1 py-2 border-t border-border-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
|
||||||
|
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9));
|
||||||
|
const p = startPage + i;
|
||||||
|
if (p > totalPages) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className={`w-7 h-7 text-xs rounded ${
|
||||||
|
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-text-3 hover:bg-bg-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 행 컴포넌트 ───────────────────────────────────────────
|
||||||
|
interface PostRowProps {
|
||||||
|
post: BoardPostItem;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostRow({ post, checked, onToggle }: PostRowProps) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-border-1 hover:bg-bg-1/50 transition-colors">
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="accent-blue-500"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center text-text-3">{post.sn}</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
||||||
|
post.categoryCd === 'NOTICE' ? 'bg-red-500/15 text-red-400' :
|
||||||
|
post.categoryCd === 'QNA' ? 'bg-purple-500/15 text-purple-400' :
|
||||||
|
'bg-blue-500/15 text-blue-400'
|
||||||
|
}`}>
|
||||||
|
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-3 text-text-1 truncate max-w-[300px]">
|
||||||
|
{post.pinnedYn === 'Y' && (
|
||||||
|
<span className="text-[10px] text-orange-400 mr-1">[고정]</span>
|
||||||
|
)}
|
||||||
|
{post.title}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center text-text-2">{post.authorName}</td>
|
||||||
|
<td className="py-2 text-center text-text-3">{post.viewCnt}</td>
|
||||||
|
<td className="py-2 text-center text-text-3">{formatDate(post.regDtm)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
204
frontend/src/tabs/admin/components/VesselSignalPanel.tsx
Normal file
204
frontend/src/tabs/admin/components/VesselSignalPanel.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
// ─── 타입 ──────────────────────────────────────────────────
|
||||||
|
const SIGNAL_SOURCES = ['VTS', 'VTS-AIS', 'V-PASS', 'E-NAVI', 'S&P AIS'] as const;
|
||||||
|
type SignalSource = (typeof SIGNAL_SOURCES)[number];
|
||||||
|
|
||||||
|
interface SignalSlot {
|
||||||
|
time: string; // HH:mm
|
||||||
|
sources: Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 상수 ──────────────────────────────────────────────────
|
||||||
|
const SOURCE_COLORS: Record<SignalSource, string> = {
|
||||||
|
VTS: '#3b82f6',
|
||||||
|
'VTS-AIS': '#a855f7',
|
||||||
|
'V-PASS': '#22c55e',
|
||||||
|
'E-NAVI': '#f97316',
|
||||||
|
'S&P AIS': '#ec4899',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
ok: '#22c55e',
|
||||||
|
warn: '#eab308',
|
||||||
|
error: '#ef4444',
|
||||||
|
none: 'rgba(255,255,255,0.06)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
|
function generateTimeSlots(date: string): SignalSlot[] {
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = date === now.toISOString().slice(0, 10);
|
||||||
|
const currentHour = isToday ? now.getHours() : 24;
|
||||||
|
const currentMin = isToday ? now.getMinutes() : 0;
|
||||||
|
|
||||||
|
const slots: SignalSlot[] = [];
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
for (let m = 0; m < 60; m += 10) {
|
||||||
|
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
const isPast = h < currentHour || (h === currentHour && m <= currentMin);
|
||||||
|
|
||||||
|
const sources = {} as Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
|
||||||
|
for (const src of SIGNAL_SOURCES) {
|
||||||
|
if (!isPast) {
|
||||||
|
sources[src] = { count: 0, status: 'none' };
|
||||||
|
} else {
|
||||||
|
const rand = Math.random();
|
||||||
|
const count = Math.floor(Math.random() * 200) + 10;
|
||||||
|
sources[src] = {
|
||||||
|
count,
|
||||||
|
status: rand > 0.15 ? 'ok' : rand > 0.05 ? 'warn' : 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots.push({ time, sources });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 타임라인 바 (10분 단위 셀) ────────────────────────────
|
||||||
|
function TimelineBar({ slots, source }: { slots: SignalSlot[]; source: SignalSource }) {
|
||||||
|
if (slots.length === 0) return null;
|
||||||
|
|
||||||
|
// 144개 슬롯을 각각 1칸씩 렌더링 (10분 = 1칸)
|
||||||
|
return (
|
||||||
|
<div className="w-full h-5 overflow-hidden flex" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||||
|
{slots.map((slot, i) => {
|
||||||
|
const s = slot.sources[source];
|
||||||
|
const color = STATUS_COLOR[s.status] || STATUS_COLOR.none;
|
||||||
|
const statusLabel = s.status === 'ok' ? '정상' : s.status === 'warn' ? '지연' : s.status === 'error' ? '오류' : '미수신';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
width: `${100 / 144}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
borderRight: '0.5px solid rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
title={`${slot.time} ${statusLabel}${s.status !== 'none' ? ` (${s.count}건)` : ''}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 패널 ─────────────────────────────────────────────
|
||||||
|
export default function VesselSignalPanel() {
|
||||||
|
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
|
const [slots, setSlots] = useState<SignalSlot[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
// TODO: 실제 API 연동 시 fetch 호출로 교체
|
||||||
|
setTimeout(() => {
|
||||||
|
setSlots(generateTimeSlots(date));
|
||||||
|
setLoading(false);
|
||||||
|
}, 300);
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => load(), 0);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// 통계 계산
|
||||||
|
const stats = SIGNAL_SOURCES.map(src => {
|
||||||
|
let total = 0, ok = 0, warn = 0, error = 0;
|
||||||
|
for (const slot of slots) {
|
||||||
|
const s = slot.sources[src];
|
||||||
|
if (s.status !== 'none') {
|
||||||
|
total++;
|
||||||
|
if (s.status === 'ok') ok++;
|
||||||
|
else if (s.status === 'warn') warn++;
|
||||||
|
else error++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { src, total, ok, warn, error, rate: total > 0 ? ((ok / total) * 100).toFixed(1) : '-' };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-b border-border-1">
|
||||||
|
<h2 className="text-sm font-semibold text-text-1">선박신호 수신 현황</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={e => setDate(e.target.value)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-xs text-text-3">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* 좌측: 소스 라벨 고정 열 */}
|
||||||
|
<div className="flex-shrink-0 flex flex-col" style={{ width: 64 }}>
|
||||||
|
{/* 시간축 높이 맞춤 빈칸 */}
|
||||||
|
<div className="h-5 mb-3" />
|
||||||
|
{SIGNAL_SOURCES.map(src => {
|
||||||
|
const c = SOURCE_COLORS[src];
|
||||||
|
const st = stats.find(s => s.src === src)!;
|
||||||
|
return (
|
||||||
|
<div key={src} className="flex flex-col justify-center mb-4" style={{ height: 20 }}>
|
||||||
|
<span className="text-[12px] font-semibold leading-tight" style={{ color: c }}>{src}</span>
|
||||||
|
<span className="text-[10px] font-mono text-text-4 mt-0.5">{st.rate}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 시간축 + 타임라인 바 */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
{/* 시간 축 (상단) */}
|
||||||
|
<div className="relative h-5 mb-3">
|
||||||
|
{HOURS.map(h => (
|
||||||
|
<span
|
||||||
|
key={h}
|
||||||
|
className="absolute text-[10px] text-text-3 font-mono"
|
||||||
|
style={{ left: `${(h / 24) * 100}%`, transform: 'translateX(-50%)' }}
|
||||||
|
>
|
||||||
|
{String(h).padStart(2, '0')}시
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className="absolute text-[10px] text-text-3 font-mono"
|
||||||
|
style={{ right: 0 }}
|
||||||
|
>
|
||||||
|
24시
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스별 타임라인 바 */}
|
||||||
|
{SIGNAL_SOURCES.map(src => (
|
||||||
|
<div key={src} className="mb-4 flex items-center" style={{ height: 20 }}>
|
||||||
|
<TimelineBar slots={slots} source={src} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
||||||
ADMIN: 'var(--red)',
|
ADMIN: 'var(--red)',
|
||||||
|
HQ_CLEANUP: '#34d399',
|
||||||
MANAGER: 'var(--orange)',
|
MANAGER: 'var(--orange)',
|
||||||
USER: 'var(--cyan)',
|
USER: 'var(--cyan)',
|
||||||
VIEWER: 'var(--t3)',
|
VIEWER: 'var(--t3)',
|
||||||
|
|||||||
94
frontend/src/tabs/admin/components/adminMenuConfig.ts
Normal file
94
frontend/src/tabs/admin/components/adminMenuConfig.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
/** 관리자 화면 9-섹션 메뉴 트리 */
|
||||||
|
|
||||||
|
export interface AdminMenuItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
children?: AdminMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_MENU: AdminMenuItem[] = [
|
||||||
|
{
|
||||||
|
id: 'env-settings', label: '환경설정', icon: '⚙️',
|
||||||
|
children: [
|
||||||
|
{ id: 'menus', label: '메뉴관리' },
|
||||||
|
{ id: 'settings', label: '시스템설정' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-info', label: '사용자정보', icon: '👥',
|
||||||
|
children: [
|
||||||
|
{ id: 'users', label: '사용자관리' },
|
||||||
|
{ id: 'permissions', label: '권한관리' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'board-mgmt', label: '게시판관리', icon: '📋',
|
||||||
|
children: [
|
||||||
|
{ id: 'notice', label: '공지사항' },
|
||||||
|
{ id: 'board', label: '게시판' },
|
||||||
|
{ id: 'qna', label: 'QNA' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reference', label: '기준정보', icon: '🗺️',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'map-mgmt', label: '지도관리',
|
||||||
|
children: [
|
||||||
|
{ id: 'map-vector', label: '지도벡데이터' },
|
||||||
|
{ id: 'map-layer', label: '레이어' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sensitive-map', label: '민감자원지도',
|
||||||
|
children: [
|
||||||
|
{ id: 'env-ecology', label: '환경/생태' },
|
||||||
|
{ id: 'social-economy', label: '사회/경제' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coast-guard-assets', label: '해경자산',
|
||||||
|
children: [
|
||||||
|
{ id: 'cleanup-equip', label: '방제장비' },
|
||||||
|
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
|
||||||
|
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
||||||
|
{ id: 'cleanup-resource', label: '방제자원' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'external', label: '연계관리', icon: '🔗',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'collection', label: '수집자료',
|
||||||
|
children: [
|
||||||
|
{ id: 'collect-vessel-signal', label: '선박신호' },
|
||||||
|
{ id: 'collect-hr', label: '인사정보' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monitoring', label: '연계모니터링',
|
||||||
|
children: [
|
||||||
|
{ id: 'monitor-realtime', label: '실시간 관측자료' },
|
||||||
|
{ id: 'monitor-forecast', label: '수치예측자료' },
|
||||||
|
{ id: 'monitor-vessel', label: '선박위치정보' },
|
||||||
|
{ id: 'monitor-hr', label: '인사' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 메뉴 ID로 라벨을 찾는 유틸리티 */
|
||||||
|
export function findMenuLabel(id: string, items: AdminMenuItem[] = ADMIN_MENU): string | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === id) return item.label;
|
||||||
|
if (item.children) {
|
||||||
|
const found = findMenuLabel(id, item.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
import { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
import { detectStreamType } from '../utils/streamUtils';
|
import { detectStreamType } from '../utils/streamUtils';
|
||||||
|
import { useOilDetection } from '../hooks/useOilDetection';
|
||||||
|
import OilDetectionOverlay from './OilDetectionOverlay';
|
||||||
|
|
||||||
interface CCTVPlayerProps {
|
interface CCTVPlayerProps {
|
||||||
cameraNm: string;
|
cameraNm: string;
|
||||||
@ -9,6 +11,13 @@ interface CCTVPlayerProps {
|
|||||||
coordDc?: string | null;
|
coordDc?: string | null;
|
||||||
sourceNm?: string | null;
|
sourceNm?: string | null;
|
||||||
cellIndex?: number;
|
cellIndex?: number;
|
||||||
|
oilDetectionEnabled?: boolean;
|
||||||
|
vesselDetectionEnabled?: boolean;
|
||||||
|
intrusionDetectionEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCTVPlayerHandle {
|
||||||
|
capture: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url';
|
type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url';
|
||||||
@ -21,15 +30,19 @@ function toProxyUrl(url: string): string {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CCTVPlayer({
|
export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
|
||||||
cameraNm,
|
cameraNm,
|
||||||
streamUrl,
|
streamUrl,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
coordDc,
|
coordDc,
|
||||||
sourceNm,
|
sourceNm,
|
||||||
cellIndex = 0,
|
cellIndex = 0,
|
||||||
}: CCTVPlayerProps) {
|
oilDetectionEnabled = false,
|
||||||
|
vesselDetectionEnabled = false,
|
||||||
|
intrusionDetectionEnabled = false,
|
||||||
|
}, ref) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
|
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
|
||||||
const [retryKey, setRetryKey] = useState(0);
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
@ -56,6 +69,73 @@ export function CCTVPlayer({
|
|||||||
? 'playing'
|
? 'playing'
|
||||||
: hlsPlayerState;
|
: 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(() => {
|
const destroyHls = useCallback(() => {
|
||||||
if (hlsRef.current) {
|
if (hlsRef.current) {
|
||||||
hlsRef.current.destroy();
|
hlsRef.current.destroy();
|
||||||
@ -185,7 +265,7 @@ export function CCTVPlayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div ref={containerRef} className="absolute inset-0">
|
||||||
{/* 로딩 오버레이 */}
|
{/* 로딩 오버레이 */}
|
||||||
{playerState === 'loading' && (
|
{playerState === 'loading' && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
|
<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 */}
|
{/* MJPEG */}
|
||||||
{streamType === 'mjpeg' && proxiedUrl && (
|
{streamType === 'mjpeg' && proxiedUrl && (
|
||||||
<img
|
<img
|
||||||
src={proxiedUrl}
|
src={proxiedUrl}
|
||||||
alt={cameraNm}
|
alt={cameraNm}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
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}
|
title={cameraNm}
|
||||||
className="absolute inset-0 w-full h-full border-none"
|
className="absolute inset-0 w-full h-full border-none"
|
||||||
allow="autoplay; encrypted-media"
|
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 오버레이 */}
|
{/* OSD 오버레이 */}
|
||||||
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
|
<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">
|
<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">
|
<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}` : ''}
|
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
|
||||||
</div>
|
</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 { fetchCctvCameras } from '../services/aerialApi'
|
||||||
import type { CctvCameraItem } from '../services/aerialApi'
|
import type { CctvCameraItem } from '../services/aerialApi'
|
||||||
import { CCTVPlayer } from './CCTVPlayer'
|
import { CCTVPlayer } from './CCTVPlayer'
|
||||||
|
import type { CCTVPlayerHandle } from './CCTVPlayer'
|
||||||
|
|
||||||
/** KHOA HLS 스트림 베이스 URL */
|
/** KHOA HLS 스트림 베이스 URL */
|
||||||
const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa';
|
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 [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null)
|
||||||
const [gridMode, setGridMode] = useState(1)
|
const [gridMode, setGridMode] = useState(1)
|
||||||
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
|
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 () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -226,7 +231,45 @@ export function CctvView() {
|
|||||||
>{g.icon}</button>
|
>{g.icon}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</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)' }}>
|
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
||||||
{cam ? (
|
{cam ? (
|
||||||
<CCTVPlayer
|
<CCTVPlayer
|
||||||
|
ref={el => { playerRefs.current[i] = el }}
|
||||||
cameraNm={cam.cameraNm}
|
cameraNm={cam.cameraNm}
|
||||||
streamUrl={cam.streamUrl}
|
streamUrl={cam.streamUrl}
|
||||||
sttsCd={cam.sttsCd}
|
sttsCd={cam.sttsCd}
|
||||||
coordDc={cam.coordDc}
|
coordDc={cam.coordDc}
|
||||||
sourceNm={cam.sourceNm}
|
sourceNm={cam.sourceNm}
|
||||||
cellIndex={i}
|
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>
|
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
||||||
|
|||||||
161
frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx
Normal file
161
frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { useRef, useEffect, memo } from 'react';
|
||||||
|
import type { OilDetectionResult } from '../utils/oilDetection';
|
||||||
|
import { OIL_CLASSES, OIL_CLASS_NAMES } from '../utils/oilDetection';
|
||||||
|
|
||||||
|
export interface OilDetectionOverlayProps {
|
||||||
|
result: OilDetectionResult | null;
|
||||||
|
isAnalyzing?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 클래스 ID → RGBA 색상 (오버레이용) */
|
||||||
|
const CLASS_COLORS: Record<number, [number, number, number, number]> = {
|
||||||
|
1: [0, 0, 204, 90], // black oil → 파란색
|
||||||
|
2: [180, 180, 180, 90], // brown oil → 회색
|
||||||
|
3: [255, 255, 0, 90], // rainbow oil → 노란색
|
||||||
|
4: [178, 102, 255, 90], // silver oil → 보라색
|
||||||
|
};
|
||||||
|
|
||||||
|
const OilDetectionOverlay = memo(({ result, isAnalyzing = false, error = null }: OilDetectionOverlayProps) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const displayW = canvas.clientWidth;
|
||||||
|
const displayH = canvas.clientHeight;
|
||||||
|
|
||||||
|
canvas.width = displayW * dpr;
|
||||||
|
canvas.height = displayH * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, displayW, displayH);
|
||||||
|
|
||||||
|
if (!result || result.regions.length === 0) return;
|
||||||
|
|
||||||
|
const { mask, maskWidth, maskHeight } = result;
|
||||||
|
|
||||||
|
// 클래스별 색상으로 마스크 렌더링
|
||||||
|
const offscreen = new OffscreenCanvas(maskWidth, maskHeight);
|
||||||
|
const offCtx = offscreen.getContext('2d');
|
||||||
|
if (offCtx) {
|
||||||
|
const imageData = new ImageData(maskWidth, maskHeight);
|
||||||
|
for (let i = 0; i < mask.length; i++) {
|
||||||
|
const classId = mask[i];
|
||||||
|
if (classId === 0) continue; // background skip
|
||||||
|
|
||||||
|
const color = CLASS_COLORS[classId];
|
||||||
|
if (!color) continue;
|
||||||
|
|
||||||
|
const pixelIdx = i * 4;
|
||||||
|
imageData.data[pixelIdx] = color[0];
|
||||||
|
imageData.data[pixelIdx + 1] = color[1];
|
||||||
|
imageData.data[pixelIdx + 2] = color[2];
|
||||||
|
imageData.data[pixelIdx + 3] = color[3];
|
||||||
|
}
|
||||||
|
offCtx.putImageData(imageData, 0, 0);
|
||||||
|
ctx.drawImage(offscreen, 0, 0, displayW, displayH);
|
||||||
|
}
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
const formatArea = (m2: number): string => {
|
||||||
|
if (m2 >= 1000) {
|
||||||
|
return `${(m2 / 1_000_000).toFixed(1)} km²`;
|
||||||
|
}
|
||||||
|
return `~${Math.round(m2)} m²`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRegions = result !== null && result.regions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className='absolute inset-0 w-full h-full pointer-events-none z-[15]'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* OSD — bottom-8로 좌표 OSD(bottom-2)와 겹침 방지 */}
|
||||||
|
<div className='absolute bottom-8 left-2 z-20 flex flex-col items-start gap-1'>
|
||||||
|
{/* 에러 표시 */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239,68,68,0.2)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.5)',
|
||||||
|
color: '#f87171',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
추론 서버 연결 불가
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 클래스별 감지 결과 */}
|
||||||
|
{hasRegions && result !== null && (
|
||||||
|
<>
|
||||||
|
{result.regions.map((region) => {
|
||||||
|
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
|
||||||
|
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : '#f87171';
|
||||||
|
const label = OIL_CLASS_NAMES[region.classId] || region.className;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={region.classId}
|
||||||
|
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||||
|
style={{
|
||||||
|
background: `${color}33`,
|
||||||
|
border: `1px solid ${color}80`,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}: {formatArea(region.areaM2)} ({region.percentage.toFixed(1)}%)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* 합계 */}
|
||||||
|
<div
|
||||||
|
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239,68,68,0.2)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.5)',
|
||||||
|
color: '#f87171',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
합계: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 감지 없음 */}
|
||||||
|
{!hasRegions && !isAnalyzing && !error && (
|
||||||
|
<div
|
||||||
|
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||||
|
style={{
|
||||||
|
background: 'rgba(34,197,94,0.15)',
|
||||||
|
border: '1px solid rgba(34,197,94,0.35)',
|
||||||
|
color: '#4ade80',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
감지 없음
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 분석 중 */}
|
||||||
|
{isAnalyzing && (
|
||||||
|
<span className='text-[9px] font-korean text-text-3 animate-pulse px-1'>
|
||||||
|
분석중...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OilDetectionOverlay.displayName = 'OilDetectionOverlay';
|
||||||
|
|
||||||
|
export default OilDetectionOverlay;
|
||||||
84
frontend/src/tabs/aerial/hooks/useOilDetection.ts
Normal file
84
frontend/src/tabs/aerial/hooks/useOilDetection.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import type { OilDetectionResult, OilDetectionConfig } from '../utils/oilDetection';
|
||||||
|
import { detectOilSpillAPI, DEFAULT_OIL_DETECTION_CONFIG } from '../utils/oilDetection';
|
||||||
|
|
||||||
|
interface UseOilDetectionOptions {
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||||
|
enabled: boolean;
|
||||||
|
config?: Partial<OilDetectionConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseOilDetectionReturn {
|
||||||
|
result: OilDetectionResult | null;
|
||||||
|
isAnalyzing: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOilDetection(options: UseOilDetectionOptions): UseOilDetectionReturn {
|
||||||
|
const { videoRef, enabled, config } = options;
|
||||||
|
|
||||||
|
const [result, setResult] = useState<OilDetectionResult | null>(null);
|
||||||
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const configRef = useRef<OilDetectionConfig>({
|
||||||
|
...DEFAULT_OIL_DETECTION_CONFIG,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
const isBusyRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configRef.current = {
|
||||||
|
...DEFAULT_OIL_DETECTION_CONFIG,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const analyze = useCallback(async () => {
|
||||||
|
if (isBusyRef.current) return; // 이전 호출이 진행 중이면 스킵
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || video.readyState < 2) return;
|
||||||
|
|
||||||
|
isBusyRef.current = true;
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detection = await detectOilSpillAPI(video, configRef.current);
|
||||||
|
setResult(detection);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
// API 실패 시 이전 결과 유지, 에러 메시지만 갱신
|
||||||
|
const message = err instanceof Error ? err.message : '추론 서버 연결 불가';
|
||||||
|
setError(message);
|
||||||
|
console.warn('[OilDetection] API 호출 실패:', message);
|
||||||
|
} finally {
|
||||||
|
isBusyRef.current = false;
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
}
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setResult(null);
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
setError(null);
|
||||||
|
isBusyRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
|
||||||
|
// 첫 분석: 2초 후 (영상 로딩 대기)
|
||||||
|
const firstTimeout = setTimeout(analyze, 2000);
|
||||||
|
// 반복 분석
|
||||||
|
const intervalId = setInterval(analyze, configRef.current.captureIntervalMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(firstTimeout);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [enabled, analyze]);
|
||||||
|
|
||||||
|
return { result, isAnalyzing, error };
|
||||||
|
}
|
||||||
165
frontend/src/tabs/aerial/utils/oilDetection.ts
Normal file
165
frontend/src/tabs/aerial/utils/oilDetection.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* 오일 유출 감지 — GPU 추론 서버 API 연동
|
||||||
|
*
|
||||||
|
* 시립대(starsafire) ResNet101+DANet 모델 기반
|
||||||
|
* 프레임 캡처 → base64 JPEG → POST /api/aerial/oil-detect → 세그멘테이션 결과
|
||||||
|
*
|
||||||
|
* 5개 클래스: background(0), black(1), brown(2), rainbow(3), silver(4)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface OilDetectionConfig {
|
||||||
|
captureIntervalMs: number; // API 호출 주기 (ms), default 5000
|
||||||
|
coverageAreaM2: number; // 카메라 커버리지 면적 (m²), default 10000
|
||||||
|
captureWidth: number; // 캡처 해상도 (너비), default 512
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 유류 클래스 정의 */
|
||||||
|
export interface OilClass {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
color: [number, number, number]; // RGB
|
||||||
|
thicknessMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 개별 유류 영역 (API 응답에서 변환) */
|
||||||
|
export interface OilRegion {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
pixelCount: number;
|
||||||
|
percentage: number;
|
||||||
|
areaM2: number;
|
||||||
|
thicknessMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 감지 결과 (오버레이에서 사용) */
|
||||||
|
export interface OilDetectionResult {
|
||||||
|
regions: OilRegion[];
|
||||||
|
totalPercentage: number;
|
||||||
|
totalAreaM2: number;
|
||||||
|
mask: Uint8Array; // 클래스 인덱스 (0-4)
|
||||||
|
maskWidth: number;
|
||||||
|
maskHeight: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DEFAULT_OIL_DETECTION_CONFIG: OilDetectionConfig = {
|
||||||
|
captureIntervalMs: 5000,
|
||||||
|
coverageAreaM2: 10000,
|
||||||
|
captureWidth: 512,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 유류 클래스 팔레트 (시립대 starsafire 기준) */
|
||||||
|
export const OIL_CLASSES: OilClass[] = [
|
||||||
|
{ classId: 1, className: 'black', color: [0, 0, 204], thicknessMm: 1.0 },
|
||||||
|
{ classId: 2, className: 'brown', color: [180, 180, 180], thicknessMm: 0.1 },
|
||||||
|
{ classId: 3, className: 'rainbow', color: [255, 255, 0], thicknessMm: 0.0003 },
|
||||||
|
{ classId: 4, className: 'silver', color: [178, 102, 255], thicknessMm: 0.0001 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OIL_CLASS_NAMES: Record<number, string> = {
|
||||||
|
1: '에멀전(Black)',
|
||||||
|
2: '원유(Brown)',
|
||||||
|
3: '무지개막(Rainbow)',
|
||||||
|
4: '은색막(Silver)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Frame Capture ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 프레임을 캡처하여 base64 JPEG 문자열로 반환한다.
|
||||||
|
*/
|
||||||
|
export function captureFrameAsBase64(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
targetWidth: number,
|
||||||
|
): string | null {
|
||||||
|
if (video.readyState < 2 || video.videoWidth === 0) return null;
|
||||||
|
|
||||||
|
const aspect = video.videoHeight / video.videoWidth;
|
||||||
|
const w = targetWidth;
|
||||||
|
const h = Math.round(w * aspect);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(video, 0, 0, w, h);
|
||||||
|
// data:image/jpeg;base64,... → base64 부분만 추출
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
return dataUrl.split(',')[1] || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Inference ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ApiInferenceRegion {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
pixelCount: number;
|
||||||
|
percentage: number;
|
||||||
|
thicknessMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiInferenceResponse {
|
||||||
|
mask: string; // base64 uint8 array
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
regions: ApiInferenceRegion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPU 추론 서버에 프레임을 전송하고 오일 감지 결과를 반환한다.
|
||||||
|
*/
|
||||||
|
export async function detectOilSpillAPI(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
config: OilDetectionConfig,
|
||||||
|
): Promise<OilDetectionResult | null> {
|
||||||
|
const imageBase64 = captureFrameAsBase64(video, config.captureWidth);
|
||||||
|
if (!imageBase64) return null;
|
||||||
|
|
||||||
|
const response = await api.post<ApiInferenceResponse>('/aerial/oil-detect', {
|
||||||
|
image: imageBase64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mask: maskB64, width, height, regions: apiRegions } = response.data;
|
||||||
|
const totalPixels = width * height;
|
||||||
|
|
||||||
|
// base64 → Uint8Array
|
||||||
|
const binaryStr = atob(maskB64);
|
||||||
|
const mask = new Uint8Array(binaryStr.length);
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
mask[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 영역 → OilRegion 변환 (면적 계산 포함)
|
||||||
|
const regions: OilRegion[] = apiRegions.map((r) => ({
|
||||||
|
classId: r.classId,
|
||||||
|
className: r.className,
|
||||||
|
pixelCount: r.pixelCount,
|
||||||
|
percentage: r.percentage,
|
||||||
|
areaM2: (r.pixelCount / totalPixels) * config.coverageAreaM2,
|
||||||
|
thicknessMm: r.thicknessMm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalPercentage = regions.reduce((sum, r) => sum + r.percentage, 0);
|
||||||
|
const totalAreaM2 = regions.reduce((sum, r) => sum + r.areaM2, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
regions,
|
||||||
|
totalPercentage,
|
||||||
|
totalAreaM2,
|
||||||
|
mask,
|
||||||
|
maskWidth: width,
|
||||||
|
maskHeight: height,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -74,6 +74,11 @@ export async function deleteBoardPost(sn: number): Promise<void> {
|
|||||||
await api.delete(`/board/${sn}`);
|
await api.delete(`/board/${sn}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 관리자 전용 삭제 — 소유자 검증 없음 */
|
||||||
|
export async function adminDeleteBoardPost(sn: number): Promise<void> {
|
||||||
|
await api.post('/board/admin-delete', { sn });
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 매뉴얼 API
|
// 매뉴얼 API
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -36,8 +36,8 @@ export interface HNSInputParams {
|
|||||||
interface HNSLeftPanelProps {
|
interface HNSLeftPanelProps {
|
||||||
activeSubTab: 'analysis' | 'list';
|
activeSubTab: 'analysis' | 'list';
|
||||||
onSubTabChange: (tab: 'analysis' | 'list') => void;
|
onSubTabChange: (tab: 'analysis' | 'list') => void;
|
||||||
incidentCoord: { lon: number; lat: number };
|
incidentCoord: { lon: number; lat: number } | null;
|
||||||
onCoordChange: (coord: { lon: number; lat: number }) => void;
|
onCoordChange: (coord: { lon: number; lat: number } | null) => void;
|
||||||
onMapSelectClick: () => void;
|
onMapSelectClick: () => void;
|
||||||
onRunPrediction: () => void;
|
onRunPrediction: () => void;
|
||||||
isRunningPrediction: boolean;
|
isRunningPrediction: boolean;
|
||||||
@ -112,7 +112,7 @@ export function HNSLeftPanel({
|
|||||||
}, [loadedParams]);
|
}, [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);
|
const tox = getSubstanceToxicity(substance);
|
||||||
@ -272,15 +272,23 @@ export function HNSLeftPanel({
|
|||||||
className="prd-i flex-1 font-mono"
|
className="prd-i flex-1 font-mono"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.0001"
|
step="0.0001"
|
||||||
value={incidentCoord.lat.toFixed(4)}
|
value={incidentCoord?.lat.toFixed(4) ?? ''}
|
||||||
onChange={(e) => onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })}
|
placeholder="위도"
|
||||||
|
onChange={(e) => {
|
||||||
|
const lat = parseFloat(e.target.value) || 0;
|
||||||
|
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="prd-i flex-1 font-mono"
|
className="prd-i flex-1 font-mono"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.0001"
|
step="0.0001"
|
||||||
value={incidentCoord.lon.toFixed(4)}
|
value={incidentCoord?.lon.toFixed(4) ?? ''}
|
||||||
onChange={(e) => onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })}
|
placeholder="경도"
|
||||||
|
onChange={(e) => {
|
||||||
|
const lon = parseFloat(e.target.value) || 0;
|
||||||
|
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="prd-map-btn" onClick={onMapSelectClick}>
|
<button className="prd-map-btn" onClick={onMapSelectClick}>
|
||||||
📍 지도
|
📍 지도
|
||||||
@ -290,7 +298,7 @@ export function HNSLeftPanel({
|
|||||||
{/* DMS 표시 */}
|
{/* DMS 표시 */}
|
||||||
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
||||||
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 유출형태 + 물질명 */}
|
{/* 유출형태 + 물질명 */}
|
||||||
|
|||||||
@ -156,7 +156,7 @@ function DispersionTimeSlider({
|
|||||||
export function HNSView() {
|
export function HNSView() {
|
||||||
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
||||||
const { user } = useAuthStore();
|
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 [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -184,6 +184,7 @@ export function HNSView() {
|
|||||||
setCurrentFrame(0);
|
setCurrentFrame(0);
|
||||||
setIsPuffPlaying(false);
|
setIsPuffPlaying(false);
|
||||||
setInputParams(null);
|
setInputParams(null);
|
||||||
|
setIncidentCoord(null);
|
||||||
hasRunOnce.current = false;
|
hasRunOnce.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -320,6 +321,11 @@ export function HNSView() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const params = paramsOverride ?? inputParams;
|
const params = paramsOverride ?? inputParams;
|
||||||
|
if (!incidentCoord) {
|
||||||
|
alert('사고 지점을 먼저 지도에서 선택하세요.');
|
||||||
|
setIsRunningPrediction(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
|
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
|
||||||
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
|
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
|
||||||
@ -694,7 +700,7 @@ export function HNSView() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<MapView
|
<MapView
|
||||||
incidentCoord={incidentCoord}
|
incidentCoord={incidentCoord ?? undefined}
|
||||||
isSelectingLocation={isSelectingLocation}
|
isSelectingLocation={isSelectingLocation}
|
||||||
onMapClick={handleMapClick}
|
onMapClick={handleMapClick}
|
||||||
oilTrajectory={[]}
|
oilTrajectory={[]}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { Source, Layer } from '@vis.gl/react-maplibre'
|
import { useMap } from '@vis.gl/react-maplibre'
|
||||||
import type { OceanForecastData } from '../services/khoaApi'
|
import type { OceanForecastData } from '../services/khoaApi'
|
||||||
|
|
||||||
interface OceanForecastOverlayProps {
|
interface OceanForecastOverlayProps {
|
||||||
@ -8,62 +8,118 @@ interface OceanForecastOverlayProps {
|
|||||||
visible?: boolean
|
visible?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 한국 해역 범위 (MapLibre image source용 좌표 배열)
|
// 한국 해역 범위 [lon, lat]
|
||||||
// [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se]
|
const BOUNDS = {
|
||||||
// [lon, lat] 순서
|
nw: [124.5, 38.5] as [number, number],
|
||||||
const KOREA_IMAGE_COORDINATES: [[number, number], [number, number], [number, number], [number, number]] = [
|
ne: [132.0, 38.5] as [number, number],
|
||||||
[124.5, 33.0], // 남서 (제주 남쪽)
|
se: [132.0, 33.0] as [number, number],
|
||||||
[124.5, 38.5], // 북서
|
sw: [124.5, 33.0] as [number, number],
|
||||||
[132.0, 38.5], // 북동 (동해 북쪽)
|
}
|
||||||
[132.0, 33.0], // 남동
|
|
||||||
]
|
// www.khoa.go.kr 이미지는 CORS 미지원 → Vite 프록시 경유
|
||||||
|
function toProxyUrl(url: string): string {
|
||||||
|
return url.replace('https://www.khoa.go.kr', '')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OceanForecastOverlay
|
* OceanForecastOverlay
|
||||||
*
|
*
|
||||||
* 기존: react-leaflet ImageOverlay + LatLngBounds
|
* MapLibre raster layer는 deck.gl 캔버스보다 항상 아래 렌더링되므로,
|
||||||
* 전환: @vis.gl/react-maplibre Source(type=image) + Layer(type=raster)
|
* WindParticleLayer와 동일하게 canvas를 직접 map 컨테이너에 삽입하는 방식 사용.
|
||||||
*
|
* z-index 500으로 WindParticleLayer(450) 위에 렌더링.
|
||||||
* MapLibre image source는 Map 컴포넌트 자식으로 직접 렌더링 가능
|
|
||||||
*/
|
*/
|
||||||
export function OceanForecastOverlay({
|
export function OceanForecastOverlay({
|
||||||
forecast,
|
forecast,
|
||||||
opacity = 0.6,
|
opacity = 0.6,
|
||||||
visible = true,
|
visible = true,
|
||||||
}: OceanForecastOverlayProps) {
|
}: OceanForecastOverlayProps) {
|
||||||
const [loadedUrl, setLoadedUrl] = useState<string | null>(null)
|
const { current: mapRef } = useMap()
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!forecast?.filePath) return
|
const map = mapRef?.getMap()
|
||||||
let cancelled = false
|
if (!map) return
|
||||||
const img = new Image()
|
|
||||||
img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) }
|
|
||||||
img.onerror = () => { if (!cancelled) setLoadedUrl(null) }
|
|
||||||
img.src = forecast.filePath
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [forecast?.filePath])
|
|
||||||
|
|
||||||
const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath
|
const container = map.getContainer()
|
||||||
|
|
||||||
if (!visible || !forecast || !imageLoaded) {
|
// canvas 생성 (최초 1회)
|
||||||
return null
|
if (!canvasRef.current) {
|
||||||
}
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.style.position = 'absolute'
|
||||||
|
canvas.style.top = '0'
|
||||||
|
canvas.style.left = '0'
|
||||||
|
canvas.style.pointerEvents = 'none'
|
||||||
|
canvas.style.zIndex = '500' // WindParticleLayer(450) 위
|
||||||
|
container.appendChild(canvas)
|
||||||
|
canvasRef.current = canvas
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const canvas = canvasRef.current
|
||||||
<Source
|
|
||||||
id="ocean-forecast-image"
|
if (!visible || !forecast?.imgFilePath) {
|
||||||
type="image"
|
canvas.style.display = 'none'
|
||||||
url={forecast.filePath}
|
return
|
||||||
coordinates={KOREA_IMAGE_COORDINATES}
|
}
|
||||||
>
|
|
||||||
<Layer
|
canvas.style.display = 'block'
|
||||||
id="ocean-forecast-raster"
|
const proxyUrl = toProxyUrl(forecast.imgFilePath)
|
||||||
type="raster"
|
|
||||||
paint={{
|
function draw() {
|
||||||
'raster-opacity': opacity,
|
const img = imgRef.current
|
||||||
'raster-resampling': 'linear',
|
if (!canvas || !img || !img.complete || img.naturalWidth === 0) return
|
||||||
}}
|
|
||||||
/>
|
const { clientWidth: w, clientHeight: h } = container
|
||||||
</Source>
|
canvas.width = w
|
||||||
)
|
canvas.height = h
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
// 4개 꼭짓점을 픽셀 좌표로 변환
|
||||||
|
const nw = map!.project(BOUNDS.nw)
|
||||||
|
const ne = map!.project(BOUNDS.ne)
|
||||||
|
const sw = map!.project(BOUNDS.sw)
|
||||||
|
|
||||||
|
const x = Math.min(nw.x, sw.x)
|
||||||
|
const y = nw.y
|
||||||
|
const w2 = ne.x - nw.x
|
||||||
|
const h2 = sw.y - nw.y
|
||||||
|
|
||||||
|
ctx.globalAlpha = opacity
|
||||||
|
ctx.drawImage(img, x, y, w2, h2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지가 바뀌었으면 새로 로드
|
||||||
|
if (!imgRef.current || imgRef.current.dataset.src !== proxyUrl) {
|
||||||
|
const img = new Image()
|
||||||
|
img.dataset.src = proxyUrl
|
||||||
|
img.onload = draw
|
||||||
|
img.src = proxyUrl
|
||||||
|
imgRef.current = img
|
||||||
|
} else {
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('move', draw)
|
||||||
|
map.on('zoom', draw)
|
||||||
|
map.on('resize', draw)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('move', draw)
|
||||||
|
map.off('zoom', draw)
|
||||||
|
map.off('resize', draw)
|
||||||
|
}
|
||||||
|
}, [mapRef, visible, forecast?.imgFilePath, opacity])
|
||||||
|
|
||||||
|
// 언마운트 시 canvas 제거
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
canvasRef.current?.remove()
|
||||||
|
canvasRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
48
frontend/src/tabs/weather/components/WeatherMapControls.tsx
Normal file
48
frontend/src/tabs/weather/components/WeatherMapControls.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useMap } from '@vis.gl/react-maplibre'
|
||||||
|
|
||||||
|
interface WeatherMapControlsProps {
|
||||||
|
center: [number, number]
|
||||||
|
zoom: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) {
|
||||||
|
const { current: map } = useMap()
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{
|
||||||
|
label: '+',
|
||||||
|
tooltip: '확대',
|
||||||
|
onClick: () => map?.zoomIn(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '−',
|
||||||
|
tooltip: '축소',
|
||||||
|
onClick: () => map?.zoomOut(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '🎯',
|
||||||
|
tooltip: '한국 해역 초기화',
|
||||||
|
onClick: () => map?.flyTo({ center, zoom, duration: 1000 }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{buttons.map(({ label, tooltip, onClick }) => (
|
||||||
|
<div key={tooltip} className="relative group">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-2 px-2 py-1 text-xs bg-bg-0 text-text-1 border border-border rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-20">
|
||||||
|
{tooltip}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -77,8 +77,6 @@ export function WeatherMapOverlay({
|
|||||||
stations.map((station) => {
|
stations.map((station) => {
|
||||||
const isSelected = selectedStationId === station.id
|
const isSelected = selectedStationId === station.id
|
||||||
const color = getWindHexColor(station.wind.speed, isSelected)
|
const color = getWindHexColor(station.wind.speed, isSelected)
|
||||||
const size = Math.min(40 + station.wind.speed * 2, 80)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
key={`wind-${station.id}`}
|
key={`wind-${station.id}`}
|
||||||
@ -87,35 +85,34 @@ export function WeatherMapOverlay({
|
|||||||
anchor="center"
|
anchor="center"
|
||||||
onClick={() => onStationClick(station)}
|
onClick={() => onStationClick(station)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center gap-1 cursor-pointer">
|
||||||
style={{
|
<div style={{ transform: `rotate(${station.wind.direction}deg)` }}>
|
||||||
width: size,
|
<svg
|
||||||
height: size,
|
width={24}
|
||||||
transform: `rotate(${station.wind.direction}deg)`,
|
height={24}
|
||||||
}}
|
viewBox="0 0 24 24"
|
||||||
className="flex items-center justify-center cursor-pointer"
|
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
|
||||||
>
|
>
|
||||||
<svg
|
{/* 위쪽이 바람 방향을 나타내는 삼각형 */}
|
||||||
width={size}
|
<polygon
|
||||||
height={size}
|
points="12,2 4,22 12,16 20,22"
|
||||||
viewBox="0 0 24 24"
|
fill={color}
|
||||||
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
|
||||||
|
className="text-xs font-bold leading-none"
|
||||||
>
|
>
|
||||||
<path
|
{station.wind.speed.toFixed(1)}
|
||||||
d="M12 2L12 20M12 2L8 6M12 2L16 6M12 20L8 16M12 20L16 16"
|
</span>
|
||||||
stroke={color}
|
|
||||||
strokeWidth="2.5"
|
|
||||||
fill="none"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="12" r="3" fill={color} opacity="0.8" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* 기상 데이터 라벨 — MapLibre Marker */}
|
{/* 기상 데이터 라벨 — 임시 비활성화
|
||||||
{enabledLayers.has('labels') &&
|
{enabledLayers.has('labels') &&
|
||||||
stations.map((station) => {
|
stations.map((station) => {
|
||||||
const isSelected = selectedStationId === station.id
|
const isSelected = selectedStationId === station.id
|
||||||
@ -139,7 +136,6 @@ export function WeatherMapOverlay({
|
|||||||
}}
|
}}
|
||||||
className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer"
|
className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer"
|
||||||
>
|
>
|
||||||
{/* 관측소명 */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: textColor,
|
color: textColor,
|
||||||
@ -150,8 +146,6 @@ export function WeatherMapOverlay({
|
|||||||
>
|
>
|
||||||
{station.name}
|
{station.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 수온 */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||||
@ -160,22 +154,12 @@ export function WeatherMapOverlay({
|
|||||||
🌡️
|
🌡️
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span
|
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||||
className="text-sm font-bold text-white"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
{station.temperature.current.toFixed(1)}
|
{station.temperature.current.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
|
||||||
className="text-[10px] text-white opacity-90"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
°C
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 파고 */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||||
@ -184,22 +168,12 @@ export function WeatherMapOverlay({
|
|||||||
🌊
|
🌊
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span
|
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||||
className="text-sm font-bold text-white"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
{station.wave.height.toFixed(1)}
|
{station.wave.height.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
|
||||||
className="text-[10px] text-white opacity-90"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
m
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 풍속 */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||||
@ -208,24 +182,17 @@ export function WeatherMapOverlay({
|
|||||||
💨
|
💨
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span
|
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||||
className="text-sm font-bold text-white"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
{station.wind.speed.toFixed(1)}
|
{station.wind.speed.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
|
||||||
className="text-[10px] text-white opacity-90"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
m/s
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
*/}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react'
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import type { Layer } from '@deck.gl/core'
|
import type { Layer } from '@deck.gl/core'
|
||||||
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
||||||
@ -12,6 +12,7 @@ import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
|||||||
import { WindParticleLayer } from './WindParticleLayer'
|
import { WindParticleLayer } from './WindParticleLayer'
|
||||||
import { useWeatherData } from '../hooks/useWeatherData'
|
import { useWeatherData } from '../hooks/useWeatherData'
|
||||||
import { useOceanForecast } from '../hooks/useOceanForecast'
|
import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||||
|
import { WeatherMapControls } from './WeatherMapControls'
|
||||||
|
|
||||||
type TimeOffset = '0' | '3' | '6' | '9'
|
type TimeOffset = '0' | '3' | '6' | '9'
|
||||||
|
|
||||||
@ -117,38 +118,6 @@ function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 줌 컨트롤
|
|
||||||
function WeatherMapControls() {
|
|
||||||
const { current: map } = useMap()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute top-4 right-4 z-10">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => map?.zoomIn()}
|
|
||||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => map?.zoomOut()}
|
|
||||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
map?.flyTo({ center: WEATHER_MAP_CENTER, zoom: WEATHER_MAP_ZOOM, duration: 1000 })
|
|
||||||
}
|
|
||||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
|
|
||||||
>
|
|
||||||
🎯
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
||||||
*/
|
*/
|
||||||
@ -159,6 +128,9 @@ interface WeatherMapInnerProps {
|
|||||||
oceanForecastOpacity: number
|
oceanForecastOpacity: number
|
||||||
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
|
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
|
||||||
onStationClick: (station: WeatherStation) => void
|
onStationClick: (station: WeatherStation) => void
|
||||||
|
mapCenter: [number, number]
|
||||||
|
mapZoom: number
|
||||||
|
clickedLocation: { lat: number; lon: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function WeatherMapInner({
|
function WeatherMapInner({
|
||||||
@ -168,6 +140,9 @@ function WeatherMapInner({
|
|||||||
oceanForecastOpacity,
|
oceanForecastOpacity,
|
||||||
selectedForecast,
|
selectedForecast,
|
||||||
onStationClick,
|
onStationClick,
|
||||||
|
mapCenter,
|
||||||
|
mapZoom,
|
||||||
|
clickedLocation,
|
||||||
}: WeatherMapInnerProps) {
|
}: WeatherMapInnerProps) {
|
||||||
// deck.gl layers 조합
|
// deck.gl layers 조합
|
||||||
const weatherDeckLayers = useWeatherDeckLayers(
|
const weatherDeckLayers = useWeatherDeckLayers(
|
||||||
@ -216,8 +191,31 @@ function WeatherMapInner({
|
|||||||
stations={weatherStations}
|
stations={weatherStations}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 클릭 위치 마커 */}
|
||||||
|
{clickedLocation && (
|
||||||
|
<Marker
|
||||||
|
longitude={clickedLocation.lon}
|
||||||
|
latitude={clickedLocation.lat}
|
||||||
|
anchor="bottom"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center pointer-events-none">
|
||||||
|
{/* 펄스 링 */}
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<div className="absolute w-8 h-8 rounded-full border-2 border-primary-cyan animate-ping opacity-60" />
|
||||||
|
<div className="w-4 h-4 rounded-full bg-primary-cyan border-2 border-white shadow-lg" />
|
||||||
|
</div>
|
||||||
|
{/* 핀 꼬리 */}
|
||||||
|
<div className="w-px h-3 bg-primary-cyan" />
|
||||||
|
{/* 좌표 라벨 */}
|
||||||
|
<div className="px-2 py-1 bg-bg-0/90 border border-primary-cyan rounded text-[10px] text-primary-cyan whitespace-nowrap backdrop-blur-sm">
|
||||||
|
{clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 줌 컨트롤 */}
|
{/* 줌 컨트롤 */}
|
||||||
<WeatherMapControls />
|
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -225,6 +223,7 @@ function WeatherMapInner({
|
|||||||
export function WeatherView() {
|
export function WeatherView() {
|
||||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedForecast,
|
selectedForecast,
|
||||||
availableTimes,
|
availableTimes,
|
||||||
@ -238,7 +237,7 @@ export function WeatherView() {
|
|||||||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind', 'labels']))
|
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
|
||||||
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||||
|
|
||||||
// 첫 관측소 자동 선택 (파생 값)
|
// 첫 관측소 자동 선택 (파생 값)
|
||||||
@ -343,12 +342,6 @@ export function WeatherView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className="px-6">
|
|
||||||
<button className="px-4 py-2 text-xs font-semibold rounded bg-status-red text-white hover:opacity-90 transition-opacity">
|
|
||||||
🚨 예보전송
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
@ -371,6 +364,9 @@ export function WeatherView() {
|
|||||||
oceanForecastOpacity={oceanForecastOpacity}
|
oceanForecastOpacity={oceanForecastOpacity}
|
||||||
selectedForecast={selectedForecast}
|
selectedForecast={selectedForecast}
|
||||||
onStationClick={handleStationClick}
|
onStationClick={handleStationClick}
|
||||||
|
mapCenter={WEATHER_MAP_CENTER}
|
||||||
|
mapZoom={WEATHER_MAP_ZOOM}
|
||||||
|
clickedLocation={selectedLocation}
|
||||||
/>
|
/>
|
||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
@ -396,6 +392,7 @@ export function WeatherView() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-text-2">🌬️ 바람 벡터</span>
|
<span className="text-xs text-text-2">🌬️ 바람 벡터</span>
|
||||||
</label>
|
</label>
|
||||||
|
{/* 기상 데이터 레이어 — 임시 비활성화
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -405,6 +402,7 @@ export function WeatherView() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-text-2">📊 기상 데이터</span>
|
<span className="text-xs text-text-2">📊 기상 데이터</span>
|
||||||
</label>
|
</label>
|
||||||
|
*/}
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -482,8 +480,8 @@ export function WeatherView() {
|
|||||||
key={`${time.day}-${time.hour}`}
|
key={`${time.day}-${time.hour}`}
|
||||||
onClick={() => selectForecast(time.day, time.hour)}
|
onClick={() => selectForecast(time.day, time.hour)}
|
||||||
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
|
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
|
||||||
selectedForecast?.day === time.day &&
|
selectedForecast?.ofcFrcstYmd === time.day &&
|
||||||
selectedForecast?.hour === time.hour
|
selectedForecast?.ofcFrcstTm === time.hour
|
||||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
|
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
|
||||||
}`}
|
}`}
|
||||||
@ -499,9 +497,9 @@ export function WeatherView() {
|
|||||||
{oceanError && <div className="text-xs text-status-red">오류 발생</div>}
|
{oceanError && <div className="text-xs text-status-red">오류 발생</div>}
|
||||||
{selectedForecast && (
|
{selectedForecast && (
|
||||||
<div className="text-xs text-text-3 pt-2 border-t border-border">
|
<div className="text-xs text-text-3 pt-2 border-t border-border">
|
||||||
현재: {selectedForecast.name} •{' '}
|
현재: {selectedForecast.ofcBrnchNm} •{' '}
|
||||||
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '}
|
{selectedForecast.ofcFrcstYmd.slice(4, 6)}/{selectedForecast.ofcFrcstYmd.slice(6, 8)}{' '}
|
||||||
{selectedForecast.hour}:00
|
{selectedForecast.ofcFrcstTm}:00
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -70,9 +70,9 @@ export function useOceanForecast(
|
|||||||
// 사용 가능한 시간대 목록 생성
|
// 사용 가능한 시간대 목록 생성
|
||||||
const availableTimes = forecasts
|
const availableTimes = forecasts
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
day: f.day,
|
day: f.ofcFrcstYmd,
|
||||||
hour: f.hour,
|
hour: f.ofcFrcstTm,
|
||||||
label: `${f.day.slice(4, 6)}/${f.day.slice(6, 8)} ${f.hour}:00`
|
label: `${f.ofcFrcstYmd.slice(4, 6)}/${f.ofcFrcstYmd.slice(6, 8)} ${f.ofcFrcstTm}:00`
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`))
|
.sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`))
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,7 @@ export function useWeatherData(stations: WeatherStation[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const obs = await getRecentObservation(obsCode)
|
const obs = await getRecentObservation(obsCode)
|
||||||
|
|
||||||
|
|
||||||
if (obs) {
|
if (obs) {
|
||||||
const r = (n: number) => Math.round(n * 10) / 10
|
const r = (n: number) => Math.round(n * 10) / 10
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
|
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
|
||||||
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || ''
|
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || ''
|
||||||
const BASE_URL = 'https://www.khoa.go.kr/api/oceangrid/DataType/search.do'
|
const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService'
|
||||||
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService'
|
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService'
|
||||||
|
|
||||||
// 지역 유형 (총 20개 지역)
|
// 지역 유형 (총 20개 지역)
|
||||||
@ -22,20 +22,21 @@ export const OCEAN_REGIONS = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface OceanForecastData {
|
export interface OceanForecastData {
|
||||||
name: string // 지역명
|
imgFileNm: string // 이미지 파일명
|
||||||
type: string // 지역유형
|
imgFilePath: string // 이미지 파일경로
|
||||||
day: string // 예보날짜 (YYYYMMDD)
|
ofcBrnchId: string // 해황예보도 지점코드
|
||||||
hour: string // 예보시간 (HH)
|
ofcBrnchNm: string // 해황예보도 지점이름
|
||||||
fileName: string // 이미지 파일명
|
ofcFrcstTm: string // 해황예보도 예보시각
|
||||||
filePath: string // 해양예측 이미지 URL
|
ofcFrcstYmd: string // 해황예보도 예보일자
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OceanForecastResponse {
|
interface OceanForecastApiResponse {
|
||||||
result: {
|
header: { resultCode: string; resultMsg: string }
|
||||||
data: OceanForecastData[]
|
body: {
|
||||||
meta: {
|
items: { item: OceanForecastData[] }
|
||||||
totalCount: number
|
pageNo: number
|
||||||
}
|
numOfRows: number
|
||||||
|
totalCount: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,9 +50,9 @@ export async function getOceanForecast(
|
|||||||
): Promise<OceanForecastData[]> {
|
): Promise<OceanForecastData[]> {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
ServiceKey: API_KEY,
|
serviceKey: API_KEY,
|
||||||
type: regionType,
|
areaCode: regionType,
|
||||||
ResultType: 'json'
|
type: 'json',
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${BASE_URL}?${params}`)
|
const response = await fetch(`${BASE_URL}?${params}`)
|
||||||
@ -60,20 +61,8 @@ export async function getOceanForecast(
|
|||||||
throw new Error(`HTTP Error: ${response.status}`)
|
throw new Error(`HTTP Error: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json() as OceanForecastApiResponse
|
||||||
|
return data?.body?.items?.item ?? []
|
||||||
// API 응답 구조에 따라 데이터 추출
|
|
||||||
if (data?.result?.data) {
|
|
||||||
return data.result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// 응답이 배열 형태인 경우
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('Unexpected API response structure:', data)
|
|
||||||
return []
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('해황예보도 조회 오류:', error)
|
console.error('해황예보도 조회 오류:', error)
|
||||||
@ -89,10 +78,9 @@ export async function getOceanForecast(
|
|||||||
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
|
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
|
||||||
if (!forecasts || forecasts.length === 0) return null
|
if (!forecasts || forecasts.length === 0) return null
|
||||||
|
|
||||||
// 날짜와 시간을 기준으로 정렬
|
|
||||||
const sorted = [...forecasts].sort((a, b) => {
|
const sorted = [...forecasts].sort((a, b) => {
|
||||||
const dateTimeA = `${a.day}${a.hour}`
|
const dateTimeA = `${a.ofcFrcstYmd}${a.ofcFrcstTm}`
|
||||||
const dateTimeB = `${b.day}${b.hour}`
|
const dateTimeB = `${b.ofcFrcstYmd}${b.ofcFrcstTm}`
|
||||||
return dateTimeB.localeCompare(dateTimeA)
|
return dateTimeB.localeCompare(dateTimeA)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -112,7 +100,7 @@ export function getForecastByTime(
|
|||||||
targetHour: string
|
targetHour: string
|
||||||
): OceanForecastData | null {
|
): OceanForecastData | null {
|
||||||
return (
|
return (
|
||||||
forecasts.find((f) => f.day === targetDay && f.hour === targetHour) || null
|
forecasts.find((f) => f.ofcFrcstYmd === targetDay && f.ofcFrcstTm === targetHour) || null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,23 +145,25 @@ export async function getRecentObservation(obsCode: string): Promise<RecentObser
|
|||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
|
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const item = data?.result?.data?.[0]
|
const item = data.body ? data.body.items.item[0] : null
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
water_temp: item.water_temp != null ? parseFloat(item.water_temp) : null,
|
water_temp: item.wtem != null ? parseFloat(item.wtem) : null,
|
||||||
air_temp: item.air_temp != null ? parseFloat(item.air_temp) : null,
|
air_temp: item.artmp != null ? parseFloat(item.artmp) : null,
|
||||||
air_pres: item.air_pres != null ? parseFloat(item.air_pres) : null,
|
air_pres: item.atmpr != null ? parseFloat(item.atmpr) : null,
|
||||||
wind_dir: item.wind_dir != null ? parseFloat(item.wind_dir) : null,
|
wind_dir: item.wndrct != null ? parseFloat(item.wndrct) : null,
|
||||||
wind_speed: item.wind_speed != null ? parseFloat(item.wind_speed) : null,
|
wind_speed: item.wspd != null ? parseFloat(item.wspd) : null,
|
||||||
current_dir: item.current_dir != null ? parseFloat(item.current_dir) : null,
|
current_dir: item.crdir != null ? parseFloat(item.crdir) : null,
|
||||||
current_speed: item.current_speed != null ? parseFloat(item.current_speed) : null,
|
current_speed: item.crsp != null ? parseFloat(item.crsp) : null,
|
||||||
tide_level: item.tide_level != null ? parseFloat(item.tide_level) : null,
|
tide_level: item.bscTdlvHgt != null ? parseFloat(item.bscTdlvHgt) : null,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)
|
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)
|
||||||
|
|||||||
@ -12,6 +12,10 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/daily_ocean': {
|
||||||
|
target: 'https://www.khoa.go.kr',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user