Compare commits

..

10 커밋

작성자 SHA1 메시지 날짜
c87b5085f7 feat(weather): KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환 2026-03-11 10:42:27 +09:00
ea529cb510 Merge commit '5b734d3cf120515199148aad176289b530692a9c' into feature/add-weather-alarm 2026-03-10 14:53:36 +09:00
174f4eb9f2 feat(weather): 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선 2026-03-09 18:53:13 +09:00
Nan Kyung Lee
5b734d3cf1 feat(aerial): CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지) 2026-03-09 10:39:14 +09:00
Nan Kyung Lee
9574594151 fix(users): /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정
Express에서 /orgs가 /:id 뒤에 등록되어 'orgs'가 파라미터로 잡히던 버그 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:34:27 +09:00
Nan Kyung Lee
ce80e620c1 feat(admin): 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
- UsersPanel: 테이블+페이징+등록모달+상세모달(비밀번호초기화/잠금해제)
- PermissionsPanel: 사용자별 역할 할당 탭 추가
- BoardMgmtPanel: 공지사항/게시판/QNA 관리자 일괄 삭제
- VesselSignalPanel: VTS/VTS-AIS/V-PASS/E-NAVI/S&P AIS 타임라인 모니터링
- AdminSidebar/AdminPlaceholder/adminMenuConfig 신규
- 권한 미들웨어 부모 리소스 fallback 로직 추가
- 조직 목록 API, 관리자 삭제 API 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:30:55 +09:00
Nan Kyung Lee
476b6b99ac Merge remote-tracking branch 'origin/develop' into feature/cctv-hns-enhancements 2026-03-06 16:00:40 +09:00
Nan Kyung Lee
626fea4c75 feat(aerial): CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
CCTV 오일 유출 감지:
- GPU 추론 서버 FastAPI 서비스 (oil_inference_server.py)
- Express 프록시 엔드포인트 (POST /api/aerial/oil-detect)
- 프론트엔드 API 연동 (oilDetection.ts, useOilDetection.ts)
- 4종 유류 클래스별 색상 오버레이 (OilDetectionOverlay.tsx)
- 캡처 기능 (비디오+오버레이 합성 PNG 다운로드)
- Rate limit HLS 스트리밍 skip + 한도 500 상향

HNS 대기확산:
- 초기 핀 포인트 제거 (지도 클릭으로 선택)
- 좌표 미선택 시 안내 메시지 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:31:02 +09:00
Nan Kyung Lee
e65e722059 Merge remote-tracking branch 'origin/develop' into feature/cctv-hns-enhancements 2026-03-06 08:11:15 +09:00
Nan Kyung Lee
99c2c142be feat(assets): 유류오염보장계약 시드 데이터 추가 (1391건)
해양수산부 공공데이터 CSV → INSERT SQL 변환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:42:43 +09:00
42개의 변경된 파일5095개의 추가작업 그리고 737개의 파일을 삭제

파일 보기

@ -558,7 +558,6 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@ -1992,7 +1991,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.12.0",

파일 보기

@ -7,6 +7,8 @@ import {
createSatRequest,
updateSatRequestStatus,
isValidSatStatus,
requestOilInference,
checkInferenceHealth,
} from './aerialService.js';
import { isValidNumber } from '../middleware/security.js';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
@ -221,4 +223,44 @@ router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'C
}
});
// ============================================================
// OIL INFERENCE 라우트
// ============================================================
// POST /api/aerial/oil-detect — 오일 유출 감지 (GPU 추론 서버 프록시)
// base64 이미지 전송을 위해 3MB JSON 파서 적용
router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const { image } = req.body;
if (!image || typeof image !== 'string') {
res.status(400).json({ error: 'image (base64) 필드가 필요합니다' });
return;
}
// base64 크기 제한 (약 2MB 이미지)
if (image.length > 3_000_000) {
res.status(400).json({ error: '이미지 크기가 너무 큽니다 (최대 2MB)' });
return;
}
const result = await requestOilInference(image);
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('abort') || message.includes('timeout')) {
console.error('[aerial] 추론 서버 타임아웃:', message);
res.status(504).json({ error: '추론 서버 응답 시간 초과' });
return;
}
console.error('[aerial] 오일 감지 오류:', err);
res.status(503).json({ error: '추론 서버 연결 불가' });
}
});
// GET /api/aerial/oil-detect/health — 추론 서버 상태 확인
router.get('/oil-detect/health', requireAuth, async (_req, res) => {
const health = await checkInferenceHealth();
res.json(health);
});
export default router;

파일 보기

