feat: 보고서 지도캡처 + 드론/CCTV/확산예측 UI 기능 개선 #91
@ -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[]
|
||||
|
||||
39
database/migration/021_kbs_cctv_stream_urls.sql
Normal file
@ -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));
|
||||
BIN
frontend/public/manual/image1.png
Normal file
|
After Width: | Height: | 크기: 1.3 MiB |
BIN
frontend/public/manual/image10.png
Normal file
|
After Width: | Height: | 크기: 438 KiB |
BIN
frontend/public/manual/image11.png
Normal file
|
After Width: | Height: | 크기: 494 KiB |
BIN
frontend/public/manual/image12.png
Normal file
|
After Width: | Height: | 크기: 522 KiB |
BIN
frontend/public/manual/image13.png
Normal file
|
After Width: | Height: | 크기: 242 KiB |
BIN
frontend/public/manual/image14.png
Normal file
|
After Width: | Height: | 크기: 255 KiB |
BIN
frontend/public/manual/image15.png
Normal file
|
After Width: | Height: | 크기: 203 KiB |
BIN
frontend/public/manual/image16.png
Normal file
|
After Width: | Height: | 크기: 410 KiB |
BIN
frontend/public/manual/image17.png
Normal file
|
After Width: | Height: | 크기: 214 KiB |
BIN
frontend/public/manual/image18.png
Normal file
|
After Width: | Height: | 크기: 252 KiB |
BIN
frontend/public/manual/image19.png
Normal file
|
After Width: | Height: | 크기: 337 KiB |
BIN
frontend/public/manual/image2.png
Normal file
|
After Width: | Height: | 크기: 1.4 MiB |
BIN
frontend/public/manual/image20.png
Normal file
|
After Width: | Height: | 크기: 249 KiB |
BIN
frontend/public/manual/image21.png
Normal file
|
After Width: | Height: | 크기: 338 KiB |
BIN
frontend/public/manual/image22.png
Normal file
|
After Width: | Height: | 크기: 217 KiB |
BIN
frontend/public/manual/image23.png
Normal file
|
After Width: | Height: | 크기: 245 KiB |
BIN
frontend/public/manual/image24.png
Normal file
|
After Width: | Height: | 크기: 326 KiB |
BIN
frontend/public/manual/image25.png
Normal file
|
After Width: | Height: | 크기: 415 KiB |
BIN
frontend/public/manual/image26.png
Normal file
|
After Width: | Height: | 크기: 203 KiB |
BIN
frontend/public/manual/image27.png
Normal file
|
After Width: | Height: | 크기: 319 KiB |
BIN
frontend/public/manual/image28.png
Normal file
|
After Width: | Height: | 크기: 267 KiB |
BIN
frontend/public/manual/image29.png
Normal file
|
After Width: | Height: | 크기: 209 KiB |
BIN
frontend/public/manual/image3.png
Normal file
|
After Width: | Height: | 크기: 1.4 MiB |
BIN
frontend/public/manual/image30.png
Normal file
|
After Width: | Height: | 크기: 361 KiB |
BIN
frontend/public/manual/image31.png
Normal file
|
After Width: | Height: | 크기: 202 KiB |
BIN
frontend/public/manual/image32.png
Normal file
|
After Width: | Height: | 크기: 540 KiB |
BIN
frontend/public/manual/image33.png
Normal file
|
After Width: | Height: | 크기: 301 KiB |
BIN
frontend/public/manual/image34.png
Normal file
|
After Width: | Height: | 크기: 232 KiB |
BIN
frontend/public/manual/image35.png
Normal file
|
After Width: | Height: | 크기: 248 KiB |
BIN
frontend/public/manual/image36.png
Normal file
|
After Width: | Height: | 크기: 336 KiB |
BIN
frontend/public/manual/image37.png
Normal file
|
After Width: | Height: | 크기: 162 KiB |
BIN
frontend/public/manual/image38.png
Normal file
|
After Width: | Height: | 크기: 436 KiB |
BIN
frontend/public/manual/image39.png
Normal file
|
After Width: | Height: | 크기: 290 KiB |
BIN
frontend/public/manual/image4.png
Normal file
|
After Width: | Height: | 크기: 1.5 MiB |
BIN
frontend/public/manual/image40.png
Normal file
|
After Width: | Height: | 크기: 225 KiB |
BIN
frontend/public/manual/image41.png
Normal file
|
After Width: | Height: | 크기: 206 KiB |
BIN
frontend/public/manual/image42.png
Normal file
|
After Width: | Height: | 크기: 262 KiB |
BIN
frontend/public/manual/image43.png
Normal file
|
After Width: | Height: | 크기: 1.6 MiB |
BIN
frontend/public/manual/image44.png
Normal file
|
After Width: | Height: | 크기: 131 KiB |
BIN
frontend/public/manual/image45.png
Normal file
|
After Width: | Height: | 크기: 311 KiB |
BIN
frontend/public/manual/image46.png
Normal file
|
After Width: | Height: | 크기: 270 KiB |
BIN
frontend/public/manual/image47.png
Normal file
|
After Width: | Height: | 크기: 240 KiB |
BIN
frontend/public/manual/image48.png
Normal file
|
After Width: | Height: | 크기: 476 KiB |
BIN
frontend/public/manual/image49.png
Normal file
|
After Width: | Height: | 크기: 99 KiB |
BIN
frontend/public/manual/image5.png
Normal file
|
After Width: | Height: | 크기: 1.5 MiB |
BIN
frontend/public/manual/image50.png
Normal file
|
After Width: | Height: | 크기: 179 KiB |
BIN
frontend/public/manual/image51.png
Normal file
|
After Width: | Height: | 크기: 151 KiB |
BIN
frontend/public/manual/image52.png
Normal file
|
After Width: | Height: | 크기: 158 KiB |
BIN
frontend/public/manual/image53.png
Normal file
|
After Width: | Height: | 크기: 171 KiB |
BIN
frontend/public/manual/image54.png
Normal file
|
After Width: | Height: | 크기: 206 KiB |
BIN
frontend/public/manual/image55.png
Normal file
|
After Width: | Height: | 크기: 230 KiB |
BIN
frontend/public/manual/image56.png
Normal file
|
After Width: | Height: | 크기: 225 KiB |
BIN
frontend/public/manual/image57.png
Normal file
|
After Width: | Height: | 크기: 227 KiB |
BIN
frontend/public/manual/image58.png
Normal file
|
After Width: | Height: | 크기: 306 KiB |
BIN
frontend/public/manual/image59.png
Normal file
|
After Width: | Height: | 크기: 362 KiB |
BIN
frontend/public/manual/image6.png
Normal file
|
After Width: | Height: | 크기: 1.4 MiB |
BIN
frontend/public/manual/image60.png
Normal file
|
After Width: | Height: | 크기: 743 KiB |
BIN
frontend/public/manual/image61.png
Normal file
|
After Width: | Height: | 크기: 906 KiB |
BIN
frontend/public/manual/image62.png
Normal file
|
After Width: | Height: | 크기: 232 KiB |
BIN
frontend/public/manual/image63.png
Normal file
|
After Width: | Height: | 크기: 3.6 MiB |
BIN
frontend/public/manual/image64.png
Normal file
|
After Width: | Height: | 크기: 305 KiB |
BIN
frontend/public/manual/image65.png
Normal file
|
After Width: | Height: | 크기: 240 KiB |
BIN
frontend/public/manual/image66.png
Normal file
|
After Width: | Height: | 크기: 275 KiB |
BIN
frontend/public/manual/image67.png
Normal file
|
After Width: | Height: | 크기: 276 KiB |
BIN
frontend/public/manual/image68.png
Normal file
|
After Width: | Height: | 크기: 195 KiB |
BIN
frontend/public/manual/image69.png
Normal file
|
After Width: | Height: | 크기: 264 KiB |
BIN
frontend/public/manual/image7.png
Normal file
|
After Width: | Height: | 크기: 185 KiB |
BIN
frontend/public/manual/image70.png
Normal file
|
After Width: | Height: | 크기: 448 KiB |
BIN
frontend/public/manual/image71.png
Normal file
|
After Width: | Height: | 크기: 165 KiB |
BIN
frontend/public/manual/image72.png
Normal file
|
After Width: | Height: | 크기: 86 KiB |
BIN
frontend/public/manual/image73.png
Normal file
|
After Width: | Height: | 크기: 106 KiB |
BIN
frontend/public/manual/image74.png
Normal file
|
After Width: | Height: | 크기: 105 KiB |
BIN
frontend/public/manual/image75.png
Normal file
|
After Width: | Height: | 크기: 215 KiB |
BIN
frontend/public/manual/image76.png
Normal file
|
After Width: | Height: | 크기: 2.7 MiB |
BIN
frontend/public/manual/image77.png
Normal file
|
After Width: | Height: | 크기: 1.2 MiB |
BIN
frontend/public/manual/image8.png
Normal file
|
After Width: | Height: | 크기: 454 KiB |
BIN
frontend/public/manual/image9.png
Normal file
|
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]">📖</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">위험 (<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>
|
||||
)
|
||||
}
|
||||
|
||||
1616
frontend/src/common/components/ui/UserManualPopup.tsx
Normal file
@ -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>
|
||||
|
||||
{/* 카메라 정보 */}
|
||||
|
||||
@ -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': {
|
||||
|
||||