Merge pull request 'release: 2026-03-16 (81건 커밋)' (#93) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 44s

This commit is contained in:
jhkang 2026-03-16 18:35:59 +09:00
커밋 99c2e8d6ae
107개의 변경된 파일3701개의 추가작업 그리고 1111개의 파일을 삭제

파일 보기

@ -1,5 +1,6 @@
import express from 'express';
import multer from 'multer';
import path from 'path';
import {
listMedia,
createMedia,
@ -13,6 +14,10 @@ import {
requestOilInference,
checkInferenceHealth,
stitchImages,
listDroneStreams,
startDroneStream,
stopDroneStream,
getHlsDirectory,
} from './aerialService.js';
import { isValidNumber } from '../middleware/security.js';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
@ -121,6 +126,92 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req
}
});
// ============================================================
// KBS 재난안전포탈 CCTV HLS 리졸버
// ============================================================
/** KBS cctvId → 실제 HLS m3u8 URL 캐시 (5분 TTL) */
const kbsHlsCache = new Map<string, { url: string; ts: number }>();
const KBS_CACHE_TTL = 5 * 60 * 1000;
// GET /api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8 — KBS CCTV를 HLS로 리졸브 + 프록시
router.get('/cctv/kbs-hls/:cctvId/stream.m3u8', async (req, res) => {
try {
const cctvId = req.params.cctvId as string;
if (!/^\d+$/.test(cctvId)) {
res.status(400).json({ error: '유효하지 않은 cctvId' });
return;
}
let m3u8Url: string | null = null;
// 캐시 확인
const cached = kbsHlsCache.get(cctvId);
if (cached && Date.now() - cached.ts < KBS_CACHE_TTL) {
m3u8Url = cached.url;
} else {
// 1단계: KBS 팝업 API에서 loomex API URL 추출
const popupRes = await fetch(
`https://d.kbs.co.kr/special/cctv/cctvPopup?type=LIVE&cctvId=${cctvId}`,
{ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' } },
);
if (!popupRes.ok) {
res.status(502).json({ error: 'KBS 팝업 API 응답 실패' });
return;
}
const popupHtml = await popupRes.text();
const urlMatch = popupHtml.match(/id="url"\s+value="([^"]+)"/);
if (!urlMatch) {
res.status(502).json({ error: 'KBS 스트림 URL을 찾을 수 없습니다' });
return;
}
// 2단계: loomex API에서 실제 m3u8 URL 획득
const loomexRes = await fetch(urlMatch[1], {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
});
if (!loomexRes.ok) {
res.status(502).json({ error: 'KBS 스트림 서버 응답 실패' });
return;
}
m3u8Url = (await loomexRes.text()).trim();
kbsHlsCache.set(cctvId, { url: m3u8Url, ts: Date.now() });
}
// 3단계: m3u8 매니페스트를 프록시하여 세그먼트 URL 재작성
const upstream = await fetch(m3u8Url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
});
if (!upstream.ok) {
// 캐시 무효화 후 재시도 유도
kbsHlsCache.delete(cctvId);
res.status(502).json({ error: 'HLS 매니페스트 가져오기 실패' });
return;
}
const text = await upstream.text();
const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1);
const proxyBase = '/api/aerial/cctv/stream-proxy?url=';
const rewritten = text.replace(/^(?!#)(\S+)/gm, (line) => {
if (line.startsWith('http://') || line.startsWith('https://')) {
return `${proxyBase}${encodeURIComponent(line)}`;
}
return `${proxyBase}${encodeURIComponent(baseUrl + line)}`;
});
res.set({
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
});
res.send(rewritten);
} catch (err) {
console.error('[aerial] KBS HLS 리졸버 오류:', err);
res.status(502).json({ error: 'KBS HLS 스트림 리졸브 실패' });
}
});
// ============================================================
// CCTV HLS 스트림 프록시 (CORS 우회)
// ============================================================
@ -129,6 +220,7 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req
const ALLOWED_STREAM_HOSTS = [
'www.khoa.go.kr',
'kbsapi.loomex.net',
'kbscctv-cache.loomex.net',
];
// GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안)
@ -201,6 +293,87 @@ router.get('/cctv/stream-proxy', async (req, res) => {
}
});
// ============================================================
// DRONE STREAM 라우트 (RTSP → HLS)
// ============================================================
// GET /api/aerial/drone/streams — 드론 스트림 목록 + 상태
router.get('/drone/streams', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
try {
const streams = listDroneStreams();
res.json(streams);
} catch (err) {
console.error('[aerial] 드론 스트림 목록 오류:', err);
res.status(500).json({ error: '드론 스트림 목록 조회 실패' });
}
});
// POST /api/aerial/drone/streams/:id/start — 드론 스트림 시작 (RTSP→HLS 변환)
router.post('/drone/streams/:id/start', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const result = startDroneStream(req.params.id as string);
if (!result.success) {
res.status(400).json({ error: result.error });
return;
}
res.json(result);
} catch (err) {
console.error('[aerial] 드론 스트림 시작 오류:', err);
res.status(500).json({ error: '드론 스트림 시작 실패' });
}
});
// POST /api/aerial/drone/streams/:id/stop — 드론 스트림 중지
router.post('/drone/streams/:id/stop', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const result = stopDroneStream(req.params.id as string);
res.json(result);
} catch (err) {
console.error('[aerial] 드론 스트림 중지 오류:', err);
res.status(500).json({ error: '드론 스트림 중지 실패' });
}
});
// GET /api/aerial/drone/hls/:id/* — HLS 정적 파일 서빙 (.m3u8, .ts)
router.get('/drone/hls/:id/*', async (req, res) => {
try {
const id = req.params.id as string;
const hlsDir = getHlsDirectory(id);
if (!hlsDir) {
res.status(404).json({ error: '스트림을 찾을 수 없습니다' });
return;
}
// wildcard: req.params[0] contains the rest of the path
// Cast through unknown because @types/express v5 types the wildcard key as string[]
const rawParams = req.params as unknown as Record<string, string | string[]>;
const wildcardRaw = rawParams['0'] ?? '';
const wildcardParam = Array.isArray(wildcardRaw) ? wildcardRaw.join('/') : wildcardRaw;
const filePath = path.join(hlsDir, wildcardParam);
// Security: prevent path traversal
if (!filePath.startsWith(hlsDir)) {
res.status(403).json({ error: '접근 거부' });
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = ext === '.m3u8' ? 'application/vnd.apple.mpegurl'
: ext === '.ts' ? 'video/mp2t'
: 'application/octet-stream';
res.set({
'Content-Type': contentType,
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
});
res.sendFile(filePath);
} catch (err) {
console.error('[aerial] HLS 파일 서빙 오류:', err);
res.status(404).json({ error: 'HLS 파일을 찾을 수 없습니다' });
}
});
// ============================================================
// SAT_REQUEST 라우트
// ============================================================

파일 보기

@ -1,4 +1,8 @@
import { wingPool } from '../db/wingDb.js';
import { spawn, type ChildProcess } from 'child_process';
import { existsSync, mkdirSync, rmSync } from 'fs';
import { execSync } from 'child_process';
import path from 'path';
// ============================================================
// AERIAL_MEDIA
@ -365,6 +369,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
// ============================================================
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
const INFERENCE_TIMEOUT_MS = 10_000;
export interface OilInferenceRegion {
@ -408,8 +413,9 @@ export async function stitchImages(
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(`${IMAGE_API_URL}/inference`, {
const response = await fetch(`${OIL_INFERENCE_URL}/inference`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageBase64 }),
@ -430,7 +436,7 @@ export async function requestOilInference(imageBase64: string): Promise<OilInfer
/** GPU 추론 서버 헬스체크 */
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
try {
const response = await fetch(`${IMAGE_API_URL}/health`, {
const response = await fetch(`${OIL_INFERENCE_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) throw new Error(`status ${response.status}`);
@ -439,3 +445,175 @@ export async function checkInferenceHealth(): Promise<{ status: string; device?:
return { status: 'unavailable' };
}
}
// ============================================================
// DRONE STREAM (RTSP → HLS via FFmpeg)
// ============================================================
export interface DroneStreamConfig {
id: string;
name: string;
shipName: string;
droneModel: string;
ip: string;
rtspUrl: string;
region: string;
}
export interface DroneStreamStatus extends DroneStreamConfig {
status: 'idle' | 'starting' | 'streaming' | 'error';
hlsUrl: string | null;
error: string | null;
}
const DRONE_STREAMS: DroneStreamConfig[] = [
{ id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산' },
{ id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천' },
{ id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포' },
];
const HLS_OUTPUT_DIR = '/tmp/wing-drone-hls';
const activeProcesses = new Map<string, { process: ChildProcess; status: 'starting' | 'streaming' | 'error'; error: string | null }>();
function getHlsDir(id: string): string {
return path.join(HLS_OUTPUT_DIR, id);
}
function checkFfmpeg(): boolean {
try {
execSync('which ffmpeg', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
export function listDroneStreams(): DroneStreamStatus[] {
return DRONE_STREAMS.map(ds => {
const active = activeProcesses.get(ds.id);
return {
...ds,
status: active?.status ?? 'idle',
hlsUrl: active?.status === 'streaming' ? `/api/aerial/drone/hls/${ds.id}/stream.m3u8` : null,
error: active?.error ?? null,
};
});
}
export function startDroneStream(id: string): { success: boolean; error?: string; hlsUrl?: string } {
const config = DRONE_STREAMS.find(d => d.id === id);
if (!config) return { success: false, error: '알 수 없는 드론 스트림 ID' };
if (activeProcesses.has(id)) {
const existing = activeProcesses.get(id)!;
if (existing.status === 'streaming' || existing.status === 'starting') {
return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` };
}
}
if (!checkFfmpeg()) {
return { success: false, error: 'FFmpeg가 설치되어 있지 않습니다. 서버에 FFmpeg를 설치하세요.' };
}
const hlsDir = getHlsDir(id);
if (!existsSync(hlsDir)) {
mkdirSync(hlsDir, { recursive: true });
}
const outputPath = path.join(hlsDir, 'stream.m3u8');
const ffmpeg = spawn('ffmpeg', [
'-rtsp_transport', 'tcp',
'-i', config.rtspUrl,
'-c:v', 'copy',
'-c:a', 'aac',
'-f', 'hls',
'-hls_time', '2',
'-hls_list_size', '5',
'-hls_flags', 'delete_segments',
'-y',
outputPath,
], { stdio: ['ignore', 'pipe', 'pipe'] });
const entry = { process: ffmpeg, status: 'starting' as const, error: null as string | null };
activeProcesses.set(id, entry);
// Monitor for m3u8 file creation to confirm streaming
const checkInterval = setInterval(() => {
if (existsSync(outputPath)) {
const e = activeProcesses.get(id);
if (e && e.status === 'starting') {
e.status = 'streaming';
}
clearInterval(checkInterval);
}
}, 500);
// Timeout after 15 seconds
setTimeout(() => {
clearInterval(checkInterval);
const e = activeProcesses.get(id);
if (e && e.status === 'starting') {
e.status = 'error';
e.error = 'RTSP 연결 시간 초과 — 내부망에서만 접속 가능합니다.';
ffmpeg.kill('SIGTERM');
}
}, 15000);
let stderrBuf = '';
ffmpeg.stderr?.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString();
});
ffmpeg.on('close', (code) => {
clearInterval(checkInterval);
const e = activeProcesses.get(id);
if (e) {
if (e.status !== 'error') {
e.status = 'error';
e.error = code !== 0
? `FFmpeg 종료 (코드: ${code})${stderrBuf.includes('Connection refused') ? ' — RTSP 연결 거부됨' : ''}`
: '스트림 종료';
}
}
console.log(`[drone] FFmpeg 종료 (${id}): code=${code}`);
});
ffmpeg.on('error', (err) => {
clearInterval(checkInterval);
const e = activeProcesses.get(id);
if (e) {
e.status = 'error';
e.error = `FFmpeg 실행 오류: ${err.message}`;
}
});
return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` };
}
export function stopDroneStream(id: string): { success: boolean } {
const entry = activeProcesses.get(id);
if (!entry) return { success: true };
entry.process.kill('SIGTERM');
activeProcesses.delete(id);
// Cleanup HLS files
const hlsDir = getHlsDir(id);
try {
if (existsSync(hlsDir)) {
rmSync(hlsDir, { recursive: true, force: true });
}
} catch (err) {
console.error(`[drone] HLS 디렉토리 정리 실패 (${id}):`, err);
}
return { success: true };
}
export function getHlsDirectory(id: string): string | null {
const config = DRONE_STREAMS.find(d => d.id === id);
if (!config) return null;
const dir = getHlsDir(id);
return existsSync(dir) ? dir : null;
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -75,6 +75,7 @@ const allowedOrigins = [
...(process.env.NODE_ENV !== 'production' ? [
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:5175',
'http://localhost:3000',
] : []),
].filter(Boolean) as string[]

파일 보기

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

파일 보기

@ -0,0 +1,39 @@
-- KBS 재난안전포탈 CCTV 스트림 URL 추가 마이그레이션
-- 기존 KBS 카메라 6건 streamUrl + 좌표 업데이트 + 신규 15건 INSERT
-- URL: 백엔드 KBS HLS 리졸버 경유 (/api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8)
-- 좌표: KBS API (d.kbs.co.kr/special/cctv/list) 정확 좌표 반영
SET search_path TO wing, public;
-- 기존 KBS 카메라 streamUrl + 좌표 업데이트 (KBS API 정확 좌표)
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9981/stream.m3u8', LON = 126.5986, LAT = 37.4541, GEOM = ST_SetSRID(ST_MakePoint(126.5986, 37.4541), 4326), CAMERA_NM = '인천 연안부두' WHERE CCTV_SN = 100;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9994/stream.m3u8', LON = 127.7557, LAT = 34.7410, GEOM = ST_SetSRID(ST_MakePoint(127.7557, 34.7410), 4326), CAMERA_NM = '여수 오동도 앞' WHERE CCTV_SN = 97;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9984/stream.m3u8', LON = 126.7489, LAT = 34.3209, GEOM = ST_SetSRID(ST_MakePoint(126.7489, 34.3209), 4326) WHERE CCTV_SN = 108;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9986/stream.m3u8', LON = 128.6001, LAT = 38.2134, GEOM = ST_SetSRID(ST_MakePoint(128.6001, 38.2134), 4326), CAMERA_NM = '속초 등대전망대' WHERE CCTV_SN = 113;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9957/stream.m3u8', LON = 131.8686, LAT = 37.2394, GEOM = ST_SetSRID(ST_MakePoint(131.8686, 37.2394), 4326) WHERE CCTV_SN = 115;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9982/stream.m3u8', LON = 126.2684, LAT = 33.1139, GEOM = ST_SetSRID(ST_MakePoint(126.2684, 33.1139), 4326) WHERE CCTV_SN = 116;
-- 신규 KBS 재난안전포탈 CCTV 추가 (15건) — KBS API 정확 좌표
INSERT INTO CCTV_CAMERA (CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT, GEOM, LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL) VALUES
-- 서해
(200, '연평도', '서해', 125.6945, 37.6620, ST_SetSRID(ST_MakePoint(125.6945, 37.6620), 4326), '인천 옹진군 연평면', '37.66°N 125.69°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9958/stream.m3u8'),
(201, '군산 비응항', '서해', 126.5265, 35.9353, ST_SetSRID(ST_MakePoint(126.5265, 35.9353), 4326), '전북 군산시 비응도동', '35.94°N 126.53°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9979/stream.m3u8'),
(202, '태안 신진항', '서해', 126.1365, 36.6779, ST_SetSRID(ST_MakePoint(126.1365, 36.6779), 4326), '충남 태안군 근흥면', '36.68°N 126.14°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9980/stream.m3u8'),
-- 남해
(203, '창원 마산항', '남해', 128.5760, 35.1979, ST_SetSRID(ST_MakePoint(128.5760, 35.1979), 4326), '경남 창원시 마산합포구', '35.20°N 128.58°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9985/stream.m3u8'),
(204, '부산 민락항', '남해', 129.1312, 35.1538, ST_SetSRID(ST_MakePoint(129.1312, 35.1538), 4326), '부산 수영구 민락동', '35.15°N 129.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9991/stream.m3u8'),
(205, '목포 북항', '남해', 126.3652, 34.8042, ST_SetSRID(ST_MakePoint(126.3652, 34.8042), 4326), '전남 목포시 죽교동', '34.80°N 126.37°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9992/stream.m3u8'),
(206, '신안 가거도', '남해', 125.1293, 34.0529, ST_SetSRID(ST_MakePoint(125.1293, 34.0529), 4326), '전남 신안군 흑산면', '34.05°N 125.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9983/stream.m3u8'),
(207, '여수 거문도', '남해', 127.3074, 34.0232, ST_SetSRID(ST_MakePoint(127.3074, 34.0232), 4326), '전남 여수시 삼산면', '34.02°N 127.31°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9993/stream.m3u8'),
-- 동해
(208, '강릉 용강동', '동해', 128.8912, 37.7521, ST_SetSRID(ST_MakePoint(128.8912, 37.7521), 4326), '강원 강릉시 용강동', '37.75°N 128.89°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9952/stream.m3u8'),
(209, '강릉 주문진방파제', '동해', 128.8335, 37.8934, ST_SetSRID(ST_MakePoint(128.8335, 37.8934), 4326), '강원 강릉시 주문진읍', '37.89°N 128.83°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9995/stream.m3u8'),
(210, '대관령', '동해', 128.7553, 37.6980, ST_SetSRID(ST_MakePoint(128.7553, 37.6980), 4326), '강원 평창군 대관령면', '37.70°N 128.76°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9989/stream.m3u8'),
(211, '울릉 저동항', '동해', 130.9122, 37.4913, ST_SetSRID(ST_MakePoint(130.9122, 37.4913), 4326), '경북 울릉군 울릉읍', '37.49°N 130.91°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9987/stream.m3u8'),
(212, '포항 두호동 해안로', '동해', 129.3896, 36.0627, ST_SetSRID(ST_MakePoint(129.3896, 36.0627), 4326), '경북 포항시 북구 두호동', '36.06°N 129.39°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9988/stream.m3u8'),
(213, '울산 달동', '동해', 129.3265, 35.5442, ST_SetSRID(ST_MakePoint(129.3265, 35.5442), 4326), '울산 남구 달동', '35.54°N 129.33°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9955/stream.m3u8'),
-- 제주
(214, '제주 도남동', '제주', 126.5195, 33.4891, ST_SetSRID(ST_MakePoint(126.5195, 33.4891), 4326), '제주시 도남동', '33.49°N 126.52°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9954/stream.m3u8');
-- 시퀀스 리셋
SELECT setval('cctv_camera_cctv_sn_seq', (SELECT MAX(cctv_sn) FROM cctv_camera));

파일 보기

@ -4,6 +4,31 @@
## [Unreleased]
## [2026-03-16]
### 추가
- 보고서 확산예측 지도 캡처 기능 (OilSpreadMapPanel, MAP_CAPTURE_IMG DB 컬럼)
- 실시간 드론 지도 뷰 — 드론 위치 아이콘 + 클릭 스트림 연결
- CCTV 지도/리스트 뷰 전환 + CCTV 아이콘 + 다크 팝업 UI
- KBS CCTV HLS 직접 재생 + CCTV 위치 지도 + 좌표 정확도 개선
- 사용자 매뉴얼 팝업 기능 추가
- 확산예측 지도 밝은 해도 스타일 적용 (육지 회색 + 바다 파랑)
- KOSPS/앙상블 준비중 팝업 + 기본 모델 POSEIDON 변경
- 오염분석 원 분석 기능 — 중심점/반경 입력으로 원형 오염 면적 계산
- 오일펜스 배치 가이드 UI 개선
### 수정
- geo.ts 중복 함수 제거 및 null 좌표 참조 오류 수정
### 변경
- 확산 예측 요약 폰트/레이아웃을 오염 종합 상황과 통일
- 오염분석 UI 개선 — HTML 디자인 참고 반영
- 범례 UI 개선 — HTML 참고 디자인 반영
- 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일
### 기타
- 프론트엔드 포트 변경(5174) + CORS 허용
## [2026-03-13]
### 추가

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 743 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 551 KiB

파일 보기

@ -3,6 +3,7 @@ import type { MainTab } from '../../types/navigation'
import { useAuthStore } from '../../store/authStore'
import { useMenuStore } from '../../store/menuStore'
import { useMapStore } from '../../store/mapStore'
import UserManualPopup from '../ui/UserManualPopup'
interface TopBarProps {
activeTab: MainTab
@ -11,6 +12,7 @@ interface TopBarProps {
export function TopBar({ activeTab, onTabChange }: TopBarProps) {
const [showQuickMenu, setShowQuickMenu] = useState(false)
const [showManual, setShowManual] = useState(false)
const quickMenuRef = useRef<HTMLDivElement>(null)
const { hasPermission, user, logout } = useAuthStore()
const { menuConfig, isLoaded } = useMenuStore()
@ -192,10 +194,26 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
</div>
</button>
))}
<div className="my-1.5 border-t border-border" />
{/* 매뉴얼 */}
<button
onClick={() => {
setShowManual(true)
setShowQuickMenu(false)
}}
className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all"
>
<span className="text-[13px]">&#x1F4D6;</span>
</button>
</div>
)}
</div>
</div>
{/* 사용자 매뉴얼 팝업 */}
<UserManualPopup isOpen={showManual} onClose={() => setShowManual(false)} />
</div>
)
}

파일 보기

@ -4,6 +4,7 @@ import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
interface HydrParticleOverlayProps {
hydrStep: HydrDataStep | null;
lightMode?: boolean;
}
const PARTICLE_COUNT = 3000;
@ -21,7 +22,7 @@ interface Particle {
age: number;
}
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
export default function HydrParticleOverlay({ hydrStep, lightMode = false }: HydrParticleOverlayProps) {
const { current: map } = useMap();
const animRef = useRef<number>();
@ -125,7 +126,8 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
// alpha band별 일괄 렌더링
ctx.lineWidth = 0.8;
for (let b = 0; b < NUM_ALPHA_BANDS; b++) {
ctx.strokeStyle = `rgba(180, 210, 255, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`;
const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255];
ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`;
ctx.beginPath();
for (const [x1, y1, x2, y2] of bands[b]) {
ctx.moveTo(x1, y1);
@ -151,7 +153,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
map.off('move', onMove);
canvas.remove();
};
}, [map, hydrStep]);
}, [map, hydrStep, lightMode]);
return null;
}