@ -339,3 +339,62 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
[sttsCd, sn]
);
}
// ============================================================
// OIL INFERENCE (GPU 서버 프록시)
// ============================================================
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
const INFERENCE_TIMEOUT_MS = 10_000;
export interface OilInferenceRegion {
classId: number;
className: string;
pixelCount: number;
percentage: number;
thicknessMm: number;
}
export interface OilInferenceResult {
mask: string; // base64 uint8 array (values 0-4)
width: number;
height: number;
regions: OilInferenceRegion[];
}
/** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
try {
const response = await fetch(`${OIL_INFERENCE_URL}/inference`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageBase64 }),
signal: controller.signal,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Inference server responded ${response.status}: ${detail}`);
}
return await response.json() as OilInferenceResult;
} finally {
clearTimeout(timeout);
}
}
/** GPU 추론 서버 헬스체크 */
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
try {
const response = await fetch(`${OIL_INFERENCE_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) throw new Error(`status ${response.status}`);
return await response.json() as { status: string; device?: string };
} catch {
return { status: 'unavailable' };
}
}

파일 보기

@ -72,10 +72,16 @@ export function requirePermission(resource: string, operation: string = 'READ')
req.resolvedPermissions = userInfo.permissions
}
const allowedOps = req.resolvedPermissions[resource]
if (allowedOps && allowedOps.includes(operation)) {
next()
return
// 정확한 리소스 매칭 → 부모 리소스 fallback (board:notice → board)
let cursor: string | undefined = resource
while (cursor) {
const allowedOps = req.resolvedPermissions[cursor]
if (allowedOps && allowedOps.includes(operation)) {
next()
return
}
const colonIdx = cursor.lastIndexOf(':')
cursor = colonIdx > 0 ? cursor.substring(0, colonIdx) : undefined
}
res.status(403).json({ error: '접근 권한이 없습니다.' })

파일 보기

@ -112,6 +112,23 @@ export async function login(
return userInfo
}
/** AUTH_PERM_TREE 없이 플랫 권한을 RSRC_CD + OPER_CD 기준으로 조회 */
async function flatPermissionsFallback(userId: string): Promise<Record<string, string[]>> {
const permsResult = await authPool.query(
`SELECT DISTINCT p.RSRC_CD as rsrc_cd, p.OPER_CD as oper_cd
FROM AUTH_PERM p
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
[userId]
)
const perms: Record<string, string[]> = {}
for (const p of permsResult.rows) {
if (!perms[p.rsrc_cd]) perms[p.rsrc_cd] = []
if (!perms[p.rsrc_cd].includes(p.oper_cd)) perms[p.rsrc_cd].push(p.oper_cd)
}
return perms
}
export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
const userResult = await authPool.query(
`SELECT u.USER_ID as user_id, u.USER_ACNT as user_acnt, u.USER_NM as user_nm,
@ -170,30 +187,15 @@ export async function getUserInfo(userId: string): Promise<AuthUserInfo> {
permissions = grantedSetToRecord(granted)
} else {
// AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback
const permsResult = await authPool.query(
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
FROM AUTH_PERM p
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
[userId]
)
permissions = {}
for (const p of permsResult.rows) {
permissions[p.rsrc_cd] = ['READ']
}
permissions = await flatPermissionsFallback(userId)
}
} catch {
// AUTH_PERM_TREE 테이블 미존재 시 fallback
const permsResult = await authPool.query(
`SELECT DISTINCT p.RSRC_CD as rsrc_cd
FROM AUTH_PERM p
JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN
WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`,
[userId]
)
permissions = {}
for (const p of permsResult.rows) {
permissions[p.rsrc_cd] = ['READ']
try {
permissions = await flatPermissionsFallback(userId)
} catch {
console.error('[auth] 권한 조회 fallback 실패, 빈 권한 반환')
permissions = {}
}
}

파일 보기

@ -2,7 +2,7 @@ import { Router } from 'express'
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
import { AuthError } from '../auth/authService.js'
import {
listPosts, getPost, createPost, updatePost, deletePost,
listPosts, getPost, createPost, updatePost, deletePost, adminDeletePost,
listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
} from './boardService.js'
@ -209,4 +209,22 @@ router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (
}
})
// POST /api/board/admin-delete — 관리자 전용 게시글 삭제 (소유자 검증 없음)
router.post('/admin-delete', requireAuth, requirePermission('admin', 'READ'), async (req, res) => {
try {
const { sn } = req.body
const postSn = typeof sn === 'number' ? sn : parseInt(sn, 10)
if (isNaN(postSn)) {
res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' })
return
}
await adminDeletePost(postSn)
res.json({ success: true })
} catch (err) {
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
console.error('[board] 관리자 삭제 오류:', err)
res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' })
}
})
export default router

파일 보기

@ -398,3 +398,18 @@ export async function deletePost(postSn: number, requesterId: string): Promise<v
[postSn]
)
}
/** 관리자 전용 삭제 — 소유자 검증 없이 논리 삭제 */
export async function adminDeletePost(postSn: number): Promise<void> {
const existing = await wingPool.query(
`SELECT POST_SN FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`,
[postSn]
)
if (existing.rows.length === 0) {
throw new AuthError('게시글을 찾을 수 없습니다.', 404)
}
await wingPool.query(
`UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`,
[postSn]
)
}

파일 보기

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
오일 유출 감지 추론 서버 (GPU)
시립대 starsafire ResNet101+DANet 모델 기반
실행: uvicorn oil_inference_server:app --host 0.0.0.0 --port 8090
모델 파일 필요: ./V7_SPECIAL.py, ./epoch_165.pth (같은 디렉토리)
"""
import os
import io
import base64
import logging
from collections import Counter
from typing import Optional
import cv2
import numpy as np
from PIL import Image
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
# ── MMSegmentation (지연 임포트 — 서버 시작 시 로드) ─────────────────────────
model = None
DEVICE = os.getenv("INFERENCE_DEVICE", "cuda:0")
CLASSES = ("background", "black", "brown", "rainbow", "silver")
PALETTE = [
[0, 0, 0], # 0: background
[0, 0, 204], # 1: black oil (에멀전)
[180, 180, 180], # 2: brown oil (원유)
[255, 255, 0], # 3: rainbow oil (박막)
[178, 102, 255], # 4: silver oil (극박막)
]
THICKNESS_MM = {
1: 1.0, # black
2: 0.1, # brown
3: 0.0003, # rainbow
4: 0.0001, # silver
}
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("oil-inference")
# ── FastAPI App ──────────────────────────────────────────────────────────────
app = FastAPI(title="Oil Spill Inference Server", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class InferenceRequest(BaseModel):
image: str # base64 encoded JPEG/PNG
class OilRegionResult(BaseModel):
classId: int
className: str
pixelCount: int
percentage: float
thicknessMm: float
class InferenceResponse(BaseModel):
mask: str # base64 encoded uint8 array (values 0-4)
width: int
height: int
regions: list[OilRegionResult]
# ── Model Loading ────────────────────────────────────────────────────────────
def load_model():
"""모델을 로드한다. 서버 시작 시 1회 호출."""
global model
try:
from mmseg.apis import init_segmentor
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "V7_SPECIAL.py")
checkpoint_path = os.path.join(script_dir, "epoch_165.pth")
if not os.path.exists(config_path):
logger.error(f"Config not found: {config_path}")
return False
if not os.path.exists(checkpoint_path):
logger.error(f"Checkpoint not found: {checkpoint_path}")
return False
logger.info(f"Loading model on {DEVICE}...")
model = init_segmentor(config_path, checkpoint_path, device=DEVICE)
model.PALETTE = PALETTE
logger.info("Model loaded successfully")
return True
except Exception as e:
logger.error(f"Model loading failed: {e}")
return False
@app.on_event("startup")
async def startup():
success = load_model()
if not success:
logger.warning("Model not loaded — inference will be unavailable")
# ── Endpoints ────────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {
"status": "ok" if model is not None else "model_not_loaded",
"device": DEVICE,
"classes": list(CLASSES),
}
@app.post("/inference", response_model=InferenceResponse)
async def inference(req: InferenceRequest):
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded")
try:
# 1. Base64 → numpy array
img_bytes = base64.b64decode(req.image)
img_pil = Image.open(io.BytesIO(img_bytes)).convert("RGB")
img_np = np.array(img_pil)
# 2. 임시 파일로 저장 (mmseg inference_segmentor는 파일 경로 필요)
import tempfile
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp_path = tmp.name
cv2.imwrite(tmp_path, cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR))
# 3. 추론
from mmseg.apis import inference_segmentor
result = inference_segmentor(model, tmp_path)
seg_map = result[0] # (H, W) uint8, values 0-4
# 임시 파일 삭제
os.unlink(tmp_path)
h, w = seg_map.shape
total_pixels = h * w
# 4. 클래스별 통계
counter = Counter(seg_map.flatten().tolist())
regions = []
for class_id in range(1, 5): # 1-4 (skip background)
count = counter.get(class_id, 0)
if count > 0:
regions.append(OilRegionResult(
classId=class_id,
className=CLASSES[class_id],
pixelCount=count,
percentage=round(count / total_pixels * 100, 2),
thicknessMm=THICKNESS_MM[class_id],
))
# 5. 마스크를 base64로 인코딩
mask_bytes = seg_map.astype(np.uint8).tobytes()
mask_b64 = base64.b64encode(mask_bytes).decode("ascii")
return InferenceResponse(
mask=mask_b64,
width=w,
height=h,
regions=regions,
)
except Exception as e:
logger.error(f"Inference error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8090)

파일 보기

@ -0,0 +1,8 @@
fastapi==0.104.1
uvicorn==0.24.0
torch>=1.13.0
mmcv-full>=1.7.0
mmsegmentation>=0.30.0
opencv-python-headless>=4.8.0
numpy>=1.24.0
Pillow>=10.0.0

파일 보기

@ -97,9 +97,13 @@ app.use(cors({
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 200, // IP당 최대 200요청
max: 500, // IP당 최대 500요청 (HLS 스트리밍 고려)
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
return req.path.startsWith('/api/aerial/cctv/stream-proxy');
},
message: {
error: '요청 횟수 초과',
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'

파일 보기

@ -10,6 +10,7 @@ import {
assignRoles,
approveUser,
rejectUser,
listOrgs,
} from './userService.js'
const router = Router()
@ -30,6 +31,17 @@ router.get('/', async (req, res) => {
}
})
// GET /api/users/orgs — 조직 목록 (/:id 보다 앞에 등록해야 함)
router.get('/orgs', async (_req, res) => {
try {
const orgs = await listOrgs()
res.json(orgs)
} catch (err) {
console.error('[users] 조직 목록 오류:', err)
res.status(500).json({ error: '조직 목록 조회 중 오류가 발생했습니다.' })
}
})
// GET /api/users/:id
router.get('/:id', async (req, res) => {
try {

파일 보기

@ -293,6 +293,32 @@ export async function changePassword(userId: string, newPassword: string): Promi
)
}
// ── 조직 목록 조회 ──
interface OrgItem {
orgSn: number
orgNm: string
orgAbbrNm: string | null
orgTpCd: string
upperOrgSn: number | null
}
export async function listOrgs(): Promise<OrgItem[]> {
const { rows } = await authPool.query(
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
FROM AUTH_ORG
WHERE USE_YN = 'Y'
ORDER BY ORG_SN`
)
return rows.map((r: Record<string, unknown>) => ({
orgSn: r.org_sn as number,
orgNm: r.org_nm as string,
orgAbbrNm: r.org_abbr_nm as string | null,
orgTpCd: r.org_tp_cd as string,
upperOrgSn: r.upper_org_sn as number | null,
}))
}
export async function assignRoles(userId: string, roleSns: number[]): Promise<void> {
await authPool.query('DELETE FROM AUTH_USER_ROLE WHERE USER_ID = $1', [userId])

파일 보기

@ -254,10 +254,11 @@ CREATE INDEX IDX_AUDIT_LOG_DTM ON AUTH_AUDIT_LOG (REQ_DTM);
-- 10. 초기 데이터: 역할
-- ============================================================
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'),
('VIEWER', '뷰어', '조회 전용 접근 권한', 'N');
('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'),
('HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'),
('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'),
('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'),
('VIEWER', '뷰어', '조회 전용 접근 권한', 'N');
-- ============================================================
@ -279,7 +280,7 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'),
(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y');
-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용
-- HQ_CLEANUP (ROLE_SN=2): 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'),
(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'),
@ -289,38 +290,52 @@ INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', 'DELETE', 'Y'),
(2, 'scat', 'READ', 'Y'), (2, 'scat', 'CREATE', 'Y'), (2, 'scat', 'UPDATE', 'Y'), (2, 'scat', 'DELETE', 'Y'),
(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'),
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'), (2, 'board', 'DELETE', 'Y'),
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', 'DELETE', 'Y'),
(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'),
(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'),
(2, 'admin', 'READ', 'N');
-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
-- MANAGER (ROLE_SN=3): admin 탭 제외, RCUD 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'),
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'),
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'),
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'),
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'),
(3, 'assets', 'READ', 'N'),
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'),
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'),
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'),
(3, 'weather', 'READ', 'Y'),
(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'), (3, 'prediction', 'DELETE', 'Y'),
(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'), (3, 'hns', 'DELETE', 'Y'),
(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'), (3, 'rescue', 'DELETE', 'Y'),
(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'), (3, 'reports', 'DELETE', 'Y'),
(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'), (3, 'aerial', 'DELETE', 'Y'),
(3, 'assets', 'READ', 'Y'), (3, 'assets', 'CREATE', 'Y'), (3, 'assets', 'UPDATE', 'Y'), (3, 'assets', 'DELETE', 'Y'),
(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'), (3, 'scat', 'DELETE', 'Y'),
(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), (3, 'incidents', 'DELETE', 'Y'),
(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), (3, 'board', 'DELETE', 'Y'),
(3, 'weather', 'READ', 'Y'), (3, 'weather', 'CREATE', 'Y'), (3, 'weather', 'UPDATE', 'Y'), (3, 'weather', 'DELETE', 'Y'),
(3, 'admin', 'READ', 'N');
-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용
-- USER (ROLE_SN=4): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(4, 'prediction', 'READ', 'Y'),
(4, 'hns', 'READ', 'Y'),
(4, 'rescue', 'READ', 'Y'),
(4, 'reports', 'READ', 'N'),
(4, 'aerial', 'READ', 'Y'),
(4, 'prediction', 'READ', 'Y'), (4, 'prediction', 'CREATE', 'Y'), (4, 'prediction', 'UPDATE', 'Y'),
(4, 'hns', 'READ', 'Y'), (4, 'hns', 'CREATE', 'Y'), (4, 'hns', 'UPDATE', 'Y'),
(4, 'rescue', 'READ', 'Y'), (4, 'rescue', 'CREATE', 'Y'), (4, 'rescue', 'UPDATE', 'Y'),
(4, 'reports', 'READ', 'Y'), (4, 'reports', 'CREATE', 'Y'), (4, 'reports', 'UPDATE', 'Y'),
(4, 'aerial', 'READ', 'Y'), (4, 'aerial', 'CREATE', 'Y'), (4, 'aerial', 'UPDATE', 'Y'),
(4, 'assets', 'READ', 'N'),
(4, 'scat', 'READ', 'N'),
(4, 'incidents', 'READ', 'Y'),
(4, 'board', 'READ', 'Y'),
(4, 'scat', 'READ', 'Y'), (4, 'scat', 'CREATE', 'Y'), (4, 'scat', 'UPDATE', 'Y'),
(4, 'incidents', 'READ', 'Y'), (4, 'incidents', 'CREATE', 'Y'), (4, 'incidents', 'UPDATE', 'Y'),
(4, 'board', 'READ', 'Y'), (4, 'board', 'CREATE', 'Y'), (4, 'board', 'UPDATE', 'Y'),
(4, 'weather', 'READ', 'Y'),
(4, 'admin', 'READ', 'N');
-- VIEWER (ROLE_SN=5): 제한적 탭의 READ만 허용
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(5, 'prediction', 'READ', 'Y'),
(5, 'hns', 'READ', 'Y'),
(5, 'rescue', 'READ', 'Y'),
(5, 'reports', 'READ', 'N'),
(5, 'aerial', 'READ', 'Y'),
(5, 'assets', 'READ', 'N'),
(5, 'scat', 'READ', 'N'),
(5, 'incidents', 'READ', 'Y'),
(5, 'board', 'READ', 'Y'),
(5, 'weather', 'READ', 'Y'),
(5, 'admin', 'READ', 'N');
-- ============================================================
-- 12. 초기 데이터: 조직

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

파일 보기

@ -0,0 +1,32 @@
-- ============================================================
-- 020: 본청방제과 역할 추가
-- ============================================================
-- 역할 추가 (이미 존재하면 무시)
INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN)
SELECT 'HQ_CLEANUP', '본청방제과', '본청 방제 업무 관리 권한', 'N'
WHERE NOT EXISTS (SELECT 1 FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP');
-- 본청방제과 권한 설정: 방제 관련 탭 RCUD + 기타 탭 READ/CREATE, admin 제외
DO $$
DECLARE
v_role_sn INT;
BEGIN
SELECT ROLE_SN INTO v_role_sn FROM AUTH_ROLE WHERE ROLE_CD = 'HQ_CLEANUP';
-- 기존 권한 초기화 (재실행 안전)
DELETE FROM AUTH_PERM WHERE ROLE_SN = v_role_sn;
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES
(v_role_sn, 'prediction', 'READ', 'Y'), (v_role_sn, 'prediction', 'CREATE', 'Y'), (v_role_sn, 'prediction', 'UPDATE', 'Y'), (v_role_sn, 'prediction', 'DELETE', 'Y'),
(v_role_sn, 'hns', 'READ', 'Y'), (v_role_sn, 'hns', 'CREATE', 'Y'), (v_role_sn, 'hns', 'UPDATE', 'Y'), (v_role_sn, 'hns', 'DELETE', 'Y'),
(v_role_sn, 'rescue', 'READ', 'Y'), (v_role_sn, 'rescue', 'CREATE', 'Y'), (v_role_sn, 'rescue', 'UPDATE', 'Y'), (v_role_sn, 'rescue', 'DELETE', 'Y'),
(v_role_sn, 'reports', 'READ', 'Y'), (v_role_sn, 'reports', 'CREATE', 'Y'), (v_role_sn, 'reports', 'UPDATE', 'Y'), (v_role_sn, 'reports', 'DELETE', 'Y'),
(v_role_sn, 'aerial', 'READ', 'Y'), (v_role_sn, 'aerial', 'CREATE', 'Y'), (v_role_sn, 'aerial', 'UPDATE', 'Y'), (v_role_sn, 'aerial', 'DELETE', 'Y'),
(v_role_sn, 'assets', 'READ', 'Y'), (v_role_sn, 'assets', 'CREATE', 'Y'), (v_role_sn, 'assets', 'UPDATE', 'Y'), (v_role_sn, 'assets', 'DELETE', 'Y'),
(v_role_sn, 'scat', 'READ', 'Y'), (v_role_sn, 'scat', 'CREATE', 'Y'), (v_role_sn, 'scat', 'UPDATE', 'Y'), (v_role_sn, 'scat', 'DELETE', 'Y'),
(v_role_sn, 'incidents', 'READ', 'Y'), (v_role_sn, 'incidents', 'CREATE', 'Y'), (v_role_sn, 'incidents', 'UPDATE', 'Y'), (v_role_sn, 'incidents', 'DELETE', 'Y'),
(v_role_sn, 'board', 'READ', 'Y'), (v_role_sn, 'board', 'CREATE', 'Y'), (v_role_sn, 'board', 'UPDATE', 'Y'),
(v_role_sn, 'weather', 'READ', 'Y'), (v_role_sn, 'weather', 'CREATE', 'Y'),
(v_role_sn, 'admin', 'READ', 'N');
END $$;

파일 보기

@ -60,12 +60,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
{ id: 'manual', label: '해경매뉴얼', icon: '📘' }
],
weather: null,
admin: [
{ id: 'users', label: '사용자 관리', icon: '👥' },
{ id: 'permissions', label: '사용자 권한 관리', icon: '🔐' },
{ id: 'menus', label: '메뉴 관리', icon: '📑' },
{ id: 'settings', label: '시스템 설정', icon: '⚙️' }
]
admin: null // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
}
// 전역 상태 관리 (간단한 방식)

파일 보기

@ -107,6 +107,20 @@ export async function assignRolesApi(id: string, roleSns: number[]): Promise<voi
await api.put(`/users/${id}/roles`, { roleSns })
}
// 조직 목록 API
export interface OrgItem {
orgSn: number
orgNm: string
orgAbbrNm: string | null
orgTpCd: string
upperOrgSn: number | null
}
export async function fetchOrgs(): Promise<OrgItem[]> {
const response = await api.get<OrgItem[]>('/users/orgs')
return response.data
}
// 역할/권한 API (ADMIN 전용)
export interface RoleWithPermissions {
sn: number

파일 보기

@ -0,0 +1,14 @@
interface AdminPlaceholderProps {
label: string;
}
/** 미구현 관리자 메뉴 placeholder */
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="text-4xl opacity-20">🚧</div>
<div className="text-sm font-korean text-text-2 font-semibold">{label}</div>
<div className="text-[11px] font-korean text-text-3"> .</div>
</div>
);
export default AdminPlaceholder;

파일 보기

@ -0,0 +1,156 @@
import { useState } from 'react';
import { ADMIN_MENU } from './adminMenuConfig';
import type { AdminMenuItem } from './adminMenuConfig';
interface AdminSidebarProps {
activeMenu: string;
onSelect: (id: string) => void;
}
/** 관리자 좌측 사이드바 — 9-섹션 아코디언 */
const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
const [expanded, setExpanded] = useState<Set<string>>(() => {
// 초기: 첫 번째 섹션 열기
const init = new Set<string>();
if (ADMIN_MENU.length > 0) init.add(ADMIN_MENU[0].id);
return init;
});
const toggle = (id: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
/** 재귀적으로 메뉴 아이템이 activeMenu를 포함하는지 확인 */
const containsActive = (item: AdminMenuItem): boolean => {
if (item.id === activeMenu) return true;
return item.children?.some(c => containsActive(c)) ?? false;
};
const renderLeaf = (item: AdminMenuItem, depth: number) => {
const isActive = item.id === activeMenu;
return (
<button
key={item.id}
onClick={() => onSelect(item.id)}
className="w-full text-left px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
style={{
paddingLeft: `${12 + depth * 14}px`,
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
color: isActive ? 'var(--cyan)' : 'var(--t2)',
fontWeight: isActive ? 600 : 400,
}}
>
{item.label}
</button>
);
};
const renderGroup = (item: AdminMenuItem, depth: number) => {
const isOpen = expanded.has(item.id);
const hasActiveChild = containsActive(item);
return (
<div key={item.id}>
<button
onClick={() => {
toggle(item.id);
// 그룹 자체에 children의 첫 leaf가 있으면 자동 선택
if (!isOpen && item.children) {
const firstLeaf = findFirstLeaf(item.children);
if (firstLeaf) onSelect(firstLeaf.id);
}
}}
className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
style={{
paddingLeft: `${12 + depth * 14}px`,
color: hasActiveChild ? 'var(--cyan)' : 'var(--t2)',
fontWeight: hasActiveChild ? 600 : 400,
}}
>
<span>{item.label}</span>
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
</span>
</button>
{isOpen && item.children && (
<div className="flex flex-col gap-px">
{item.children.map(child => renderItem(child, depth + 1))}
</div>
)}
</div>
);
};
const renderItem = (item: AdminMenuItem, depth: number) => {
if (item.children && item.children.length > 0) {
return renderGroup(item, depth);
}
return renderLeaf(item, depth);
};
return (
<div
className="flex flex-col bg-bg-1 border-r border-border overflow-y-auto shrink-0"
style={{ width: 240, scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}
>
{/* 헤더 */}
<div className="px-4 py-3 border-b border-border bg-bg-2 shrink-0">
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
<span></span>
</div>
</div>
{/* 메뉴 목록 */}
<div className="flex flex-col gap-0.5 p-2">
{ADMIN_MENU.map(section => {
const isOpen = expanded.has(section.id);
const hasActiveChild = containsActive(section);
return (
<div key={section.id} className="mb-0.5">
{/* 섹션 헤더 */}
<button
onClick={() => toggle(section.id)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-[11px] font-bold font-korean transition-colors cursor-pointer"
style={{
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
color: hasActiveChild ? 'var(--cyan)' : 'var(--t1)',
}}
>
<span className="text-sm">{section.icon}</span>
<span className="flex-1 text-left">{section.label}</span>
<span className="text-[9px] text-text-3 transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}>
</span>
</button>
{/* 하위 메뉴 */}
{isOpen && section.children && (
<div className="flex flex-col gap-px mt-0.5 ml-1">
{section.children.map(child => renderItem(child, 1))}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
/** children 중 첫 번째 leaf 노드를 찾는다 */
function findFirstLeaf(items: AdminMenuItem[]): AdminMenuItem | null {
for (const item of items) {
if (!item.children || item.children.length === 0) return item;
const found = findFirstLeaf(item.children);
if (found) return found;
}
return null;
}
export default AdminSidebar;

파일 보기

@ -1,21 +1,42 @@
import { useSubMenu } from '@common/hooks/useSubMenu'
import UsersPanel from './UsersPanel'
import PermissionsPanel from './PermissionsPanel'
import MenusPanel from './MenusPanel'
import SettingsPanel from './SettingsPanel'
import { useState } from 'react';
import AdminSidebar from './AdminSidebar';
import AdminPlaceholder from './AdminPlaceholder';
import { findMenuLabel } from './adminMenuConfig';
import UsersPanel from './UsersPanel';
import PermissionsPanel from './PermissionsPanel';
import MenusPanel from './MenusPanel';
import SettingsPanel from './SettingsPanel';
import BoardMgmtPanel from './BoardMgmtPanel';
import VesselSignalPanel from './VesselSignalPanel';
/** 기존 패널이 있는 메뉴 ID 매핑 */
const PANEL_MAP: Record<string, () => JSX.Element> = {
users: () => <UsersPanel />,
permissions: () => <PermissionsPanel />,
menus: () => <MenusPanel />,
settings: () => <SettingsPanel />,
notice: () => <BoardMgmtPanel initialCategory="NOTICE" />,
board: () => <BoardMgmtPanel initialCategory="DATA" />,
qna: () => <BoardMgmtPanel initialCategory="QNA" />,
'collect-vessel-signal': () => <VesselSignalPanel />,
};
// ─── AdminView ────────────────────────────────────────────
export function AdminView() {
const { activeSubTab } = useSubMenu('admin')
const [activeMenu, setActiveMenu] = useState('users');
const renderContent = () => {
const factory = PANEL_MAP[activeMenu];
if (factory) return factory();
const label = findMenuLabel(activeMenu) ?? activeMenu;
return <AdminPlaceholder label={label} />;
};
return (
<div className="flex flex-1 overflow-hidden bg-bg-0">
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
<div className="flex-1 flex flex-col overflow-hidden">
{activeSubTab === 'users' && <UsersPanel />}
{activeSubTab === 'permissions' && <PermissionsPanel />}
{activeSubTab === 'menus' && <MenusPanel />}
{activeSubTab === 'settings' && <SettingsPanel />}
{renderContent()}
</div>
</div>
)
);
}

파일 보기

@ -0,0 +1,293 @@
import { useState, useEffect, useCallback } from 'react';
import {
fetchBoardPosts,
adminDeleteBoardPost,
type BoardPostItem,
type BoardListResponse,
} from '@tabs/board/services/boardApi';
// ─── 상수 ──────────────────────────────────────────────────
const PAGE_SIZE = 20;
const CATEGORY_TABS = [
{ code: '', label: '전체' },
{ code: 'NOTICE', label: '공지사항' },
{ code: 'DATA', label: '게시판' },
{ code: 'QNA', label: 'Q&A' },
] as const;
const CATEGORY_LABELS: Record<string, string> = {
NOTICE: '공지사항',
DATA: '게시판',
QNA: 'Q&A',
};
function formatDate(dateStr: string | null) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
// ─── 메인 패널 ─────────────────────────────────────────────
interface BoardMgmtPanelProps {
initialCategory?: string;
}
export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelProps) {
const [activeCategory, setActiveCategory] = useState(initialCategory);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [page, setPage] = useState(1);
const [data, setData] = useState<BoardListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [deleting, setDeleting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await fetchBoardPosts({
categoryCd: activeCategory || undefined,
search: search || undefined,
page,
size: PAGE_SIZE,
});
setData(result);
setSelected(new Set());
} catch {
console.error('게시글 목록 로드 실패');
} finally {
setLoading(false);
}
}, [activeCategory, search, page]);
useEffect(() => { load(); }, [load]);
const totalPages = data ? Math.ceil(data.totalCount / PAGE_SIZE) : 0;
const items = data?.items ?? [];
const toggleSelect = (sn: number) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(sn)) next.delete(sn);
else next.add(sn);
return next;
});
};
const toggleAll = () => {
if (selected.size === items.length) {
setSelected(new Set());
} else {
setSelected(new Set(items.map(i => i.sn)));
}
};
const handleDelete = async () => {
if (selected.size === 0) return;
if (!confirm(`선택한 ${selected.size}건의 게시글을 삭제하시겠습니까?`)) return;
setDeleting(true);
try {
await Promise.all([...selected].map(sn => adminDeleteBoardPost(sn)));
await load();
} catch {
alert('삭제 중 오류가 발생했습니다.');
} finally {
setDeleting(false);
}
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
const handleCategoryChange = (code: string) => {
setActiveCategory(code);
setPage(1);
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1">
<h2 className="text-sm font-semibold text-text-1"> </h2>
<span className="text-xs text-text-3">
{data?.totalCount ?? 0}
</span>
</div>
{/* 카테고리 탭 + 검색 */}
<div className="flex items-center gap-3 px-5 py-2 border-b border-border-1">
<div className="flex gap-1">
{CATEGORY_TABS.map(tab => (
<button
key={tab.code}
onClick={() => handleCategoryChange(tab.code)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
activeCategory === tab.code
? 'bg-blue-500/20 text-blue-400 font-medium'
: 'text-text-3 hover:text-text-2 hover:bg-bg-2'
}`}
>
{tab.label}
</button>
))}
</div>
<form onSubmit={handleSearch} className="flex gap-1 ml-auto">
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
placeholder="제목/작성자 검색"
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1 placeholder:text-text-4 w-48"
/>
<button
type="submit"
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
>
</button>
</form>
</div>
{/* 액션 바 */}
<div className="flex items-center gap-2 px-5 py-2 border-b border-border-1">
<button
onClick={handleDelete}
disabled={selected.size === 0 || deleting}
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
</button>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-bg-1 z-10">
<tr className="border-b border-border-1 text-text-3">
<th className="w-8 py-2 text-center">
<input
type="checkbox"
checked={items.length > 0 && selected.size === items.length}
onChange={toggleAll}
className="accent-blue-500"
/>
</th>
<th className="w-12 py-2 text-center"></th>
<th className="w-20 py-2 text-center"></th>
<th className="py-2 text-left pl-3"></th>
<th className="w-24 py-2 text-center"></th>
<th className="w-16 py-2 text-center"></th>
<th className="w-36 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="py-8 text-center text-text-3"> ...</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-text-3"> .</td>
</tr>
) : (
items.map(post => (
<PostRow
key={post.sn}
post={post}
checked={selected.has(post.sn)}
onToggle={() => toggleSelect(post.sn)}
/>
))
)}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-1 py-2 border-t border-border-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
>
&lt;
</button>
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9));
const p = startPage + i;
if (p > totalPages) return null;
return (
<button
key={p}
onClick={() => setPage(p)}
className={`w-7 h-7 text-xs rounded ${
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-text-3 hover:bg-bg-2'
}`}
>
{p}
</button>
);
})}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
>
&gt;
</button>
</div>
)}
</div>
);
}
// ─── 행 컴포넌트 ───────────────────────────────────────────
interface PostRowProps {
post: BoardPostItem;
checked: boolean;
onToggle: () => void;
}
function PostRow({ post, checked, onToggle }: PostRowProps) {
return (
<tr className="border-b border-border-1 hover:bg-bg-1/50 transition-colors">
<td className="py-2 text-center">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="accent-blue-500"
/>
</td>
<td className="py-2 text-center text-text-3">{post.sn}</td>
<td className="py-2 text-center">
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${
post.categoryCd === 'NOTICE' ? 'bg-red-500/15 text-red-400' :
post.categoryCd === 'QNA' ? 'bg-purple-500/15 text-purple-400' :
'bg-blue-500/15 text-blue-400'
}`}>
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
</span>
</td>
<td className="py-2 pl-3 text-text-1 truncate max-w-[300px]">
{post.pinnedYn === 'Y' && (
<span className="text-[10px] text-orange-400 mr-1">[]</span>
)}
{post.title}
</td>
<td className="py-2 text-center text-text-2">{post.authorName}</td>
<td className="py-2 text-center text-text-3">{post.viewCnt}</td>
<td className="py-2 text-center text-text-3">{formatDate(post.regDtm)}</td>
</tr>
);
}

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

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

파일 보기

@ -0,0 +1,204 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────
const SIGNAL_SOURCES = ['VTS', 'VTS-AIS', 'V-PASS', 'E-NAVI', 'S&P AIS'] as const;
type SignalSource = (typeof SIGNAL_SOURCES)[number];
interface SignalSlot {
time: string; // HH:mm
sources: Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
}
// ─── 상수 ──────────────────────────────────────────────────
const SOURCE_COLORS: Record<SignalSource, string> = {
VTS: '#3b82f6',
'VTS-AIS': '#a855f7',
'V-PASS': '#22c55e',
'E-NAVI': '#f97316',
'S&P AIS': '#ec4899',
};
const STATUS_COLOR: Record<string, string> = {
ok: '#22c55e',
warn: '#eab308',
error: '#ef4444',
none: 'rgba(255,255,255,0.06)',
};
const HOURS = Array.from({ length: 24 }, (_, i) => i);
function generateTimeSlots(date: string): SignalSlot[] {
const now = new Date();
const isToday = date === now.toISOString().slice(0, 10);
const currentHour = isToday ? now.getHours() : 24;
const currentMin = isToday ? now.getMinutes() : 0;
const slots: SignalSlot[] = [];
for (let h = 0; h < 24; h++) {
for (let m = 0; m < 60; m += 10) {
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
const isPast = h < currentHour || (h === currentHour && m <= currentMin);
const sources = {} as Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
for (const src of SIGNAL_SOURCES) {
if (!isPast) {
sources[src] = { count: 0, status: 'none' };
} else {
const rand = Math.random();
const count = Math.floor(Math.random() * 200) + 10;
sources[src] = {
count,
status: rand > 0.15 ? 'ok' : rand > 0.05 ? 'warn' : 'error',
};
}
}
slots.push({ time, sources });
}
}
return slots;
}
// ─── 타임라인 바 (10분 단위 셀) ────────────────────────────
function TimelineBar({ slots, source }: { slots: SignalSlot[]; source: SignalSource }) {
if (slots.length === 0) return null;
// 144개 슬롯을 각각 1칸씩 렌더링 (10분 = 1칸)
return (
<div className="w-full h-5 overflow-hidden flex" style={{ background: 'rgba(255,255,255,0.04)' }}>
{slots.map((slot, i) => {
const s = slot.sources[source];
const color = STATUS_COLOR[s.status] || STATUS_COLOR.none;
const statusLabel = s.status === 'ok' ? '정상' : s.status === 'warn' ? '지연' : s.status === 'error' ? '오류' : '미수신';
return (
<div
key={i}
className="h-full"
style={{
width: `${100 / 144}%`,
backgroundColor: color,
borderRight: '0.5px solid rgba(0,0,0,0.15)',
}}
title={`${slot.time} ${statusLabel}${s.status !== 'none' ? ` (${s.count}건)` : ''}`}
/>
);
})}
</div>
);
}
// ─── 메인 패널 ─────────────────────────────────────────────
export default function VesselSignalPanel() {
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [slots, setSlots] = useState<SignalSlot[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(() => {
setLoading(true);
// TODO: 실제 API 연동 시 fetch 호출로 교체
setTimeout(() => {
setSlots(generateTimeSlots(date));
setLoading(false);
}, 300);
}, [date]);
useEffect(() => {
const timer = setTimeout(() => load(), 0);
return () => clearTimeout(timer);
}, [load]);
// 통계 계산
const stats = SIGNAL_SOURCES.map(src => {
let total = 0, ok = 0, warn = 0, error = 0;
for (const slot of slots) {
const s = slot.sources[src];
if (s.status !== 'none') {
total++;
if (s.status === 'ok') ok++;
else if (s.status === 'warn') warn++;
else error++;
}
}
return { src, total, ok, warn, error, rate: total > 0 ? ((ok / total) * 100).toFixed(1) : '-' };
});
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-3 border-b border-border-1">
<h2 className="text-sm font-semibold text-text-1"> </h2>
<div className="flex items-center gap-3">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-2 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-1"
/>
<button
onClick={load}
className="px-3 py-1 text-xs rounded bg-bg-2 border border-border-1 text-text-2 hover:bg-bg-3"
>
</button>
</div>
</div>
{/* 메인 콘텐츠 */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center h-full">
<span className="text-xs text-text-3"> ...</span>
</div>
) : (
<div className="flex gap-2">
{/* 좌측: 소스 라벨 고정 열 */}
<div className="flex-shrink-0 flex flex-col" style={{ width: 64 }}>
{/* 시간축 높이 맞춤 빈칸 */}
<div className="h-5 mb-3" />
{SIGNAL_SOURCES.map(src => {
const c = SOURCE_COLORS[src];
const st = stats.find(s => s.src === src)!;
return (
<div key={src} className="flex flex-col justify-center mb-4" style={{ height: 20 }}>
<span className="text-[12px] font-semibold leading-tight" style={{ color: c }}>{src}</span>
<span className="text-[10px] font-mono text-text-4 mt-0.5">{st.rate}%</span>
</div>
);
})}
</div>
{/* 우측: 시간축 + 타임라인 바 */}
<div className="flex-1 min-w-0 flex flex-col">
{/* 시간 축 (상단) */}
<div className="relative h-5 mb-3">
{HOURS.map(h => (
<span
key={h}
className="absolute text-[10px] text-text-3 font-mono"
style={{ left: `${(h / 24) * 100}%`, transform: 'translateX(-50%)' }}
>
{String(h).padStart(2, '0')}
</span>
))}
<span
className="absolute text-[10px] text-text-3 font-mono"
style={{ right: 0 }}
>
24
</span>
</div>
{/* 소스별 타임라인 바 */}
{SIGNAL_SOURCES.map(src => (
<div key={src} className="mb-4 flex items-center" style={{ height: 20 }}>
<TimelineBar slots={slots} source={src} />
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

파일 보기

@ -1,5 +1,6 @@
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
ADMIN: 'var(--red)',
HQ_CLEANUP: '#34d399',
MANAGER: 'var(--orange)',
USER: 'var(--cyan)',
VIEWER: 'var(--t3)',

파일 보기

@ -0,0 +1,94 @@
/** 관리자 화면 9-섹션 메뉴 트리 */
export interface AdminMenuItem {
id: string;
label: string;
icon?: string;
children?: AdminMenuItem[];
}
export const ADMIN_MENU: AdminMenuItem[] = [
{
id: 'env-settings', label: '환경설정', icon: '⚙️',
children: [
{ id: 'menus', label: '메뉴관리' },
{ id: 'settings', label: '시스템설정' },
],
},
{
id: 'user-info', label: '사용자정보', icon: '👥',
children: [
{ id: 'users', label: '사용자관리' },
{ id: 'permissions', label: '권한관리' },
],
},
{
id: 'board-mgmt', label: '게시판관리', icon: '📋',
children: [
{ id: 'notice', label: '공지사항' },
{ id: 'board', label: '게시판' },
{ id: 'qna', label: 'QNA' },
],
},
{
id: 'reference', label: '기준정보', icon: '🗺️',
children: [
{
id: 'map-mgmt', label: '지도관리',
children: [
{ id: 'map-vector', label: '지도벡데이터' },
{ id: 'map-layer', label: '레이어' },
],
},
{
id: 'sensitive-map', label: '민감자원지도',
children: [
{ id: 'env-ecology', label: '환경/생태' },
{ id: 'social-economy', label: '사회/경제' },
],
},
{
id: 'coast-guard-assets', label: '해경자산',
children: [
{ id: 'cleanup-equip', label: '방제장비' },
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
{ id: 'vessel-materials', label: '방제선 보유자재' },
{ id: 'cleanup-resource', label: '방제자원' },
],
},
],
},
{
id: 'external', label: '연계관리', icon: '🔗',
children: [
{
id: 'collection', label: '수집자료',
children: [
{ id: 'collect-vessel-signal', label: '선박신호' },
{ id: 'collect-hr', label: '인사정보' },
],
},
{
id: 'monitoring', label: '연계모니터링',
children: [
{ id: 'monitor-realtime', label: '실시간 관측자료' },
{ id: 'monitor-forecast', label: '수치예측자료' },
{ id: 'monitor-vessel', label: '선박위치정보' },
{ id: 'monitor-hr', label: '인사' },
],
},
],
},
];
/** 메뉴 ID로 라벨을 찾는 유틸리티 */
export function findMenuLabel(id: string, items: AdminMenuItem[] = ADMIN_MENU): string | null {
for (const item of items) {
if (item.id === id) return item.label;
if (item.children) {
const found = findMenuLabel(id, item.children);
if (found) return found;
}
}
return null;
}

파일 보기

@ -1,6 +1,8 @@
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import Hls from 'hls.js';
import { detectStreamType } from '../utils/streamUtils';
import { useOilDetection } from '../hooks/useOilDetection';
import OilDetectionOverlay from './OilDetectionOverlay';
interface CCTVPlayerProps {
cameraNm: string;
@ -9,6 +11,13 @@ interface CCTVPlayerProps {
coordDc?: string | null;
sourceNm?: string | null;
cellIndex?: number;
oilDetectionEnabled?: boolean;
vesselDetectionEnabled?: boolean;
intrusionDetectionEnabled?: boolean;
}
export interface CCTVPlayerHandle {
capture: () => void;
}
type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url';
@ -21,15 +30,19 @@ function toProxyUrl(url: string): string {
return url;
}
export function CCTVPlayer({
export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
cameraNm,
streamUrl,
sttsCd,
coordDc,
sourceNm,
cellIndex = 0,
}: CCTVPlayerProps) {
oilDetectionEnabled = false,
vesselDetectionEnabled = false,
intrusionDetectionEnabled = false,
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
const [retryKey, setRetryKey] = useState(0);
@ -56,6 +69,73 @@ export function CCTVPlayer({
? 'playing'
: hlsPlayerState;
const { result: oilResult, isAnalyzing: oilAnalyzing, error: oilError } = useOilDetection({
videoRef,
enabled: oilDetectionEnabled && playerState === 'playing' && (streamType === 'hls' || streamType === 'mp4'),
});
useImperativeHandle(ref, () => ({
capture: () => {
const container = containerRef.current;
if (!container) return;
const w = container.clientWidth;
const h = container.clientHeight;
const canvas = document.createElement('canvas');
canvas.width = w * 2;
canvas.height = h * 2;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(2, 2);
// 1) video frame
const video = videoRef.current;
if (video && video.readyState >= 2) {
ctx.drawImage(video, 0, 0, w, h);
}
// 2) oil detection overlay
const overlayCanvas = container.querySelector<HTMLCanvasElement>('canvas');
if (overlayCanvas) {
ctx.drawImage(overlayCanvas, 0, 0, w, h);
}
// 3) OSD: camera name + timestamp
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(8, 8, ctx.measureText(cameraNm).width + 20, 22);
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText(cameraNm, 18, 23);
const ts = new Date().toLocaleString('ko-KR');
ctx.font = '10px monospace';
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const tsW = ctx.measureText(ts).width + 16;
ctx.fillRect(8, h - 26, tsW, 20);
ctx.fillStyle = '#a0aec0';
ctx.fillText(ts, 16, h - 12);
// 4) oil detection info
if (oilResult && oilResult.regions.length > 0) {
const areaText = oilResult.totalAreaM2 >= 1000
? `오일 감지: ${(oilResult.totalAreaM2 / 1_000_000).toFixed(1)} km² (${oilResult.totalPercentage.toFixed(1)}%)`
: `오일 감지: ~${Math.round(oilResult.totalAreaM2)} m² (${oilResult.totalPercentage.toFixed(1)}%)`;
ctx.font = 'bold 11px sans-serif';
const atW = ctx.measureText(areaText).width + 16;
ctx.fillStyle = 'rgba(239,68,68,0.25)';
ctx.fillRect(8, h - 48, atW, 18);
ctx.fillStyle = '#f87171';
ctx.fillText(areaText, 16, h - 34);
}
// download
const link = document.createElement('a');
link.download = `CCTV_${cameraNm}_${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
},
}), [cameraNm, oilResult]);
const destroyHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy();
@ -185,7 +265,7 @@ export function CCTVPlayer({
}
return (
<>
<div ref={containerRef} className="absolute inset-0">
{/* 로딩 오버레이 */}
{playerState === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
@ -207,13 +287,18 @@ export function CCTVPlayer({
/>
)}
{/* 오일 감지 오버레이 */}
{oilDetectionEnabled && (
<OilDetectionOverlay result={oilResult} isAnalyzing={oilAnalyzing} error={oilError} />
)}
{/* MJPEG */}
{streamType === 'mjpeg' && proxiedUrl && (
<img
src={proxiedUrl}
alt={cameraNm}
className="absolute inset-0 w-full h-full object-cover"
onError={() => setPlayerState('error')}
onError={() => setHlsPlayerState('error')}
/>
)}
@ -224,10 +309,28 @@ export function CCTVPlayer({
title={cameraNm}
className="absolute inset-0 w-full h-full border-none"
allow="autoplay; encrypted-media"
onError={() => setPlayerState('error')}
onError={() => setHlsPlayerState('error')}
/>
)}
{/* 안전관리 감지 상태 배지 */}
{(vesselDetectionEnabled || intrusionDetectionEnabled) && (
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
{vesselDetectionEnabled && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(59,130,246,.3)', color: '#93c5fd' }}>
🚢
</div>
)}
{intrusionDetectionEnabled && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(249,115,22,.3)', color: '#fdba74' }}>
🚨
</div>
)}
</div>
)}
{/* OSD 오버레이 */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
@ -245,6 +348,8 @@ export function CCTVPlayer({
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20">
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
</div>
</>
</div>
);
}
});
CCTVPlayer.displayName = 'CCTVPlayer';

파일 보기

@ -1,7 +1,8 @@
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { fetchCctvCameras } from '../services/aerialApi'
import type { CctvCameraItem } from '../services/aerialApi'
import { CCTVPlayer } from './CCTVPlayer'
import type { CCTVPlayerHandle } from './CCTVPlayer'
/** KHOA HLS 스트림 베이스 URL */
const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa';
@ -54,6 +55,10 @@ export function CctvView() {
const [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null)
const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false)
const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false)
const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false)
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
const loadData = useCallback(async () => {
setLoading(true)
@ -226,7 +231,45 @@ export function CctvView() {
>{g.icon}</button>
))}
</div>
<button className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors">📷 </button>
<button
onClick={() => setOilDetectionEnabled(v => !v)}
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
style={oilDetectionEnabled
? { background: 'rgba(239,68,68,.15)', borderColor: 'rgba(239,68,68,.4)', color: 'var(--red)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
}
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '오일 유출 감지'}
>
{oilDetectionEnabled ? '🛢 감지 ON' : '🛢 오일 감지'}
</button>
<button
onClick={() => setVesselDetectionEnabled(v => !v)}
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
style={vesselDetectionEnabled
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.4)', color: 'var(--blue, #3b82f6)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
}
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '선박 출입 감지'}
>
{vesselDetectionEnabled ? '🚢 감지 ON' : '🚢 선박 출입'}
</button>
<button
onClick={() => setIntrusionDetectionEnabled(v => !v)}
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
style={intrusionDetectionEnabled
? { background: 'rgba(249,115,22,.15)', borderColor: 'rgba(249,115,22,.4)', color: 'var(--orange, #f97316)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
}
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '침입 감지'}
>
{intrusionDetectionEnabled ? '🚨 감지 ON' : '🚨 침입 감지'}
</button>
<button
onClick={() => {
playerRefs.current.forEach(r => r?.capture())
}}
className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors"
>📷 </button>
</div>
</div>
@ -242,12 +285,16 @@ export function CctvView() {
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
{cam ? (
<CCTVPlayer
ref={el => { playerRefs.current[i] = el }}
cameraNm={cam.cameraNm}
streamUrl={cam.streamUrl}
sttsCd={cam.sttsCd}
coordDc={cam.coordDc}
sourceNm={cam.sourceNm}
cellIndex={i}
oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9}
vesselDetectionEnabled={vesselDetectionEnabled && gridMode !== 9}
intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9}
/>
) : (
<div className="text-[10px] text-text-3 font-korean opacity-40"> </div>

파일 보기

@ -0,0 +1,161 @@
import { useRef, useEffect, memo } from 'react';
import type { OilDetectionResult } from '../utils/oilDetection';
import { OIL_CLASSES, OIL_CLASS_NAMES } from '../utils/oilDetection';
export interface OilDetectionOverlayProps {
result: OilDetectionResult | null;
isAnalyzing?: boolean;
error?: string | null;
}
/** 클래스 ID → RGBA 색상 (오버레이용) */
const CLASS_COLORS: Record<number, [number, number, number, number]> = {
1: [0, 0, 204, 90], // black oil → 파란색
2: [180, 180, 180, 90], // brown oil → 회색
3: [255, 255, 0, 90], // rainbow oil → 노란색
4: [178, 102, 255, 90], // silver oil → 보라색
};
const OilDetectionOverlay = memo(({ result, isAnalyzing = false, error = null }: OilDetectionOverlayProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.clientWidth;
const displayH = canvas.clientHeight;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, displayW, displayH);
if (!result || result.regions.length === 0) return;
const { mask, maskWidth, maskHeight } = result;
// 클래스별 색상으로 마스크 렌더링
const offscreen = new OffscreenCanvas(maskWidth, maskHeight);
const offCtx = offscreen.getContext('2d');
if (offCtx) {
const imageData = new ImageData(maskWidth, maskHeight);
for (let i = 0; i < mask.length; i++) {
const classId = mask[i];
if (classId === 0) continue; // background skip
const color = CLASS_COLORS[classId];
if (!color) continue;
const pixelIdx = i * 4;
imageData.data[pixelIdx] = color[0];
imageData.data[pixelIdx + 1] = color[1];
imageData.data[pixelIdx + 2] = color[2];
imageData.data[pixelIdx + 3] = color[3];
}
offCtx.putImageData(imageData, 0, 0);
ctx.drawImage(offscreen, 0, 0, displayW, displayH);
}
}, [result]);
const formatArea = (m2: number): string => {
if (m2 >= 1000) {
return `${(m2 / 1_000_000).toFixed(1)} km²`;
}
return `~${Math.round(m2)}`;
};
const hasRegions = result !== null && result.regions.length > 0;
return (
<>
<canvas
ref={canvasRef}
className='absolute inset-0 w-full h-full pointer-events-none z-[15]'
/>
{/* OSD — bottom-8로 좌표 OSD(bottom-2)와 겹침 방지 */}
<div className='absolute bottom-8 left-2 z-20 flex flex-col items-start gap-1'>
{/* 에러 표시 */}
{error && (
<div
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.5)',
color: '#f87171',
}}
>
</div>
)}
{/* 클래스별 감지 결과 */}
{hasRegions && result !== null && (
<>
{result.regions.map((region) => {
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : '#f87171';
const label = OIL_CLASS_NAMES[region.classId] || region.className;
return (
<div
key={region.classId}
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: `${color}33`,
border: `1px solid ${color}80`,
color,
}}
>
{label}: {formatArea(region.areaM2)} ({region.percentage.toFixed(1)}%)
</div>
);
})}
{/* 합계 */}
<div
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.5)',
color: '#f87171',
}}
>
: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
</div>
</>
)}
{/* 감지 없음 */}
{!hasRegions && !isAnalyzing && !error && (
<div
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
style={{
background: 'rgba(34,197,94,0.15)',
border: '1px solid rgba(34,197,94,0.35)',
color: '#4ade80',
}}
>
</div>
)}
{/* 분석 중 */}
{isAnalyzing && (
<span className='text-[9px] font-korean text-text-3 animate-pulse px-1'>
...
</span>
)}
</div>
</>
);
});
OilDetectionOverlay.displayName = 'OilDetectionOverlay';
export default OilDetectionOverlay;

파일 보기

@ -0,0 +1,84 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import type { OilDetectionResult, OilDetectionConfig } from '../utils/oilDetection';
import { detectOilSpillAPI, DEFAULT_OIL_DETECTION_CONFIG } from '../utils/oilDetection';
interface UseOilDetectionOptions {
videoRef: React.RefObject<HTMLVideoElement | null>;
enabled: boolean;
config?: Partial<OilDetectionConfig>;
}
interface UseOilDetectionReturn {
result: OilDetectionResult | null;
isAnalyzing: boolean;
error: string | null;
}
export function useOilDetection(options: UseOilDetectionOptions): UseOilDetectionReturn {
const { videoRef, enabled, config } = options;
const [result, setResult] = useState<OilDetectionResult | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
const configRef = useRef<OilDetectionConfig>({
...DEFAULT_OIL_DETECTION_CONFIG,
...config,
});
const isBusyRef = useRef(false);
useEffect(() => {
configRef.current = {
...DEFAULT_OIL_DETECTION_CONFIG,
...config,
};
}, [config]);
const analyze = useCallback(async () => {
if (isBusyRef.current) return; // 이전 호출이 진행 중이면 스킵
const video = videoRef.current;
if (!video || video.readyState < 2) return;
isBusyRef.current = true;
setIsAnalyzing(true);
try {
const detection = await detectOilSpillAPI(video, configRef.current);
setResult(detection);
setError(null);
} catch (err) {
// API 실패 시 이전 결과 유지, 에러 메시지만 갱신
const message = err instanceof Error ? err.message : '추론 서버 연결 불가';
setError(message);
console.warn('[OilDetection] API 호출 실패:', message);
} finally {
isBusyRef.current = false;
setIsAnalyzing(false);
}
}, [videoRef]);
useEffect(() => {
if (!enabled) {
setResult(null);
setIsAnalyzing(false);
setError(null);
isBusyRef.current = false;
return;
}
setIsAnalyzing(true);
// 첫 분석: 2초 후 (영상 로딩 대기)
const firstTimeout = setTimeout(analyze, 2000);
// 반복 분석
const intervalId = setInterval(analyze, configRef.current.captureIntervalMs);
return () => {
clearTimeout(firstTimeout);
clearInterval(intervalId);
};
}, [enabled, analyze]);
return { result, isAnalyzing, error };
}

파일 보기

@ -0,0 +1,165 @@
/**
* GPU API
*
* (starsafire) ResNet101+DANet
* base64 JPEG POST /api/aerial/oil-detect
*
* 5 클래스: background(0), black(1), brown(2), rainbow(3), silver(4)
*/
import { api } from '@common/services/api';
// ── Types ──────────────────────────────────────────────────────────────────
export interface OilDetectionConfig {
captureIntervalMs: number; // API 호출 주기 (ms), default 5000
coverageAreaM2: number; // 카메라 커버리지 면적 (m²), default 10000
captureWidth: number; // 캡처 해상도 (너비), default 512
}
/** 유류 클래스 정의 */
export interface OilClass {
classId: number;
className: string;
color: [number, number, number]; // RGB
thicknessMm: number;
}
/** 개별 유류 영역 (API 응답에서 변환) */
export interface OilRegion {
classId: number;
className: string;
pixelCount: number;
percentage: number;
areaM2: number;
thicknessMm: number;
}
/** 감지 결과 (오버레이에서 사용) */
export interface OilDetectionResult {
regions: OilRegion[];
totalPercentage: number;
totalAreaM2: number;
mask: Uint8Array; // 클래스 인덱스 (0-4)
maskWidth: number;
maskHeight: number;
timestamp: number;
}
// ── Constants ──────────────────────────────────────────────────────────────
export const DEFAULT_OIL_DETECTION_CONFIG: OilDetectionConfig = {
captureIntervalMs: 5000,
coverageAreaM2: 10000,
captureWidth: 512,
};
/** 유류 클래스 팔레트 (시립대 starsafire 기준) */
export const OIL_CLASSES: OilClass[] = [
{ classId: 1, className: 'black', color: [0, 0, 204], thicknessMm: 1.0 },
{ classId: 2, className: 'brown', color: [180, 180, 180], thicknessMm: 0.1 },
{ classId: 3, className: 'rainbow', color: [255, 255, 0], thicknessMm: 0.0003 },
{ classId: 4, className: 'silver', color: [178, 102, 255], thicknessMm: 0.0001 },
];
export const OIL_CLASS_NAMES: Record<number, string> = {
1: '에멀전(Black)',
2: '원유(Brown)',
3: '무지개막(Rainbow)',
4: '은색막(Silver)',
};
// ── Frame Capture ──────────────────────────────────────────────────────────
/**
* base64 JPEG .
*/
export function captureFrameAsBase64(
video: HTMLVideoElement,
targetWidth: number,
): string | null {
if (video.readyState < 2 || video.videoWidth === 0) return null;
const aspect = video.videoHeight / video.videoWidth;
const w = targetWidth;
const h = Math.round(w * aspect);
try {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(video, 0, 0, w, h);
// data:image/jpeg;base64,... → base64 부분만 추출
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
return dataUrl.split(',')[1] || null;
} catch {
return null;
}
}
// ── API Inference ──────────────────────────────────────────────────────────
interface ApiInferenceRegion {
classId: number;
className: string;
pixelCount: number;
percentage: number;
thicknessMm: number;
}
interface ApiInferenceResponse {
mask: string; // base64 uint8 array
width: number;
height: number;
regions: ApiInferenceRegion[];
}
/**
* GPU .
*/
export async function detectOilSpillAPI(
video: HTMLVideoElement,
config: OilDetectionConfig,
): Promise<OilDetectionResult | null> {
const imageBase64 = captureFrameAsBase64(video, config.captureWidth);
if (!imageBase64) return null;
const response = await api.post<ApiInferenceResponse>('/aerial/oil-detect', {
image: imageBase64,
});
const { mask: maskB64, width, height, regions: apiRegions } = response.data;
const totalPixels = width * height;
// base64 → Uint8Array
const binaryStr = atob(maskB64);
const mask = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
mask[i] = binaryStr.charCodeAt(i);
}
// API 영역 → OilRegion 변환 (면적 계산 포함)
const regions: OilRegion[] = apiRegions.map((r) => ({
classId: r.classId,
className: r.className,
pixelCount: r.pixelCount,
percentage: r.percentage,
areaM2: (r.pixelCount / totalPixels) * config.coverageAreaM2,
thicknessMm: r.thicknessMm,
}));
const totalPercentage = regions.reduce((sum, r) => sum + r.percentage, 0);
const totalAreaM2 = regions.reduce((sum, r) => sum + r.areaM2, 0);
return {
regions,
totalPercentage,
totalAreaM2,
mask,
maskWidth: width,
maskHeight: height,
timestamp: Date.now(),
};
}

파일 보기

@ -74,6 +74,11 @@ export async function deleteBoardPost(sn: number): Promise<void> {
await api.delete(`/board/${sn}`);
}
/** 관리자 전용 삭제 — 소유자 검증 없음 */
export async function adminDeleteBoardPost(sn: number): Promise<void> {
await api.post('/board/admin-delete', { sn });
}
// ============================================================
// 매뉴얼 API
// ============================================================

파일 보기

@ -36,8 +36,8 @@ export interface HNSInputParams {
interface HNSLeftPanelProps {
activeSubTab: 'analysis' | 'list';
onSubTabChange: (tab: 'analysis' | 'list') => void;
incidentCoord: { lon: number; lat: number };
onCoordChange: (coord: { lon: number; lat: number }) => void;
incidentCoord: { lon: number; lat: number } | null;
onCoordChange: (coord: { lon: number; lat: number } | null) => void;
onMapSelectClick: () => void;
onRunPrediction: () => void;
isRunningPrediction: boolean;
@ -112,7 +112,7 @@ export function HNSLeftPanel({
}, [loadedParams]);
// 기상정보 자동조회 (사고 발생 일시 기반)
const weather = useWeatherFetch(incidentCoord.lat, incidentCoord.lon, accidentDate, accidentTime);
const weather = useWeatherFetch(incidentCoord?.lat ?? 0, incidentCoord?.lon ?? 0, accidentDate, accidentTime);
// 물질 독성 정보
const tox = getSubstanceToxicity(substance);
@ -272,15 +272,23 @@ export function HNSLeftPanel({
className="prd-i flex-1 font-mono"
type="number"
step="0.0001"
value={incidentCoord.lat.toFixed(4)}
onChange={(e) => onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })}
value={incidentCoord?.lat.toFixed(4) ?? ''}
placeholder="위도"
onChange={(e) => {
const lat = parseFloat(e.target.value) || 0;
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat });
}}
/>
<input
className="prd-i flex-1 font-mono"
type="number"
step="0.0001"
value={incidentCoord.lon.toFixed(4)}
onChange={(e) => onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })}
value={incidentCoord?.lon.toFixed(4) ?? ''}
placeholder="경도"
onChange={(e) => {
const lon = parseFloat(e.target.value) || 0;
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon });
}}
/>
<button className="prd-map-btn" onClick={onMapSelectClick}>
📍
@ -290,7 +298,7 @@ export function HNSLeftPanel({
{/* DMS 표시 */}
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
{toDMS(incidentCoord.lat, 'lat')} / {toDMS(incidentCoord.lon, 'lon')}
{incidentCoord ? `${toDMS(incidentCoord.lat, 'lat')} / ${toDMS(incidentCoord.lon, 'lon')}` : '지도에서 위치를 선택하세요'}
</div>
{/* 유출형태 + 물질명 */}

파일 보기

@ -156,7 +156,7 @@ function DispersionTimeSlider({
export function HNSView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
const { user } = useAuthStore();
const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 });
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -184,6 +184,7 @@ export function HNSView() {
setCurrentFrame(0);
setIsPuffPlaying(false);
setInputParams(null);
setIncidentCoord(null);
hasRunOnce.current = false;
}, []);
@ -320,6 +321,11 @@ export function HNSView() {
try {
const params = paramsOverride ?? inputParams;
if (!incidentCoord) {
alert('사고 지점을 먼저 지도에서 선택하세요.');
setIsRunningPrediction(false);
return;
}
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
@ -694,7 +700,7 @@ export function HNSView() {
) : (
<>
<MapView
incidentCoord={incidentCoord}
incidentCoord={incidentCoord ?? undefined}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,5 +1,5 @@
import { useState, useMemo, useCallback } from 'react'
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import type { Layer } from '@deck.gl/core'
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
@ -12,6 +12,7 @@ import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
import { WindParticleLayer } from './WindParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData'
import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls'
type TimeOffset = '0' | '3' | '6' | '9'
@ -117,38 +118,6 @@ function DeckGLOverlay({ layers }: { layers: Layer[] }) {
return null
}
// 줌 컨트롤
function WeatherMapControls() {
const { current: map } = useMap()
return (
<div className="absolute top-4 right-4 z-10">
<div className="flex flex-col gap-2">
<button
onClick={() => map?.zoomIn()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
+
</button>
<button
onClick={() => map?.zoomOut()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
</button>
<button
onClick={() =>
map?.flyTo({ center: WEATHER_MAP_CENTER, zoom: WEATHER_MAP_ZOOM, duration: 1000 })
}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
>
&#x1F3AF;
</button>
</div>
</div>
)
}
/**
* WeatherMapInner Map (useMap / useControl )
*/
@ -159,6 +128,9 @@ interface WeatherMapInnerProps {
oceanForecastOpacity: number
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
onStationClick: (station: WeatherStation) => void
mapCenter: [number, number]
mapZoom: number
clickedLocation: { lat: number; lon: number } | null
}
function WeatherMapInner({
@ -168,6 +140,9 @@ function WeatherMapInner({
oceanForecastOpacity,
selectedForecast,
onStationClick,
mapCenter,
mapZoom,
clickedLocation,
}: WeatherMapInnerProps) {
// deck.gl layers 조합
const weatherDeckLayers = useWeatherDeckLayers(
@ -216,8 +191,31 @@ function WeatherMapInner({
stations={weatherStations}
/>
{/* 클릭 위치 마커 */}
{clickedLocation && (
<Marker
longitude={clickedLocation.lon}
latitude={clickedLocation.lat}
anchor="bottom"
>
<div className="flex flex-col items-center pointer-events-none">
{/* 펄스 링 */}
<div className="relative flex items-center justify-center">
<div className="absolute w-8 h-8 rounded-full border-2 border-primary-cyan animate-ping opacity-60" />
<div className="w-4 h-4 rounded-full bg-primary-cyan border-2 border-white shadow-lg" />
</div>
{/* 핀 꼬리 */}
<div className="w-px h-3 bg-primary-cyan" />
{/* 좌표 라벨 */}
<div className="px-2 py-1 bg-bg-0/90 border border-primary-cyan rounded text-[10px] text-primary-cyan whitespace-nowrap backdrop-blur-sm">
{clickedLocation.lat.toFixed(3)}°N&nbsp;{clickedLocation.lon.toFixed(3)}°E
</div>
</div>
</Marker>
)}
{/* 줌 컨트롤 */}
<WeatherMapControls />
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
</>
)
}
@ -225,6 +223,7 @@ function WeatherMapInner({
export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
const {
selectedForecast,
availableTimes,
@ -238,7 +237,7 @@ export function WeatherView() {
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | 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)
// 첫 관측소 자동 선택 (파생 값)
@ -343,12 +342,6 @@ export function WeatherView() {
</div>
<div className="flex-1" />
<div className="px-6">
<button className="px-4 py-2 text-xs font-semibold rounded bg-status-red text-white hover:opacity-90 transition-opacity">
🚨
</button>
</div>
</div>
{/* Map */}
@ -371,6 +364,9 @@ export function WeatherView() {
oceanForecastOpacity={oceanForecastOpacity}
selectedForecast={selectedForecast}
onStationClick={handleStationClick}
mapCenter={WEATHER_MAP_CENTER}
mapZoom={WEATHER_MAP_ZOOM}
clickedLocation={selectedLocation}
/>
</Map>
@ -396,6 +392,7 @@ export function WeatherView() {
/>
<span className="text-xs text-text-2">🌬 </span>
</label>
{/*
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@ -405,6 +402,7 @@ export function WeatherView() {
/>
<span className="text-xs text-text-2">📊 </span>
</label>
*/}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@ -482,8 +480,8 @@ export function WeatherView() {
key={`${time.day}-${time.hour}`}
onClick={() => selectForecast(time.day, time.hour)}
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
selectedForecast?.day === time.day &&
selectedForecast?.hour === time.hour
selectedForecast?.ofcFrcstYmd === time.day &&
selectedForecast?.ofcFrcstTm === time.hour
? 'bg-primary-cyan text-bg-0 font-semibold'
: '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>}
{selectedForecast && (
<div className="text-xs text-text-3 pt-2 border-t border-border">
: {selectedForecast.name} {' '}
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '}
{selectedForecast.hour}:00
: {selectedForecast.ofcBrnchNm} {' '}
{selectedForecast.ofcFrcstYmd.slice(4, 6)}/{selectedForecast.ofcFrcstYmd.slice(6, 8)}{' '}
{selectedForecast.ofcFrcstTm}:00
</div>
)}
</div>

파일 보기

@ -70,9 +70,9 @@ export function useOceanForecast(
// 사용 가능한 시간대 목록 생성
const availableTimes = forecasts
.map((f) => ({
day: f.day,
hour: f.hour,
label: `${f.day.slice(4, 6)}/${f.day.slice(6, 8)} ${f.hour}:00`
day: f.ofcFrcstYmd,
hour: f.ofcFrcstTm,
label: `${f.ofcFrcstYmd.slice(4, 6)}/${f.ofcFrcstYmd.slice(6, 8)} ${f.ofcFrcstTm}:00`
}))
.sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`))

파일 보기

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

파일 보기

@ -2,7 +2,7 @@
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || ''
const BASE_URL = 'https://www.khoa.go.kr/api/oceangrid/DataType/search.do'
const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService'
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService'
// 지역 유형 (총 20개 지역)
@ -22,20 +22,21 @@ export const OCEAN_REGIONS = {
} as const
export interface OceanForecastData {
name: string // 지역
type: string // 지역유형
day: string // 예보날짜 (YYYYMMDD)
hour: string // 예보시간 (HH)
fileName: string // 이미지 파일명
filePath: string // 해양예측 이미지 URL
imgFileNm: string // 이미지 파일
imgFilePath: string // 이미지 파일경로
ofcBrnchId: string // 해황예보도 지점코드
ofcBrnchNm: string // 해황예보도 지점이름
ofcFrcstTm: string // 해황예보도 예보시각
ofcFrcstYmd: string // 해황예보도 예보일자
}
export interface OceanForecastResponse {
result: {
data: OceanForecastData[]
meta: {
totalCount: number
}
interface OceanForecastApiResponse {
header: { resultCode: string; resultMsg: string }
body: {
items: { item: OceanForecastData[] }
pageNo: number
numOfRows: number
totalCount: number
}
}
@ -49,9 +50,9 @@ export async function getOceanForecast(
): Promise<OceanForecastData[]> {
try {
const params = new URLSearchParams({
ServiceKey: API_KEY,
type: regionType,
ResultType: 'json'
serviceKey: API_KEY,
areaCode: regionType,
type: 'json',
})
const response = await fetch(`${BASE_URL}?${params}`)
@ -60,20 +61,8 @@ export async function getOceanForecast(
throw new Error(`HTTP Error: ${response.status}`)
}
const data = await response.json()
// API 응답 구조에 따라 데이터 추출
if (data?.result?.data) {
return data.result.data
}
// 응답이 배열 형태인 경우
if (Array.isArray(data)) {
return data
}
console.warn('Unexpected API response structure:', data)
return []
const data = await response.json() as OceanForecastApiResponse
return data?.body?.items?.item ?? []
} catch (error) {
console.error('해황예보도 조회 오류:', error)
@ -89,10 +78,9 @@ export async function getOceanForecast(
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
if (!forecasts || forecasts.length === 0) return null
// 날짜와 시간을 기준으로 정렬
const sorted = [...forecasts].sort((a, b) => {
const dateTimeA = `${a.day}${a.hour}`
const dateTimeB = `${b.day}${b.hour}`
const dateTimeA = `${a.ofcFrcstYmd}${a.ofcFrcstTm}`
const dateTimeB = `${b.ofcFrcstYmd}${b.ofcFrcstTm}`
return dateTimeB.localeCompare(dateTimeA)
})
@ -112,7 +100,7 @@ export function getForecastByTime(
targetHour: string
): OceanForecastData | null {
return (
forecasts.find((f) => f.day === targetDay && f.hour === targetHour) || null
forecasts.find((f) => f.ofcFrcstYmd === targetDay && f.ofcFrcstTm === targetHour) || null
)
}
@ -157,23 +145,25 @@ export async function getRecentObservation(obsCode: string): Promise<RecentObser
})
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const item = data?.result?.data?.[0]
const item = data.body ? data.body.items.item[0] : null
if (!item) return null
return {
water_temp: item.water_temp != null ? parseFloat(item.water_temp) : null,
air_temp: item.air_temp != null ? parseFloat(item.air_temp) : null,
air_pres: item.air_pres != null ? parseFloat(item.air_pres) : null,
wind_dir: item.wind_dir != null ? parseFloat(item.wind_dir) : null,
wind_speed: item.wind_speed != null ? parseFloat(item.wind_speed) : null,
current_dir: item.current_dir != null ? parseFloat(item.current_dir) : null,
current_speed: item.current_speed != null ? parseFloat(item.current_speed) : null,
tide_level: item.tide_level != null ? parseFloat(item.tide_level) : null,
water_temp: item.wtem != null ? parseFloat(item.wtem) : null,
air_temp: item.artmp != null ? parseFloat(item.artmp) : null,
air_pres: item.atmpr != null ? parseFloat(item.atmpr) : null,
wind_dir: item.wndrct != null ? parseFloat(item.wndrct) : null,
wind_speed: item.wspd != null ? parseFloat(item.wspd) : null,
current_dir: item.crdir != null ? parseFloat(item.crdir) : null,
current_speed: item.crsp != null ? parseFloat(item.crsp) : null,
tide_level: item.bscTdlvHgt != null ? parseFloat(item.bscTdlvHgt) : null,
}
} catch (error) {
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)

파일 보기

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