diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts index 3adb1c5..c1f12ec 100644 --- a/backend/src/aerial/aerialRouter.ts +++ b/backend/src/aerial/aerialRouter.ts @@ -1,4 +1,5 @@ import express from 'express'; +import path from 'path'; import multer from 'multer'; import { listMedia, @@ -10,6 +11,10 @@ import { createSatRequest, updateSatRequestStatus, isValidSatStatus, + listDroneStreams, + startDroneStream, + stopDroneStream, + getHlsDirectory, requestOilInference, checkInferenceHealth, stitchImages, @@ -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(); +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; + 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 라우트 // ============================================================ @@ -296,6 +469,46 @@ router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requireP } }); +// GET /api/aerial/oil-detect/health — 추론 서버 상태 확인 +router.get('/oil-detect/health', requireAuth, async (_req, res) => { + const health = await checkInferenceHealth(); + res.json(health); +}); + +// ============================================================ +// OIL INFERENCE 라우트 +// ============================================================ + +// POST /api/aerial/oil-detect — 오일 유출 감지 (GPU 추론 서버 프록시) +// base64 이미지 전송을 위해 3MB JSON 파서 적용 +router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { + try { + const { image } = req.body; + if (!image || typeof image !== 'string') { + res.status(400).json({ error: 'image (base64) 필드가 필요합니다' }); + return; + } + + // base64 크기 제한 (약 2MB 이미지) + if (image.length > 3_000_000) { + res.status(400).json({ error: '이미지 크기가 너무 큽니다 (최대 2MB)' }); + return; + } + + const result = await requestOilInference(image); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('abort') || message.includes('timeout')) { + console.error('[aerial] 추론 서버 타임아웃:', message); + res.status(504).json({ error: '추론 서버 응답 시간 초과' }); + return; + } + console.error('[aerial] 오일 감지 오류:', err); + res.status(503).json({ error: '추론 서버 연결 불가' }); + } +}); + // ============================================================ // STITCH (이미지 합성) 라우트 // ============================================================ diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts index 23c6391..006d00d 100644 --- a/backend/src/aerial/aerialService.ts +++ b/backend/src/aerial/aerialService.ts @@ -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 @@ -364,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis // OIL INFERENCE (GPU 서버 프록시) // ============================================================ -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 { @@ -382,6 +386,49 @@ export interface OilInferenceResult { regions: OilInferenceRegion[]; } +/** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */ +export async function requestOilInference(imageBase64: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS); + + try { + const response = await fetch(`${OIL_INFERENCE_URL}/inference`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image: imageBase64 }), + signal: controller.signal, + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(`Inference server responded ${response.status}: ${detail}`); + } + + return await response.json() as OilInferenceResult; + } finally { + clearTimeout(timeout); + } +} + +/** GPU 추론 서버 헬스체크 */ +export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> { + try { + const response = await fetch(`${OIL_INFERENCE_URL}/health`, { + signal: AbortSignal.timeout(3000), + }); + if (!response.ok) throw new Error(`status ${response.status}`); + return await response.json() as { status: string; device?: string }; + } catch { + return { status: 'unavailable' }; + } +} + +// ============================================================ +// IMAGE STITCH (이미지 합성) +// ============================================================ + +const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001'; + /** 여러 이미지를 이미지 분석 서버의 /stitch 엔드포인트로 전송해 합성 JPEG를 반환한다. */ export async function stitchImages( files: Express.Multer.File[], @@ -404,38 +451,175 @@ export async function stitchImages( return Buffer.from(await response.arrayBuffer()); } -/** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */ -export async function requestOilInference(imageBase64: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS); - try { - const response = await fetch(`${IMAGE_API_URL}/inference`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ image: imageBase64 }), - signal: controller.signal, - }); +// ============================================================ +// DRONE STREAM (RTSP → HLS via FFmpeg) +// ============================================================ - if (!response.ok) { - const detail = await response.text().catch(() => ''); - throw new Error(`Inference server responded ${response.status}: ${detail}`); - } - - return await response.json() as OilInferenceResult; - } finally { - clearTimeout(timeout); - } +export interface DroneStreamConfig { + id: string; + name: string; + shipName: string; + droneModel: string; + ip: string; + rtspUrl: string; + region: string; } -/** GPU 추론 서버 헬스체크 */ -export async function checkInferenceHealth(): Promise<{ status: string; device?: 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(); + +function getHlsDir(id: string): string { + return path.join(HLS_OUTPUT_DIR, id); +} + +function checkFfmpeg(): boolean { try { - const response = await fetch(`${IMAGE_API_URL}/health`, { - signal: AbortSignal.timeout(3000), - }); - if (!response.ok) throw new Error(`status ${response.status}`); - return await response.json() as { status: string; device?: string }; + execSync('which ffmpeg', { stdio: 'ignore' }); + return true; } catch { - return { status: 'unavailable' }; + 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; +} + diff --git a/backend/src/server.ts b/backend/src/server.ts index f0357e3..d406340 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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[] diff --git a/database/migration/021_kbs_cctv_stream_urls.sql b/database/migration/021_kbs_cctv_stream_urls.sql new file mode 100644 index 0000000..5b9b12f --- /dev/null +++ b/database/migration/021_kbs_cctv_stream_urls.sql @@ -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)); diff --git a/frontend/public/manual/image1.png b/frontend/public/manual/image1.png new file mode 100644 index 0000000..35f8f23 Binary files /dev/null and b/frontend/public/manual/image1.png differ diff --git a/frontend/public/manual/image10.png b/frontend/public/manual/image10.png new file mode 100644 index 0000000..3355ad0 Binary files /dev/null and b/frontend/public/manual/image10.png differ diff --git a/frontend/public/manual/image11.png b/frontend/public/manual/image11.png new file mode 100644 index 0000000..5ba4275 Binary files /dev/null and b/frontend/public/manual/image11.png differ diff --git a/frontend/public/manual/image12.png b/frontend/public/manual/image12.png new file mode 100644 index 0000000..e601a22 Binary files /dev/null and b/frontend/public/manual/image12.png differ diff --git a/frontend/public/manual/image13.png b/frontend/public/manual/image13.png new file mode 100644 index 0000000..c22adf8 Binary files /dev/null and b/frontend/public/manual/image13.png differ diff --git a/frontend/public/manual/image14.png b/frontend/public/manual/image14.png new file mode 100644 index 0000000..9a397f2 Binary files /dev/null and b/frontend/public/manual/image14.png differ diff --git a/frontend/public/manual/image15.png b/frontend/public/manual/image15.png new file mode 100644 index 0000000..6d019df Binary files /dev/null and b/frontend/public/manual/image15.png differ diff --git a/frontend/public/manual/image16.png b/frontend/public/manual/image16.png new file mode 100644 index 0000000..3980d40 Binary files /dev/null and b/frontend/public/manual/image16.png differ diff --git a/frontend/public/manual/image17.png b/frontend/public/manual/image17.png new file mode 100644 index 0000000..f532ae5 Binary files /dev/null and b/frontend/public/manual/image17.png differ diff --git a/frontend/public/manual/image18.png b/frontend/public/manual/image18.png new file mode 100644 index 0000000..bc046cf Binary files /dev/null and b/frontend/public/manual/image18.png differ diff --git a/frontend/public/manual/image19.png b/frontend/public/manual/image19.png new file mode 100644 index 0000000..0267818 Binary files /dev/null and b/frontend/public/manual/image19.png differ diff --git a/frontend/public/manual/image2.png b/frontend/public/manual/image2.png new file mode 100644 index 0000000..5861df8 Binary files /dev/null and b/frontend/public/manual/image2.png differ diff --git a/frontend/public/manual/image20.png b/frontend/public/manual/image20.png new file mode 100644 index 0000000..de0a98c Binary files /dev/null and b/frontend/public/manual/image20.png differ diff --git a/frontend/public/manual/image21.png b/frontend/public/manual/image21.png new file mode 100644 index 0000000..03a5bb1 Binary files /dev/null and b/frontend/public/manual/image21.png differ diff --git a/frontend/public/manual/image22.png b/frontend/public/manual/image22.png new file mode 100644 index 0000000..9eda05f Binary files /dev/null and b/frontend/public/manual/image22.png differ diff --git a/frontend/public/manual/image23.png b/frontend/public/manual/image23.png new file mode 100644 index 0000000..80cd5fb Binary files /dev/null and b/frontend/public/manual/image23.png differ diff --git a/frontend/public/manual/image24.png b/frontend/public/manual/image24.png new file mode 100644 index 0000000..3ad0bdf Binary files /dev/null and b/frontend/public/manual/image24.png differ diff --git a/frontend/public/manual/image25.png b/frontend/public/manual/image25.png new file mode 100644 index 0000000..69f09f8 Binary files /dev/null and b/frontend/public/manual/image25.png differ diff --git a/frontend/public/manual/image26.png b/frontend/public/manual/image26.png new file mode 100644 index 0000000..44a4341 Binary files /dev/null and b/frontend/public/manual/image26.png differ diff --git a/frontend/public/manual/image27.png b/frontend/public/manual/image27.png new file mode 100644 index 0000000..7da9af8 Binary files /dev/null and b/frontend/public/manual/image27.png differ diff --git a/frontend/public/manual/image28.png b/frontend/public/manual/image28.png new file mode 100644 index 0000000..b11b297 Binary files /dev/null and b/frontend/public/manual/image28.png differ diff --git a/frontend/public/manual/image29.png b/frontend/public/manual/image29.png new file mode 100644 index 0000000..75a3ee6 Binary files /dev/null and b/frontend/public/manual/image29.png differ diff --git a/frontend/public/manual/image3.png b/frontend/public/manual/image3.png new file mode 100644 index 0000000..8d3ef4b Binary files /dev/null and b/frontend/public/manual/image3.png differ diff --git a/frontend/public/manual/image30.png b/frontend/public/manual/image30.png new file mode 100644 index 0000000..d109b73 Binary files /dev/null and b/frontend/public/manual/image30.png differ diff --git a/frontend/public/manual/image31.png b/frontend/public/manual/image31.png new file mode 100644 index 0000000..cd3de33 Binary files /dev/null and b/frontend/public/manual/image31.png differ diff --git a/frontend/public/manual/image32.png b/frontend/public/manual/image32.png new file mode 100644 index 0000000..039e898 Binary files /dev/null and b/frontend/public/manual/image32.png differ diff --git a/frontend/public/manual/image33.png b/frontend/public/manual/image33.png new file mode 100644 index 0000000..69d95f0 Binary files /dev/null and b/frontend/public/manual/image33.png differ diff --git a/frontend/public/manual/image34.png b/frontend/public/manual/image34.png new file mode 100644 index 0000000..f41fd9f Binary files /dev/null and b/frontend/public/manual/image34.png differ diff --git a/frontend/public/manual/image35.png b/frontend/public/manual/image35.png new file mode 100644 index 0000000..01a3d48 Binary files /dev/null and b/frontend/public/manual/image35.png differ diff --git a/frontend/public/manual/image36.png b/frontend/public/manual/image36.png new file mode 100644 index 0000000..90d1c4c Binary files /dev/null and b/frontend/public/manual/image36.png differ diff --git a/frontend/public/manual/image37.png b/frontend/public/manual/image37.png new file mode 100644 index 0000000..9db4c39 Binary files /dev/null and b/frontend/public/manual/image37.png differ diff --git a/frontend/public/manual/image38.png b/frontend/public/manual/image38.png new file mode 100644 index 0000000..4fd4bcb Binary files /dev/null and b/frontend/public/manual/image38.png differ diff --git a/frontend/public/manual/image39.png b/frontend/public/manual/image39.png new file mode 100644 index 0000000..73875c7 Binary files /dev/null and b/frontend/public/manual/image39.png differ diff --git a/frontend/public/manual/image4.png b/frontend/public/manual/image4.png new file mode 100644 index 0000000..3404fad Binary files /dev/null and b/frontend/public/manual/image4.png differ diff --git a/frontend/public/manual/image40.png b/frontend/public/manual/image40.png new file mode 100644 index 0000000..0df13a1 Binary files /dev/null and b/frontend/public/manual/image40.png differ diff --git a/frontend/public/manual/image41.png b/frontend/public/manual/image41.png new file mode 100644 index 0000000..6f09a7f Binary files /dev/null and b/frontend/public/manual/image41.png differ diff --git a/frontend/public/manual/image42.png b/frontend/public/manual/image42.png new file mode 100644 index 0000000..ee96650 Binary files /dev/null and b/frontend/public/manual/image42.png differ diff --git a/frontend/public/manual/image43.png b/frontend/public/manual/image43.png new file mode 100644 index 0000000..767e1ea Binary files /dev/null and b/frontend/public/manual/image43.png differ diff --git a/frontend/public/manual/image44.png b/frontend/public/manual/image44.png new file mode 100644 index 0000000..8d9135c Binary files /dev/null and b/frontend/public/manual/image44.png differ diff --git a/frontend/public/manual/image45.png b/frontend/public/manual/image45.png new file mode 100644 index 0000000..dec4163 Binary files /dev/null and b/frontend/public/manual/image45.png differ diff --git a/frontend/public/manual/image46.png b/frontend/public/manual/image46.png new file mode 100644 index 0000000..f338731 Binary files /dev/null and b/frontend/public/manual/image46.png differ diff --git a/frontend/public/manual/image47.png b/frontend/public/manual/image47.png new file mode 100644 index 0000000..c4d83e3 Binary files /dev/null and b/frontend/public/manual/image47.png differ diff --git a/frontend/public/manual/image48.png b/frontend/public/manual/image48.png new file mode 100644 index 0000000..6130f68 Binary files /dev/null and b/frontend/public/manual/image48.png differ diff --git a/frontend/public/manual/image49.png b/frontend/public/manual/image49.png new file mode 100644 index 0000000..4e8fdaf Binary files /dev/null and b/frontend/public/manual/image49.png differ diff --git a/frontend/public/manual/image5.png b/frontend/public/manual/image5.png new file mode 100644 index 0000000..e274910 Binary files /dev/null and b/frontend/public/manual/image5.png differ diff --git a/frontend/public/manual/image50.png b/frontend/public/manual/image50.png new file mode 100644 index 0000000..b3d0729 Binary files /dev/null and b/frontend/public/manual/image50.png differ diff --git a/frontend/public/manual/image51.png b/frontend/public/manual/image51.png new file mode 100644 index 0000000..cb373d2 Binary files /dev/null and b/frontend/public/manual/image51.png differ diff --git a/frontend/public/manual/image52.png b/frontend/public/manual/image52.png new file mode 100644 index 0000000..24cba58 Binary files /dev/null and b/frontend/public/manual/image52.png differ diff --git a/frontend/public/manual/image53.png b/frontend/public/manual/image53.png new file mode 100644 index 0000000..8bf2c49 Binary files /dev/null and b/frontend/public/manual/image53.png differ diff --git a/frontend/public/manual/image54.png b/frontend/public/manual/image54.png new file mode 100644 index 0000000..f637998 Binary files /dev/null and b/frontend/public/manual/image54.png differ diff --git a/frontend/public/manual/image55.png b/frontend/public/manual/image55.png new file mode 100644 index 0000000..d6e9ec7 Binary files /dev/null and b/frontend/public/manual/image55.png differ diff --git a/frontend/public/manual/image56.png b/frontend/public/manual/image56.png new file mode 100644 index 0000000..f0817cc Binary files /dev/null and b/frontend/public/manual/image56.png differ diff --git a/frontend/public/manual/image57.png b/frontend/public/manual/image57.png new file mode 100644 index 0000000..39bac57 Binary files /dev/null and b/frontend/public/manual/image57.png differ diff --git a/frontend/public/manual/image58.png b/frontend/public/manual/image58.png new file mode 100644 index 0000000..7d35b36 Binary files /dev/null and b/frontend/public/manual/image58.png differ diff --git a/frontend/public/manual/image59.png b/frontend/public/manual/image59.png new file mode 100644 index 0000000..dc80e0c Binary files /dev/null and b/frontend/public/manual/image59.png differ diff --git a/frontend/public/manual/image6.png b/frontend/public/manual/image6.png new file mode 100644 index 0000000..b13de43 Binary files /dev/null and b/frontend/public/manual/image6.png differ diff --git a/frontend/public/manual/image60.png b/frontend/public/manual/image60.png new file mode 100644 index 0000000..c68209f Binary files /dev/null and b/frontend/public/manual/image60.png differ diff --git a/frontend/public/manual/image61.png b/frontend/public/manual/image61.png new file mode 100644 index 0000000..81fb99e Binary files /dev/null and b/frontend/public/manual/image61.png differ diff --git a/frontend/public/manual/image62.png b/frontend/public/manual/image62.png new file mode 100644 index 0000000..5e25bc6 Binary files /dev/null and b/frontend/public/manual/image62.png differ diff --git a/frontend/public/manual/image63.png b/frontend/public/manual/image63.png new file mode 100644 index 0000000..0d3a435 Binary files /dev/null and b/frontend/public/manual/image63.png differ diff --git a/frontend/public/manual/image64.png b/frontend/public/manual/image64.png new file mode 100644 index 0000000..a4f4008 Binary files /dev/null and b/frontend/public/manual/image64.png differ diff --git a/frontend/public/manual/image65.png b/frontend/public/manual/image65.png new file mode 100644 index 0000000..cdd0724 Binary files /dev/null and b/frontend/public/manual/image65.png differ diff --git a/frontend/public/manual/image66.png b/frontend/public/manual/image66.png new file mode 100644 index 0000000..8a201e1 Binary files /dev/null and b/frontend/public/manual/image66.png differ diff --git a/frontend/public/manual/image67.png b/frontend/public/manual/image67.png new file mode 100644 index 0000000..746fddc Binary files /dev/null and b/frontend/public/manual/image67.png differ diff --git a/frontend/public/manual/image68.png b/frontend/public/manual/image68.png new file mode 100644 index 0000000..1c1d3b9 Binary files /dev/null and b/frontend/public/manual/image68.png differ diff --git a/frontend/public/manual/image69.png b/frontend/public/manual/image69.png new file mode 100644 index 0000000..7620400 Binary files /dev/null and b/frontend/public/manual/image69.png differ diff --git a/frontend/public/manual/image7.png b/frontend/public/manual/image7.png new file mode 100644 index 0000000..a948b66 Binary files /dev/null and b/frontend/public/manual/image7.png differ diff --git a/frontend/public/manual/image70.png b/frontend/public/manual/image70.png new file mode 100644 index 0000000..ede13f7 Binary files /dev/null and b/frontend/public/manual/image70.png differ diff --git a/frontend/public/manual/image71.png b/frontend/public/manual/image71.png new file mode 100644 index 0000000..a6976b0 Binary files /dev/null and b/frontend/public/manual/image71.png differ diff --git a/frontend/public/manual/image72.png b/frontend/public/manual/image72.png new file mode 100644 index 0000000..f3f41d7 Binary files /dev/null and b/frontend/public/manual/image72.png differ diff --git a/frontend/public/manual/image73.png b/frontend/public/manual/image73.png new file mode 100644 index 0000000..de5025e Binary files /dev/null and b/frontend/public/manual/image73.png differ diff --git a/frontend/public/manual/image74.png b/frontend/public/manual/image74.png new file mode 100644 index 0000000..fa88e25 Binary files /dev/null and b/frontend/public/manual/image74.png differ diff --git a/frontend/public/manual/image75.png b/frontend/public/manual/image75.png new file mode 100644 index 0000000..bdb2628 Binary files /dev/null and b/frontend/public/manual/image75.png differ diff --git a/frontend/public/manual/image76.png b/frontend/public/manual/image76.png new file mode 100644 index 0000000..65c4b3d Binary files /dev/null and b/frontend/public/manual/image76.png differ diff --git a/frontend/public/manual/image77.png b/frontend/public/manual/image77.png new file mode 100644 index 0000000..6a9c060 Binary files /dev/null and b/frontend/public/manual/image77.png differ diff --git a/frontend/public/manual/image8.png b/frontend/public/manual/image8.png new file mode 100644 index 0000000..bc34f4f Binary files /dev/null and b/frontend/public/manual/image8.png differ diff --git a/frontend/public/manual/image9.png b/frontend/public/manual/image9.png new file mode 100644 index 0000000..c4fe430 Binary files /dev/null and b/frontend/public/manual/image9.png differ diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index a1db12e..642187e 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -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(null) const { hasPermission, user, logout } = useAuthStore() const { menuConfig, isLoaded } = useMenuStore() @@ -192,10 +194,26 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { ))} + +
+ + {/* 매뉴얼 */} +
)} + + {/* 사용자 매뉴얼 팝업 */} + setShowManual(false)} /> ) } diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 3be27d9..7d4f9e7 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -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 포함) @@ -187,17 +347,8 @@ interface MapViewProps { // 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용) externalCurrentTime?: number mapCaptureRef?: React.MutableRefObject<(() => string | null) | null> - onIncidentFlyEnd?: () => void - flyToIncident?: { lon: number; lat: number } - showCurrent?: boolean - showWind?: boolean - showBeached?: boolean - showTimeLabel?: boolean - simulationStartTime?: string - drawAnalysisMode?: 'polygon' | 'circle' | null - analysisPolygonPoints?: Array<{ lat: number; lon: number }> - analysisCircleCenter?: { lat: number; lon: number } | null - analysisCircleRadiusM?: number + /** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */ + lightMode?: boolean } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) @@ -318,17 +469,7 @@ export function MapView({ hydrData = [], externalCurrentTime, mapCaptureRef, - onIncidentFlyEnd, - flyToIncident, - showCurrent = true, - showWind = true, - showBeached = false, - showTimeLabel = false, - simulationStartTime, - drawAnalysisMode = null, - analysisPolygonPoints = [], - analysisCircleCenter, - analysisCircleRadiusM = 0, + lightMode = false, }: MapViewProps) { const { mapToggles } = useMapStore() const isControlled = externalCurrentTime !== undefined @@ -969,8 +1110,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 (
@@ -1152,97 +1293,127 @@ interface MapLegendProps { selectedModels?: Set } -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 ( -
-
-
📍
-
-

사고 위치

-
- {incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E +
+ {/* 헤더 + 최소화 버튼 */} +
setMinimized(!minimized)}> + 범례 + {minimized ? '▶' : '▼'} +
+ {!minimized && ( +
+
+
📍
+
+

사고 위치

+
+ {incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E +
+
+
+
+
+ 물질 + {dispersionResult.substance} +
+
+ 풍향 + SW {dispersionResult.windDirection}° +
+
+ 확산 구역 + {dispersionResult.zones.length}개 +
+
+
+
위험 구역
+
+
+
+ 치명적 위험 구역 (AEGL-3) +
+
+
+ 높은 위험 구역 (AEGL-2) +
+
+
+ 중간 위험 구역 (AEGL-1) +
+
+
+
+
🧭
+ 풍향 (방사형)
-
-
-
- 물질 - {dispersionResult.substance} -
-
- 풍향 - SW {dispersionResult.windDirection}° -
-
- 확산 구역 - {dispersionResult.zones.length}개 -
-
-
-
위험 구역
-
-
-
- 치명적 위험 구역 (AEGL-3) -
-
-
- 높은 위험 구역 (AEGL-2) -
-
-
- 중간 위험 구역 (AEGL-1) -
-
-
-
-
🧭
- 풍향 (방사형) -
+ )}
) } if (oilTrajectory.length > 0) { return ( -
-

범례

-
- {Array.from(selectedModels).map(model => ( -
-
- {model} -
- ))} - {selectedModels.size === 3 && ( -
- (앙상블 모드) -
- )} -
-
-
- 사고 지점 -
- {boomLines.length > 0 && ( - <> -
-
-
- 긴급 오일펜스 -
-
-
- 중요 오일펜스 -
-
-
- 보통 오일펜스 -
- - )} +
+ {/* 헤더 + 접기/펼치기 */} +
setMinimized(!minimized)} + > + 범례 + {minimized ? '▶' : '▼'}
+ + {!minimized && ( +
+ {/* 모델별 색상 */} + {Array.from(selectedModels).map(model => ( +
+
+ {model} +
+ ))} + {/* 앙상블 */} +
+
+ 앙상블 +
+ + {/* 오일펜스 라인 */} +
+
+
+
+
+
+
+ 오일펜스 라인 +
+ + {/* 도달시간별 선종 */} +
+
+
+ 위험 (<6h) +
+
+
+ 경고 (6~12h) +
+
+
+ 주의 (12~24h) +
+
+
+ 안전 +
+
+ )}
) } diff --git a/frontend/src/common/components/ui/UserManualPopup.tsx b/frontend/src/common/components/ui/UserManualPopup.tsx new file mode 100644 index 0000000..89e4256 --- /dev/null +++ b/frontend/src/common/components/ui/UserManualPopup.tsx @@ -0,0 +1,1616 @@ +import { useState } from 'react'; + +interface UserManualPopupProps { + isOpen: boolean; + onClose: () => void; +} + +interface InputItem { + label: string; + type: string; + required: boolean; + desc: string; +} + +interface ScreenItem { + id: string; + name: string; + menuPath: string; + imageIndex: number; + overview: string; + description?: string; + procedure?: string[]; + inputs?: InputItem[]; + notes?: string[]; +} + +interface Chapter { + id: string; + number: string; + title: string; + subtitle: string; + screens: ScreenItem[]; +} + +const CHAPTERS: Chapter[] = [ + { + id: 'ch01', + number: '01', + title: '유출유 확산예측', + subtitle: 'Oil Spill Dispersion Prediction', + screens: [ + { + id: '001', + name: '직접입력', + menuPath: '유출유확산예측 > 유출유확산분석', + imageIndex: 1, + overview: + '해양 유출유 사고 발생 시 오염원 위치와 유출 조건을 직접 입력하여 확산 범위를 예측하는 주요 분석 화면이다. KOSPS·POSEIDON·OpenDrift·앙상블의 4종 수치 모델을 선택적으로 적용할 수 있다. 실시간 기상·해양 데이터와 연계하여 즉시 예측 결과를 지도에 표출한다.', + description: + '화면 좌측에 예측정보 입력 패널(사고명, 위치, 유종, 유출량, 예측 시간)이 위치한다. 중앙 지도에서 클릭하여 사고 발생 위치를 직접 지정하거나 위·경도 좌표를 수동 입력할 수 있다. 하단 타임라인 슬라이더로 시간 경과에 따른 확산 경과를 재생할 수 있다. 우측 \'분석 요약\' 패널에서 예측 면적, 이동거리, 이동 방향, 풍화 비율 등을 확인할 수 있다.', + procedure: [ + '상단 메뉴에서 \'유출유확산예측 > 유출유확산분석\'을 클릭하여 화면을 이동한다.', + '좌측 패널에서 사고명, 날짜, 유종, 유출량, 예측 시간을 입력한다.', + '지도를 클릭하거나 좌표 입력란에 위·경도를 직접 입력하여 사고 위치를 지정한다.', + '적용할 확산 모델(KOSPS·POSEIDON·OpenDrift·앙상블) 체크박스를 선택한다.', + '\'확산예측 실행\' 버튼을 클릭하여 예측을 시작한다.', + '하단 타임라인 재생 버튼으로 시간별 확산 결과를 확인한다.', + ], + inputs: [ + { label: '사고명', type: '텍스트', required: true, desc: '사고 식별 명칭' }, + { label: '날짜/시간', type: '날짜+시간', required: true, desc: '사고 발생 일시' }, + { label: '위도/경도', type: '숫자', required: true, desc: '지도 클릭 자동 입력 가능' }, + { label: '유종', type: '드롭다운', required: true, desc: '벙커C유·경유·연료유 등' }, + { label: '유출량', type: '숫자 kL', required: true, desc: '유출 유류 총량' }, + { label: '예측 시간', type: '숫자 h', required: true, desc: '확산 예측 기간' }, + { label: '적용 모델', type: '체크박스', required: true, desc: 'KOSPS·POSEIDON·OpenDrift·앙상블 중 선택' }, + ], + notes: [ + '좌표 입력 후 반드시 \'적용\' 버튼을 클릭해야 지도에 반영된다.', + '앙상블 모델 선택 시 3개 모델이 동시 실행되어 계산 시간이 증가할 수 있다.', + '유출량이 0 이하이거나 예측 시간이 입력되지 않으면 실행 버튼이 비활성화된다.', + ], + }, + { + id: '002', + name: '모델종류별 확산예측 실행', + menuPath: '유출유확산예측 > 유출유확산분석', + imageIndex: 2, + overview: + 'KOSPS·POSEIDON·OpenDrift 3개 모델의 예측 결과를 동시에 지도에 표출하여 모델 간 비교 분석을 지원한다. 모델별 입자 궤적(파란점·빨간점·하늘점)이 중첩 표시되어 확산 경향의 편차를 한눈에 파악할 수 있다.', + description: + '지도에 모델별 색상 구분 입자가 동시에 표시되며, 각 모델의 확산 방향과 범위 차이를 시각적으로 비교할 수 있다. 우측 \'분석 요약\' 패널에 예측 면적(km2), 이동거리(km), 방향, 이동속도(cm/s)가 표출된다. 풍화 상태 막대그래프(유출량·해면연소·자연분산·수중분산·잔류 비율)를 제공한다. \'다각형 분석수행\' 버튼으로 특정 구역 내 오염 면적을 별도 산정할 수 있다.', + procedure: [ + '확산분석 화면에서 적용 모델 체크박스를 2개 이상 선택한다.', + '\'확산예측 실행\' 버튼을 클릭한다.', + '지도에서 각 모델의 입자 분포와 확산 범위를 비교한다.', + '우측 \'분석 요약\' 탭에서 모델별 정량 지표를 확인한다.', + '하단 타임라인으로 시간 경과별 확산 변화를 재생한다.', + ], + notes: [ + '여러 모델을 동시에 선택하면 계산 자원 소모가 증가하여 결과 표출까지 수 분이 소요될 수 있다.', + '모델별 예측 결과에 차이가 있을 경우, 앙상블 결과 또는 현장 데이터와 교차 검토하여 활용한다.', + ], + }, + { + id: '003', + name: '사고정보 조회', + menuPath: '유출유확산예측 > 유출유확산분석', + imageIndex: 3, + overview: + '시스템에 등록된 실제 사고 정보를 불러와 확산분석에 자동 연동하는 기능이다. 사고코드·선박명·유종·유출량·발생 좌표 등이 자동으로 예측정보 입력란에 채워진다.', + description: + '좌측 \'사고정보\' 패널에 진행 중인 사고 목록이 표시된다. 사고를 선택하면 지도 중심이 해당 사고 발생 위치로 자동 이동하고 위치 핀이 표시된다. 사고 상태는 \'진행중(빨간 배지)\'과 \'종료(회색 배지)\'로 구분된다.', + procedure: [ + '좌측 \'사고정보\' 패널의 펼침 버튼을 클릭하여 패널을 연다.', + '목록에서 분석할 사고를 클릭한다.', + '사고 정보가 예측정보 입력란에 자동으로 입력되었는지 확인한다.', + '필요 시 유출량·예측 시간 등 추가 정보를 수정한다.', + '\'확산예측 실행\'을 클릭하여 분석을 시작한다.', + ], + notes: [ + '\'진행중\' 상태의 사고만 실시간 확산예측과 연동된다.', + '사고 데이터는 관계 기관으로부터 등록된 정보를 기반으로 하며, 입력 오류 시 관리자에게 문의한다.', + ], + }, + { + id: '004', + name: '영향 민감자원 레이어 중첩 표시', + menuPath: '유출유확산예측 > 유출유확산분석', + imageIndex: 4, + overview: + '유출유 확산 예측 결과와 해양 민감자원(환경생태·수산자원·관광지 등) 레이어를 지도에 동시 표출하는 기능이다. 잠재적 피해 자원을 사전에 파악하여 방제 우선순위 설정에 활용한다.', + description: + '좌측 하단 \'정보 레이어\' 패널에서 환경생태·사회경제·민감도평가·해경관할구역 등 대분류 토글을 제공한다. 각 항목 우측에 해당 레이어 데이터 건수가 표시된다. \'전체 켜기/끄기\' 버튼으로 모든 레이어를 일괄 제어할 수 있다.', + procedure: [ + '좌측 하단 \'정보 레이어\' 패널을 열고 원하는 레이어 항목을 활성화한다.', + '확산예측 실행 후 지도에 표출된 오염 범위와 레이어 분포를 비교한다.', + '오염 영향이 우려되는 자원 레이어를 클릭하여 상세 정보를 확인한다.', + '필요한 레이어 조합을 선택하여 보고서용 지도 캡처에 활용한다.', + ], + notes: [ + '레이어 수가 많을 경우 지도 로딩 속도가 느려질 수 있으므로 필요한 레이어만 선택적으로 활성화한다.', + ], + }, + { + id: '005', + name: 'AI자동추천 배치안 적용', + menuPath: '유출유확산예측 > 유출유확산분석 > 오일펜스 배치 가이드', + imageIndex: 5, + overview: + '확산예측 결과를 기반으로 AI(NSGA-II 알고리즘)가 최적 오일펜스 배치안을 자동으로 생성하는 기능이다. 최대 3개 방어선의 위치·방향·총 길이·차단율을 자동 계산하여 지도에 표출한다.', + description: + '\'오일펜스 배치 가이드\' 패널 상단 \'AI자동 추천\' 탭에서 배치 결과를 확인한다. 1차~3차 방어선별 배치 위치(좌표), 방향, 오일펜스 길이, 예상 차단율이 표시된다.', + procedure: [ + '확산예측 실행 완료 후 좌측 \'오일펜스 배치 가이드\' 패널을 연다.', + '\'AI자동 추천\' 탭을 선택하여 추천 배치안을 확인한다.', + '방어선별 차단율 및 오일펜스 총 길이를 검토한다.', + '\'추천 배치안 적용하기\' 버튼을 클릭하여 지도에 표출한다.', + '현장 여건(조류·지형·자원 현황)을 고려하여 최종 배치안을 확정한다.', + ], + inputs: [ + { label: '배치 수', type: '숫자', required: false, desc: '투입할 오일펜스 방어선 수' }, + { label: '최소 차단율', type: '숫자 %', required: false, desc: '목표 차단율' }, + ], + notes: [ + 'AI 추천 기능은 확산예측이 완료된 후에 활성화된다.', + '추천 배치안은 수치 모델 기반 결과로, 현장 조류 변동·수심·접근 가능성 등을 반드시 현장 담당자가 최종 확인해야 한다.', + ], + }, + { + id: '006', + name: '이미지업로드', + menuPath: '유출유확산예측 > 유출유확산분석', + imageIndex: 6, + overview: + '위성·드론·항공기에서 촬영한 이미지를 업로드하여 실제 오염 현황과 모델 예측 결과를 비교 분석하는 기능이다.', + description: + '예측정보 입력 패널 상단 \'이미지 업로드\' 탭을 선택하면 파일 업로드 영역이 활성화된다. 업로드된 이미지는 지도에 자동으로 오버레이 표출된다.', + procedure: [ + '예측정보 입력 패널에서 \'이미지 업로드\' 탭을 선택한다.', + '\'파일 선택\' 버튼을 클릭하거나 파일을 드래그하여 업로드한다.', + '업로드된 이미지가 지도에 올바르게 표출되는지 위치를 확인한다.', + '확산예측 결과와 이미지를 동시에 표출하여 비교한다.', + ], + inputs: [ + { label: '업로드 파일', type: '파일 PNG/JPG', required: false, desc: '위성·드론·항공기 촬영 이미지' }, + ], + notes: [ + '지원 파일 형식은 PNG, JPG이다.', + '업로드한 이미지의 좌표 기준(GCP)이 없을 경우 지도 상 위치 정확도가 낮을 수 있다.', + ], + }, + { + id: '007', + name: '분석 목록', + menuPath: '유출유확산예측', + imageIndex: 7, + overview: + '완료 또는 진행 중인 유출유 확산예측 분석 이력을 목록 형식으로 조회하는 화면이다. 사고명·날짜·유종·유출량·모델 상태 등을 한눈에 파악할 수 있다.', + description: + '목록에는 번호·사고명·사고일시·예측시간·유종·유출량·모델별 상태(KOSPS·POSEIDON·OPENDRIFT)·담당자 등이 표시된다. 모델 상태는 \'완료(녹색 배지)\'와 \'대기(회색 배지)\'로 구분된다.', + procedure: [ + '상단 메뉴에서 \'유출유확산예측\'을 클릭하여 분석 목록 화면으로 이동한다.', + '검색창에 사고명을 입력하거나 페이지를 이동하여 원하는 분석을 찾는다.', + '사고명 링크를 클릭하여 해당 분석 결과 상세 화면으로 이동한다.', + '신규 분석이 필요한 경우 우측 상단 \'+ 새 분석\' 버튼을 클릭한다.', + ], + notes: [ + '분석 결과 삭제 시 복구가 불가하므로, 삭제 전 보고서 출력 또는 데이터 저장 여부를 확인한다.', + ], + }, + { + id: '008', + name: '분석 조회(test001)', + menuPath: '유출유확산예측 > 분석 목록', + imageIndex: 8, + overview: + '분석 목록에서 특정 분석 건을 선택하면 해당 확산분석 결과 화면으로 이동하여 상세 내용을 조회한다. 이전 분석 결과를 재검토하거나 조건 변경 후 재분석할 수 있다.', + procedure: [ + '분석 목록에서 조회할 사고명 링크를 클릭한다.', + '로드된 분석 정보를 확인한다.', + '필요 시 입력 조건을 수정하고 \'확산예측 실행\'을 다시 클릭하여 재분석한다.', + '결과를 보고서로 출력하거나 저장한다.', + ], + notes: [ + '기존 분석에서 모델 조건 변경 후 재실행 시 이전 결과가 덮어써질 수 있으므로, 원본 결과를 먼저 저장한다.', + ], + }, + { + id: '009', + name: '유출유확산모델 이론 - 시스템 개요', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 9, + overview: + 'Wing 시스템에 탑재된 유출유 확산 수치 모델의 개요와 운용 체계를 안내하는 이론 화면이다. KOSPS·POSEIDON·OpenDrift 3종 모델의 특징·비교·데이터 흐름을 확인할 수 있다.', + procedure: [ + '상단 메뉴에서 \'유출유확산예측 > 유출유확산모델 이론\'을 클릭한다.', + '\'시스템 개요\' 탭을 선택하여 전체 모델 체계를 확인한다.', + '각 탭(KOSPS·POSEIDON·OpenDrift 등)을 클릭하여 상세 이론을 열람한다.', + ], + }, + { + id: '010', + name: '유출유확산모델 이론 - KOSPS', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 10, + overview: + '한국해양과학기술원(KIOST)이 개발한 KOSPS 모델의 상세 이론을 안내한다. 국내 연안 특성에 최적화된 조류예측(CHARRY) 및 풍류 경험식 적용 원리를 설명한다.', + notes: [ + 'KOSPS는 국내 연안 중심으로 검증된 모델로, 원해·심해 적용 시 정확도 제한이 있을 수 있다.', + ], + }, + { + id: '011', + name: '유출유확산모델 이론 - POSEIDON', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 11, + overview: + '입자추적 최적화 예측 시스템 POSEIDON의 이론 및 MOHID 3D 해양순환모델 적용 원리를 안내한다. GA·DE·HS·PSO 등 4종 최적화 알고리즘을 통한 파라미터 자동 최적화 방법을 설명한다.', + }, + { + id: '012', + name: '유출유확산모델 이론 - OpenDrift', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 12, + overview: + '노르웨이 MET Norway가 개발한 오픈소스 라그랑지안 확산 프레임워크 OpenDrift의 이론을 안내한다. NEMO·ROMS·HYCOM·Copernicus CMEMS 등 다양한 해양 예보 모델과 연동 가능한 범용성을 설명한다.', + }, + { + id: '013', + name: '유출유확산모델 이론 - 입자추적법', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 13, + overview: + '유출유 수치 모델에 적용되는 라그랑지안 입자추적법(Lagrangian Particle Tracking Method)의 수학적 이론과 수식을 안내한다.', + }, + { + id: '014', + name: '유출유확산모델 이론 - 풍화 프로세스', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 14, + overview: + '유출유가 시간 경과에 따라 겪는 풍화(Weathering) 프로세스(증발·유화·자연분산 등)의 이론과 타임라인을 안내한다.', + notes: [ + '풍화 속도는 유종·기온·해수온·파랑 조건에 따라 크게 달라질 수 있으므로 참고용으로 활용한다.', + ], + }, + { + id: '015', + name: '유출유확산모델 이론 - 해양환경 입력', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 15, + overview: + '유출유 확산 수치 모델에 사용되는 해양환경 입력 데이터(기상·해류·조류·해수면 온도) 체계를 안내한다. KMA RDAPS·ECMWF·NIFS ROMS·HYCOM·TPXO9 등 데이터 소스 설명.', + }, + { + id: '016', + name: '유출유확산모델 이론 - 모델 검증', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 16, + overview: + '유출유 확산 모델의 정확도 검증 사례(2007 허베이스피리트, 2014 무이산 등)와 RMSE·Skill Score 통계 지표를 안내한다.', + }, + { + id: '017', + name: '유출유확산모델 이론 - 앙상블', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 17, + overview: + 'KOSPS·POSEIDON·OpenDrift 3종 모델의 결과를 통합하는 앙상블 예측 방법론과 가중평균 알고리즘을 안내한다. 최악 시나리오(Worst Case) 산출 방법도 설명한다.', + }, + { + id: '018', + name: '유출유확산모델 이론 - 발전 방향', + menuPath: '유출유확산예측 > 유출유확산모델 이론', + imageIndex: 18, + overview: + '현재 유출유 확산 모델의 한계와 향후 개선 방향(4단계 로드맵)을 안내한다.', + }, + { + id: '019', + name: '오일펜스 배치 알고리즘 이론 - 개요', + menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', + imageIndex: 19, + overview: + '오일펜스 최적 배치 알고리즘의 전체 개요와 최적화 목표(차단 면적 최대화·도달시간 최소화·자원 효율화)를 안내한다.', + }, + { + id: '020', + name: '오일펜스 배치 알고리즘 이론 - 배치 이론', + menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', + imageIndex: 20, + overview: + '차단 효율 함수(E(theta, U))와 V형·U형·J형 배치 형태별 이론적 차단 원리를 안내한다. 다단계 차단선 배치 시 총 차단 효율 산출 공식도 포함한다.', + }, + { + id: '021', + name: '오일펜스 배치 알고리즘 이론 - 최적화 알고리즘', + menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', + imageIndex: 21, + overview: + 'NSGA-II(Non-dominated Sorting Genetic Algorithm II) 기반 다목적 최적화 알고리즘의 원리와 보조 알고리즘(PSO·Greedy) 비교를 안내한다.', + }, + { + id: '022', + name: '오일펜스 배치 알고리즘 이론 - 유체역학 모델', + menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', + imageIndex: 22, + overview: + '오일펜스 차단 성능 평가에 사용되는 유체역학 모델의 이론을 안내한다.', + }, + { + id: '023', + name: '오일펜스 배치 알고리즘 이론 - 현장 적용', + menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', + imageIndex: 23, + overview: + '오일펜스 배치 알고리즘의 실제 사고 현장 적용 사례와 현장 운용 시 고려사항을 안내한다.', + }, + { + id: '024', + name: '오일펜스 배치 알고리즘 이론 - 참고문헌', + menuPath: '유출유확산예측 > 오일펜스 배치 알고리즘 이론', + imageIndex: 24, + overview: + '오일펜스 배치 알고리즘 이론에 사용된 학술 논문·기술 보고서·국제 기준 등 참고문헌 목록을 안내한다.', + }, + ], + }, + { + id: 'ch02', + number: '02', + title: 'HNS·대기확산', + subtitle: 'HNS Atmospheric Dispersion', + screens: [ + { + id: '025', + name: '대기확산예측 실행', + menuPath: 'HNS대기확산 > 대기확산분석', + imageIndex: 25, + overview: + 'HNS(위험유해물질) 해상 유출 시 대기 확산 범위, 위험 농도 구역, 영향 인구를 예측하는 핵심 분석 화면이다. ALOHA(EPA) 또는 이문진박사모델 두 가지 알고리즘 중 선택하여 가우시안 Plume/Puff 모델을 적용한다.', + description: + '화면 좌측에 사고 기본정보·물질 및 유출 조건·기상조건 입력 패널이 위치한다. 중앙 지도에 AEGL-1~3 위험 구역이 색상별 Plume 형태로 표출된다. 우측 \'예측 결과\' 패널에 최대 농도(ppm), AEGL-1 영향 면적, 위험 등급 등이 표시된다.', + procedure: [ + '상단 메뉴에서 \'HNS대기확산 > 대기확산분석\'을 클릭한다.', + '사고 기본정보(사고명·날짜·시간·위치)를 입력한다.', + 'HNS 물질 종류 및 누출 방식·유출량을 선택·입력한다.', + '기상조건(풍향·풍속·기온·대기안정도·예측 시간)을 입력한다.', + '적용 알고리즘(ALOHA/이문진박사모델)을 선택한다.', + '\'확산예측 실행\' 버튼을 클릭한다.', + '지도의 위험 구역 Plume과 우측 예측 결과 패널을 확인한다.', + ], + inputs: [ + { label: '사고명', type: '텍스트', required: true, desc: '사고 식별 명칭' }, + { label: '사고 일시', type: '날짜+시간', required: true, desc: '사고 발생 일시' }, + { label: '위도/경도', type: '숫자', required: true, desc: '사고 발생 지점 좌표' }, + { label: 'HNS 물질', type: '드롭다운', required: true, desc: 'AEGL/ERPG 기준값 자동 로드' }, + { label: '누출 방식', type: '라디오', required: true, desc: '순간 유출 또는 지속 유출' }, + { label: '유출량', type: '숫자', required: true, desc: 'ton 또는 g/s 단위' }, + { label: '풍향', type: '숫자', required: true, desc: '0~360도' }, + { label: '풍속', type: '숫자 m/s', required: true, desc: '최소 0.5 m/s 이상' }, + { label: '기온', type: '숫자', required: true, desc: '현재 기온' }, + { label: '대기안정도', type: '선택 A~F', required: true, desc: 'Pasquill-Gifford 분류' }, + { label: '예측 시간', type: '숫자 h', required: true, desc: '확산 예측 기간' }, + { label: '적용 알고리즘', type: '라디오', required: true, desc: 'ALOHA 또는 이문진박사모델' }, + ], + notes: [ + '풍속은 최소 0.5 m/s 이상 입력해야 하며 0 입력 시 오류 발생.', + 'HNS 물질 선택 후 AEGL/ERPGs 기준값이 자동 로드되었는지 반드시 확인.', + ], + }, + { + id: '026', + name: 'HNS 분석 목록', + menuPath: 'HNS대기확산', + imageIndex: 26, + overview: + '완료된 HNS 대기확산 분석 이력을 목록 형식으로 조회하는 화면이다. AEGL-1~3 거리·위험 등급·피해반경·담당자 등 주요 결과 정보를 한눈에 파악할 수 있다.', + procedure: [ + '상단 메뉴에서 \'HNS대기확산\'을 클릭하여 분석 목록으로 이동한다.', + '검색창에서 분석명을 검색하거나 목록을 스크롤하여 원하는 분석을 찾는다.', + '분석명 링크를 클릭하여 해당 분석 결과 지도 화면으로 이동한다.', + ], + }, + { + id: '027', + name: '시나리오 상세', + menuPath: 'HNS대기확산 > 시나리오 관리', + imageIndex: 27, + overview: + '단일 HNS 사고에 대해 여러 기상·유출 조건 시나리오(S-01~S-05 등)를 생성·비교 관리하는 화면이다. 시나리오별 위험도·위험 구역·대응 권고사항을 한눈에 비교할 수 있다.', + description: + '시나리오 목록 카드에 시나리오명·위험도 배지(CRITICAL/HIGH/MEDIUM/RESOLVED)·최대농도·IDLH 거리·ERPG-2 거리·영향인구 요약이 표시된다.', + procedure: [ + '\'HNS대기확산 > 시나리오 관리\'를 클릭하여 이동한다.', + '원하는 사고 건을 선택하면 시나리오 목록이 표시된다.', + '시나리오 카드를 클릭하여 상세 위험 구역과 대응 권고사항을 확인한다.', + '\'비교 차트\' 탭으로 이동하여 시나리오 간 시간별 변화를 비교한다.', + ], + }, + { + id: '028', + name: '비교 차트', + menuPath: 'HNS대기확산 > 시나리오 관리', + imageIndex: 28, + overview: + '생성된 여러 HNS 시나리오의 최대 지표면 농도·위험 반경·영향 인구를 시간별 변화 차트로 비교하는 화면이다.', + description: + '상단은 시나리오별 최대 지표면 농도(ppm) 시간별 변화 그래프. 중단에 위험 반경(km)과 영향 인구(명) 변화 차트. 하단에 피대농도·IDLH 거리·ERPG-2 반경·영향인구·풍향/풍속·위험등급 항목별 비교표가 제공된다.', + }, + { + id: '029', + name: '확산범위 오버레이', + menuPath: 'HNS대기확산 > 시나리오 관리', + imageIndex: 29, + overview: + 'HNS 대기확산 시나리오의 시간대별(T+0h~T+4h) 확산 범위를 지도에 오버레이 표출하는 화면이다.', + procedure: [ + '시나리오 관리 화면에서 \'확산범위 오버레이\' 탭을 클릭한다.', + '지도에서 시간대별 확산 범위를 확인한다.', + '슬라이더를 이동하여 원하는 시간대의 확산 상태를 조회한다.', + ], + }, + { + id: '030', + name: '신규 시나리오', + menuPath: 'HNS대기확산 > 시나리오 관리', + imageIndex: 30, + overview: + '기상·유출 조건을 변경하여 새로운 HNS 대기확산 시나리오를 생성하는 입력 모달 화면이다.', + procedure: [ + '시나리오 관리 화면 우측 상단 \'신규 시나리오\' 버튼을 클릭한다.', + '시나리오명과 기준 시각을 입력한다.', + 'HNS 물질·누출 구분·유출량을 입력한다.', + '기상조건(풍향·풍속·기온·대기안정도)을 입력한다.', + '\'시나리오 생성 및 예측 실행\'을 클릭한다.', + ], + inputs: [ + { label: '시나리오명', type: '텍스트', required: true, desc: '시나리오 식별 명칭' }, + { label: 'HNS 물질', type: '드롭다운', required: true, desc: '유출 물질 선택' }, + { label: '누출 구분', type: '라디오', required: true, desc: '순간/지속' }, + { label: '유출량', type: '숫자', required: true, desc: 'ton 또는 g/s' }, + { label: '풍향/풍속', type: '숫자', required: true, desc: '각도와 m/s' }, + { label: '기온', type: '숫자', required: true, desc: '섭씨' }, + { label: '대기안정도', type: '선택 A~F', required: true, desc: 'Pasquill-Gifford' }, + ], + }, + { + id: '031', + name: 'HNS 대응매뉴얼', + menuPath: 'HNS대기확산', + imageIndex: 31, + overview: + '해양 HNS 사고 대응 절차를 정리한 Marine HNS Response Manual(Bonn Agreement·HELCOM·REMPEC 기반)을 시스템 내에서 직접 열람하는 화면이다. 8개 챕터. SEBC 거동 분류 5유형 카드.', + procedure: [ + '상단 메뉴에서 \'HNS대기확산 > HNS 대응매뉴얼\'을 클릭한다.', + '원하는 챕터 카드를 클릭하여 세부 내용을 확인한다.', + '하단 SEBC 분류 카드를 클릭하여 거동 유형별 대응 방법을 확인한다.', + ], + }, + { + id: '032', + name: 'HNS 확산모델 이론 - 시스템 개요', + menuPath: 'HNS대기확산 > 확산모델 이론', + imageIndex: 32, + overview: + 'Wing에 탑재된 HNS 대기확산 모델(WRF-Chem·Gaussian Plume/Puff·ROMS 연동)의 전체 체계와 처리 흐름을 안내한다.', + }, + { + id: '033', + name: 'HNS 확산모델 이론 - 가우시안 모델', + menuPath: 'HNS대기확산 > 확산모델 이론', + imageIndex: 33, + overview: + 'HNS 대기확산 예측에 적용되는 Gaussian Plume·Puff 모델의 수학적 이론과 Pasquill-Gifford 대기안정도 분류 체계를 안내한다.', + }, + { + id: '034', + name: 'HNS 확산모델 이론 - 물질별 시나리오', + menuPath: 'HNS대기확산 > 확산모델 이론', + imageIndex: 34, + overview: + '주요 HNS 물질(NH3·CH3OH·H2·LNG 등)의 물질 특성 및 대기확산 시나리오 적용 기준을 안내한다.', + }, + { + id: '035', + name: 'HNS 확산모델 이론 - 해양환경 보정', + menuPath: 'HNS대기확산 > 확산모델 이론', + imageIndex: 35, + overview: + '해양 환경 특성(해풍·육풍 순환·해수면 거칠기·SST 영향·MABL 구조)에 따른 대기확산 모델 보정 인자를 안내한다.', + }, + { + id: '036', + name: 'HNS 확산모델 이론 - 모델 검증', + menuPath: 'HNS대기확산 > 확산모델 이론', + imageIndex: 36, + overview: + 'HNS 대기확산 모델의 실측값 대비 예측 정확도 검증 결과(ALOHA·이문진박사모델·실측값 3종 비교)를 안내한다.', + }, + { + id: '037', + name: 'HNS 확산모델 이론 - 실시간 비교', + menuPath: 'HNS대기확산 > 확산모델 이론', + imageIndex: 37, + overview: + '동일 조건에서 ALOHA와 이문진박사모델의 예측 결과를 실시간으로 비교하는 시뮬레이션 화면이다.', + }, + { + id: '038', + name: 'HNS 확산모델 이론 - WRF-Chem 발전', + menuPath: 'HNS대기확산 > 확산모델 이론', + imageIndex: 38, + overview: + 'WRF-Chem 기상-화학 연계 모델 및 ROMS 해양확산 수치모의 검증 결과와 고도화 방향을 안내한다.', + }, + { + id: '039', + name: 'HNS물질정보 - SEBC 거동분류', + menuPath: 'HNS대기확산 > HNS물질정보', + imageIndex: 39, + overview: + 'HNS 물질의 해양 거동 특성을 SEBC 기준으로 분류한 정보를 제공한다. G(기체)·E(증발)·F(부유)·D(용해)·S(침강) 5가지 기본 유형과 복합 거동 유형을 안내한다.', + }, + { + id: '040', + name: 'HNS물질정보 - 주요 물질 특성', + menuPath: 'HNS대기확산 > HNS물질정보', + imageIndex: 40, + overview: + 'NH3·CH4·H2·페놀·톨루엔 등 주요 HNS 물질의 상세 물리화학적 특성을 카드 형식으로 제공하는 화면이다.', + }, + { + id: '041', + name: 'HNS물질정보 - 위험도 기준', + menuPath: 'HNS대기확산 > HNS물질정보', + imageIndex: 41, + overview: + 'HNS 물질의 위험도 평가에 사용되는 AEGL·ERPG·IDLH·TWA 등 주요 독성 기준값 체계를 안내한다.', + }, + { + id: '042', + name: 'HNS물질정보 - 물질 상세검색', + menuPath: 'HNS대기확산 > HNS물질정보', + imageIndex: 42, + overview: + 'HNS 6,500여 종의 물질 데이터베이스에서 CAS 번호·물질명·거동 분류 조건으로 물질을 검색하는 화면이다.', + inputs: [ + { label: '검색어', type: '텍스트', required: true, desc: 'CAS 번호 또는 물질명' }, + { label: '거동 분류', type: '체크박스', required: false, desc: 'SEBC 유형 필터' }, + ], + notes: [ + 'CAS 번호 입력 시 하이픈(-) 포함 정확한 형식으로 입력해야 검색된다.', + ], + }, + ], + }, + { + id: 'ch03', + number: '03', + title: '긴급구난', + subtitle: 'Emergency Rescue', + screens: [ + { + id: '043', + name: '긴급구난예측', + menuPath: '긴급구난', + imageIndex: 43, + overview: + '해상 조난자·표류 선박·표류 물체의 위치를 예측하는 긴급구난(SAR) 분석 화면이다. 해류·바람·파랑 데이터 기반 라그랑지안 표류 궤적 시뮬레이션을 수행한다.', + description: + '화면 좌측에 사고 발생 위치·일시·표류체 종류·예측 시간·기상조건 입력 패널이 위치한다. 중앙 지도에 표류 예측 궤적과 탐색 구역(최우선·일반)이 표출된다.', + procedure: [ + '상단 메뉴에서 \'긴급구난\'을 클릭한다.', + '사고 발생 위치(위경도)와 일시를 입력한다.', + '표류체 종류(선박/사람/컨테이너 등)를 선택한다.', + '예측 시간과 기상 조건을 입력한다.', + '\'예측 실행\' 버튼을 클릭하여 표류 궤적과 탐색 구역을 확인한다.', + ], + inputs: [ + { label: '사고 위치', type: '숫자', required: true, desc: '위·경도' }, + { label: '사고 일시', type: '날짜+시간', required: true, desc: '발생 일시' }, + { label: '표류체 종류', type: '드롭다운', required: true, desc: '선박·사람·컨테이너·구명정 등' }, + { label: '예측 시간', type: '숫자 h', required: true, desc: '표류 예측 기간' }, + { label: '기상조건', type: '숫자', required: false, desc: '미입력 시 실시간 자동 적용' }, + ], + notes: [ + '표류체 종류 선택이 부적절한 경우 예측 정확도가 낮아질 수 있다.', + '신속한 대응을 위해 입력 완료 즉시 실행하고 기상 변화 시 재분석을 수행한다.', + ], + }, + { + id: '044', + name: '긴급구난 목록', + menuPath: '긴급구난', + imageIndex: 44, + overview: + '완료 또는 진행 중인 긴급구난 예측 분석 이력을 목록 형식으로 조회하는 화면이다.', + procedure: [ + '상단 메뉴에서 \'긴급구난\'을 클릭하면 목록 화면이 표시된다.', + '원하는 분석을 검색하거나 목록에서 선택한다.', + '사고명 링크를 클릭하여 상세 분석 결과로 이동한다.', + ], + }, + { + id: '045', + name: '시나리오 상세', + menuPath: '긴급구난 > 시나리오 관리', + imageIndex: 45, + overview: + '긴급구난 사고에 대해 기상·해양 조건을 달리 설정한 복수 시나리오를 생성·비교 관리하는 화면이다.', + procedure: [ + '\'긴급구난 > 시나리오 관리\'를 클릭하여 이동한다.', + '생성된 시나리오 카드를 확인하거나 \'신규 시나리오\' 버튼으로 추가 생성한다.', + '시나리오 카드를 클릭하여 상세 내용을 확인한다.', + ], + }, + { + id: '046', + name: '비교 차트', + menuPath: '긴급구난 > 시나리오 관리', + imageIndex: 46, + overview: + '긴급구난 시나리오별 탐색 면적·표류 속도·최대 이동거리를 시간별 변화 차트로 비교하는 화면이다.', + }, + { + id: '047', + name: '지도 오버레이', + menuPath: '긴급구난 > 시나리오 관리', + imageIndex: 47, + overview: + '긴급구난 시나리오의 시간대별 표류 예측 궤적 및 탐색 구역을 지도에 오버레이 표출하는 화면이다.', + }, + { + id: '048', + name: '긴급구난모델 이론', + menuPath: '긴급구난', + imageIndex: 48, + overview: + '긴급구난 표류 예측에 적용되는 라그랑지안 표류 모델·탐색 구역(Search Area) 산출 방법·IMO SAR 기준 적용 이론을 안내한다.', + }, + ], + }, + { + id: 'ch04', + number: '04', + title: '보고자료', + subtitle: 'Reports', + screens: [ + { + id: '049', + name: '보고서 목록', + menuPath: '보고자료', + imageIndex: 49, + overview: + '시스템에서 생성된 모든 사고 대응 보고서를 목록 형식으로 관리하는 화면이다. 보고서명·사고명·생성일시·유형·작성자·상태가 표시된다.', + procedure: [ + '상단 메뉴에서 \'보고자료\'를 클릭하여 보고서 목록으로 이동한다.', + '검색창에서 원하는 보고서를 검색하거나 목록에서 선택한다.', + '보고서명을 클릭하여 미리보기하거나 PDF를 다운로드한다.', + ], + }, + { + id: '050', + name: '초기보고서', + menuPath: '보고자료 > 표준보고서 템플릿', + imageIndex: 50, + overview: + '사고 발생 초기 지휘부 보고를 위한 표준 보고서 템플릿을 제공한다. 확산예측 결과와 사고 기본정보가 자동 연동된다.', + procedure: [ + '보고자료 메뉴에서 \'표준보고서 템플릿\'을 클릭한다.', + '좌측 사이드바에서 \'초기보고서\'를 선택한다.', + '자동 입력된 항목을 검토하고 필요 내용을 추가 입력한다.', + '\'저장\' 또는 \'PDF 출력\' 버튼을 활용한다.', + ], + }, + { + id: '051', + name: '지휘부 보고', + menuPath: '보고자료 > 표준보고서 템플릿', + imageIndex: 51, + overview: + '사고 지휘부 보고용 표준 보고서 템플릿. 방제 대응 현황·자원 투입 현황·향후 계획이 포함된다.', + }, + { + id: '052', + name: '예측보고서', + menuPath: '보고자료 > 표준보고서 템플릿', + imageIndex: 52, + overview: + '확산예측 결과를 포함한 예측보고서 템플릿. 모델 종류·예측 시간·주요 결과가 자동 연동된다.', + }, + { + id: '053', + name: '종합보고서', + menuPath: '보고자료 > 표준보고서 템플릿', + imageIndex: 53, + overview: + '사고 종료 후 방제 대응 전 과정을 정리하는 종합보고서. 사고개요·대응경과·방제실적·환경영향평가·교훈이 포함된다.', + }, + { + id: '054', + name: '유출유 보고', + menuPath: '보고자료 > 표준보고서 템플릿', + imageIndex: 54, + overview: + '유출유 사고 전용 표준 보고서 템플릿. 확산예측·오일펜스 배치·풍화 상태가 자동 연동된다.', + }, + { + id: '055', + name: '유출유 확산예측 보고서 생성', + menuPath: '보고자료 > 보고서 생성', + imageIndex: 55, + overview: + '유출유 확산예측 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다.', + procedure: [ + '보고자료 메뉴에서 \'보고서 생성\'을 클릭한다.', + '\'유출유 확산예측\' 유형을 선택한다.', + '연동할 분석 결과를 드롭다운에서 선택한다.', + '보고 대상을 선택한다.', + '\'보고서 생성\' 버튼을 클릭한다.', + ], + inputs: [ + { label: '분석 결과', type: '드롭다운', required: true, desc: '연동할 분석 건 선택' }, + { label: '보고 대상', type: '체크박스', required: true, desc: '지휘부·현장·외부 기관' }, + ], + }, + { + id: '056', + name: 'HNS 대기확산 보고서 생성', + menuPath: '보고자료 > 보고서 생성', + imageIndex: 56, + overview: + 'HNS 대기확산 분석 결과를 기반으로 보고서를 자동 생성하는 화면이다.', + }, + { + id: '057', + name: '긴급구난 보고서 생성', + menuPath: '보고자료 > 보고서 생성', + imageIndex: 57, + overview: + '긴급구난 예측 분석 결과를 기반으로 구조 현장 지휘부용 보고서를 자동 생성하는 화면이다.', + }, + ], + }, + { + id: 'ch05', + number: '05', + title: '항공탐색', + subtitle: 'Aerial Surveillance', + screens: [ + { + id: '058', + name: '영상사진관리', + menuPath: '항공탐색', + imageIndex: 58, + overview: + '드론·항공기·위성에서 수집된 영상 및 사진을 통합 관리하는 화면이다. 촬영 일시·위치·기기 유형별 분류 조회와 유출유 면적분석 연계를 지원한다.', + procedure: [ + '상단 메뉴에서 \'항공탐색 > 영상사진관리\'를 클릭한다.', + '목록에서 원하는 영상/사진을 선택하거나 지도 마커를 클릭하여 확인한다.', + '신규 파일 업로드 시 \'파일 업로드\' 버튼을 클릭한다.', + ], + inputs: [ + { label: '업로드 파일', type: '파일', required: false, desc: 'JPG·PNG·GeoTIFF·KMZ' }, + { label: '촬영 일시', type: '날짜+시간', required: false, desc: '촬영 시점' }, + { label: '장비 유형', type: '드롭다운', required: false, desc: '드론·항공기·위성·CCTV' }, + ], + notes: [ + '50MB 초과 파일은 업로드 전 압축하거나 관리자에게 문의한다.', + ], + }, + { + id: '059', + name: '유출유면적분석', + menuPath: '항공탐색', + imageIndex: 59, + overview: + '항공·위성 이미지를 기반으로 유출유 오염 면적을 AI 자동 산출하는 화면이다. 자동 추출된 오염 경계선을 수동 편집하여 정밀 면적을 산정할 수 있다.', + procedure: [ + '영상사진관리에서 분석 대상 이미지를 선택하고 \'면적 분석\' 버튼을 클릭한다.', + '\'AI 자동 분석 실행\' 버튼을 클릭하여 오염 경계선을 추출한다.', + '폴리곤 경계선을 검토하고 필요 시 편집 도구로 수동 조정한다.', + '최종 면적·둘레·중심 좌표를 확인하고 저장한다.', + ], + }, + { + id: '060', + name: '실시간드론', + menuPath: '항공탐색', + imageIndex: 60, + overview: + '현장 드론에서 실시간 전송되는 영상 스트리밍을 시스템 내에서 직접 모니터링하는 화면이다.', + procedure: [ + '상단 메뉴에서 \'항공탐색 > 실시간 드론\'을 클릭한다.', + '연결된 드론 목록에서 모니터링할 드론을 선택한다.', + '실시간 영상을 확인하고 필요 시 스냅샷을 저장한다.', + ], + notes: [ + '드론 스트리밍은 네트워크 연결 상태에 따라 화질 및 지연이 달라질 수 있다.', + ], + }, + { + id: '061', + name: '오염선박 3D분석', + menuPath: '항공탐색', + imageIndex: 61, + overview: + '항공·위성 이미지를 기반으로 오염 선박의 3D 모델을 생성하고 오염 분포를 입체적으로 분석하는 화면이다.', + }, + { + id: '062', + name: '위성요청', + menuPath: '항공탐색', + imageIndex: 62, + overview: + 'SAR·광학 위성 촬영 요청 및 수신 결과를 관리하는 화면이다.', + inputs: [ + { label: '요청 위치', type: '숫자', required: true, desc: '위·경도' }, + { label: '촬영 희망 일시', type: '날짜+시간', required: true, desc: '촬영 필요 일시' }, + { label: '위성 종류', type: '라디오', required: true, desc: 'SAR 또는 광학' }, + { label: '해상도', type: '드롭다운', required: false, desc: '요청 이미지 해상도' }, + ], + notes: [ + '위성 촬영 가능 여부는 궤도 조건에 따라 결정되며 요청이 항상 수용되지 않을 수 있다.', + ], + }, + { + id: '063', + name: 'CCTV 조회', + menuPath: '항공탐색', + imageIndex: 63, + overview: + '해안·항만 CCTV의 실시간 영상을 조회하는 화면이다. 사고 인근 CCTV를 지도에서 선택하여 스트리밍으로 확인할 수 있다.', + procedure: [ + '상단 메뉴에서 \'항공탐색 > CCTV조회\'를 클릭한다.', + '지도에서 확인할 CCTV 마커를 클릭한다.', + '실시간 영상을 확인하고 필요 시 스냅샷을 저장한다.', + ], + }, + { + id: '064', + name: '항공탐색 이론 - 개요', + menuPath: '항공탐색 > 항공탐색 이론', + imageIndex: 64, + overview: + 'Wing 항공탐색 기능에 적용되는 원격탐사·ESI 방제지도·면적 산정·확산예측 연계 이론의 전체 개요를 안내한다.', + }, + { + id: '065', + name: '항공탐색 이론 - 탐지 장비', + menuPath: '항공탐색 > 항공탐색 이론', + imageIndex: 65, + overview: + '해양 오염 항공 탐지에 사용되는 주요 장비(SAR·적외선 카메라·UV 센서·광학 카메라)의 특성과 적용 원리를 안내한다.', + }, + { + id: '066', + name: '항공탐색 이론 - 원격탐사', + menuPath: '항공탐색 > 항공탐색 이론', + imageIndex: 66, + overview: + '위성 및 항공 원격탐사(Remote Sensing)의 기본 원리와 해양 오염 탐지 적용 방법을 안내한다.', + }, + { + id: '067', + name: '항공탐색 이론 - ESI 방제지도', + menuPath: '항공탐색 > 항공탐색 이론', + imageIndex: 67, + overview: + '환경민감지수(ESI) 방제지도의 해안선 민감도 등급 분류 체계와 방제 우선순위 결정 방법을 안내한다.', + }, + { + id: '068', + name: '항공탐색 이론 - 면적 산정', + menuPath: '항공탐색 > 항공탐색 이론', + imageIndex: 68, + overview: + '항공·위성 이미지 기반 유출유 면적 산정에 적용되는 AI 알고리즘(객체 탐지·영상 분할)의 원리를 안내한다.', + }, + { + id: '069', + name: '항공탐색 이론 - 확산예측 연계', + menuPath: '항공탐색 > 항공탐색 이론', + imageIndex: 69, + overview: + '항공 탐색 결과(실측 면적·위치)와 유출유 확산예측 모델 보정 연계 방법을 안내한다.', + }, + { + id: '070', + name: '항공탐색 이론 - 논문 특허', + menuPath: '항공탐색 > 항공탐색 이론', + imageIndex: 70, + overview: + 'Wing 항공탐색 기능의 기반이 되는 관련 논문과 특허 목록을 안내한다.', + }, + ], + }, + { + id: 'ch06', + number: '06', + title: '게시판', + subtitle: 'Bulletin Board', + screens: [ + { + id: '071', + name: '전체 게시판', + menuPath: '게시판', + imageIndex: 71, + overview: + '공지사항·자료실·Q&A·해경매뉴얼 게시물을 통합 조회하는 전체 게시판 화면이다.', + procedure: [ + '상단 메뉴에서 \'게시판\'을 클릭하여 전체 게시판으로 이동한다.', + '유형 필터 탭을 선택하거나 검색창에 키워드를 입력한다.', + '원하는 게시물 제목을 클릭하여 상세 내용을 확인한다.', + ], + }, + { + id: '072', + name: '공지사항', + menuPath: '게시판', + imageIndex: 72, + overview: + '시스템 공지·업데이트·장애 안내 등 운영 공지사항을 게시하는 화면이다. 관리자만 등록·수정 가능.', + }, + { + id: '073', + name: '자료실', + menuPath: '게시판', + imageIndex: 73, + overview: + '매뉴얼·지침서·참고자료 등 업무 관련 문서 파일을 공유하는 게시판 화면이다.', + }, + { + id: '074', + name: 'QNA', + menuPath: '게시판', + imageIndex: 74, + overview: + '시스템 사용 관련 질문을 등록하고 답변을 확인하는 Q&A 게시판 화면이다.', + inputs: [ + { label: '제목', type: '텍스트', required: true, desc: '질문 제목' }, + { label: '카테고리', type: '드롭다운', required: true, desc: '기능문의·오류신고·개선요청' }, + { label: '내용', type: '텍스트', required: true, desc: '질문 내용' }, + { label: '첨부 파일', type: '파일', required: false, desc: '스크린샷 등' }, + ], + }, + { + id: '075', + name: '해경 매뉴얼', + menuPath: '게시판', + imageIndex: 75, + overview: + '해양경찰청 방제·대응 매뉴얼을 시스템 내에서 바로 조회할 수 있는 화면이다. 관리자만 등록·수정 가능.', + }, + ], + }, + { + id: 'ch07', + number: '07', + title: '기상정보', + subtitle: 'Weather', + screens: [ + { + id: '076', + name: '기상정보', + menuPath: '기상정보', + imageIndex: 76, + overview: + '현재 사고 지역 주변의 실시간 기상 정보(풍향·풍속·기온·파고·시정 등)를 조회하는 화면이다. 기상 데이터는 확산예측 모델 입력으로 자동 연동된다.', + description: + '사고 위치 기준 반경 내 기상 관측소 목록과 최신 관측값이 표시된다. 시계열 그래프로 과거 24시간 기상 변화를 확인할 수 있다.', + procedure: [ + '상단 메뉴에서 \'기상정보\'를 클릭하여 이동한다.', + '지도에서 관측소 마커를 클릭하거나 목록에서 관측소를 선택한다.', + '최신 기상값과 시계열 그래프를 확인한다.', + ], + notes: [ + '극한 기상 조건에서는 확산예측 정확도가 낮아질 수 있으므로 현장 기상 관측값과 병행 확인한다.', + ], + }, + ], + }, + { + id: 'ch08', + number: '08', + title: '통합조회', + subtitle: 'Integrated Search', + screens: [ + { + id: '077', + name: '통합조회', + menuPath: '통합조회', + imageIndex: 77, + overview: + 'Wing 시스템 전체 분석 이력(유출유·HNS·긴급구난·보고서)을 통합 조회하는 화면이다. 사고명·날짜·분석 유형·담당자 등 복합 조건으로 검색하고 결과를 지도와 목록으로 표출한다.', + description: + '화면 좌측에 날짜 범위·분석 유형·담당자·사고 지역 복합 필터 패널이 위치한다. 중앙 지도에 검색 결과 사고지점 마커가 표출된다. Excel·CSV 내보내기를 지원한다.', + procedure: [ + '상단 메뉴에서 \'통합조회\'를 클릭하여 이동한다.', + '날짜 범위·분석 유형 등 필터 조건을 설정한다.', + '\'검색\' 버튼을 클릭하여 결과를 조회한다.', + '지도 마커 또는 목록 항목을 클릭하여 해당 분석 결과 상세 화면으로 이동한다.', + 'Excel·CSV 내보내기 버튼으로 이력 자료를 저장한다.', + ], + inputs: [ + { label: '날짜 범위', type: '날짜', required: false, desc: '시작·종료 날짜' }, + { label: '분석 유형', type: '체크박스', required: false, desc: '유출유·HNS·긴급구난·보고서' }, + { label: '담당자', type: '텍스트', required: false, desc: '이름 필터' }, + { label: '사고 지역', type: '텍스트', required: false, desc: '지역명 필터' }, + ], + }, + ], + }, +]; + +const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => { + const [selectedChapterId, setSelectedChapterId] = useState('ch01'); + const [expandedScreenIds, setExpandedScreenIds] = useState>(new Set()); + const [lightboxSrc, setLightboxSrc] = useState(null); + + if (!isOpen) return null; + + const selectedChapter = CHAPTERS.find((ch) => ch.id === selectedChapterId) ?? CHAPTERS[0]; + + const toggleScreen = (screenId: string) => { + setExpandedScreenIds((prev) => { + const next = new Set(prev); + if (next.has(screenId)) { + next.delete(screenId); + } else { + next.add(screenId); + } + return next; + }); + }; + + const expandAll = () => { + setExpandedScreenIds(new Set(selectedChapter.screens.map((s) => s.id))); + }; + + const collapseAll = () => { + setExpandedScreenIds(new Set()); + }; + + const allExpanded = + selectedChapter.screens.length > 0 && + selectedChapter.screens.every((s) => expandedScreenIds.has(s.id)); + + return ( + <> +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+ + Wing 사용자 매뉴얼 + + + v0.5 + +
+ +
+ + {/* Body */} +
+ {/* Left Sidebar */} +
+ {CHAPTERS.map((chapter) => { + const isActive = chapter.id === selectedChapterId; + return ( + + ); + })} +
+ + {/* Right Content */} +
+ {/* Chapter heading */} +
+
+
+ + CH {selectedChapter.number} + +

+ {selectedChapter.title} +

+ + {selectedChapter.subtitle} + +
+
+ + {selectedChapter.screens.length}개 화면 + + +
+
+
+ + {/* Screen cards */} +
+ {selectedChapter.screens.map((screen) => { + const isExpanded = expandedScreenIds.has(screen.id); + const imageSrc = `/manual/image${screen.imageIndex}.png`; + return ( +
+ {/* Screen header (toggle) */} + + + {/* Screen detail (expanded) */} + {isExpanded && ( +
+ {/* Screenshot image */} +
+ {screen.name} setLightboxSrc(imageSrc)} + style={{ + width: '100%', + borderRadius: '6px', + border: '1px solid #1e2a45', + cursor: 'zoom-in', + display: 'block', + }} + /> +

+ 이미지를 클릭하면 크게 볼 수 있다 +

+
+ + {/* Menu path breadcrumb */} +
+ {screen.menuPath} +
+ + {/* Overview */} +
+

+ {screen.overview} +

+
+ + {/* Description */} + {screen.description && ( +
+
+ 화면 설명 +
+

+ {screen.description} +

+
+ )} + + {/* Procedure */} + {screen.procedure && screen.procedure.length > 0 && ( +
+
+ 사용 절차 +
+
    + {screen.procedure.map((step, idx) => ( +
  1. + + {idx + 1} + + + {step} + +
  2. + ))} +
+
+ )} + + {/* Inputs */} + {screen.inputs && screen.inputs.length > 0 && ( +
+
+ 입력 항목 +
+
+ + + + + + + + + + + {screen.inputs.map((input, idx) => ( + + + + + + + ))} + +
+ 항목 + + 유형 + + 필수 + + 설명 +
+ {input.label} + + {input.type} + + {input.required ? ( + + 필수 + + ) : ( + + 선택 + + )} + + {input.desc} +
+
+
+ )} + + {/* Notes */} + {screen.notes && screen.notes.length > 0 && ( +
+
+ 유의사항 +
+
    + {screen.notes.map((note, idx) => ( +
  • + + + {note} + +
  • + ))} +
+
+ )} +
+ )} +
+ ); + })} +
+
+
+
+
+ + {/* Lightbox */} + {lightboxSrc !== null && ( +
setLightboxSrc(null)} + > +
e.stopPropagation()} + > + 확대 이미지 + +
+
+ )} + + ); +}; + +export default UserManualPopup; diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 213da42..373fe49 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -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; diff --git a/frontend/src/common/utils/geo.ts b/frontend/src/common/utils/geo.ts index 47d3327..c8be317 100755 --- a/frontend/src/common/utils/geo.ts +++ b/frontend/src/common/utils/geo.ts @@ -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 diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/tabs/aerial/components/CctvView.tsx index 538d535..079c160 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/tabs/aerial/components/CctvView.tsx @@ -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(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() { 실시간 해안 CCTV
-
- API 상태 +
+ {/* 지도/리스트 뷰 토글 */} +
+ + +
+ API
@@ -164,7 +237,14 @@ export function CctvView() { }} >
-
📹
+
+ + + + + + +
@@ -273,36 +353,195 @@ export function CctvView() {
- {/* 영상 그리드 */} -
- {Array.from({ length: totalCells }).map((_, i) => { - const cam = activeCells[i] - return ( -
- {cam ? ( - { 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} - /> - ) : ( -
카메라를 선택하세요
- )} -
- ) - })} -
+ {/* 영상 그리드 / CCTV 위치 지도 / 리스트 뷰 */} + {viewMode === 'list' && activeCells.length === 0 ? ( + /* ── 리스트 뷰: 출처별 · 지역별 그리드 ── */ +
+ {(() => { + // 출처별 그룹핑 + const sourceGroups: Record = {} + 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 = {} + for (const cam of group.cameras) { + const rgn = cam.regionNm ?? '기타' + if (!regionGroups[rgn]) regionGroups[rgn] = [] + regionGroups[rgn].push(cam) + } + + return ( +
+ {/* 출처 헤더 */} +
+ {group.icon} + {group.label} + {group.cameras.length}개 +
+ + {Object.entries(regionGroups).map(([rgn, cams]) => ( +
+ {/* 지역 소제목 */} +
+ {rgn} + ({cams.length}) +
+ {/* 테이블 헤더 */} +
+ 카메라명 + 위치 + 상태 + 최종갱신 +
+ {/* 테이블 행 */} + {cams.map(cam => ( +
{ 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', + }} + > + {cam.cameraNm} + {cam.locDc ?? '—'} + + {cam.sttsCd === 'LIVE' ? ( + ● LIVE + ) : ( + ● OFF + )} + + {now} +
+ ))} +
+ ))} +
+ ) + }) + })()} +
+ ) : showMap ? ( +
+ + {filtered.filter(c => c.lon && c.lat).map(cam => ( + { e.originalEvent.stopPropagation(); setMapPopup(cam) }} + > +
+ {/* CCTV 아이콘 */} + + {/* 카메라 본체 */} + + {/* 렌즈 */} + + + {/* 마운트 기둥 */} + + + {/* LIVE 표시등 */} + {cam.sttsCd === 'LIVE' && } + + {/* 이름 라벨 */} +
+ {cam.cameraNm} +
+
+
+ ))} + {mapPopup && mapPopup.lon && mapPopup.lat && ( + setMapPopup(null)} + closeOnClick={false} + offset={14} + className="cctv-dark-popup" + > +
+
{mapPopup.cameraNm}
+
{mapPopup.locDc ?? ''}
+
+ {mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'} + {mapPopup.sourceNm} +
+ +
+
+ )} +
+ {/* 지도 위 안내 배지 */} +
+ 📹 CCTV 마커를 클릭하여 영상을 선택하세요 ({filtered.length}개) +
+
+ ) : ( +
+ {Array.from({ length: totalCells }).map((_, i) => { + const cam = activeCells[i] + return ( +
+ {cam ? ( + { 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} + /> + ) : ( +
카메라를 선택하세요
+ )} +
+ ) + })} +
+ )} {/* 하단 정보 바 */}
@@ -320,26 +559,37 @@ export function CctvView() { 🗺 위치 지도 클릭하여 선택
- {/* 미니맵 (placeholder) */} -
-
지도 영역
- {/* 간략 지도 표현 */} -
- {cameras.filter(c => c.sttsCd === 'LIVE').slice(0, 6).map((c, i) => ( -
handleSelectCamera(c)} - /> + {/* 미니맵 */} +
+ + {cameras.filter(c => c.lon && c.lat).map(cam => ( + { e.originalEvent.stopPropagation(); handleSelectCamera(cam) }} + > +
+ ))} -
+
{/* 카메라 정보 */} diff --git a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx index 3525b2d..6ebef2b 100644 --- a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx +++ b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx @@ -1,13 +1,20 @@ -import { useState, useEffect, useMemo } from 'react' -import { Map, useControl } from '@vis.gl/react-maplibre' -import { MapboxOverlay } from '@deck.gl/mapbox' -import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers' +import { useState, useEffect, useCallback, useRef } from 'react' +import { Map, Marker, Popup } from '@vis.gl/react-maplibre' import type { StyleSpecification } from 'maplibre-gl' -import type { PickingInfo } from '@deck.gl/core' import 'maplibre-gl/dist/maplibre-gl.css' +import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi' +import type { DroneStreamItem } from '../services/aerialApi' +import { CCTVPlayer } from './CCTVPlayer' +import type { CCTVPlayerHandle } from './CCTVPlayer' -// ── 지도 스타일 ───────────────────────────────────────── -const BASE_STYLE: StyleSpecification = { +/** 함정 위치 + 드론 비행 위치 */ +const DRONE_POSITIONS: Record = { + 'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.1100, lon: 129.1100 } }, + 'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.4800, lon: 126.5600 } }, + 'mokpo-3015': { ship: { lat: 34.7780, lon: 126.3780 }, drone: { lat: 34.8050, lon: 126.4100 } }, +} + +const DRONE_MAP_STYLE: StyleSpecification = { version: 8, sources: { 'carto-dark': { @@ -18,608 +25,479 @@ const BASE_STYLE: StyleSpecification = { 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', ], tileSize: 256, - attribution: '© OSM © CARTO', }, }, - layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], + layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }], } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function DeckGLOverlay({ layers }: { layers: any[] }) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) - overlay.setProps({ layers }) - return null -} - -// ── Mock 데이터 ───────────────────────────────────────── -interface DroneInfo { - id: string - name: string - status: 'active' | 'returning' | 'standby' | 'charging' - battery: number - altitude: number - speed: number - sensor: string - color: string - lon: number - lat: number -} - -const drones: DroneInfo[] = [ - { id: 'D-01', name: 'DJI M300 #1', status: 'active', battery: 78, altitude: 150, speed: 12, sensor: '광학 4K', color: '#3b82f6', lon: 128.68, lat: 34.72 }, - { id: 'D-02', name: 'DJI M300 #2', status: 'active', battery: 65, altitude: 200, speed: 8, sensor: 'IR 열화상', color: '#ef4444', lon: 128.74, lat: 34.68 }, - { id: 'D-03', name: 'Mavic 3E', status: 'active', battery: 82, altitude: 120, speed: 15, sensor: '광학 4K', color: '#a855f7', lon: 128.88, lat: 34.60 }, - { id: 'D-04', name: 'DJI M30T', status: 'active', battery: 45, altitude: 180, speed: 10, sensor: '다중센서', color: '#22c55e', lon: 128.62, lat: 34.56 }, - { id: 'D-05', name: 'DJI M300 #3', status: 'returning', battery: 12, altitude: 80, speed: 18, sensor: '광학 4K', color: '#f97316', lon: 128.80, lat: 34.75 }, - { id: 'D-06', name: 'Mavic 3E #2', status: 'charging', battery: 35, altitude: 0, speed: 0, sensor: '광학 4K', color: '#6b7280', lon: 128.70, lat: 34.65 }, -] - -interface VesselInfo { - name: string - lon: number - lat: number - aisOff: boolean -} - -const vessels: VesselInfo[] = [ - { name: '영풍호', lon: 128.72, lat: 34.74, aisOff: false }, - { name: '불명-A', lon: 128.82, lat: 34.65, aisOff: true }, - { name: '금성호', lon: 128.60, lat: 34.62, aisOff: false }, - { name: '불명-B', lon: 128.92, lat: 34.70, aisOff: true }, - { name: '태양호', lon: 128.66, lat: 34.58, aisOff: false }, -] - -interface ZoneInfo { - id: string - lon: number - lat: number - radius: number - color: [number, number, number] -} - -const searchZones: ZoneInfo[] = [ - { id: 'A', lon: 128.70, lat: 34.72, radius: 3000, color: [6, 182, 212] }, - { id: 'B', lon: 128.88, lat: 34.60, radius: 2500, color: [249, 115, 22] }, - { id: 'C', lon: 128.62, lat: 34.56, radius: 2000, color: [234, 179, 8] }, -] - -const oilSpill = { lon: 128.85, lat: 34.58 } -const hnsPoint = { lon: 128.58, lat: 34.52 } - -interface AlertItem { - time: string - type: 'warning' | 'info' | 'danger' - message: string -} - -const alerts: AlertItem[] = [ - { time: '15:42', type: 'danger', message: 'D-05 배터리 부족 — 자동 복귀' }, - { time: '15:38', type: 'warning', message: '오염원 신규 탐지 (34.82°N)' }, - { time: '15:35', type: 'info', message: 'D-01~D-03 다시점 융합 완료' }, - { time: '15:30', type: 'warning', message: 'AIS OFF 선박 2척 추가 탐지' }, - { time: '15:25', type: 'info', message: 'D-04 센서 데이터 수집 시작' }, - { time: '15:20', type: 'danger', message: '유류오염 확산 속도 증가 감지' }, - { time: '15:15', type: 'info', message: '3D 재구성 시작 (불명선박-B)' }, -] - -// ── 유틸 ──────────────────────────────────────────────── -function hexToRgba(hex: string): [number, number, number, number] { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - return [r, g, b, 255] -} - -// ── 컴포넌트 ──────────────────────────────────────────── export function RealtimeDrone() { - const [reconProgress, setReconProgress] = useState(0) - const [reconDone, setReconDone] = useState(false) - const [selectedDrone, setSelectedDrone] = useState(null) - const [animFrame, setAnimFrame] = useState(0) + const [streams, setStreams] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedStream, setSelectedStream] = useState(null) + const [gridMode, setGridMode] = useState(1) + const [activeCells, setActiveCells] = useState([]) + const [mapPopup, setMapPopup] = useState(null) + const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) - // 3D 재구성 진행률 - useEffect(() => { - if (reconDone) return - const timer = setInterval(() => { - setReconProgress(prev => { - if (prev >= 100) { - clearInterval(timer) - setReconDone(true) - return 100 - } - return prev + 2 - }) - }, 300) - return () => clearInterval(timer) - }, [reconDone]) + const showMap = activeCells.length === 0 - // 애니메이션 루프 (~20fps) - useEffect(() => { - let frame = 0 - let raf: number - const tick = () => { - frame++ - if (frame % 3 === 0) setAnimFrame(f => f + 1) - raf = requestAnimationFrame(tick) + const loadStreams = useCallback(async () => { + try { + const items = await fetchDroneStreams() + setStreams(items) + // Update selected stream and active cells with latest status + setSelectedStream(prev => prev ? items.find(s => s.id === prev.id) ?? prev : prev) + setActiveCells(prev => prev.map(cell => items.find(s => s.id === cell.id) ?? cell)) + } catch { + // Fallback: show configured streams as idle + setStreams([ + { 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: '부산', status: 'idle', hlsUrl: null, error: null }, + { id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천', status: 'idle', hlsUrl: null, error: null }, + { 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: '목포', status: 'idle', hlsUrl: null, error: null }, + ]) + } finally { + setLoading(false) } - raf = requestAnimationFrame(tick) - return () => cancelAnimationFrame(raf) }, []) - const activeDrones = useMemo(() => drones.filter(d => d.status !== 'charging'), []) + useEffect(() => { + loadStreams() + }, [loadStreams]) - // ── deck.gl 레이어 ────────────────────────────────── - const deckLayers = useMemo(() => { - const t = animFrame * 0.05 + // Poll status every 3 seconds when any stream is starting + useEffect(() => { + const hasStarting = streams.some(s => s.status === 'starting') + if (!hasStarting) return + const timer = setInterval(loadStreams, 3000) + return () => clearInterval(timer) + }, [streams, loadStreams]) - // 탐색 구역 (반투명 원 + 테두리) - const zoneFillLayer = new ScatterplotLayer({ - id: 'search-zones-fill', - data: searchZones, - getPosition: d => [d.lon, d.lat], - getRadius: d => d.radius + Math.sin(t + searchZones.indexOf(d)) * 100, - getFillColor: d => [...d.color, 15], - getLineColor: d => [...d.color, 80], - getLineWidth: 2, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 30, - lineWidthMinPixels: 1.5, - }) + const handleStartStream = async (id: string) => { + try { + await startDroneStreamApi(id) + // Immediately update to 'starting' state + setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'starting' as const, error: null } : s)) + // Poll for status update + setTimeout(loadStreams, 2000) + } catch { + setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s)) + } + } - // 구역 라벨 - const zoneLabels = new TextLayer({ - id: 'zone-labels', - data: searchZones, - getPosition: d => [d.lon, d.lat + 0.025], - getText: d => `${d.id}구역`, - getColor: d => [...d.color, 180], - getSize: 12, - fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 180], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - }) + const handleStopStream = async (id: string) => { + try { + await stopDroneStreamApi(id) + setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s)) + setActiveCells(prev => prev.filter(c => c.id !== id)) + } catch { + // ignore + } + } - // 유류 확산 (동심원 3개) - const oilRings = [0, 1, 2].map(ring => - new ScatterplotLayer({ - id: `oil-spill-${ring}`, - data: [oilSpill], - getPosition: () => [oilSpill.lon, oilSpill.lat], - getRadius: 800 + ring * 500 + Math.sin(t * 0.5 + ring) * 80, - getFillColor: [249, 115, 22, Math.max(4, 20 - ring * 6)], - getLineColor: [249, 115, 22, ring === 0 ? 120 : 40], - getLineWidth: ring === 0 ? 2 : 1, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 15, - lineWidthMinPixels: ring === 0 ? 1.5 : 0.8, - }), - ) - - // 유류 확산 라벨 - const oilLabel = new TextLayer({ - id: 'oil-label', - data: [oilSpill], - getPosition: () => [oilSpill.lon, oilSpill.lat - 0.015], - getText: () => '유류확산', - getColor: [249, 115, 22, 200], - getSize: 11, - fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 180], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - }) - - // HNS 의심 - const hnsLayer = new ScatterplotLayer({ - id: 'hns-point', - data: [hnsPoint], - getPosition: () => [hnsPoint.lon, hnsPoint.lat], - getRadius: 400 + Math.sin(t * 1.2) * 80, - getFillColor: [234, 179, 8, 50], - getLineColor: [234, 179, 8, 100], - getLineWidth: 1.5, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 6, - lineWidthMinPixels: 1, - }) - const hnsCore = new ScatterplotLayer({ - id: 'hns-core', - data: [hnsPoint], - getPosition: () => [hnsPoint.lon, hnsPoint.lat], - getRadius: 150, - getFillColor: [234, 179, 8, 200], - filled: true, - radiusUnits: 'meters', - radiusMinPixels: 4, - }) - const hnsLabel = new TextLayer({ - id: 'hns-label', - data: [hnsPoint], - getPosition: () => [hnsPoint.lon, hnsPoint.lat - 0.008], - getText: () => 'HNS 의심', - getColor: [234, 179, 8, 180], - getSize: 10, - fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 180], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - }) - - // 선박 — AIS OFF는 red 경고 원 - const vesselAlertLayer = new ScatterplotLayer({ - id: 'vessel-alert', - data: vessels.filter(v => v.aisOff), - getPosition: d => [d.lon, d.lat], - getRadius: 600 + Math.sin(t * 1.5) * 150, - getFillColor: [239, 68, 68, 20], - getLineColor: [239, 68, 68, 60], - getLineWidth: 1, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 10, - lineWidthMinPixels: 0.8, - }) - const vesselLayer = new ScatterplotLayer({ - id: 'vessels', - data: vessels, - getPosition: d => [d.lon, d.lat], - getRadius: 200, - getFillColor: d => d.aisOff ? [239, 68, 68, 255] : [96, 165, 250, 255], - getLineColor: d => d.aisOff ? [239, 68, 68, 120] : [96, 165, 250, 80], - getLineWidth: 1.5, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: 5, - lineWidthMinPixels: 1, - }) - const vesselLabels = new TextLayer({ - id: 'vessel-labels', - data: vessels, - getPosition: d => [d.lon, d.lat + 0.005], - getText: d => d.name, - getColor: [255, 255, 255, 190], - getSize: 11, - fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 180], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - }) - - // 드론 간 메시 링크 - const droneLinks: { path: [number, number][] }[] = [] - for (let i = 0; i < activeDrones.length; i++) { - for (let j = i + 1; j < activeDrones.length; j++) { - const a = activeDrones[i] - const b = activeDrones[j] - droneLinks.push({ - path: [ - [a.lon + Math.sin(t + i * 2) * 0.002, a.lat + Math.cos(t * 0.8 + i) * 0.001], - [b.lon + Math.sin(t + j * 2) * 0.002, b.lat + Math.cos(t * 0.8 + j) * 0.001], - ], + const handleSelectStream = (stream: DroneStreamItem) => { + setSelectedStream(stream) + if (stream.status === 'streaming' && stream.hlsUrl) { + if (gridMode === 1) { + setActiveCells([stream]) + } else { + setActiveCells(prev => { + if (prev.length < gridMode && !prev.find(c => c.id === stream.id)) return [...prev, stream] + return prev }) } } - const linkLayer = new PathLayer({ - id: 'drone-links', - data: droneLinks, - getPath: d => d.path, - getColor: [77, 208, 225, 35], - getWidth: 1, - getDashArray: [6, 8], - dashJustified: true, - widthMinPixels: 0.7, - }) - - // 드론 글로우 (뒤쪽 큰 원) - const droneGlowLayer = new ScatterplotLayer({ - id: 'drone-glow', - data: activeDrones, - getPosition: d => { - const i = activeDrones.indexOf(d) - return [ - d.lon + Math.sin(t + i * 2) * 0.002, - d.lat + Math.cos(t * 0.8 + i) * 0.001, - ] - }, - getRadius: d => selectedDrone === d.id ? 500 : 350, - getFillColor: d => { - const [r, g, b] = hexToRgba(d.color) - return [r, g, b, selectedDrone === d.id ? 40 : 20] - }, - filled: true, - radiusUnits: 'meters', - radiusMinPixels: selectedDrone ? 12 : 8, - updateTriggers: { - getPosition: [animFrame], - getFillColor: [selectedDrone], - getRadius: [selectedDrone], - }, - }) - - // 드론 본체 - const droneLayer = new ScatterplotLayer({ - id: 'drones', - data: activeDrones, - getPosition: d => { - const i = activeDrones.indexOf(d) - return [ - d.lon + Math.sin(t + i * 2) * 0.002, - d.lat + Math.cos(t * 0.8 + i) * 0.001, - ] - }, - getRadius: d => selectedDrone === d.id ? 200 : 150, - getFillColor: d => hexToRgba(d.color), - getLineColor: [255, 255, 255, 200], - getLineWidth: d => selectedDrone === d.id ? 2 : 1, - filled: true, - stroked: true, - radiusUnits: 'meters', - radiusMinPixels: selectedDrone ? 6 : 4, - lineWidthMinPixels: 1, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) setSelectedDrone(info.object.id) - }, - updateTriggers: { - getPosition: [animFrame], - getRadius: [selectedDrone], - getLineWidth: [selectedDrone], - }, - }) - - // 드론 라벨 - const droneLabels = new TextLayer({ - id: 'drone-labels', - data: activeDrones, - getPosition: d => { - const i = activeDrones.indexOf(d) - return [ - d.lon + Math.sin(t + i * 2) * 0.002, - d.lat + Math.cos(t * 0.8 + i) * 0.001 + 0.006, - ] - }, - getText: d => d.id, - getColor: d => { - const [r, g, b] = hexToRgba(d.color) - return selectedDrone === d.id ? [255, 255, 255, 255] : [r, g, b, 230] - }, - getSize: d => selectedDrone === d.id ? 13 : 10, - fontFamily: 'Outfit, monospace', - fontWeight: 'bold', - characterSet: 'auto', - outlineWidth: 2, - outlineColor: [15, 21, 36, 200], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - updateTriggers: { - getPosition: [animFrame], - getColor: [selectedDrone], - getSize: [selectedDrone], - }, - }) - - return [ - zoneFillLayer, - zoneLabels, - ...oilRings, - oilLabel, - hnsLayer, - hnsCore, - hnsLabel, - vesselAlertLayer, - vesselLayer, - vesselLabels, - linkLayer, - droneGlowLayer, - droneLayer, - droneLabels, - ] - }, [animFrame, selectedDrone, activeDrones]) - - // ── UI 유틸 ─────────────────────────────────────────── - const statusLabel = (s: string) => { - if (s === 'active') return { text: '비행중', cls: 'text-status-green' } - if (s === 'returning') return { text: '복귀중', cls: 'text-status-orange' } - if (s === 'charging') return { text: '충전중', cls: 'text-text-3' } - return { text: '대기', cls: 'text-text-3' } } - const alertColor = (t: string) => - t === 'danger' ? 'border-l-status-red bg-[rgba(239,68,68,0.05)]' - : t === 'warning' ? 'border-l-status-orange bg-[rgba(249,115,22,0.05)]' - : 'border-l-primary-blue bg-[rgba(59,130,246,0.05)]' + const statusInfo = (status: string) => { + switch (status) { + case 'streaming': return { label: '송출중', color: 'var(--green)', bg: 'rgba(34,197,94,.12)' } + case 'starting': return { label: '연결중', color: 'var(--cyan)', bg: 'rgba(6,182,212,.12)' } + case 'error': return { label: '오류', color: 'var(--red)', bg: 'rgba(239,68,68,.12)' } + default: return { label: '대기', color: 'var(--t3)', bg: 'rgba(255,255,255,.06)' } + } + } + + const gridCols = gridMode === 1 ? 1 : 2 + const totalCells = gridMode return (
- {/* 지도 영역 */} -
- - - - - {/* 오버레이 통계 */} -
- {[ - { label: '탐지 객체', value: '847', unit: '건', color: 'text-primary-blue' }, - { label: '식별 선박', value: '312', unit: '척', color: 'text-primary-cyan' }, - { label: 'AIS OFF', value: '14', unit: '척', color: 'text-status-red' }, - { label: '오염 탐지', value: '3', unit: '건', color: 'text-status-orange' }, - ].map((s, i) => ( -
-
{s.label}
-
- {s.value} - {s.unit} -
+ {/* 좌측: 드론 스트림 목록 */} +
+ {/* 헤더 */} +
+
+
+ s.status === 'streaming') ? 'var(--green)' : 'var(--t3)' }} /> + 실시간 드론 영상
- ))} + +
+
ViewLink RTSP 스트림 · 내부망 전용
- {/* 3D 재구성 진행률 */} -
-
- 🧊 3D 재구성 - {reconProgress}% -
-
-
-
- {!reconDone ? ( -
D-01~D-03 다각도 영상 융합중...
- ) : ( -
✅ 완료 — 클릭하여 정밀분석
- )} -
- - {/* 실시간 영상 패널 */} - {selectedDrone && (() => { - const drone = drones.find(d => d.id === selectedDrone) - if (!drone) return null - return ( -
-
-
-
- {drone.id} 실시간 영상 -
- -
-
-
-
-
LIVE FEED
-
-
- {drone.id} - {drone.sensor} -
{drone.lat.toFixed(2)}°N, {drone.lon.toFixed(2)}°E
-
-
-
REC -
-
- ALT {drone.altitude}m · SPD {drone.speed}m/s · HDG 045° -
-
-
-
비행 정보
- {[ - ['드론 ID', drone.id], - ['기체', drone.name], - ['배터리', `${drone.battery}%`], - ['고도', `${drone.altitude}m`], - ['속도', `${drone.speed}m/s`], - ['센서', drone.sensor], - ['상태', statusLabel(drone.status).text], - ].map(([k, v], i) => ( -
- {k} - {v} + {/* 드론 스트림 카드 */} +
+ {loading ? ( +
불러오는 중...
+ ) : streams.map(stream => { + const si = statusInfo(stream.status) + const isSelected = selectedStream?.id === stream.id + return ( +
handleSelectStream(stream)} + className="px-3.5 py-3 border-b cursor-pointer transition-colors" + style={{ + borderColor: 'rgba(255,255,255,.04)', + background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent', + }} + > +
+
+
🚁
+
+
{stream.shipName} ({stream.droneModel})
+
{stream.ip}
- ))} +
+ {si.label} +
+ +
+ {stream.region} + RTSP :554 +
+ + {stream.error && ( +
+ {stream.error} +
+ )} + + {/* 시작/중지 버튼 */} +
+ {stream.status === 'idle' || stream.status === 'error' ? ( + + ) : ( + + )}
-
- ) - })()} + ) + })} +
+ + {/* 하단 안내 */} +
+
+ RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다. + ViewLink 프로그램과 연동됩니다. +
+
- {/* 우측 사이드바 */} -
- {/* 군집 드론 현황 */} -
-
군집 드론 현황 · {activeDrones.length}/{drones.length} 운용
-
- {drones.map(d => { - const st = statusLabel(d.status) - return ( -
d.status !== 'charging' && setSelectedDrone(d.id)} - className={`flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer transition-colors ${ - selectedDrone === d.id ? 'bg-[rgba(6,182,212,0.08)] border border-primary-cyan/20' : 'hover:bg-white/[0.02] border border-transparent' - }`} + {/* 중앙: 영상 뷰어 */} +
+ {/* 툴바 */} +
+
+
+ {selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'} +
+ {selectedStream?.status === 'streaming' && ( +
+ LIVE +
+ )} + {selectedStream?.status === 'starting' && ( +
+ 연결중 +
+ )} +
+
+ {/* 분할 모드 */} +
+ {[ + { mode: 1, icon: '▣', label: '1화면' }, + { mode: 4, icon: '⊞', label: '4분할' }, + ].map(g => ( + + ))} +
+ +
+
+ + {/* 드론 위치 지도 또는 영상 그리드 */} + {showMap ? ( +
+ + {streams.map(stream => { + const pos = DRONE_POSITIONS[stream.id] + if (!pos) return null + const statusColor = stream.status === 'streaming' ? '#22c55e' : stream.status === 'starting' ? '#06b6d4' : stream.status === 'error' ? '#ef4444' : '#94a3b8' + return ( + { e.originalEvent.stopPropagation(); setMapPopup(stream) }} + > +
+ + {/* 연결선 (점선) */} + + + {/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */} + + + {/* 함정명 라벨 */} + + {stream.shipName.replace(/서 /, ' ')} + + {/* ── 드론: 쿼드콥터 아이콘 ── */} + {/* 외곽 원 */} + + {/* X자 팔 */} + + + {/* 프로펠러 4개 (회전 애니메이션) */} + + + + + + + + + + + + + {/* 본체 */} + + {/* 카메라 렌즈 */} + + + {/* 송출중 REC LED */} + {stream.status === 'streaming' && ( + + + + )} + {/* 드론 모델명 */} + + {stream.droneModel.split(' ').slice(-1)[0]} + +
+
+ ) + })} + {/* 드론 클릭 팝업 */} + {mapPopup && DRONE_POSITIONS[mapPopup.id] && ( + setMapPopup(null)} + closeOnClick={false} + offset={36} + className="cctv-dark-popup" > -
-
-
{d.id}
-
{d.name}
-
-
-
{st.text}
-
{d.battery}%
+
+
+ 🚁 +
{mapPopup.shipName}
+
+
{mapPopup.droneModel}
+
{mapPopup.ip} · {mapPopup.region}
+
+ ● {statusInfo(mapPopup.status).label} +
+ {mapPopup.status === 'idle' || mapPopup.status === 'error' ? ( + + ) : mapPopup.status === 'streaming' ? ( + + ) : ( +
연결 중...
+ )}
+ + )} + + {/* 지도 위 안내 배지 */} +
+ 🚁 드론 위치를 클릭하여 스트림을 시작하세요 ({streams.length}대) +
+
+ ) : ( +
+ {Array.from({ length: totalCells }).map((_, i) => { + const stream = activeCells[i] + return ( +
+ {stream && stream.status === 'streaming' && stream.hlsUrl ? ( + { playerRefs.current[i] = el }} + cameraNm={stream.shipName} + streamUrl={stream.hlsUrl} + sttsCd="LIVE" + coordDc={`${stream.ip} · RTSP`} + sourceNm="ViewLink" + cellIndex={i} + /> + ) : stream && stream.status === 'starting' ? ( +
+
🚁
+
RTSP 스트림 연결 중...
+
{stream.ip}:554
+
+ ) : stream && stream.status === 'error' ? ( +
+
⚠️
+
연결 실패
+
{stream.error}
+ +
+ ) : ( +
+ {streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'} +
+ )}
) })}
+ )} + + {/* 하단 정보 바 */} +
+
선택: {selectedStream?.shipName ?? '–'}
+
IP: {selectedStream?.ip ?? '–'}
+
지역: {selectedStream?.region ?? '–'}
+
RTSP → HLS · ViewLink 연동
+
+
+ + {/* 우측: 정보 패널 */} +
+ {/* 헤더 */} +
+ 📋 스트림 정보
- {/* 다각화 분석 */} -
-
다각화 분석
-
- {[ - { icon: '🎯', label: '다시점 융합', value: '28건', sub: '360° 식별' }, - { icon: '🧊', label: '3D 재구성', value: '12건', sub: '선박+오염원' }, - { icon: '📡', label: '다센서 융합', value: '45건', sub: '광학+IR+SAR' }, - { icon: '🛢️', label: '오염원 3D', value: '3건', sub: '유류+HNS' }, - ].map((a, i) => ( -
-
{a.icon}
-
{a.label}
-
{a.value}
-
{a.sub}
+
+ {selectedStream ? ( +
+ {[ + ['함정명', selectedStream.shipName], + ['드론명', selectedStream.name], + ['기체모델', selectedStream.droneModel], + ['IP 주소', selectedStream.ip], + ['RTSP 포트', '554'], + ['지역', selectedStream.region], + ['프로토콜', 'RTSP → HLS'], + ['상태', statusInfo(selectedStream.status).label], + ].map(([k, v], i) => ( +
+ {k} + {v} +
+ ))} + {selectedStream.hlsUrl && ( +
+
HLS URL
+
{selectedStream.hlsUrl}
+
+ )} +
+ ) : ( +
드론 스트림을 선택하세요
+ )} + + {/* 연동 시스템 */} +
+
🔗 연동 시스템
+
+
+ ViewLink 3.5 + ● RTSP
- ))} +
+ FFmpeg 변환 + RTSP→HLS +
+
-
- {/* 실시간 경보 */} -
-
실시간 경보
-
- {alerts.map((a, i) => ( -
- {a.time} - {a.message} -
- ))} + {/* 전체 상태 요약 */} +
+
📊 스트림 현황
+
+ {[ + { label: '전체', value: streams.length, color: 'text-text-1' }, + { label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-status-green' }, + { label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-primary-cyan' }, + { label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-status-red' }, + ].map((item, i) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
diff --git a/frontend/src/tabs/aerial/services/aerialApi.ts b/frontend/src/tabs/aerial/services/aerialApi.ts index 49af96d..8b7d72d 100644 --- a/frontend/src/tabs/aerial/services/aerialApi.ts +++ b/frontend/src/tabs/aerial/services/aerialApi.ts @@ -130,3 +130,32 @@ export async function stitchImages(files: File[]): Promise { }); 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 { + const response = await api.get('/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; +} diff --git a/frontend/src/tabs/aerial/utils/streamUtils.ts b/frontend/src/tabs/aerial/utils/streamUtils.ts index eeeb453..026368c 100644 --- a/frontend/src/tabs/aerial/utils/streamUtils.ts +++ b/frontend/src/tabs/aerial/utils/streamUtils.ts @@ -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'; } diff --git a/frontend/src/tabs/prediction/components/OilBoomSection.tsx b/frontend/src/tabs/prediction/components/OilBoomSection.tsx index 39f72f8..c01dde6 100644 --- a/frontend/src/tabs/prediction/components/OilBoomSection.tsx +++ b/frontend/src/tabs/prediction/components/OilBoomSection.tsx @@ -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 (
@@ -54,23 +87,28 @@ const OilBoomSection = ({ {expanded && (
- {/* Tab Buttons + Reset */} + {/* 탭 버튼 + 초기화 */}
{[ { id: 'ai' as const, label: 'AI 자동 추천' }, - { id: 'manual' as const, label: '수동 배치' }, - { id: 'simulation' as const, label: '시뮬레이션' } + { id: 'simulation' as const, label: '시뮬레이션' }, ].map(tab => ( ))}
- {/* Key Metrics (동적) */} + {/* 초기화 확인 팝업 */} + {showResetConfirm && ( +
+
+ ⚠ 오일펜스 배치 가이드를 초기화 합니다 +
+
+ 배치된 오일펜스 라인과 시뮬레이션 결과가 삭제됩니다. 확산 예측 결과는 유지됩니다. +
+
+ + +
+
+ )} + + {/* Key Metrics */}
{[ { 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) => (
{metric.value} @@ -129,61 +203,24 @@ const OilBoomSection = ({ ))}
- {/* ===== AI 자동 추천 탭 ===== */} - {boomPlacementTab === 'ai' && ( + {/* ===== 시뮬레이션 탭 ===== */} + {boomPlacementTab === 'simulation' && ( <> -
-
- 0 ? 'var(--green)' : 'var(--t3)' }} /> - 0 ? 'var(--green)' : 'var(--t3)' }} className="text-[10px] font-bold"> - {oilTrajectory.length > 0 ? '확산 데이터 준비 완료' : '확산 예측을 먼저 실행하세요'} + {/* 전제조건 체크 */} +
+
+ 0 ? 'var(--green)' : 'var(--red)' }} /> + 0 ? 'var(--green)' : 'var(--t3)' }}> + 확산 궤적 데이터 {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
- -

- 확산 예측 기반 최적 배치안 -

- -

- {oilTrajectory.length > 0 - ? '확산 궤적을 분석하여 해류 직교 방향 1차 방어선, U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 생성합니다.' - : '상단에서 확산 예측을 실행한 뒤 AI 배치를 적용할 수 있습니다.' - } -

- -
{/* 알고리즘 설정 */}

- 📊 배치 알고리즘 설정 + 📊 V자형 배치 알고리즘 설정

{[ @@ -194,7 +231,7 @@ const OilBoomSection = ({ ].map((setting) => (
● {setting.label}
@@ -214,227 +251,50 @@ const OilBoomSection = ({ ))}
- - )} - {/* ===== 수동 배치 탭 ===== */} - {boomPlacementTab === 'manual' && ( - <> - {/* 드로잉 컨트롤 */} -
- {!isDrawingBoom ? ( - - ) : ( - <> - - - - )} -
- - {/* 드로잉 실시간 정보 */} - {isDrawingBoom && drawingPoints.length > 0 && ( -
- 포인트: {drawingPoints.length} - 길이: {computePolylineLength(drawingPoints).toFixed(0)}m - {drawingPoints.length >= 2 && ( - 방위각: {computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]).toFixed(0)}° - )} -
- )} - - {/* 배치된 라인 목록 */} - {boomLines.length === 0 ? ( -

- 배치된 오일펜스 라인이 없습니다. -

- ) : ( - boomLines.map((line, idx) => ( -
-
- { - 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" - /> - -
-
-
- 길이 -
{line.length.toFixed(0)}m
-
-
- 각도 -
{line.angle.toFixed(0)}°
-
-
- 우선순위 - -
-
-
- )) - )} - - )} - - {/* ===== 시뮬레이션 탭 ===== */} - {boomPlacementTab === 'simulation' && ( - <> - {/* 전제조건 체크 */} -
-
- 0 ? 'var(--green)' : 'var(--red)' }} /> - 0 ? 'var(--green)' : 'var(--t3)' }}> - 확산 궤적 데이터 {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'} - -
-
- 0 ? 'var(--green)' : 'var(--red)' }} /> - 0 ? 'var(--green)' : 'var(--t3)' }}> - 오일펜스 라인 {boomLines.length > 0 ? `(${boomLines.length}개 배치)` : '없음'} - -
-
- - {/* 실행 버튼 */} + {/* V자형 배치 + 시뮬레이션 실행 버튼 */} +

+ 확산 궤적을 분석하여 해류 직교 방향 1차 방어선(V형), U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 배치하고 차단 시뮬레이션을 실행합니다. +

+ {/* 시뮬레이션 결과 */} {containmentResult && containmentResult.totalParticles > 0 && (
{/* 전체 효율 */}
{containmentResult.overallEfficiency}%
-
- 전체 차단 효율 -
+
전체 차단 효율
{/* 차단/통과 카운트 */}
-
- {containmentResult.blockedParticles} -
+
{containmentResult.blockedParticles}
차단 입자
-
- {containmentResult.passedParticles} -
+
{containmentResult.passedParticles}
통과 입자
@@ -443,20 +303,16 @@ const OilBoomSection = ({
= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)' + background: containmentResult.overallEfficiency >= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)', }} />
{/* 라인별 분석 */}
-

- 라인별 차단 분석 -

+

라인별 차단 분석

{containmentResult.perLineResults.map((r) => ( -
+
{r.boomLineName} = 50 ? 'var(--green)' : 'var(--orange)', marginLeft: '8px' }} className="font-bold font-mono"> {r.blocked}차단 / {r.efficiency}% @@ -464,60 +320,52 @@ const OilBoomSection = ({
))}
+ + {/* 배치된 방어선 카드 */} + {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 ( +
+
+ + 🛡 {idx + 1}차 방어선 ({line.type}) + + + {priorityLabel} + +
+
+
+ 길이 +
{line.length.toFixed(0)}m
+
+
+ 각도 +
{line.angle.toFixed(0)}°
+
+
+
+ = 80 ? 'var(--green)' : 'var(--orange)' }} /> + = 80 ? 'var(--green)' : 'var(--orange)' }} className="text-[9px] font-semibold"> + 차단 효율 {line.efficiency}% + +
+
+ ) + })}
)} )} - {/* 배치된 방어선 카드 (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 ( -
-
- - 🛡 {idx + 1}차 방어선 ({line.type}) - - - {priorityLabel} - -
-
-
- 길이 -
- {line.length.toFixed(0)}m -
-
-
- 각도 -
- {line.angle.toFixed(0)}° -
-
-
-
- = 80 ? 'var(--green)' : 'var(--orange)' }} /> - = 80 ? 'var(--green)' : 'var(--orange)' }} className="text-[9px] font-semibold"> - 차단 효율 {line.efficiency}% - -
-
- ) - })} - - )} -
)}
diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index 5455aac..b48eeb0 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -8,17 +8,14 @@ import { BoomDeploymentTheoryView } from './BoomDeploymentTheoryView' import { BacktrackModal } from './BacktrackModal' import { RecalcModal } from './RecalcModal' import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar' -import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu' +import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu' import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' -import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi' -import { useSimulationStatus } from '../hooks/useSimulationStatus' -import SimulationLoadingOverlay from './SimulationLoadingOverlay' -import SimulationErrorModal from './SimulationErrorModal' +import type { ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse } from '../services/predictionApi' import { api } from '@common/services/api' -import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo' +import { generateAIBoomLines } from '@common/utils/geo' import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' @@ -114,16 +111,10 @@ export function OilSpillView() { const [enabledLayers, setEnabledLayers] = useState>(new Set()) const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null) const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined) - const flyToTarget = null - const fitBoundsTarget = null const [isSelectingLocation, setIsSelectingLocation] = useState(false) const [oilTrajectory, setOilTrajectory] = useState([]) - const [centerPoints, setCenterPoints] = useState([]) - const [windData, setWindData] = useState([]) - const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([]) const [isRunningSimulation, setIsRunningSimulation] = useState(false) - const [simulationError, setSimulationError] = useState(null) - const [selectedModels, setSelectedModels] = useState>(new Set(['OpenDrift'])) + const [selectedModels, setSelectedModels] = useState>(new Set(['POSEIDON'])) const [predictionTime, setPredictionTime] = useState(48) const [accidentTime, setAccidentTime] = useState('') const [spillType, setSpillType] = useState('연속') @@ -152,7 +143,7 @@ export function OilSpillView() { const [layerBrightness, setLayerBrightness] = useState(50) // 표시 정보 제어 - const [displayControls, setDisplayControls] = useState({ + const [displayControls] = useState({ showCurrent: true, showWind: true, showBeached: false, @@ -188,20 +179,21 @@ export function OilSpillView() { // 재계산 상태 const [recalcModalOpen, setRecalcModalOpen] = useState(false) - const [currentExecSn, setCurrentExecSn] = useState(null) - const [simulationSummary, setSimulationSummary] = useState(null) - const { data: simStatus } = useSimulationStatus(currentExecSn) - // 오염분석 상태 - const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon') - const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null) - const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([]) - const [circleRadiusNm, setCircleRadiusNm] = useState(5) - const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null) - - // 원 분석용 derived 값 (state 아님) - 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(['POSEIDON'])) + const demoTrajectory = generateDemoTrajectory(incidentCoord, models, predictionTime) + setOilTrajectory(demoTrajectory) + if (incidentCoord) { + 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 => { @@ -367,47 +359,9 @@ export function OilSpillView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [incidentCoord]) - const handleFlyEnd = useCallback(() => { - setFlyToCoord(undefined) - if (pendingPlayRef.current) { - pendingPlayRef.current = false - setIsPlaying(true) - } - }, []) - - // 시뮬레이션 폴링 결과 처리 - useEffect(() => { - if (!simStatus) return; - if (simStatus.status === 'DONE' && simStatus.trajectory) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setOilTrajectory(simStatus.trajectory); - setSimulationSummary(simStatus.summary ?? null); - setCenterPoints(simStatus.centerPoints ?? []); - setWindData(simStatus.windData ?? []); - setHydrData(simStatus.hydrData ?? []); - setIsRunningSimulation(false); - setCurrentExecSn(null); - // AI 방어선 자동 생성 - if (incidentCoord) { - const booms = generateAIBoomLines(simStatus.trajectory, incidentCoord, algorithmSettings); - setBoomLines(booms); - } - setSensitiveResources(DEMO_SENSITIVE_RESOURCES); - // 새 시뮬레이션 완료 시 flyTo 없으므로 즉시 재생 - setCurrentStep(0); - setIsPlaying(true); - } - if (simStatus.status === 'ERROR') { - setIsRunningSimulation(false); - setCurrentExecSn(null); - setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.'); - } - }, [simStatus, incidentCoord, algorithmSettings]); - // trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리) useEffect(() => { if (oilTrajectory.length > 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect setCurrentStep(0); } }, [oilTrajectory.length]); @@ -424,7 +378,6 @@ export function OilSpillView() { useEffect(() => { if (!isPlaying || timeSteps.length === 0) return; if (currentStep >= maxTime) { - // eslint-disable-next-line react-hooks/set-state-in-effect setIsPlaying(false); return; } @@ -447,7 +400,6 @@ export function OilSpillView() { setIsPlaying(false) setCurrentStep(0) setSelectedAnalysis(analysis) - setCenterPoints([]) if (analysis.occurredAt) { setAccidentTime(analysis.occurredAt.slice(0, 16)) } @@ -486,13 +438,9 @@ export function OilSpillView() { // OpenDrift 완료된 경우 실제 궤적 로드, 없으면 데모로 fallback if (analysis.opendriftStatus === 'completed') { try { - const { trajectory, summary, centerPoints: cp, windData: wd, hydrData: hd } = await fetchAnalysisTrajectory(analysis.acdntSn) + const { trajectory } = await fetchAnalysisTrajectory(analysis.acdntSn) if (trajectory && trajectory.length > 0) { setOilTrajectory(trajectory) - if (summary) setSimulationSummary(summary) - setCenterPoints(cp ?? []) - setWindData(wd ?? []) - setHydrData(hd ?? []) if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings)) setSensitiveResources(DEMO_SENSITIVE_RESOURCES) // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 @@ -524,65 +472,12 @@ export function OilSpillView() { const handleMapClick = (lon: number, lat: number) => { if (isDrawingBoom) { setDrawingPoints(prev => [...prev, { lat, lon }]) - } else if (drawAnalysisMode === 'polygon') { - setAnalysisPolygonPoints(prev => [...prev, { lat, lon }]) } else { setIncidentCoord({ lon, lat }) setIsSelectingLocation(false) } } - const handleStartPolygonDraw = () => { - setDrawAnalysisMode('polygon') - setAnalysisPolygonPoints([]) - setAnalysisResult(null) - } - - const handleRunPolygonAnalysis = () => { - if (analysisPolygonPoints.length < 3) return - const currentParticles = oilTrajectory.filter(p => p.time === currentStep) - const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 - const inside = currentParticles.filter(p => pointInPolygon({ lat: p.lat, lon: p.lon }, analysisPolygonPoints)).length - const sensitiveCount = sensitiveResources.filter(r => pointInPolygon({ lat: r.lat, lon: r.lon }, analysisPolygonPoints)).length - setAnalysisResult({ - area: polygonAreaKm2(analysisPolygonPoints), - particleCount: inside, - particlePercent: Math.round((inside / totalIds) * 100), - sensitiveCount, - }) - setDrawAnalysisMode(null) - } - - const handleRunCircleAnalysis = () => { - if (!incidentCoord) return - const radiusM = circleRadiusNm * 1852 - const currentParticles = oilTrajectory.filter(p => p.time === currentStep) - const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 - const inside = currentParticles.filter(p => - haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: p.lat, lon: p.lon }) <= radiusM - ).length - const sensitiveCount = sensitiveResources.filter(r => - haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: r.lat, lon: r.lon }) <= radiusM - ).length - setAnalysisResult({ - area: circleAreaKm2(radiusM), - particleCount: inside, - particlePercent: Math.round((inside / totalIds) * 100), - sensitiveCount, - }) - } - - const handleCancelAnalysis = () => { - setDrawAnalysisMode(null) - setAnalysisPolygonPoints([]) - } - - const handleClearAnalysis = () => { - setDrawAnalysisMode(null) - setAnalysisPolygonPoints([]) - setAnalysisResult(null) - } - const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => { setIncidentCoord({ lat: result.lat, lon: result.lon }) setFlyToCoord({ lat: result.lat, lon: result.lon }) @@ -627,7 +522,6 @@ export function OilSpillView() { } setIsRunningSimulation(true); - setSimulationSummary(null); try { const payload: Record = { acdntSn: existingAcdntSn, @@ -650,7 +544,7 @@ export function OilSpillView() { } const { data } = await api.post('/simulation/run', payload); - setCurrentExecSn(data.execSn); + setIsRunningSimulation(false); // 직접 입력으로 신규 생성된 경우: selectedAnalysis 갱신 + incidentName 초기화 if (data.acdntSn && isDirectInput) { @@ -676,72 +570,12 @@ export function OilSpillView() { // 다음 실행 시 동일 사고 재생성 방지 — 이후에는 selectedAnalysis.acdntSn 사용 setIncidentName(''); } - // setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리 } catch (err) { setIsRunningSimulation(false); - const msg = - (err as { message?: string })?.message - ?? '시뮬레이션 실행 중 오류가 발생했습니다.'; - setSimulationError(msg); + console.error('[simulation] 실행 중 오류:', err); } } - const handleOpenReport = () => { - const OIL_TYPE_CODE: Record = { - '벙커C유': 'BUNKER_C', '경유': 'DIESEL', '원유': 'CRUDE_OIL', '윤활유': 'LUBE_OIL', - }; - const accidentName = - selectedAnalysis?.acdntNm || - analysisDetail?.acdnt?.acdntNm || - incidentName || - '(미입력)'; - const occurTime = - selectedAnalysis?.occurredAt || - analysisDetail?.acdnt?.occurredAt || - accidentTime || - ''; - const wx = analysisDetail?.weather?.[0] ?? null; - - const payload: OilReportPayload = { - incident: { - name: accidentName, - occurTime, - location: selectedAnalysis?.location || analysisDetail?.acdnt?.location || '', - lat: incidentCoord?.lat ?? selectedAnalysis?.lat ?? null, - lon: incidentCoord?.lon ?? selectedAnalysis?.lon ?? null, - pollutant: OIL_TYPE_CODE[oilType] || oilType, - spillAmount: `${spillAmount} ${spillUnit}`, - shipName: analysisDetail?.vessels?.[0]?.vesselNm || '', - }, - pollution: { - spillAmount: `${spillAmount.toFixed(2)} ${spillUnit}`, - weathered: simulationSummary ? `${simulationSummary.weatheredVolume.toFixed(2)} m³` : '—', - seaRemain: simulationSummary ? `${simulationSummary.remainingVolume.toFixed(2)} m³` : '—', - pollutionArea: simulationSummary ? `${simulationSummary.pollutionArea.toFixed(2)} km²` : '—', - coastAttach: simulationSummary ? `${simulationSummary.beachedVolume.toFixed(2)} m³` : '—', - coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—', - oilType: OIL_TYPE_CODE[oilType] || oilType, - }, - weather: wx - ? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp } - : null, - spread: { kosps: '—', openDrift: '—', poseidon: '—' }, - coastal: { - firstTime: (() => { - const beachedTimes = oilTrajectory.filter(p => p.stranded === 1).map(p => p.time); - if (beachedTimes.length === 0) return null; - const d = new Date(Math.min(...beachedTimes) * 1000); - return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; - })(), - }, - hasSimulation: simulationSummary !== null, - }; - - setOilReportPayload(payload); - setReportGenCategory(0); - navigateToTab('reports', 'generate'); - }; - return (
{/* Left Sidebar */} @@ -815,18 +649,8 @@ export function OilSpillView() { layerOpacity={layerOpacity} layerBrightness={layerBrightness} sensitiveResources={sensitiveResources} - centerPoints={centerPoints} - windData={windData} - hydrData={hydrData} - flyToTarget={flyToTarget} - fitBoundsTarget={fitBoundsTarget} - onIncidentFlyEnd={handleFlyEnd} - drawAnalysisMode={drawAnalysisMode} - analysisPolygonPoints={analysisPolygonPoints} - analysisCircleCenter={analysisCircleCenter} - analysisCircleRadiusM={analysisCircleRadiusM} - externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined} - backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? { + lightMode + backtrackReplay={isReplayActive && replayShips.length > 0 ? { isActive: true, ships: replayShips, collisionEvent: collisionEvent ?? null, @@ -1023,46 +847,7 @@ export function OilSpillView() {
{/* Right Panel */} - {activeSubTab === 'analysis' && ( - setRecalcModalOpen(true)} - onOpenReport={handleOpenReport} - detail={analysisDetail} - summary={simulationSummary} - displayControls={displayControls} - onDisplayControlsChange={setDisplayControls} - analysisTab={analysisTab} - onSwitchAnalysisTab={setAnalysisTab} - drawAnalysisMode={drawAnalysisMode} - analysisPolygonPoints={analysisPolygonPoints} - circleRadiusNm={circleRadiusNm} - onCircleRadiusChange={setCircleRadiusNm} - analysisResult={analysisResult} - incidentCoord={incidentCoord} - onStartPolygonDraw={handleStartPolygonDraw} - onRunPolygonAnalysis={handleRunPolygonAnalysis} - onRunCircleAnalysis={handleRunCircleAnalysis} - onCancelAnalysis={handleCancelAnalysis} - onClearAnalysis={handleClearAnalysis} - /> - )} - - {/* 확산 예측 실행 중 로딩 오버레이 */} - {isRunningSimulation && ( - - )} - - {/* 확산 예측 에러 팝업 */} - {simulationError && ( - setSimulationError(null)} - /> - )} + {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} oilTrajectory={oilTrajectory} />} {/* 재계산 모달 */} setInputMode('direct')} - className="accent-[var(--cyan)] m-0 w-[11px] h-[11px]" + className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]" /> 직접 입력 @@ -141,7 +141,7 @@ const PredictionInputSection = ({ name="prdType" checked={inputMode === 'upload'} onChange={() => setInputMode('upload')} - className="accent-[var(--cyan)] m-0 w-[11px] h-[11px]" + className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]" /> 이미지 업로드 @@ -385,12 +385,18 @@ const PredictionInputSection = ({ {/* 임시 비활성화 — 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 => (
{ + if (!m.ready) { + alert(`${m.id} 모델은 현재 준비중입니다.`) + return + } const next = new Set(selectedModels) if (next.has(m.id)) { next.delete(m.id) @@ -408,11 +414,7 @@ const PredictionInputSection = ({
{ - if (selectedModels.size === ALL_MODELS.length) { - onModelsChange(new Set(['KOSPS'])) - } else { - onModelsChange(new Set(ALL_MODELS)) - } + alert('앙상블 모델은 현재 준비중입니다.') }} > diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index 4246ab2..3f33721 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -1,53 +1,27 @@ import { useState } from 'react' -import type { PredictionDetail, SimulationSummary } from '../services/predictionApi' -import type { DisplayControls } from './OilSpillView' - -interface AnalysisResult { - area: number - particleCount: number - particlePercent: number - sensitiveCount: number -} +import type { PredictionDetail } from '../services/predictionApi' +import { analyzeSpillPolygon } from '@common/utils/geo' interface RightPanelProps { onOpenBacktrack?: () => void onOpenRecalc?: () => void onOpenReport?: () => void detail?: PredictionDetail | null - summary?: SimulationSummary | null - displayControls?: DisplayControls - onDisplayControlsChange?: (controls: DisplayControls) => void - analysisTab?: 'polygon' | 'circle' - onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void - drawAnalysisMode?: 'polygon' | null - analysisPolygonPoints?: Array<{ lat: number; lon: number }> - circleRadiusNm?: number - onCircleRadiusChange?: (nm: number) => void - analysisResult?: AnalysisResult | null - incidentCoord?: { lat: number; lon: number } | null - onStartPolygonDraw?: () => void - onRunPolygonAnalysis?: () => void - onRunCircleAnalysis?: () => void - onCancelAnalysis?: () => void - onClearAnalysis?: () => void + oilTrajectory?: Array<{ lat: number; lon: number; time: number }> } -export function RightPanel({ - onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary, - displayControls, onDisplayControlsChange, - analysisTab = 'polygon', onSwitchAnalysisTab, - drawAnalysisMode, analysisPolygonPoints = [], - circleRadiusNm = 5, onCircleRadiusChange, - analysisResult, - onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis, - onCancelAnalysis, onClearAnalysis, -}: RightPanelProps) { +export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, oilTrajectory = [] }: RightPanelProps) { const vessel = detail?.vessels?.[0] const vessel2 = detail?.vessels?.[1] const spill = detail?.spill const insurance = vessel?.insuranceData as Array<{ type: string; insurer: string; value: string; currency: string }> | null const [shipExpanded, setShipExpanded] = useState(false) const [insuranceExpanded, setInsuranceExpanded] = useState(false) + const [polygonResult, setPolygonResult] = useState<{ areaKm2: number; perimeterKm: number; particleCount: number; hullPoints: number } | null>(null) + const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon') + const [circleRadiusNm, setCircleRadiusNm] = useState('5') + const [circleResult, setCircleResult] = useState<{ areaKm2: number; areaNm2: number; circumferenceKm: number; radiusNm: number } | null>(null) + const NM_PRESETS = [1, 3, 5, 10, 15, 20, 30, 50] return (
@@ -88,114 +62,156 @@ export function RightPanel({ {/* 오염분석 */}
- {/* 탭 전환 */} -
- {(['polygon', 'circle'] as const).map((tab) => ( - - ))} + {/* 탭 버튼 */} +
+ +
- {/* 다각형 패널 */} + {/* ── 다각형 분석 탭 ── */} {analysisTab === 'polygon' && ( -
-

+ <> +

지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.

- {!drawAnalysisMode && !analysisResult && ( + + + )} + + {/* ── 원 분석 탭 ── */} + {analysisTab === 'circle' && ( + <> +

+ 반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다. +

+ + {/* 반경 선택 (NM) */} +
반경 선택 (NM)
+
+ {NM_PRESETS.map(nm => ( + + ))} +
+ + {/* 직접 입력 + 분석 실행 */} +
+ 직접 입력 + setCircleRadiusNm(e.target.value)} + className="w-[60px] px-2 py-1.5 bg-bg-3 border border-border rounded text-[10px] font-mono text-text-1 text-center outline-none focus:border-[var(--cyan)] transition-colors" + /> + NM - )} - {drawAnalysisMode === 'polygon' && ( -
-
- 지도를 클릭하여 꼭짓점을 추가하세요
- 현재 {analysisPolygonPoints.length}개 선택됨 -
-
- - -
+ onClick={() => { + const nm = parseFloat(circleRadiusNm) + if (isNaN(nm) || nm <= 0) { + alert('반경을 올바르게 입력하세요.') + return + } + const km = nm * 1.852 + const areaKm2 = Math.PI * km * km + const areaNm2 = Math.PI * nm * nm + const circumferenceKm = 2 * Math.PI * km + setCircleResult({ areaKm2, areaNm2, circumferenceKm, radiusNm: nm }) + }} + disabled={!circleRadiusNm || parseFloat(circleRadiusNm) <= 0} + className="ml-auto px-3 py-1.5 rounded text-[10px] font-bold font-korean cursor-pointer shrink-0 transition-colors" + style={{ + background: 'rgba(6,182,212,0.15)', + border: '1px solid var(--cyan)', + color: 'var(--cyan)', + }} + >분석 실행 +
+ + )} + + {/* 원 분석 결과 */} + {analysisTab === 'circle' && circleResult && ( +
+
⭕ 원 분석 결과 (반경 {circleResult.radiusNm} NM)
+
+
+
{circleResult.areaNm2.toFixed(1)}
+
면적 (NM²)
- )} - {analysisResult && !drawAnalysisMode && ( - - )} +
+
{circleResult.areaKm2.toFixed(1)}
+
면적 (km²)
+
+
+
{circleResult.circumferenceKm.toFixed(1)}
+
원 둘레 (km)
+
+
+
{(circleResult.radiusNm * 1.852).toFixed(1)}
+
반경 (km)
+
+
)} - {/* 원 분석 패널 */} - {analysisTab === 'circle' && ( -
-

- 반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다. -

-
반경 선택 (NM)
-
- {[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => ( - - ))} + {/* 다각형 분석 결과 */} + {analysisTab === 'polygon' && polygonResult && ( +
+
📐 Convex Hull 다각형 분석 결과
+
+
+
{polygonResult.areaKm2.toFixed(2)}
+
오염 면적 (km²)
+
+
+
{polygonResult.perimeterKm.toFixed(1)}
+
외곽 둘레 (km)
+
+
+
{polygonResult.particleCount.toLocaleString()}
+
분석 입자 수
+
+
+
{polygonResult.hullPoints}
+
외곽 꼭짓점
+
-
- 직접 입력 - onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)} - className="w-14 text-center py-1 px-1 bg-bg-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan" - style={{ colorScheme: 'dark' }} - /> - NM - -
- {analysisResult && ( - - )}
)}
@@ -215,10 +231,10 @@ export function RightPanel({ {/* 확산 예측 요약 */}
-
+
- - + +
@@ -400,7 +416,6 @@ function ControlledCheckbox({ onChange(e.target.checked)} className="w-[13px] h-[13px] accent-[var(--cyan)]" /> @@ -434,16 +449,9 @@ function StatBox({ function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) { return ( -
-
- {value} -
-
- {label} -
+
+ {label} + {value}
) } @@ -600,78 +608,3 @@ function InsuranceCard({
) } - -function PollResult({ - result, - summary, - onClear, - onRerun, - radiusNm, -}: { - result: AnalysisResult - summary?: SimulationSummary | null - onClear?: () => void - onRerun?: () => void - radiusNm?: number -}) { - const pollutedArea = (result.area * result.particlePercent / 100).toFixed(2) - return ( -
-
- {radiusNm && ( -
- 분석 결과 - 반경 {radiusNm} NM -
- )} -
-
-
{result.area.toFixed(2)}
-
분석면적(km²)
-
-
-
{result.particlePercent}%
-
오염비율
-
-
-
{pollutedArea}
-
오염면적(km²)
-
-
-
- {summary && ( -
- 해상잔존량 - {summary.remainingVolume.toFixed(2)} kL -
- )} - {summary && ( -
- 연안부착량 - {summary.beachedVolume.toFixed(2)} kL -
- )} -
- 민감자원 포함 - {result.sensitiveCount}개소 -
-
-
- - {onRerun && ( - - )} -
-
- ) -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e2b8726..33ced3e 100755 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,6 +6,7 @@ import path from 'path' export default defineConfig({ plugins: [react()], server: { + port: 5174, proxy: { // HLS 스트림 프록시 등 상대 경로 API 요청을 백엔드로 전달 '/api': {