파일 보기

@ -49,6 +49,166 @@ const BASE_STYLE: StyleSpecification = {
],
}
// MarineTraffic 스타일 — 깔끔한 연한 회색 육지 + 흰색 바다 + 한글 라벨
const LIGHT_STYLE: StyleSpecification = {
version: 8,
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
sources: {
'ofm-chart': {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet',
},
},
layers: [
// ── 배경 = 육지 (연한 회색) ──
{
id: 'land-bg',
type: 'background',
paint: { 'background-color': '#e8e8e8' },
},
// ── 바다/호수/강 = water 레이어 (파란색) ──
{
id: 'water',
type: 'fill',
source: 'ofm-chart',
'source-layer': 'water',
paint: { 'fill-color': '#a8cce0' },
},
// ── 주요 도로 (zoom 9+) ──
{
id: 'roads-major',
type: 'line',
source: 'ofm-chart',
'source-layer': 'transportation',
minzoom: 9,
filter: ['in', 'class', 'motorway', 'trunk', 'primary'],
paint: {
'line-color': '#c0c0c0',
'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.4, 14, 1.5],
},
},
// ── 보조 도로 (zoom 12+) ──
{
id: 'roads-secondary',
type: 'line',
source: 'ofm-chart',
'source-layer': 'transportation',
minzoom: 12,
filter: ['in', 'class', 'secondary', 'tertiary'],
paint: {
'line-color': '#cccccc',
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.3, 14, 1],
},
},
// ── 건물 (zoom 14+) ──
{
id: 'buildings',
type: 'fill',
source: 'ofm-chart',
'source-layer': 'building',
minzoom: 14,
paint: { 'fill-color': '#cbcbcb', 'fill-opacity': 0.5 },
},
// ── 국경선 ──
{
id: 'boundaries-country',
type: 'line',
source: 'ofm-chart',
'source-layer': 'boundary',
filter: ['==', 'admin_level', 2],
paint: { 'line-color': '#999999', 'line-width': 1, 'line-dasharray': [4, 2] },
},
// ── 시도 경계 (zoom 5+) ──
{
id: 'boundaries-province',
type: 'line',
source: 'ofm-chart',
'source-layer': 'boundary',
minzoom: 5,
filter: ['==', 'admin_level', 4],
paint: { 'line-color': '#bbbbbb', 'line-width': 0.5, 'line-dasharray': [3, 2] },
},
// ── 국가/시도 라벨 (한글) ──
{
id: 'place-labels-major',
type: 'symbol',
source: 'ofm-chart',
'source-layer': 'place',
minzoom: 3,
filter: ['in', 'class', 'country', 'state'],
layout: {
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
'text-font': ['Open Sans Bold'],
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 11, 8, 16],
'text-max-width': 8,
},
paint: {
'text-color': '#555555',
'text-halo-color': '#ffffff',
'text-halo-width': 2,
},
},
{
id: 'place-labels-city',
type: 'symbol',
source: 'ofm-chart',
'source-layer': 'place',
minzoom: 5,
filter: ['in', 'class', 'city', 'town'],
layout: {
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
'text-font': ['Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 12, 14],
'text-max-width': 7,
},
paint: {
'text-color': '#666666',
'text-halo-color': '#ffffff',
'text-halo-width': 1.5,
},
},
// ── 해양 지명 (water_name) ──
{
id: 'water-labels',
type: 'symbol',
source: 'ofm-chart',
'source-layer': 'water_name',
layout: {
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
'text-font': ['Open Sans Italic'],
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 10, 8, 14],
'text-max-width': 10,
'text-letter-spacing': 0.15,
},
paint: {
'text-color': '#8899aa',
'text-halo-color': 'rgba(168,204,224,0.7)',
'text-halo-width': 1,
},
},
// ── 마을/소지명 (zoom 10+) ──
{
id: 'place-labels-village',
type: 'symbol',
source: 'ofm-chart',
'source-layer': 'place',
minzoom: 10,
filter: ['in', 'class', 'village', 'suburb', 'hamlet'],
layout: {
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
'text-font': ['Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 9, 14, 12],
'text-max-width': 6,
},
paint: {
'text-color': '#777777',
'text-halo-color': '#ffffff',
'text-halo-width': 1,
},
},
],
}
// 3D 위성 스타일 (VWorld 위성 배경 + OpenFreeMap 건물 extrusion)
// VWorld WMTS: {z}/{y}/{x} (row/col 순서)
// OpenFreeMap: CORS 허용, 전 세계 OSM 벡터타일 (building: render_height 포함)
@ -186,7 +346,7 @@ interface MapViewProps {
hydrData?: (HydrDataStep | null)[]
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
externalCurrentTime?: number
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
mapCaptureRef?: React.MutableRefObject<(() => Promise<string | null>) | null>
onIncidentFlyEnd?: () => void
flyToIncident?: { lon: number; lat: number }
showCurrent?: boolean
@ -198,6 +358,10 @@ interface MapViewProps {
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
analysisCircleCenter?: { lat: number; lon: number } | null
analysisCircleRadiusM?: number
/** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */
lightMode?: boolean
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
showOverlays?: boolean
}
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@ -273,15 +437,34 @@ function MapFlyToIncident({ lon, lat, onFlyEnd }: { lon?: number; lat?: number;
return null
}
// 지도 캡처 지원 (preserveDrawingBuffer 필요)
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => string | null) | null> }) {
// 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지)
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => Promise<string | null>) | null> }) {
const { current: map } = useMap();
useEffect(() => {
if (!map) return;
captureRef.current = () => {
try { return map.getCanvas().toDataURL('image/png'); }
catch { return null; }
};
captureRef.current = () =>
new Promise<string | null>((resolve) => {
map.once('render', () => {
try {
// WebGL 캔버스는 alpha=0 투명 배경이므로 불투명 배경과 합성 후 추출
// 최대 1200px로 리사이즈 + JPEG 압축으로 전송 크기 절감
const src = map.getCanvas();
const maxW = 1200;
const scale = src.width > maxW ? maxW / src.width : 1;
const composite = document.createElement('canvas');
composite.width = Math.round(src.width * scale);
composite.height = Math.round(src.height * scale);
const ctx = composite.getContext('2d')!;
ctx.fillStyle = '#0f1117';
ctx.fillRect(0, 0, composite.width, composite.height);
ctx.drawImage(src, 0, 0, composite.width, composite.height);
resolve(composite.toDataURL('image/jpeg', 0.82));
} catch {
resolve(null);
}
});
map.triggerRepaint();
});
}, [map, captureRef]);
return null;
}
@ -329,6 +512,8 @@ export function MapView({
analysisPolygonPoints = [],
analysisCircleCenter,
analysisCircleRadiusM = 0,
lightMode = false,
showOverlays = true,
}: MapViewProps) {
const { mapToggles } = useMapStore()
const isControlled = externalCurrentTime !== undefined
@ -844,7 +1029,7 @@ export function MapView({
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`,
getSize: 12,
getColor: [255, 255, 255, 200],
getColor: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [number, number, number, number],
getPixelOffset: [0, -20],
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
@ -865,7 +1050,7 @@ export function MapView({
id: 'center-path',
data: [{ path: visibleCenters.map(p => [p.lon, p.lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [255, 220, 50, 200],
getColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 200]) as [number, number, number, number],
getWidth: 2,
widthMinPixels: 2,
widthMaxPixels: 4,
@ -879,7 +1064,7 @@ export function MapView({
data: visibleCenters,
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
getRadius: 5,
getFillColor: [255, 220, 50, 230],
getFillColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 230]) as [number, number, number, number],
radiusMinPixels: 4,
radiusMaxPixels: 8,
pickable: false,
@ -904,11 +1089,11 @@ export function MapView({
return `+${d.time}h`;
},
getSize: 12,
getColor: [255, 220, 50, 220] as [number, number, number, number],
getColor: (lightMode ? [20, 40, 100, 240] : [255, 220, 50, 220]) as [number, number, number, number],
getPixelOffset: [0, 16] as [number, number],
fontWeight: 'bold',
outlineWidth: 2,
outlineColor: [15, 21, 36, 200] as [number, number, number, number],
outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [number, number, number, number],
billboard: true,
sizeUnits: 'pixels' as const,
updateTriggers: {
@ -966,11 +1151,11 @@ export function MapView({
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
sensitiveResources, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
])
// 3D 모드에 따른 지도 스타일 전환
const currentMapStyle = mapToggles.threeD ? SATELLITE_3D_STYLE : BASE_STYLE
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
const currentMapStyle = mapToggles.threeD ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE
return (
<div className="w-full h-full relative">
@ -1026,7 +1211,7 @@ export function MapView({
{/* 해류 파티클 오버레이 */}
{hydrData.length > 0 && showCurrent && (
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} lightMode={lightMode} />
)}
{/* 사고 위치 마커 (MapLibre Marker) */}
@ -1078,15 +1263,15 @@ export function MapView({
)}
{/* 기상청 연계 정보 */}
<WeatherInfoPanel position={currentPosition} />
{showOverlays && <WeatherInfoPanel position={currentPosition} />}
{/* 범례 */}
<MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />
{showOverlays && <MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />}
{/* 좌표 표시 */}
<CoordinateDisplay
{showOverlays && <CoordinateDisplay
position={incidentCoord ? [incidentCoord.lat, incidentCoord.lon] : currentPosition}
/>
/>}
{/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */}
{!isControlled && oilTrajectory.length > 0 && (
@ -1152,97 +1337,127 @@ interface MapLegendProps {
selectedModels?: Set<PredictionModel>
}
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
const [minimized, setMinimized] = useState(false)
if (dispersionResult && incidentCoord) {
return (
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg p-3.5 min-w-[200px] z-[20]">
<div className="flex items-center gap-1.5 mb-2.5">
<div className="text-base">📍</div>
<div>
<h4 className="text-[11px] font-bold text-primary-orange"> </h4>
<div className="text-[8px] text-text-3 font-mono">
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg min-w-[200px] z-[20]">
{/* 헤더 + 최소화 버튼 */}
<div className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer" onClick={() => setMinimized(!minimized)}>
<span className="text-[10px] font-bold text-text-3 uppercase tracking-wider"></span>
<span className="text-[10px] text-text-3 hover:text-text-1 transition-colors">{minimized ? '▶' : '▼'}</span>
</div>
{!minimized && (
<div className="px-3.5 pb-3.5">
<div className="flex items-center gap-1.5 mb-2.5">
<div className="text-base">📍</div>
<div>
<h4 className="text-[11px] font-bold text-primary-orange"> </h4>
<div className="text-[8px] text-text-3 font-mono">
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
</div>
</div>
</div>
<div className="text-[9px] text-text-2 mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold text-status-orange">{dispersionResult.substance}</span>
</div>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold font-mono">SW {dispersionResult.windDirection}°</span>
</div>
<div className="flex justify-between">
<span className="text-text-3"> </span>
<span className="font-semibold text-primary-cyan">{dispersionResult.zones.length}</span>
</div>
</div>
<div>
<h5 className="text-[9px] font-bold text-text-3 mb-2"> </h5>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(239,68,68,0.7)' }} />
<span> (AEGL-3)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(249,115,22,0.7)' }} />
<span> (AEGL-2)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(234,179,8,0.7)' }} />
<span> (AEGL-1)</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 mt-2 rounded" style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}>
<div className="text-xs">🧭</div>
<span className="text-[9px] text-text-3"> ()</span>
</div>
</div>
</div>
<div className="text-[9px] text-text-2 mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold text-status-orange">{dispersionResult.substance}</span>
</div>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold font-mono">SW {dispersionResult.windDirection}°</span>
</div>
<div className="flex justify-between">
<span className="text-text-3"> </span>
<span className="font-semibold text-primary-cyan">{dispersionResult.zones.length}</span>
</div>
</div>
<div>
<h5 className="text-[9px] font-bold text-text-3 mb-2"> </h5>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(239,68,68,0.7)' }} />
<span> (AEGL-3)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(249,115,22,0.7)' }} />
<span> (AEGL-2)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(234,179,8,0.7)' }} />
<span> (AEGL-1)</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 mt-2 rounded" style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}>
<div className="text-xs">🧭</div>
<span className="text-[9px] text-text-3"> ()</span>
</div>
)}
</div>
)
}
if (oilTrajectory.length > 0) {
return (
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-md p-3.5 min-w-[180px] z-[20]">
<h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3 mb-2.5"></h4>
<div className="flex flex-col gap-1.5">
{Array.from(selectedModels).map(model => (
<div key={model} className="flex items-center gap-2 text-xs text-text-2">
<div className="w-3.5 h-3.5 rounded-full" style={{ background: MODEL_COLORS[model] }} />
<span className="font-korean">{model}</span>
</div>
))}
{selectedModels.size === 3 && (
<div className="flex items-center gap-2 text-[9px] text-text-3">
<span className="font-korean">( )</span>
</div>
)}
<div className="h-px bg-border my-1" />
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-3.5 h-3.5 rounded-full bg-primary-cyan" />
<span className="font-korean"> </span>
</div>
{boomLines.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-[14px] h-[3px] bg-[#ef4444] rounded-[1px]" />
<span className="font-korean"> </span>
</div>
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-[14px] h-[3px] bg-[#f97316] rounded-[1px]" />
<span className="font-korean"> </span>
</div>
<div className="flex items-center gap-2 text-xs text-text-2">
<div className="w-[14px] h-[3px] bg-[#eab308] rounded-[1px]" />
<span className="font-korean"> </span>
</div>
</>
)}
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md z-[20]" style={{ minWidth: 155 }}>
{/* 헤더 + 접기/펼치기 */}
<div
className="flex items-center justify-between px-3 py-2 cursor-pointer select-none"
onClick={() => setMinimized(!minimized)}
>
<span className="text-[10px] font-bold text-text-2 font-korean"></span>
<span className="text-[9px] text-text-3 hover:text-text-1 transition-colors ml-3">{minimized ? '▶' : '▼'}</span>
</div>
{!minimized && (
<div className="px-3 pb-2.5 flex flex-col gap-[5px]">
{/* 모델별 색상 */}
{Array.from(selectedModels).map(model => (
<div key={model} className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-[14px] h-[3px] rounded-sm" style={{ background: MODEL_COLORS[model] }} />
<span className="font-korean">{model}</span>
</div>
))}
{/* 앙상블 */}
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-[14px] h-[3px] rounded-sm" style={{ background: '#a855f7' }} />
<span className="font-korean"></span>
</div>
{/* 오일펜스 라인 */}
<div className="h-px bg-border my-0.5" />
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="flex gap-px">
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
</div>
<span className="font-korean"> </span>
</div>
{/* 도달시간별 선종 */}
<div className="h-px bg-border my-0.5" />
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-[14px] h-[3px] rounded-sm bg-[#ef4444]" />
<span className="font-korean"> (&lt;6h)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-[14px] h-[3px] rounded-sm bg-[#f97316]" />
<span className="font-korean"> (6~12h)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-[14px] h-[3px] rounded-sm bg-[#eab308]" />
<span className="font-korean"> (12~24h)</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-text-2">
<div className="w-[14px] h-[3px] rounded-sm bg-[#22c55e]" />
<span className="font-korean"></span>
</div>
</div>
)}
</div>
)
}

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

파일 보기

@ -169,6 +169,14 @@ export function consumeHnsReportPayload(): HnsReportPayload | null {
}
// ─── 유출유 예측 보고서 실 데이터 전달 ──────────────────────────
export interface OilReportMapParticle {
lat: number;
lon: number;
time: number;
particle?: number;
stranded?: 0 | 1;
}
export interface OilReportPayload {
incident: {
name: string;
@ -204,6 +212,14 @@ export interface OilReportPayload {
firstTime: string | null;
};
hasSimulation: boolean;
mapData: {
center: [number, number];
zoom: number;
trajectory: OilReportMapParticle[];
currentStep: number;
centerPoints: { lat: number; lon: number; time: number }[];
simulationStartTime: string;
} | null;
}
let _oilReportPayload: OilReportPayload | null = null;

파일 보기

@ -1,4 +1,29 @@
@layer components {
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
.cctv-dark-popup .maplibregl-popup-content {
background: #1a1f2e;
border-radius: 8px;
padding: 0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.cctv-dark-popup .maplibregl-popup-tip {
border-top-color: #1a1f2e;
border-bottom-color: #1a1f2e;
border-left-color: transparent;
border-right-color: transparent;
}
.cctv-dark-popup .maplibregl-popup-close-button {
color: #888;
font-size: 16px;
right: 4px;
top: 2px;
}
.cctv-dark-popup .maplibregl-popup-close-button:hover {
color: #fff;
background: transparent;
}
/* ═══ Scrollbar ═══ */
.scrollbar-thin {
scrollbar-width: thin;

파일 보기

@ -4,6 +4,101 @@ const DEG2RAD = Math.PI / 180
const RAD2DEG = 180 / Math.PI
const EARTH_RADIUS = 6371000 // meters
// ============================================================
// Convex Hull + 면적 계산
// ============================================================
interface LatLon { lat: number; lon: number }
/** Convex Hull (Graham Scan) — 입자 좌표 배열 → 외곽 다각형 좌표 반환 */
export function convexHull(points: LatLon[]): LatLon[] {
if (points.length < 3) return [...points]
// 가장 아래(lat 최소) 점 찾기 (동일하면 lon 최소)
const sorted = [...points].sort((a, b) => a.lat - b.lat || a.lon - b.lon)
const pivot = sorted[0]
// pivot 기준 극각으로 정렬
const rest = sorted.slice(1).sort((a, b) => {
const angleA = Math.atan2(a.lon - pivot.lon, a.lat - pivot.lat)
const angleB = Math.atan2(b.lon - pivot.lon, b.lat - pivot.lat)
if (angleA !== angleB) return angleA - angleB
// 같은 각도면 거리 순
const dA = (a.lat - pivot.lat) ** 2 + (a.lon - pivot.lon) ** 2
const dB = (b.lat - pivot.lat) ** 2 + (b.lon - pivot.lon) ** 2
return dA - dB
})
const hull: LatLon[] = [pivot]
for (const p of rest) {
while (hull.length >= 2) {
const a = hull[hull.length - 2]
const b = hull[hull.length - 1]
const cross = (b.lon - a.lon) * (p.lat - a.lat) - (b.lat - a.lat) * (p.lon - a.lon)
if (cross <= 0) hull.pop()
else break
}
hull.push(p)
}
return hull
}
/** Shoelace 공식으로 다각형 면적 계산 (km²) — 위경도 좌표를 미터 변환 후 계산 */
export function polygonAreaKm2(polygon: LatLon[]): number {
if (polygon.length < 3) return 0
// 중심 기준 위경도 → 미터 변환
const centerLat = polygon.reduce((s, p) => s + p.lat, 0) / polygon.length
const mPerDegLat = 111320
const mPerDegLon = 111320 * Math.cos(centerLat * DEG2RAD)
const pts = polygon.map(p => ({
x: (p.lon - polygon[0].lon) * mPerDegLon,
y: (p.lat - polygon[0].lat) * mPerDegLat,
}))
// Shoelace
let area = 0
for (let i = 0; i < pts.length; i++) {
const j = (i + 1) % pts.length
area += pts[i].x * pts[j].y
area -= pts[j].x * pts[i].y
}
return Math.abs(area) / 2 / 1_000_000 // m² → km²
}
/** 오일 궤적 입자 → Convex Hull 외곽 다각형 + 면적 + 둘레 계산 */
export function analyzeSpillPolygon(trajectory: LatLon[]): {
hull: LatLon[]
areaKm2: number
perimeterKm: number
particleCount: number
} {
if (trajectory.length < 3) {
return { hull: [], areaKm2: 0, perimeterKm: 0, particleCount: trajectory.length }
}
const hull = convexHull(trajectory)
const areaKm2 = polygonAreaKm2(hull)
// 둘레 계산
let perimeter = 0
for (let i = 0; i < hull.length; i++) {
const j = (i + 1) % hull.length
perimeter += haversineDistance(
{ lat: hull[i].lat, lon: hull[i].lon },
{ lat: hull[j].lat, lon: hull[j].lon },
)
}
return {
hull,
areaKm2,
perimeterKm: perimeter / 1000,
particleCount: trajectory.length,
}
}
/** 두 좌표 간 Haversine 거리 (m) */
export function haversineDistance(p1: BoomLineCoord, p2: BoomLineCoord): number {
const dLat = (p2.lat - p1.lat) * DEG2RAD
@ -204,23 +299,6 @@ export function pointInPolygon(
return inside
}
/** 다각형 면적 (km²) — Shoelace formula, 구면 보정 포함 */
export function polygonAreaKm2(polygon: { lat: number; lon: number }[]): number {
if (polygon.length < 3) return 0
const n = polygon.length
const latCenter = polygon.reduce((s, p) => s + p.lat, 0) / n
const cosLat = Math.cos(latCenter * DEG2RAD)
let area = 0
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const x1 = polygon[i].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
const y1 = polygon[i].lat * EARTH_RADIUS * DEG2RAD / 1000
const x2 = polygon[j].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
const y2 = polygon[j].lat * EARTH_RADIUS * DEG2RAD / 1000
area += x1 * y2 - x2 * y1
}
return Math.abs(area) / 2
}
/** 원 면적 (km²) */
export function circleAreaKm2(radiusM: number): number {

파일 보기

@ -1,4 +1,7 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { fetchCctvCameras } from '../services/aerialApi'
import type { CctvCameraItem } from '../services/aerialApi'
import { CCTVPlayer } from './CCTVPlayer'
@ -12,6 +15,28 @@ function khoaHlsUrl(siteName: string): string {
return `${KHOA_HLS}/${siteName}/s.m3u8`;
}
/** KBS 재난안전포탈 CCTV — 백엔드 HLS 리졸버 경유 */
function kbsCctvUrl(cctvId: number): string {
return `/api/aerial/cctv/kbs-hls/${cctvId}/stream.m3u8`;
}
/** 지도 스타일 (CartoDB Dark Matter) */
const MAP_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }],
}
const cctvFavorites = [
{ name: '여수항 해무관측', reason: '유출 사고 인접' },
{ name: '부산항 조위관측소', reason: '주요 방제 거점' },
@ -25,7 +50,10 @@ const FALLBACK_CAMERAS: CctvCameraItem[] = [
{ cctvSn: 30, cameraNm: '인천항 해무관측', regionNm: '서해', lon: 126.6161, lat: 37.3797, locDc: '인천광역시 중구 항동', coordDc: 'N 37°22\'47" E 126°36\'58"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Incheon') },
{ cctvSn: 31, cameraNm: '대산항 해무관측', regionNm: '서해', lon: 126.3526, lat: 37.0058, locDc: '충남 서산시 대산읍', coordDc: '37.01°N 126.35°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Daesan') },
{ cctvSn: 32, cameraNm: '평택·당진항 해무관측', regionNm: '서해', lon: 126.3936, lat: 37.1131, locDc: '충남 당진시 송악읍', coordDc: 'N 37°06\'47" E 126°23\'37"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_PTDJ') },
{ cctvSn: 100, cameraNm: '인천 연안부두', regionNm: '서해', lon: 126.6125, lat: 37.4625, locDc: '인천광역시 중구 연안부두', coordDc: '37.46°N 126.61°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ cctvSn: 100, cameraNm: '인천 연안부두', regionNm: '서해', lon: 126.5986, lat: 37.4541, locDc: '인천광역시 중구 연안부두', coordDc: '37.45°N 126.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9981) },
{ cctvSn: 200, cameraNm: '연평도', regionNm: '서해', lon: 125.6945, lat: 37.6620, locDc: '인천 옹진군 연평면', coordDc: '37.66°N 125.69°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9958) },
{ cctvSn: 201, cameraNm: '군산 비응항', regionNm: '서해', lon: 126.5265, lat: 35.9353, locDc: '전북 군산시 비응도동', coordDc: '35.94°N 126.53°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9979) },
{ cctvSn: 202, cameraNm: '태안 신진항', regionNm: '서해', lon: 126.1365, lat: 36.6779, locDc: '충남 태안군 근흥면', coordDc: '36.68°N 126.14°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9980) },
// 남해
{ cctvSn: 35, cameraNm: '목포항 해무관측', regionNm: '남해', lon: 126.3780, lat: 34.7780, locDc: '전남 목포시 항동', coordDc: '34.78°N 126.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Mokpo') },
{ cctvSn: 36, cameraNm: '진도항 조위관측소', regionNm: '남해', lon: 126.3085, lat: 34.4710, locDc: '전남 진도군 진도읍', coordDc: '34.47°N 126.31°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Jindo') },
@ -34,17 +62,29 @@ const FALLBACK_CAMERAS: CctvCameraItem[] = [
{ cctvSn: 39, cameraNm: '부산항 조위관측소', regionNm: '남해', lon: 129.0756, lat: 35.0969, locDc: '부산광역시 중구 중앙동', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Busan') },
{ cctvSn: 40, cameraNm: '부산항 해무관측', regionNm: '남해', lon: 129.0780, lat: 35.0980, locDc: '부산광역시 중구', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Busan') },
{ cctvSn: 41, cameraNm: '해운대 해무관측', regionNm: '남해', lon: 129.1718, lat: 35.1587, locDc: '부산광역시 해운대구', coordDc: '35.16°N 129.17°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Haeundae') },
{ cctvSn: 97, cameraNm: '오동도', regionNm: '남해', lon: 127.7836, lat: 34.7369, locDc: '전남 여수시 수정동', coordDc: '34.74°N 127.78°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ cctvSn: 108, cameraNm: '완도항', regionNm: '남해', lon: 126.7550, lat: 34.3114, locDc: '전남 완도군 완도읍', coordDc: '34.31°N 126.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ cctvSn: 97, cameraNm: '여수 오동도 앞', regionNm: '남해', lon: 127.7557, lat: 34.7410, locDc: '전남 여수시 수정동', coordDc: '34.74°N 127.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9994) },
{ cctvSn: 108, cameraNm: '완도항', regionNm: '남해', lon: 126.7489, lat: 34.3209, locDc: '전남 완도군 완도읍', coordDc: '34.32°N 126.75°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9984) },
{ cctvSn: 203, cameraNm: '창원 마산항', regionNm: '남해', lon: 128.5760, lat: 35.1979, locDc: '경남 창원시 마산합포구', coordDc: '35.20°N 128.58°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9985) },
{ cctvSn: 204, cameraNm: '부산 민락항', regionNm: '남해', lon: 129.1312, lat: 35.1538, locDc: '부산 수영구 민락동', coordDc: '35.15°N 129.13°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9991) },
{ cctvSn: 205, cameraNm: '목포 북항', regionNm: '남해', lon: 126.3652, lat: 34.8042, locDc: '전남 목포시 죽교동', coordDc: '34.80°N 126.37°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9992) },
{ cctvSn: 206, cameraNm: '신안 가거도', regionNm: '남해', lon: 125.1293, lat: 34.0529, locDc: '전남 신안군 흑산면', coordDc: '34.05°N 125.13°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9983) },
{ cctvSn: 207, cameraNm: '여수 거문도', regionNm: '남해', lon: 127.3074, lat: 34.0232, locDc: '전남 여수시 삼산면', coordDc: '34.02°N 127.31°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9993) },
// 동해
{ cctvSn: 42, cameraNm: '울산항 해무관측', regionNm: '동해', lon: 129.3870, lat: 35.5000, locDc: '울산광역시 남구', coordDc: '35.50°N 129.39°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Ulsan') },
{ cctvSn: 43, cameraNm: '포항항 해무관측', regionNm: '동해', lon: 129.3798, lat: 36.0323, locDc: '경북 포항시 북구', coordDc: '36.03°N 129.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Pohang') },
{ cctvSn: 44, cameraNm: '묵호항 조위관측소', regionNm: '동해', lon: 129.1146, lat: 37.5500, locDc: '강원 동해시 묵호동', coordDc: '37.55°N 129.11°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Mukho') },
{ cctvSn: 113, cameraNm: '속초등대', regionNm: '동해', lon: 128.5964, lat: 38.2070, locDc: '강원 속초시 영랑동', coordDc: '38.21°N 128.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ cctvSn: 115, cameraNm: '독도', regionNm: '동해', lon: 131.8689, lat: 37.2394, locDc: '경북 울릉군 울릉읍 독도리', coordDc: '37.24°N 131.87°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ cctvSn: 113, cameraNm: '속초 등대전망대', regionNm: '동해', lon: 128.6001, lat: 38.2134, locDc: '강원 속초시 영랑동', coordDc: '38.21°N 128.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9986) },
{ cctvSn: 115, cameraNm: '독도', regionNm: '동해', lon: 131.8686, lat: 37.2394, locDc: '경북 울릉군 울릉읍 독도리', coordDc: '37.24°N 131.87°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9957) },
{ cctvSn: 208, cameraNm: '강릉 용강동', regionNm: '동해', lon: 128.8912, lat: 37.7521, locDc: '강원 강릉시 용강동', coordDc: '37.75°N 128.89°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9952) },
{ cctvSn: 209, cameraNm: '강릉 주문진방파제', regionNm: '동해', lon: 128.8335, lat: 37.8934, locDc: '강원 강릉시 주문진읍', coordDc: '37.89°N 128.83°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9995) },
{ cctvSn: 210, cameraNm: '대관령', regionNm: '동해', lon: 128.7553, lat: 37.6980, locDc: '강원 평창군 대관령면', coordDc: '37.70°N 128.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9989) },
{ cctvSn: 211, cameraNm: '울릉 저동항', regionNm: '동해', lon: 130.9122, lat: 37.4913, locDc: '경북 울릉군 울릉읍', coordDc: '37.49°N 130.91°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9987) },
{ cctvSn: 212, cameraNm: '포항 두호동 해안로', regionNm: '동해', lon: 129.3896, lat: 36.0627, locDc: '경북 포항시 북구 두호동', coordDc: '36.06°N 129.39°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9988) },
{ cctvSn: 213, cameraNm: '울산 달동', regionNm: '동해', lon: 129.3265, lat: 35.5442, locDc: '울산 남구 달동', coordDc: '35.54°N 129.33°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9955) },
// 제주
{ cctvSn: 45, cameraNm: '모슬포항 조위관측소', regionNm: '제주', lon: 126.2519, lat: 33.2136, locDc: '제주 서귀포시 대정읍', coordDc: '33.21°N 126.25°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Moseulpo') },
{ cctvSn: 116, cameraNm: '마라도', regionNm: '제주', lon: 126.2669, lat: 33.1140, locDc: '제주 서귀포시 대정읍 마라리', coordDc: '33.11°N 126.27°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ cctvSn: 116, cameraNm: '마라도', regionNm: '제주', lon: 126.2684, lat: 33.1139, locDc: '제주 서귀포시 대정읍 마라리', coordDc: '33.11°N 126.27°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9982) },
{ cctvSn: 214, cameraNm: '제주 도남동', regionNm: '제주', lon: 126.5195, lat: 33.4891, locDc: '제주시 도남동', coordDc: '33.49°N 126.52°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9954) },
]
export function CctvView() {
@ -58,13 +98,25 @@ export function CctvView() {
const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false)
const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false)
const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false)
const [mapPopup, setMapPopup] = useState<CctvCameraItem | null>(null)
const [viewMode, setViewMode] = useState<'list' | 'map'>('map')
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
/** 지도 모드이거나, 리스트 모드에서 카메라 미선택 시 지도 표시 */
const showMap = viewMode === 'map' && activeCells.length === 0
const loadData = useCallback(async () => {
setLoading(true)
try {
const items = await fetchCctvCameras()
setCameras(items.length > 0 ? items : FALLBACK_CAMERAS)
if (items.length > 0) {
// DB 데이터에 폴백 전용 카메라(DB 미등록분) 병합
const dbIds = new Set(items.map(i => i.cctvSn))
const missing = FALLBACK_CAMERAS.filter(f => !dbIds.has(f.cctvSn))
setCameras([...items, ...missing])
} else {
setCameras(FALLBACK_CAMERAS)
}
} catch {
setCameras(FALLBACK_CAMERAS)
} finally {
@ -111,8 +163,29 @@ export function CctvView() {
<span className="w-[7px] h-[7px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />
CCTV
</div>
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-text-3 font-korean">API </span>
<div className="flex items-center gap-1">
{/* 지도/리스트 뷰 토글 */}
<div className="flex border border-border rounded-[5px] overflow-hidden mr-1.5">
<button
onClick={() => setViewMode('map')}
className="px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer border-none font-korean transition-colors"
style={viewMode === 'map'
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
: { background: 'var(--bg3)', color: 'var(--t3)' }
}
title="지도 보기"
>🗺 </button>
<button
onClick={() => setViewMode('list')}
className="px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer border-none font-korean transition-colors"
style={viewMode === 'list'
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
: { background: 'var(--bg3)', color: 'var(--t3)' }
}
title="리스트 보기"
> </button>
</div>
<span className="text-[9px] text-text-3 font-korean">API</span>
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: 'var(--green)' }} />
</div>
</div>
@ -164,7 +237,14 @@ export function CctvView() {
}}
>
<div className="relative shrink-0">
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center text-sm">📹</div>
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect x="3" y="6" width="13" height="10" rx="2" fill={cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)'} />
<circle cx="9.5" cy="11" r="2.8" fill="var(--bg3)" />
<circle cx="9.5" cy="11" r="1.3" fill={cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)'} />
<path d="M17 9l4-2v10l-4-2V9z" fill={cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)'} opacity="0.7" />
</svg>
</div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)' }} />
</div>
<div className="flex-1 min-w-0">
@ -273,36 +353,195 @@ export function CctvView() {
</div>
</div>
{/* 영상 그리드 */}
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
style={{
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
}}>
{Array.from({ length: totalCells }).map((_, i) => {
const cam = activeCells[i]
return (
<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>
)}
</div>
)
})}
</div>
{/* 영상 그리드 / CCTV 위치 지도 / 리스트 뷰 */}
{viewMode === 'list' && activeCells.length === 0 ? (
/* ── 리스트 뷰: 출처별 · 지역별 그리드 ── */
<div className="flex-1 overflow-y-auto p-4" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{(() => {
// 출처별 그룹핑
const sourceGroups: Record<string, { label: string; icon: string; cameras: CctvCameraItem[] }> = {}
for (const cam of filtered) {
const src = cam.sourceNm ?? '기타'
if (!sourceGroups[src]) {
sourceGroups[src] = {
label: src === 'KHOA' ? '국립해양조사원 (KHOA)' : src === 'KBS' ? 'KBS 재난안전포털' : src,
icon: src === 'KHOA' ? '🌊' : src === 'KBS' ? '📡' : '📹',
cameras: [],
}
}
sourceGroups[src].cameras.push(cam)
}
const now = new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
return Object.entries(sourceGroups).map(([srcKey, group]) => {
// 출처 내에서 지역별 그룹핑
const regionGroups: Record<string, CctvCameraItem[]> = {}
for (const cam of group.cameras) {
const rgn = cam.regionNm ?? '기타'
if (!regionGroups[rgn]) regionGroups[rgn] = []
regionGroups[rgn].push(cam)
}
return (
<div key={srcKey} className="mb-5">
{/* 출처 헤더 */}
<div className="flex items-center gap-2 mb-2 pb-1.5 border-b border-border">
<span className="text-sm">{group.icon}</span>
<span className="text-[12px] font-bold text-text-1 font-korean">{group.label}</span>
<span className="text-[10px] text-text-3 font-korean ml-auto">{group.cameras.length}</span>
</div>
{Object.entries(regionGroups).map(([rgn, cams]) => (
<div key={rgn} className="mb-3">
{/* 지역 소제목 */}
<div className="flex items-center gap-1.5 mb-1.5 px-1">
<span className="text-[10px] font-bold text-primary-cyan font-korean">{rgn}</span>
<span className="text-[9px] text-text-3">({cams.length})</span>
</div>
{/* 테이블 헤더 */}
<div className="grid px-2 py-1 bg-bg-3 rounded-t text-[9px] font-bold text-text-3 font-korean border border-border"
style={{ gridTemplateColumns: '1fr 1.2fr 70px 130px' }}>
<span></span>
<span></span>
<span className="text-center"></span>
<span className="text-center"></span>
</div>
{/* 테이블 행 */}
{cams.map(cam => (
<div
key={cam.cctvSn}
onClick={() => { handleSelectCamera(cam); setViewMode('map') }}
className="grid px-2 py-1.5 border-b border-x border-border cursor-pointer transition-colors hover:bg-bg-hover"
style={{
gridTemplateColumns: '1fr 1.2fr 70px 130px',
background: selectedCamera?.cctvSn === cam.cctvSn ? 'rgba(6,182,212,.08)' : 'transparent',
}}
>
<span className="text-[10px] text-text-1 font-korean font-semibold truncate">{cam.cameraNm}</span>
<span className="text-[9px] text-text-3 font-korean truncate">{cam.locDc ?? '—'}</span>
<span className="text-center">
{cam.sttsCd === 'LIVE' ? (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full inline-block" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)' }}> LIVE</span>
) : (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full inline-block" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--t3)' }}> OFF</span>
)}
</span>
<span className="text-[9px] text-text-3 font-mono text-center">{now}</span>
</div>
))}
</div>
))}
</div>
)
})
})()}
</div>
) : showMap ? (
<div className="flex-1 overflow-hidden relative">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
mapStyle={MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
{filtered.filter(c => c.lon && c.lat).map(cam => (
<Marker
key={cam.cctvSn}
longitude={cam.lon!}
latitude={cam.lat!}
anchor="bottom"
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(cam) }}
>
<div className="flex flex-col items-center cursor-pointer group" title={cam.cameraNm}>
{/* CCTV 아이콘 */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="drop-shadow-md transition-transform group-hover:scale-110">
{/* 카메라 본체 */}
<rect x="4" y="6" width="12" height="9" rx="2" fill={cam.sttsCd === 'LIVE' ? '#10b981' : '#94a3b8'} stroke="#fff" strokeWidth="1.5" />
{/* 렌즈 */}
<circle cx="10" cy="10.5" r="2.5" fill="#fff" fillOpacity="0.8" />
<circle cx="10" cy="10.5" r="1.2" fill={cam.sttsCd === 'LIVE' ? '#065f46' : '#64748b'} />
{/* 마운트 기둥 */}
<rect x="17" y="8" width="3" height="2" rx="0.5" fill={cam.sttsCd === 'LIVE' ? '#10b981' : '#94a3b8'} stroke="#fff" strokeWidth="1" />
<rect x="19" y="6" width="1.5" height="12" fill="#fff" fillOpacity="0.9" />
{/* LIVE 표시등 */}
{cam.sttsCd === 'LIVE' && <circle cx="6.5" cy="8" r="1" fill="#ef4444"><animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" /></circle>}
</svg>
{/* 이름 라벨 */}
<div className="px-1 py-px rounded text-[7px] font-bold font-korean whitespace-nowrap mt-0.5"
style={{ background: 'rgba(0,0,0,.65)', color: '#fff', maxWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{cam.cameraNm}
</div>
</div>
</Marker>
))}
{mapPopup && mapPopup.lon && mapPopup.lat && (
<Popup
longitude={mapPopup.lon}
latitude={mapPopup.lat}
anchor="bottom"
onClose={() => setMapPopup(null)}
closeOnClick={false}
offset={14}
className="cctv-dark-popup"
>
<div className="p-2" style={{ minWidth: 150, background: '#1a1f2e', borderRadius: 6 }}>
<div className="text-[11px] font-bold text-white mb-1">{mapPopup.cameraNm}</div>
<div className="text-[9px] text-gray-400 mb-1.5">{mapPopup.locDc ?? ''}</div>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
style={mapPopup.sttsCd === 'LIVE'
? { background: 'rgba(34,197,94,.2)', color: '#4ade80' }
: { background: 'rgba(148,163,184,.15)', color: '#94a3b8' }
}
>{mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'}</span>
<span className="text-[8px] text-gray-500">{mapPopup.sourceNm}</span>
</div>
<button
onClick={() => { handleSelectCamera(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors hover:brightness-125"
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
> </button>
</div>
</Popup>
)}
</Map>
{/* 지도 위 안내 배지 */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
style={{ background: 'rgba(0,0,0,.7)', color: 'rgba(255,255,255,.7)', backdropFilter: 'blur(4px)' }}>
📹 CCTV ({filtered.length})
</div>
</div>
) : (
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
style={{
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
}}>
{Array.from({ length: totalCells }).map((_, i) => {
const cam = activeCells[i]
return (
<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>
)}
</div>
)
})}
</div>
)}
{/* 하단 정보 바 */}
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
@ -320,26 +559,37 @@ export function CctvView() {
<span>🗺 </span>
<span className="text-[9px] text-text-3 font-normal"> </span>
</div>
{/* 미니맵 (placeholder) */}
<div className="w-full bg-bg-3 flex items-center justify-center shrink-0 relative h-[210px]">
<div className="text-[10px] text-text-3 font-korean opacity-50"> </div>
{/* 간략 지도 표현 */}
<div className="absolute inset-2 rounded-md border border-border/30 overflow-hidden" style={{ background: 'linear-gradient(180deg, rgba(6,182,212,.03), rgba(59,130,246,.05))' }}>
{cameras.filter(c => c.sttsCd === 'LIVE').slice(0, 6).map((c, i) => (
<div
key={i}
className="absolute w-2 h-2 rounded-full cursor-pointer"
style={{
background: selectedCamera?.cctvSn === c.cctvSn ? 'var(--cyan)' : 'var(--green)',
boxShadow: selectedCamera?.cctvSn === c.cctvSn ? '0 0 6px var(--cyan)' : 'none',
top: `${20 + (i * 25) % 70}%`,
left: `${15 + (i * 30) % 70}%`,
}}
title={c.cameraNm}
onClick={() => handleSelectCamera(c)}
/>
{/* 미니맵 */}
<div className="w-full shrink-0 relative h-[210px] overflow-hidden">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 5.3 }}
mapStyle={MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
interactive={true}
>
{cameras.filter(c => c.lon && c.lat).map(cam => (
<Marker
key={`mini-${cam.cctvSn}`}
longitude={cam.lon!}
latitude={cam.lat!}
anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); handleSelectCamera(cam) }}
>
<div
className="cursor-pointer"
style={{
width: selectedCamera?.cctvSn === cam.cctvSn ? 10 : 7,
height: selectedCamera?.cctvSn === cam.cctvSn ? 10 : 7,
borderRadius: '50%',
background: selectedCamera?.cctvSn === cam.cctvSn ? 'var(--cyan)' : cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)',
boxShadow: selectedCamera?.cctvSn === cam.cctvSn ? '0 0 8px var(--cyan)' : 'none',
border: '1.5px solid rgba(255,255,255,.7)',
}}
/>
</Marker>
))}
</div>
</Map>
</div>
{/* 카메라 정보 */}

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

파일 보기

@ -130,3 +130,32 @@ export async function stitchImages(files: File[]): Promise<Blob> {
});
return response.data;
}
// === DRONE STREAM ===
export interface DroneStreamItem {
id: string;
name: string;
shipName: string;
droneModel: string;
ip: string;
rtspUrl: string;
region: string;
status: 'idle' | 'starting' | 'streaming' | 'error';
hlsUrl: string | null;
error: string | null;
}
export async function fetchDroneStreams(): Promise<DroneStreamItem[]> {
const response = await api.get<DroneStreamItem[]>('/aerial/drone/streams');
return response.data;
}
export async function startDroneStreamApi(id: string): Promise<{ success: boolean; hlsUrl?: string; error?: string }> {
const response = await api.post<{ success: boolean; hlsUrl?: string; error?: string }>(`/aerial/drone/streams/${id}/start`);
return response.data;
}
export async function stopDroneStreamApi(id: string): Promise<{ success: boolean }> {
const response = await api.post<{ success: boolean }>(`/aerial/drone/streams/${id}/stop`);
return response.data;
}

파일 보기

@ -13,6 +13,11 @@ export function detectStreamType(url: string): StreamType {
return 'iframe';
}
// KBS 재난안전포탈 CCTV 공유 페이지 (iframe 임베드)
if (lower.includes('d.kbs.co.kr')) {
return 'iframe';
}
if (lower.includes('.m3u8') || lower.includes('/hls/')) {
return 'hls';
}

파일 보기

@ -1,6 +1,6 @@
import { useState } from 'react'
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } from '@common/utils/geo'
import { generateAIBoomLines, runContainmentAnalysis } from '@common/utils/geo'
interface OilBoomSectionProps {
expanded: boolean
@ -19,6 +19,13 @@ interface OilBoomSectionProps {
onContainmentResultChange: (result: ContainmentResult | null) => void
}
const DEFAULT_SETTINGS: AlgorithmSettings = {
currentOrthogonalCorrection: 15,
safetyMarginMinutes: 60,
minContainmentEfficiency: 80,
waveHeightCorrectionFactor: 1.0,
}
const OilBoomSection = ({
expanded,
onToggle,
@ -28,14 +35,40 @@ const OilBoomSection = ({
incidentCoord,
algorithmSettings,
onAlgorithmSettingsChange,
isDrawingBoom,
onDrawingBoomChange,
drawingPoints,
onDrawingPointsChange,
containmentResult,
onContainmentResultChange,
}: OilBoomSectionProps) => {
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'manual' | 'simulation'>('simulation')
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'simulation'>('simulation')
const [showResetConfirm, setShowResetConfirm] = useState(false)
const hasData = boomLines.length > 0 || containmentResult !== null
/** V자형 오일붐 배치 + 차단 시뮬레이션 실행 */
const handleRunSimulation = () => {
// 1단계: V자형 오일붐 자동 배치
const lines = generateAIBoomLines(
oilTrajectory,
{ lat: incidentCoord.lat, lon: incidentCoord.lon },
algorithmSettings,
)
onBoomLinesChange(lines)
// 2단계: 차단 시뮬레이션 실행
const result = runContainmentAnalysis(oilTrajectory, lines)
onContainmentResultChange(result)
}
/** 초기화 (오일펜스만, 확산예측 유지) */
const handleReset = () => {
onBoomLinesChange([])
onDrawingBoomChange(false)
onDrawingPointsChange([])
onContainmentResultChange(null)
onAlgorithmSettingsChange({ ...DEFAULT_SETTINGS })
setShowResetConfirm(false)
}
return (
<div className="border-b border-border">
@ -54,23 +87,28 @@ const OilBoomSection = ({
{expanded && (
<div className="px-4 pb-4 flex flex-col gap-3">
{/* Tab Buttons + Reset */}
{/* 탭 버튼 + 초기화 */}
<div className="flex gap-1.5">
{[
{ id: 'ai' as const, label: 'AI 자동 추천' },
{ id: 'manual' as const, label: '수동 배치' },
{ id: 'simulation' as const, label: '시뮬레이션' }
{ id: 'simulation' as const, label: '시뮬레이션' },
].map(tab => (
<button
key={tab.id}
onClick={() => setBoomPlacementTab(tab.id)}
onClick={() => {
if (tab.id === 'ai') {
alert('AI 자동 추천 기능은 향후 오픈 예정입니다.')
return
}
setBoomPlacementTab(tab.id)
}}
style={{
padding: '6px 8px',
borderRadius: 'var(--rS)',
border: boomPlacementTab === tab.id ? '1px solid var(--orange)' : '1px solid var(--bd)',
background: boomPlacementTab === tab.id ? 'rgba(245,158,11,0.1)' : 'var(--bg0)',
color: boomPlacementTab === tab.id ? 'var(--orange)' : 'var(--t3)',
transition: '0.15s'
transition: '0.15s',
}}
className="flex-1 text-[10px] font-semibold cursor-pointer"
>
@ -78,26 +116,15 @@ const OilBoomSection = ({
</button>
))}
<button
onClick={() => {
onBoomLinesChange([])
onDrawingBoomChange(false)
onDrawingPointsChange([])
onContainmentResultChange(null)
onAlgorithmSettingsChange({
currentOrthogonalCorrection: 15,
safetyMarginMinutes: 60,
minContainmentEfficiency: 80,
waveHeightCorrectionFactor: 1.0,
})
}}
disabled={boomLines.length === 0 && !isDrawingBoom && !containmentResult}
onClick={() => setShowResetConfirm(true)}
disabled={!hasData}
style={{
padding: '6px 10px',
borderRadius: 'var(--rS)',
border: '1px solid var(--bd)',
background: 'var(--bg0)',
color: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'var(--t3)' : 'var(--red)',
cursor: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'not-allowed' : 'pointer',
color: hasData ? 'var(--red)' : 'var(--t3)',
cursor: hasData ? 'pointer' : 'not-allowed',
transition: '0.15s',
}}
className="text-[10px] font-semibold shrink-0"
@ -106,18 +133,65 @@ const OilBoomSection = ({
</button>
</div>
{/* Key Metrics (동적) */}
{/* 초기화 확인 팝업 */}
{showResetConfirm && (
<div style={{
padding: '14px',
background: 'rgba(239,68,68,0.06)',
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 'var(--rM)',
}}>
<div className="text-[11px] font-bold text-text-1 font-korean mb-2">
</div>
<div className="text-[9px] text-text-3 font-korean mb-3">
. .
</div>
<div className="flex gap-2">
<button
onClick={handleReset}
style={{
padding: '6px 14px',
background: 'rgba(239,68,68,0.15)',
border: '1px solid var(--red)',
borderRadius: 'var(--rS)',
color: 'var(--red)',
transition: '0.15s',
}}
className="flex-1 text-[10px] font-bold cursor-pointer"
>
</button>
<button
onClick={() => setShowResetConfirm(false)}
style={{
padding: '6px 14px',
background: 'var(--bg0)',
border: '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: 'var(--t2)',
transition: '0.15s',
}}
className="flex-1 text-[10px] font-bold cursor-pointer"
>
</button>
</div>
</div>
)}
{/* Key Metrics */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
{[
{ value: String(boomLines.length), label: '배치 라인', color: 'var(--orange)' },
{ value: boomLines.length > 0 ? `${(boomLines.reduce((s, l) => s + l.length, 0) / 1000).toFixed(1)}km` : '0km', label: '총 길이', color: 'var(--cyan)' },
{ value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--orange)' }
{ value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--orange)' },
].map((metric, idx) => (
<div key={idx} style={{
padding: '10px 8px',
background: 'var(--bg0)',
borderRadius: 'var(--rS)',
textAlign: 'center'
textAlign: 'center',
}} className="border border-border">
<div style={{ color: metric.color }} className="text-lg font-bold font-mono mb-[2px]">
{metric.value}
@ -129,61 +203,24 @@ const OilBoomSection = ({
))}
</div>
{/* ===== AI 자동 추천 탭 ===== */}
{boomPlacementTab === 'ai' && (
{/* ===== 시뮬레이션 탭 ===== */}
{boomPlacementTab === 'simulation' && (
<>
<div style={{
padding: '12px',
background: 'rgba(245,158,11,0.05)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: 'var(--rM)'
}}>
<div className="flex items-center gap-1 mb-2">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }} />
<span style={{ color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }} className="text-[10px] font-bold">
{oilTrajectory.length > 0 ? '확산 데이터 준비 완료' : '확산 예측을 먼저 실행하세요'}
{/* 전제조건 체크 */}
<div className="flex flex-col gap-1.5">
<div style={{ background: 'var(--bg0)', borderRadius: 'var(--rS)' }}
className="flex items-center gap-1.5 p-[6px_10px] border border-border text-[10px]">
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--red)' }} />
<span style={{ color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
{oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
</span>
</div>
<h4 className="text-[13px] font-bold mb-2">
</h4>
<p className="leading-normal text-[9px] text-text-3 mb-2.5">
{oilTrajectory.length > 0
? '확산 궤적을 분석하여 해류 직교 방향 1차 방어선, U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 생성합니다.'
: '상단에서 확산 예측을 실행한 뒤 AI 배치를 적용할 수 있습니다.'
}
</p>
<button
onClick={() => {
const lines = generateAIBoomLines(
oilTrajectory,
{ lat: incidentCoord.lat, lon: incidentCoord.lon },
algorithmSettings
)
onBoomLinesChange(lines)
}}
disabled={oilTrajectory.length === 0}
style={{
background: oilTrajectory.length > 0 ? 'rgba(245,158,11,0.15)' : 'var(--bg0)',
border: oilTrajectory.length > 0 ? '2px solid var(--orange)' : '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: oilTrajectory.length > 0 ? 'var(--orange)' : 'var(--t3)',
cursor: oilTrajectory.length > 0 ? 'pointer' : 'not-allowed',
transition: '0.15s'
}}
className="w-full p-[10px] text-[11px] font-bold"
>
🛡
</button>
</div>
{/* 알고리즘 설정 */}
<div>
<h4 className="text-[11px] font-bold text-primary-cyan mb-2" style={{ letterSpacing: '0.5px' }}>
📊
📊 V자형
</h4>
<div className="flex flex-col gap-2">
{[
@ -194,7 +231,7 @@ const OilBoomSection = ({
].map((setting) => (
<div key={setting.key} style={{
background: 'var(--bg0)',
borderRadius: 'var(--rS)'
borderRadius: 'var(--rS)',
}} className="flex items-center justify-between p-[6px_8px] border border-border">
<span className="text-[9px] text-text-3"> {setting.label}</span>
<div className="flex items-center gap-[2px]">
@ -214,227 +251,50 @@ const OilBoomSection = ({
))}
</div>
</div>
</>
)}
{/* ===== 수동 배치 탭 ===== */}
{boomPlacementTab === 'manual' && (
<>
{/* 드로잉 컨트롤 */}
<div className="flex gap-1.5">
{!isDrawingBoom ? (
<button
onClick={() => { onDrawingBoomChange(true); onDrawingPointsChange([]) }}
style={{
background: 'rgba(245,158,11,0.15)', border: '2px solid var(--orange)',
borderRadius: 'var(--rS)', color: 'var(--orange)', transition: '0.15s'
}}
className="flex-1 p-[10px] text-[11px] font-bold cursor-pointer"
>
🛡
</button>
) : (
<>
<button
onClick={() => {
if (drawingPoints.length >= 2) {
const newLine: BoomLine = {
id: `boom-manual-${Date.now()}`,
name: `수동 방어선 ${boomLines.length + 1}`,
priority: 'HIGH',
type: '기타',
coords: [...drawingPoints],
length: computePolylineLength(drawingPoints),
angle: computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]),
efficiency: 0,
status: 'PLANNED',
}
onBoomLinesChange([...boomLines, newLine])
}
onDrawingBoomChange(false)
onDrawingPointsChange([])
}}
disabled={drawingPoints.length < 2}
style={{
background: drawingPoints.length >= 2 ? 'rgba(34,197,94,0.15)' : 'var(--bg0)',
border: drawingPoints.length >= 2 ? '2px solid var(--green)' : '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: drawingPoints.length >= 2 ? 'var(--green)' : 'var(--t3)',
cursor: drawingPoints.length >= 2 ? 'pointer' : 'not-allowed', transition: '0.15s'
}}
className="flex-1 p-[10px] text-[11px] font-bold"
>
({drawingPoints.length})
</button>
<button
onClick={() => { onDrawingBoomChange(false); onDrawingPointsChange([]) }}
style={{
padding: '10px 14px',
background: 'rgba(239,68,68,0.1)', border: '1px solid var(--red)',
borderRadius: 'var(--rS)', color: 'var(--red)', transition: '0.15s'
}}
className="text-[11px] font-bold cursor-pointer"
>
</button>
</>
)}
</div>
{/* 드로잉 실시간 정보 */}
{isDrawingBoom && drawingPoints.length > 0 && (
<div style={{
padding: '8px 10px', background: 'rgba(245,158,11,0.05)',
border: '1px solid rgba(245,158,11,0.3)', borderRadius: 'var(--rS)',
}} className="flex gap-3 text-[10px] text-text-2">
<span>: <strong className="text-status-orange font-mono">{drawingPoints.length}</strong></span>
<span>: <strong className="text-primary-cyan font-mono">{computePolylineLength(drawingPoints).toFixed(0)}m</strong></span>
{drawingPoints.length >= 2 && (
<span>: <strong className="font-mono">{computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]).toFixed(0)}°</strong></span>
)}
</div>
)}
{/* 배치된 라인 목록 */}
{boomLines.length === 0 ? (
<p className="text-[10px] text-text-3 text-center py-4">
.
</p>
) : (
boomLines.map((line, idx) => (
<div key={line.id} style={{
padding: '10px', background: 'var(--bg0)',
borderLeft: `3px solid ${line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'}`,
borderRadius: 'var(--rS)'
}} className="border border-border">
<div className="flex items-center justify-between mb-1.5">
<input
type="text"
value={line.name}
onChange={(e) => {
const updated = [...boomLines]
updated[idx] = { ...updated[idx], name: e.target.value }
onBoomLinesChange(updated)
}}
className="flex-1 text-[11px] font-bold bg-transparent border-none outline-none"
/>
<button
onClick={() => onBoomLinesChange(boomLines.filter(l => l.id !== line.id))}
className="text-[10px] text-status-red bg-transparent border-none cursor-pointer px-1.5 py-[2px]"
>
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }} className="text-[9px]">
<div>
<span className="text-text-3"></span>
<div className="font-bold font-mono">{line.length.toFixed(0)}m</div>
</div>
<div>
<span className="text-text-3"></span>
<div className="font-bold font-mono">{line.angle.toFixed(0)}°</div>
</div>
<div>
<span className="text-text-3"></span>
<select
value={line.priority}
onChange={(e) => {
const updated = [...boomLines]
updated[idx] = { ...updated[idx], priority: e.target.value as BoomLine['priority'] }
onBoomLinesChange(updated)
}}
style={{
background: 'var(--bg0)',
borderRadius: '3px',
padding: '2px',
}}
className="w-full text-[10px] font-semibold border border-border outline-none"
>
<option value="CRITICAL"></option>
<option value="HIGH"></option>
<option value="MEDIUM"></option>
</select>
</div>
</div>
</div>
))
)}
</>
)}
{/* ===== 시뮬레이션 탭 ===== */}
{boomPlacementTab === 'simulation' && (
<>
{/* 전제조건 체크 */}
<div className="flex flex-col gap-1.5">
<div style={{
background: 'var(--bg0)',
borderRadius: 'var(--rS)',
}} className="flex items-center gap-1.5 p-[6px_10px] border border-border text-[10px]">
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--red)' }} />
<span style={{ color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
{oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
</span>
</div>
<div style={{
background: 'var(--bg0)',
borderRadius: 'var(--rS)',
}} className="flex items-center gap-1.5 p-[6px_10px] border border-border text-[10px]">
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: boomLines.length > 0 ? 'var(--green)' : 'var(--red)' }} />
<span style={{ color: boomLines.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
{boomLines.length > 0 ? `(${boomLines.length}개 배치)` : '없음'}
</span>
</div>
</div>
{/* 실행 버튼 */}
{/* V자형 배치 + 시뮬레이션 실행 버튼 */}
<button
onClick={() => {
const result = runContainmentAnalysis(oilTrajectory, boomLines)
onContainmentResultChange(result)
}}
disabled={oilTrajectory.length === 0 || boomLines.length === 0}
onClick={handleRunSimulation}
disabled={oilTrajectory.length === 0}
style={{
background: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'rgba(6,182,212,0.15)' : 'var(--bg0)',
border: (oilTrajectory.length > 0 && boomLines.length > 0) ? '2px solid var(--cyan)' : '1px solid var(--bd)',
background: oilTrajectory.length > 0 ? 'rgba(6,182,212,0.15)' : 'var(--bg0)',
border: oilTrajectory.length > 0 ? '2px solid var(--cyan)' : '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'var(--cyan)' : 'var(--t3)',
cursor: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'pointer' : 'not-allowed',
transition: '0.15s'
color: oilTrajectory.length > 0 ? 'var(--cyan)' : 'var(--t3)',
cursor: oilTrajectory.length > 0 ? 'pointer' : 'not-allowed',
transition: '0.15s',
}}
className="w-full p-[10px] text-[11px] font-bold"
>
🔬
🛡 V자형 +
</button>
<p className="text-[9px] text-text-3 leading-relaxed font-korean">
1 (V형), U형 2 , 3 .
</p>
{/* 시뮬레이션 결과 */}
{containmentResult && containmentResult.totalParticles > 0 && (
<div className="flex flex-col gap-2.5">
{/* 전체 효율 */}
<div style={{
padding: '16px', background: 'rgba(6,182,212,0.05)',
border: '1px solid rgba(6,182,212,0.3)', borderRadius: 'var(--rM)', textAlign: 'center'
border: '1px solid rgba(6,182,212,0.3)', borderRadius: 'var(--rM)', textAlign: 'center',
}}>
<div className="text-[28px] font-bold text-primary-cyan font-mono">
{containmentResult.overallEfficiency}%
</div>
<div className="text-[10px] text-text-3 mt-[2px]">
</div>
<div className="text-[10px] text-text-3 mt-[2px]"> </div>
</div>
{/* 차단/통과 카운트 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
<div style={{ padding: '10px', background: 'var(--bg0)', borderRadius: 'var(--rS)', textAlign: 'center' }} className="border border-border">
<div className="text-base font-bold text-status-green font-mono">
{containmentResult.blockedParticles}
</div>
<div className="text-base font-bold text-status-green font-mono">{containmentResult.blockedParticles}</div>
<div className="text-[8px] text-text-3"> </div>
</div>
<div style={{ padding: '10px', background: 'var(--bg0)', borderRadius: 'var(--rS)', textAlign: 'center' }} className="border border-border">
<div className="text-base font-bold text-status-red font-mono">
{containmentResult.passedParticles}
</div>
<div className="text-base font-bold text-status-red font-mono">{containmentResult.passedParticles}</div>
<div className="text-[8px] text-text-3"> </div>
</div>
</div>
@ -443,20 +303,16 @@ const OilBoomSection = ({
<div className="boom-eff-bar">
<div className="boom-eff-fill" style={{
width: `${containmentResult.overallEfficiency}%`,
background: containmentResult.overallEfficiency >= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)'
background: containmentResult.overallEfficiency >= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)',
}} />
</div>
{/* 라인별 분석 */}
<div>
<h4 className="text-[10px] font-bold text-text-3 mb-1.5">
</h4>
<h4 className="text-[10px] font-bold text-text-3 mb-1.5"> </h4>
{containmentResult.perLineResults.map((r) => (
<div key={r.boomLineId} style={{
background: 'var(--bg0)',
borderRadius: 'var(--rS)',
}} className="flex items-center justify-between p-[6px_8px] mb-1 border border-border text-[9px]">
<div key={r.boomLineId} style={{ background: 'var(--bg0)', borderRadius: 'var(--rS)' }}
className="flex items-center justify-between p-[6px_8px] mb-1 border border-border text-[9px]">
<span className="text-text-2 flex-1">{r.boomLineName}</span>
<span style={{ color: r.efficiency >= 50 ? 'var(--green)' : 'var(--orange)', marginLeft: '8px' }} className="font-bold font-mono">
{r.blocked} / {r.efficiency}%
@ -464,60 +320,52 @@ const OilBoomSection = ({
</div>
))}
</div>
{/* 배치된 방어선 카드 */}
{boomLines.map((line, idx) => {
const priorityColor = line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'
const priorityLabel = line.priority === 'CRITICAL' ? '긴급' : line.priority === 'HIGH' ? '중요' : '보통'
return (
<div key={line.id} style={{
padding: '10px', background: 'var(--bg0)',
borderLeft: `3px solid ${priorityColor}`, borderRadius: 'var(--rS)',
}} className="border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold">
🛡 {idx + 1} ({line.type})
</span>
<span style={{
padding: '2px 6px',
background: `${priorityColor}20`, border: `1px solid ${priorityColor}`,
borderRadius: '3px', color: priorityColor,
}} className="text-[8px] font-bold">
{priorityLabel}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} className="mb-1.5">
<div>
<span className="text-[8px] text-text-3"></span>
<div className="text-sm font-bold font-mono">{line.length.toFixed(0)}m</div>
</div>
<div>
<span className="text-[8px] text-text-3"></span>
<div className="text-sm font-bold font-mono">{line.angle.toFixed(0)}°</div>
</div>
</div>
<div className="flex items-center gap-1">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} />
<span style={{ color: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} className="text-[9px] font-semibold">
{line.efficiency}%
</span>
</div>
</div>
)
})}
</div>
)}
</>
)}
{/* 배치된 방어선 카드 (AI/수동 공통 표시) */}
{boomPlacementTab !== 'simulation' && boomLines.length > 0 && boomPlacementTab === 'ai' && (
<>
{boomLines.map((line, idx) => {
const priorityColor = line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'
const priorityLabel = line.priority === 'CRITICAL' ? '긴급' : line.priority === 'HIGH' ? '중요' : '보통'
return (
<div key={line.id} style={{
padding: '10px', background: 'var(--bg0)',
borderLeft: `3px solid ${priorityColor}`, borderRadius: 'var(--rS)'
}} className="border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold">
🛡 {idx + 1} ({line.type})
</span>
<span style={{
padding: '2px 6px',
background: `${priorityColor}20`, border: `1px solid ${priorityColor}`,
borderRadius: '3px', color: priorityColor
}} className="text-[8px] font-bold">
{priorityLabel}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} className="mb-1.5">
<div>
<span className="text-[8px] text-text-3"></span>
<div className="text-sm font-bold font-mono">
{line.length.toFixed(0)}m
</div>
</div>
<div>
<span className="text-[8px] text-text-3"></span>
<div className="text-sm font-bold font-mono">
{line.angle.toFixed(0)}°
</div>
</div>
</div>
<div className="flex items-center gap-1">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} />
<span style={{ color: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} className="text-[9px] font-semibold">
{line.efficiency}%
</span>
</div>
</div>
)
})}
</>
)}
</div>
)}
</div>

