chore: feature/cctv-hns-enhancements 머지 충돌 해결

- aerialRouter/Service: stitch(이미지합성) + drone stream 기능 통합
- aerialService: IMAGE_API_URL(stitch) / OIL_INFERENCE_URL(inference) 분리
- aerialApi: stitchImages + DroneStream API 함수 공존
- MapView: analysis props(HEAD) + lightMode prop(INCOMING) 통합
- CctvView: 지도/리스트/그리드 3-way 뷰 채택 (INCOMING 확장)
- OilSpillView: analysis 상태 + 데모 자동 표시 useEffect 통합
- PredictionInputSection: POSEIDON/KOSPS 모델 추가 (ready 필드 포함)
- RightPanel: controlled props 방식 유지, 미사용 내부 상태 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jeonghyo.k 2026-03-16 10:58:00 +09:00
커밋 a3b2787ba0
95개의 변경된 파일3415개의 추가작업 그리고 1061개의 파일을 삭제

파일 보기

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

파일 보기

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

파일 보기

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

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

파일 보기

@ -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 포함)
@ -198,6 +358,8 @@ interface MapViewProps {
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
analysisCircleCenter?: { lat: number; lon: number } | null
analysisCircleRadiusM?: number
/** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */
lightMode?: boolean
}
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@ -329,6 +491,7 @@ export function MapView({
analysisPolygonPoints = [],
analysisCircleCenter,
analysisCircleRadiusM = 0,
lightMode = false,
}: MapViewProps) {
const { mapToggles } = useMapStore()
const isControlled = externalCurrentTime !== undefined
@ -969,8 +1132,8 @@ export function MapView({
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
])
// 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">
@ -1152,97 +1315,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

파일 보기

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

파일 보기

@ -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,19 @@ 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 demoTrajectory = generateDemoTrajectory(incidentCoord, models, predictionTime)
setOilTrajectory(demoTrajectory)
const demoBooms = generateAIBoomLines(demoTrajectory, incidentCoord, algorithmSettings)
setBoomLines(demoBooms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSubTab])
const handleToggleLayer = (layerId: string, enabled: boolean) => {
setEnabledLayers(prev => {
const newSet = new Set(prev)

파일 보기

@ -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)' }} />

파일 보기

@ -9,6 +9,7 @@ interface AnalysisResult {
sensitiveCount: number
}
interface RightPanelProps {
onOpenBacktrack?: () => void
onOpenRecalc?: () => void
@ -215,10 +216,10 @@ export function RightPanel({
{/* 확산 예측 요약 */}
<Section title="확산 예측 요약 (+18h)" badge="위험" badgeColor="red">
<div className="grid grid-cols-2 gap-0.5">
<div className="grid grid-cols-2 gap-0.5 text-[9px]">
<PredictionCard value="4.7 km²" label="영향 면적" color="var(--red)" />
<PredictionCard value="6.2 km" label="최대 확산 거리" color="var(--orange)" />
<PredictionCard value="NE 42°" label="확산 방향" color="var(--cyan)" />
<PredictionCard value="6.2 km" label="확산 거리" color="var(--orange)" />
<PredictionCard value="NE 42°" label="확산 방향" color="var(--cyan)" />
<PredictionCard value="0.35 m/s" label="확산 속도" color="var(--t1)" />
</div>
</Section>
@ -434,16 +435,9 @@ function StatBox({
function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) {
return (
<div className="text-center py-[5px] px-1 bg-bg-0 border border-border rounded-[3px]">
<div
style={{ color }}
className="text-xs font-extrabold font-mono"
>
{value}
</div>
<div className="text-[7px] text-text-3 font-korean">
{label}
</div>
<div className="flex justify-between px-2 py-1 bg-bg-0 border border-border rounded-[3px] text-[9px]">
<span className="text-text-3 font-korean">{label}</span>
<span style={{ fontWeight: 700, color, fontFamily: 'var(--fM)' }}>{value}</span>
</div>
)
}

파일 보기

@ -6,6 +6,7 @@ import path from 'path'
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
// HLS 스트림 프록시 등 상대 경로 API 요청을 백엔드로 전달
'/api': {