파일 보기

@ -203,6 +203,20 @@ export function OilSpillView() {
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
const analysisCircleRadiusM = circleRadiusNm * 1852
// 분석 탭 초기 진입 시 기본 데모 자동 표시
useEffect(() => {
if (activeSubTab === 'analysis' && oilTrajectory.length === 0 && !selectedAnalysis) {
const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set<PredictionModel>(['OpenDrift']))
const coord = incidentCoord ?? { lat: 37.39, lon: 126.64 }
const demoTrajectory = generateDemoTrajectory(coord, models, predictionTime)
setOilTrajectory(demoTrajectory)
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
setBoomLines(demoBooms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSubTab])
const handleToggleLayer = (layerId: string, enabled: boolean) => {
setEnabledLayers(prev => {
const newSet = new Set(prev)
@ -735,6 +749,14 @@ export function OilSpillView() {
})(),
},
hasSimulation: simulationSummary !== null,
mapData: incidentCoord ? {
center: [incidentCoord.lat, incidentCoord.lon],
zoom: 10,
trajectory: oilTrajectory,
currentStep,
centerPoints,
simulationStartTime: accidentTime,
} : null,
};
setOilReportPayload(payload);
@ -815,6 +837,7 @@ export function OilSpillView() {
layerOpacity={layerOpacity}
layerBrightness={layerBrightness}
sensitiveResources={sensitiveResources}
lightMode
centerPoints={centerPoints}
windData={windData}
hydrData={hydrData}

파일 보기

@ -378,19 +378,21 @@ const PredictionInputSection = ({
<div className="h-px bg-border my-0.5" />
{/* Model Selection (다중 선택) */}
{/* TODO: 현재 OpenDrift만 구동 가능. KOSPS·POSEIDON·앙상블은 엔진 연동 완료 후 활성화 예정 */}
{/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
<div className="flex flex-wrap gap-[3px]">
{/* OpenDrift (KOSPS )
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, */}
{/* OpenDrift (POSEIDON )
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, */}
{([
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false },
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true },
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)', ready: true },
] as const).map(m => (
<div
key={m.id}
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer`}
onClick={() => {
if (!m.ready) {
alert(`${m.id} 모델은 현재 준비중입니다.`)
return
}
const next = new Set(selectedModels)
if (next.has(m.id)) {
next.delete(m.id)
@ -408,11 +410,7 @@ const PredictionInputSection = ({
<div
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
onClick={() => {
if (selectedModels.size === ALL_MODELS.length) {
onModelsChange(new Set(['KOSPS']))
} else {
onModelsChange(new Set(ALL_MODELS))
}
alert('앙상블 모델은 현재 준비중입니다.')
}}
>
<span className="prd-md" style={{ background: 'var(--purple)' }} />

Some files were not shown because too many files have changed in this diff Show More