diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts index 3adb1c5..3921d71 100644 --- a/backend/src/aerial/aerialRouter.ts +++ b/backend/src/aerial/aerialRouter.ts @@ -1,5 +1,6 @@ import express from 'express'; import multer from 'multer'; +import path from 'path'; import { listMedia, createMedia, @@ -13,6 +14,10 @@ import { requestOilInference, checkInferenceHealth, stitchImages, + listDroneStreams, + startDroneStream, + stopDroneStream, + getHlsDirectory, } from './aerialService.js'; import { isValidNumber } from '../middleware/security.js'; import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; @@ -121,6 +126,92 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req } }); +// ============================================================ +// KBS 재난안전포탈 CCTV HLS 리졸버 +// ============================================================ + +/** KBS cctvId → 실제 HLS m3u8 URL 캐시 (5분 TTL) */ +const kbsHlsCache = new Map(); +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 라우트 // ============================================================ diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts index 23c6391..c3ec980 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 @@ -365,6 +369,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis // ============================================================ const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001'; +const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090'; const INFERENCE_TIMEOUT_MS = 10_000; export interface OilInferenceRegion { @@ -408,8 +413,9 @@ export async function stitchImages( export async function requestOilInference(imageBase64: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS); + try { - const response = await fetch(`${IMAGE_API_URL}/inference`, { + const response = await fetch(`${OIL_INFERENCE_URL}/inference`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: imageBase64 }), @@ -430,7 +436,7 @@ export async function requestOilInference(imageBase64: string): Promise { try { - const response = await fetch(`${IMAGE_API_URL}/health`, { + const response = await fetch(`${OIL_INFERENCE_URL}/health`, { signal: AbortSignal.timeout(3000), }); if (!response.ok) throw new Error(`status ${response.status}`); @@ -439,3 +445,175 @@ export async function checkInferenceHealth(): Promise<{ status: string; device?: return { status: 'unavailable' }; } } + +// ============================================================ +// DRONE STREAM (RTSP → HLS via FFmpeg) +// ============================================================ + +export interface DroneStreamConfig { + id: string; + name: string; + shipName: string; + droneModel: string; + ip: string; + rtspUrl: string; + region: string; +} + +export interface DroneStreamStatus extends DroneStreamConfig { + status: 'idle' | 'starting' | 'streaming' | 'error'; + hlsUrl: string | null; + error: string | null; +} + +const DRONE_STREAMS: DroneStreamConfig[] = [ + { id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산' }, + { id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천' }, + { id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포' }, +]; + +const HLS_OUTPUT_DIR = '/tmp/wing-drone-hls'; +const activeProcesses = new Map(); + +function getHlsDir(id: string): string { + return path.join(HLS_OUTPUT_DIR, id); +} + +function checkFfmpeg(): boolean { + try { + execSync('which ffmpeg', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +export function listDroneStreams(): DroneStreamStatus[] { + return DRONE_STREAMS.map(ds => { + const active = activeProcesses.get(ds.id); + return { + ...ds, + status: active?.status ?? 'idle', + hlsUrl: active?.status === 'streaming' ? `/api/aerial/drone/hls/${ds.id}/stream.m3u8` : null, + error: active?.error ?? null, + }; + }); +} + +export function startDroneStream(id: string): { success: boolean; error?: string; hlsUrl?: string } { + const config = DRONE_STREAMS.find(d => d.id === id); + if (!config) return { success: false, error: '알 수 없는 드론 스트림 ID' }; + + if (activeProcesses.has(id)) { + const existing = activeProcesses.get(id)!; + if (existing.status === 'streaming' || existing.status === 'starting') { + return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` }; + } + } + + if (!checkFfmpeg()) { + return { success: false, error: 'FFmpeg가 설치되어 있지 않습니다. 서버에 FFmpeg를 설치하세요.' }; + } + + const hlsDir = getHlsDir(id); + if (!existsSync(hlsDir)) { + mkdirSync(hlsDir, { recursive: true }); + } + + const outputPath = path.join(hlsDir, 'stream.m3u8'); + + const ffmpeg = spawn('ffmpeg', [ + '-rtsp_transport', 'tcp', + '-i', config.rtspUrl, + '-c:v', 'copy', + '-c:a', 'aac', + '-f', 'hls', + '-hls_time', '2', + '-hls_list_size', '5', + '-hls_flags', 'delete_segments', + '-y', + outputPath, + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + const entry = { process: ffmpeg, status: 'starting' as const, error: null as string | null }; + activeProcesses.set(id, entry); + + // Monitor for m3u8 file creation to confirm streaming + const checkInterval = setInterval(() => { + if (existsSync(outputPath)) { + const e = activeProcesses.get(id); + if (e && e.status === 'starting') { + e.status = 'streaming'; + } + clearInterval(checkInterval); + } + }, 500); + + // Timeout after 15 seconds + setTimeout(() => { + clearInterval(checkInterval); + const e = activeProcesses.get(id); + if (e && e.status === 'starting') { + e.status = 'error'; + e.error = 'RTSP 연결 시간 초과 — 내부망에서만 접속 가능합니다.'; + ffmpeg.kill('SIGTERM'); + } + }, 15000); + + let stderrBuf = ''; + ffmpeg.stderr?.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString(); + }); + + ffmpeg.on('close', (code) => { + clearInterval(checkInterval); + const e = activeProcesses.get(id); + if (e) { + if (e.status !== 'error') { + e.status = 'error'; + e.error = code !== 0 + ? `FFmpeg 종료 (코드: ${code})${stderrBuf.includes('Connection refused') ? ' — RTSP 연결 거부됨' : ''}` + : '스트림 종료'; + } + } + console.log(`[drone] FFmpeg 종료 (${id}): code=${code}`); + }); + + ffmpeg.on('error', (err) => { + clearInterval(checkInterval); + const e = activeProcesses.get(id); + if (e) { + e.status = 'error'; + e.error = `FFmpeg 실행 오류: ${err.message}`; + } + }); + + return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` }; +} + +export function stopDroneStream(id: string): { success: boolean } { + const entry = activeProcesses.get(id); + if (!entry) return { success: true }; + + entry.process.kill('SIGTERM'); + activeProcesses.delete(id); + + // Cleanup HLS files + const hlsDir = getHlsDir(id); + try { + if (existsSync(hlsDir)) { + rmSync(hlsDir, { recursive: true, force: true }); + } + } catch (err) { + console.error(`[drone] HLS 디렉토리 정리 실패 (${id}):`, err); + } + + return { success: true }; +} + +export function getHlsDirectory(id: string): string | null { + const config = DRONE_STREAMS.find(d => d.id === id); + if (!config) return null; + const dir = getHlsDir(id); + return existsSync(dir) ? dir : null; +} diff --git a/backend/src/middleware/security.ts b/backend/src/middleware/security.ts index 4f5d53e..3945c1b 100755 --- a/backend/src/middleware/security.ts +++ b/backend/src/middleware/security.ts @@ -153,9 +153,9 @@ export function sanitizeQuery(req: Request, res: Response, next: NextFunction): } /** - * JSON 본문 크기 제한 (기본 100kb) + * JSON 본문 크기 제한 (보고서 지도 캡처 이미지 포함 대응: 5mb) */ -export const BODY_SIZE_LIMIT = '100kb' +export const BODY_SIZE_LIMIT = '5mb' /** * 응답 헤더에서 서버 정보 제거 diff --git a/backend/src/reports/reportsRouter.ts b/backend/src/reports/reportsRouter.ts index 5455dd1..1644497 100644 --- a/backend/src/reports/reportsRouter.ts +++ b/backend/src/reports/reportsRouter.ts @@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req // ============================================================ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => { try { - const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections } = req.body; + const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg } = req.body; const result = await createReport({ tmplSn, ctgrSn, @@ -101,6 +101,7 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req jrsdCd, sttsCd, authorId: req.user!.sub, + mapCaptureImg, sections, }); res.status(201).json(result); @@ -124,8 +125,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'), res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' }); return; } - const { title, jrsdCd, sttsCd, acdntSn, sections } = req.body; - await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections }, req.user!.sub); + const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body; + await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub); res.json({ success: true }); } catch (err) { if (err instanceof AuthError) { diff --git a/backend/src/reports/reportsService.ts b/backend/src/reports/reportsService.ts index 63437e6..db655d9 100644 --- a/backend/src/reports/reportsService.ts +++ b/backend/src/reports/reportsService.ts @@ -62,6 +62,7 @@ interface ReportListItem { authorName: string; regDtm: string; mdfcnDtm: string | null; + hasMapCapture: boolean; } interface SectionData { @@ -74,6 +75,7 @@ interface SectionData { interface ReportDetail extends ReportListItem { acdntSn: number | null; sections: SectionData[]; + mapCaptureImg: string | null; } interface ListReportsInput { @@ -100,6 +102,7 @@ interface CreateReportInput { jrsdCd?: string; sttsCd?: string; authorId: string; + mapCaptureImg?: string; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; } @@ -108,6 +111,7 @@ interface UpdateReportInput { jrsdCd?: string; sttsCd?: string; acdntSn?: number | null; + mapCaptureImg?: string | null; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; } @@ -256,7 +260,8 @@ export async function listReports(input: ListReportsInput): Promise '' THEN true ELSE false END AS HAS_MAP_CAPTURE FROM REPORT r LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN @@ -281,6 +286,7 @@ export async function listReports(input: ListReportsInput): Promise { c.CTGR_CD, c.CTGR_NM, r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN, r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME, - r.REG_DTM, r.MDFCN_DTM + r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG, + CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE FROM REPORT r LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN @@ -331,6 +338,8 @@ export async function getReport(reportSn: number): Promise { authorName: r.author_name || '', regDtm: r.reg_dtm, mdfcnDtm: r.mdfcn_dtm, + mapCaptureImg: r.map_capture_img, + hasMapCapture: r.has_map_capture, sections: sectRes.rows.map((s) => ({ sectCd: s.sect_cd, includeYn: s.include_yn, @@ -350,8 +359,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb await client.query('BEGIN'); const res = await client.query( - `INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING REPORT_SN`, [ input.tmplSn || null, @@ -361,6 +370,7 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb input.jrsdCd || null, input.sttsCd || 'DRAFT', input.authorId, + input.mapCaptureImg || null, ] ); const reportSn = res.rows[0].report_sn; @@ -432,6 +442,10 @@ export async function updateReport( sets.push(`ACDNT_SN = $${idx++}`); params.push(input.acdntSn); } + if (input.mapCaptureImg !== undefined) { + sets.push(`MAP_CAPTURE_IMG = $${idx++}`); + params.push(input.mapCaptureImg); + } params.push(reportSn); await client.query( 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/007_reports.sql b/database/migration/007_reports.sql index 76cfb5a..fe0a09d 100644 --- a/database/migration/007_reports.sql +++ b/database/migration/007_reports.sql @@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS REPORT ( USE_YN CHAR(1) DEFAULT 'Y', REG_DTM TIMESTAMPTZ DEFAULT NOW(), MDFCN_DTM TIMESTAMPTZ, + MAP_CAPTURE_IMG TEXT CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED')) ); 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/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 18d7385..dc21fa3 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,31 @@ ## [Unreleased] +## [2026-03-16] + +### 추가 +- 보고서 확산예측 지도 캡처 기능 (OilSpreadMapPanel, MAP_CAPTURE_IMG DB 컬럼) +- 실시간 드론 지도 뷰 — 드론 위치 아이콘 + 클릭 스트림 연결 +- CCTV 지도/리스트 뷰 전환 + CCTV 아이콘 + 다크 팝업 UI +- KBS CCTV HLS 직접 재생 + CCTV 위치 지도 + 좌표 정확도 개선 +- 사용자 매뉴얼 팝업 기능 추가 +- 확산예측 지도 밝은 해도 스타일 적용 (육지 회색 + 바다 파랑) +- KOSPS/앙상블 준비중 팝업 + 기본 모델 POSEIDON 변경 +- 오염분석 원 분석 기능 — 중심점/반경 입력으로 원형 오염 면적 계산 +- 오일펜스 배치 가이드 UI 개선 + +### 수정 +- geo.ts 중복 함수 제거 및 null 좌표 참조 오류 수정 + +### 변경 +- 확산 예측 요약 폰트/레이아웃을 오염 종합 상황과 통일 +- 오염분석 UI 개선 — HTML 디자인 참고 반영 +- 범례 UI 개선 — HTML 참고 디자인 반영 +- 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일 + +### 기타 +- 프론트엔드 포트 변경(5174) + CORS 허용 + ## [2026-03-13] ### 추가 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/HydrParticleOverlay.tsx b/frontend/src/common/components/map/HydrParticleOverlay.tsx index 2ff9154..c6454f6 100644 --- a/frontend/src/common/components/map/HydrParticleOverlay.tsx +++ b/frontend/src/common/components/map/HydrParticleOverlay.tsx @@ -4,6 +4,7 @@ import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'; interface HydrParticleOverlayProps { hydrStep: HydrDataStep | null; + lightMode?: boolean; } const PARTICLE_COUNT = 3000; @@ -21,7 +22,7 @@ interface Particle { age: number; } -export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) { +export default function HydrParticleOverlay({ hydrStep, lightMode = false }: HydrParticleOverlayProps) { const { current: map } = useMap(); const animRef = useRef(); @@ -125,7 +126,8 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro // alpha band별 일괄 렌더링 ctx.lineWidth = 0.8; for (let b = 0; b < NUM_ALPHA_BANDS; b++) { - ctx.strokeStyle = `rgba(180, 210, 255, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`; + const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255]; + ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`; ctx.beginPath(); for (const [x1, y1, x2, y2] of bands[b]) { ctx.moveTo(x1, y1); @@ -151,7 +153,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro map.off('move', onMove); canvas.remove(); }; - }, [map, hydrStep]); + }, [map, hydrStep, lightMode]); return null; } diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 3be27d9..fa7ab07 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 포함) @@ -186,7 +346,7 @@ interface MapViewProps { hydrData?: (HydrDataStep | null)[] // 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용) externalCurrentTime?: number - mapCaptureRef?: React.MutableRefObject<(() => string | null) | null> + mapCaptureRef?: React.MutableRefObject<(() => Promise) | null> onIncidentFlyEnd?: () => void flyToIncident?: { lon: number; lat: number } showCurrent?: boolean @@ -198,6 +358,10 @@ interface MapViewProps { analysisPolygonPoints?: Array<{ lat: number; lon: number }> analysisCircleCenter?: { lat: number; lon: number } | null analysisCircleRadiusM?: number + /** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */ + lightMode?: boolean + /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ + showOverlays?: boolean } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) @@ -273,15 +437,34 @@ function MapFlyToIncident({ lon, lat, onFlyEnd }: { lon?: number; lat?: number; return null } -// 지도 캡처 지원 (preserveDrawingBuffer 필요) -function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => string | null) | null> }) { +// 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지) +function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => Promise) | null> }) { const { current: map } = useMap(); useEffect(() => { if (!map) return; - captureRef.current = () => { - try { return map.getCanvas().toDataURL('image/png'); } - catch { return null; } - }; + captureRef.current = () => + new Promise((resolve) => { + map.once('render', () => { + try { + // WebGL 캔버스는 alpha=0 투명 배경이므로 불투명 배경과 합성 후 추출 + // 최대 1200px로 리사이즈 + JPEG 압축으로 전송 크기 절감 + const src = map.getCanvas(); + const maxW = 1200; + const scale = src.width > maxW ? maxW / src.width : 1; + const composite = document.createElement('canvas'); + composite.width = Math.round(src.width * scale); + composite.height = Math.round(src.height * scale); + const ctx = composite.getContext('2d')!; + ctx.fillStyle = '#0f1117'; + ctx.fillRect(0, 0, composite.width, composite.height); + ctx.drawImage(src, 0, 0, composite.width, composite.height); + resolve(composite.toDataURL('image/jpeg', 0.82)); + } catch { + resolve(null); + } + }); + map.triggerRepaint(); + }); }, [map, captureRef]); return null; } @@ -329,6 +512,8 @@ export function MapView({ analysisPolygonPoints = [], analysisCircleCenter, analysisCircleRadiusM = 0, + lightMode = false, + showOverlays = true, }: MapViewProps) { const { mapToggles } = useMapStore() const isControlled = externalCurrentTime !== undefined @@ -844,7 +1029,7 @@ export function MapView({ getPosition: (d: SensitiveResource) => [d.lon, d.lat], getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`, getSize: 12, - getColor: [255, 255, 255, 200], + getColor: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [number, number, number, number], getPixelOffset: [0, -20], fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', fontWeight: 'bold', @@ -865,7 +1050,7 @@ export function MapView({ id: 'center-path', data: [{ path: visibleCenters.map(p => [p.lon, p.lat] as [number, number]) }], getPath: (d: { path: [number, number][] }) => d.path, - getColor: [255, 220, 50, 200], + getColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 200]) as [number, number, number, number], getWidth: 2, widthMinPixels: 2, widthMaxPixels: 4, @@ -879,7 +1064,7 @@ export function MapView({ data: visibleCenters, getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat], getRadius: 5, - getFillColor: [255, 220, 50, 230], + getFillColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 230]) as [number, number, number, number], radiusMinPixels: 4, radiusMaxPixels: 8, pickable: false, @@ -904,11 +1089,11 @@ export function MapView({ return `+${d.time}h`; }, getSize: 12, - getColor: [255, 220, 50, 220] as [number, number, number, number], + getColor: (lightMode ? [20, 40, 100, 240] : [255, 220, 50, 220]) as [number, number, number, number], getPixelOffset: [0, 16] as [number, number], fontWeight: 'bold', outlineWidth: 2, - outlineColor: [15, 21, 36, 200] as [number, number, number, number], + outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [number, number, number, number], billboard: true, sizeUnits: 'pixels' as const, updateTriggers: { @@ -966,11 +1151,11 @@ export function MapView({ dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, sensitiveResources, centerPoints, windData, showWind, showBeached, showTimeLabel, simulationStartTime, - analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, + analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, ]) - // 3D 모드에 따른 지도 스타일 전환 - const currentMapStyle = mapToggles.threeD ? SATELLITE_3D_STYLE : BASE_STYLE + // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 + const currentMapStyle = mapToggles.threeD ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE return (
@@ -1026,7 +1211,7 @@ export function MapView({ {/* 해류 파티클 오버레이 */} {hydrData.length > 0 && showCurrent && ( - + )} {/* 사고 위치 마커 (MapLibre Marker) */} @@ -1078,15 +1263,15 @@ export function MapView({ )} {/* 기상청 연계 정보 */} - + {showOverlays && } {/* 범례 */} - + {showOverlays && } {/* 좌표 표시 */} - + />} {/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */} {!isControlled && oilTrajectory.length > 0 && ( @@ -1152,97 +1337,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/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index fb11da7..593d846 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -169,6 +169,14 @@ export function consumeHnsReportPayload(): HnsReportPayload | null { } // ─── 유출유 예측 보고서 실 데이터 전달 ────────────────────────── +export interface OilReportMapParticle { + lat: number; + lon: number; + time: number; + particle?: number; + stranded?: 0 | 1; +} + export interface OilReportPayload { incident: { name: string; @@ -204,6 +212,14 @@ export interface OilReportPayload { firstTime: string | null; }; hasSimulation: boolean; + mapData: { + center: [number, number]; + zoom: number; + trajectory: OilReportMapParticle[]; + currentStep: number; + centerPoints: { lat: number; lon: number; time: number }[]; + simulationStartTime: string; + } | null; } let _oilReportPayload: OilReportPayload | null = null; 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..d955236 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 @@ -204,23 +299,6 @@ export function pointInPolygon( return inside } -/** 다각형 면적 (km²) — Shoelace formula, 구면 보정 포함 */ -export function polygonAreaKm2(polygon: { lat: number; lon: number }[]): number { - if (polygon.length < 3) return 0 - const n = polygon.length - const latCenter = polygon.reduce((s, p) => s + p.lat, 0) / n - const cosLat = Math.cos(latCenter * DEG2RAD) - let area = 0 - for (let i = 0; i < n; i++) { - const j = (i + 1) % n - const x1 = polygon[i].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000 - const y1 = polygon[i].lat * EARTH_RADIUS * DEG2RAD / 1000 - const x2 = polygon[j].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000 - const y2 = polygon[j].lat * EARTH_RADIUS * DEG2RAD / 1000 - area += x1 * y2 - x2 * y1 - } - return Math.abs(area) / 2 -} /** 원 면적 (km²) */ export function circleAreaKm2(radiusM: number): number { 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..bacb50c 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -203,6 +203,20 @@ export function OilSpillView() { const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null const analysisCircleRadiusM = circleRadiusNm * 1852 + // 분석 탭 초기 진입 시 기본 데모 자동 표시 + useEffect(() => { + if (activeSubTab === 'analysis' && oilTrajectory.length === 0 && !selectedAnalysis) { + const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set(['OpenDrift'])) + const coord = incidentCoord ?? { lat: 37.39, lon: 126.64 } + const demoTrajectory = generateDemoTrajectory(coord, models, predictionTime) + setOilTrajectory(demoTrajectory) + const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) + setBoomLines(demoBooms) + setSensitiveResources(DEMO_SENSITIVE_RESOURCES) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeSubTab]) + const handleToggleLayer = (layerId: string, enabled: boolean) => { setEnabledLayers(prev => { const newSet = new Set(prev) @@ -735,6 +749,14 @@ export function OilSpillView() { })(), }, hasSimulation: simulationSummary !== null, + mapData: incidentCoord ? { + center: [incidentCoord.lat, incidentCoord.lon], + zoom: 10, + trajectory: oilTrajectory, + currentStep, + centerPoints, + simulationStartTime: accidentTime, + } : null, }; setOilReportPayload(payload); @@ -815,6 +837,7 @@ export function OilSpillView() { layerOpacity={layerOpacity} layerBrightness={layerBrightness} sensitiveResources={sensitiveResources} + lightMode centerPoints={centerPoints} windData={windData} hydrData={hydrData} diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index c89bdad..c91d7a5 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -378,19 +378,21 @@ const PredictionInputSection = ({
{/* Model Selection (다중 선택) */} - {/* TODO: 현재 OpenDrift만 구동 가능. KOSPS·POSEIDON·앙상블은 엔진 연동 완료 후 활성화 예정 */} + {/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
- {/* 임시 비활성화 — OpenDrift만 구동 가능 (KOSPS 엔진 미연동) - { id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, */} - {/* 임시 비활성화 — OpenDrift만 구동 가능 (POSEIDON 엔진 미연동) - { id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, */} {([ - { id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' }, + { id: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false }, + { id: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true }, + { id: 'OpenDrift' as PredictionModel, color: 'var(--blue)', ready: true }, ] as const).map(m => (
{ + if (!m.ready) { + alert(`${m.id} 모델은 현재 준비중입니다.`) + return + } const next = new Set(selectedModels) if (next.has(m.id)) { next.delete(m.id) @@ -408,11 +410,7 @@ const PredictionInputSection = ({
{ - 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..11154dd 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -9,6 +9,7 @@ interface AnalysisResult { sensitiveCount: number } + interface RightPanelProps { onOpenBacktrack?: () => void onOpenRecalc?: () => void @@ -215,10 +216,10 @@ export function RightPanel({ {/* 확산 예측 요약 */}
-
+
- - + +
@@ -434,16 +435,9 @@ function StatBox({ function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) { return ( -
-
- {value} -
-
- {label} -
+
+ {label} + {value}
) } diff --git a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx index b9de350..c469a46 100755 --- a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx +++ b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx @@ -38,6 +38,8 @@ export interface OilSpillReportData { etcEquipment: string recovery: { shipName: string; period: string }[] result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string } + capturedMapImage?: string; + hasMapCapture?: boolean; } // eslint-disable-next-line react-refresh/only-export-components diff --git a/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx b/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx new file mode 100644 index 0000000..9278a81 --- /dev/null +++ b/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx @@ -0,0 +1,106 @@ +import { useRef, useState } from 'react'; +import { MapView } from '@common/components/map/MapView'; +import type { OilReportPayload } from '@common/hooks/useSubMenu'; + +interface OilSpreadMapPanelProps { + mapData: OilReportPayload['mapData']; + capturedImage: string | null; + onCapture: (dataUrl: string) => void; + onReset: () => void; +} + +const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSpreadMapPanelProps) => { + const captureRef = useRef<(() => Promise) | null>(null); + const [isCapturing, setIsCapturing] = useState(false); + + const handleCapture = async () => { + if (!captureRef.current) return; + setIsCapturing(true); + const dataUrl = await captureRef.current(); + setIsCapturing(false); + if (dataUrl) { + onCapture(dataUrl); + } + }; + + if (!mapData) { + return ( +
+ 확산 예측 데이터가 없습니다. 예측 탭에서 시뮬레이션을 실행 후 보고서를 생성하세요. +
+ ); + } + + return ( +
+ {/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */} +
+ + + {/* 캡처 이미지 오버레이 — 우측 상단 */} + {capturedImage && ( +
+
+ 확산예측 지도 캡처 +
+ + 📷 캡처 완료 + + +
+
+
+ )} +
+ + {/* 하단 안내 + 캡처 버튼 */} +
+

+ {capturedImage + ? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.' + : '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'} +

+ +
+
+ ); +}; + +export default OilSpreadMapPanel; diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index c478d59..5aaac64 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -3,6 +3,7 @@ import { createEmptyReport, } from './OilSpillReportTemplate'; import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'; +import OilSpreadMapPanel from './OilSpreadMapPanel'; import { saveReport } from '../services/reportsApi'; import { CATEGORIES, @@ -34,6 +35,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { const [hnsPayload, setHnsPayload] = useState(null) // OIL 실 데이터 (없으면 sampleOilData fallback) const [oilPayload, setOilPayload] = useState(null) + // 확산예측 지도 캡처 이미지 + const [oilMapCaptured, setOilMapCaptured] = useState(null) // 외부에서 카테고리 힌트가 변경되면 반영 useEffect(() => { @@ -84,6 +87,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { report.incident.spillAmount = sampleOilData.pollution.spillAmount; } } + if (activeCat === 0 && oilMapCaptured) { + report.capturedMapImage = oilMapCaptured; + } try { await saveReport(report) onSave() @@ -99,6 +105,24 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { let content = `

${sec.desc}

`; // OIL 섹션에 실 데이터 삽입 + if (activeCat === 0) { + if (sec.id === 'oil-spread') { + const mapImg = oilMapCaptured + ? `` + : '
[확산예측 지도 미캡처]
'; + const spreadRows = oilPayload + ? [ + ['KOSPS', oilPayload.spread.kosps], + ['OpenDrift', oilPayload.spread.openDrift], + ['POSEIDON', oilPayload.spread.poseidon], + ] + : [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']]; + const tds = spreadRows.map(r => + `${r[0]}
${r[1]}` + ).join(''); + content = `${mapImg}${tds}
`; + } + } if (activeCat === 0 && oilPayload) { if (sec.id === 'oil-pollution') { const rows = [ @@ -290,9 +314,12 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { {/* ── 유출유 확산예측 섹션들 ── */} {sec.id === 'oil-spread' && ( <> -
- [확산예측 지도 - 범위 조절 작업] -
+ setOilMapCaptured(dataUrl)} + onReset={() => setOilMapCaptured(null)} + />
{[ { label: 'KOSPS', value: oilPayload?.spread.kosps || sampleOilData.spread.kosps, color: '#06b6d4' }, diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx index 014c1a6..ced51db 100755 --- a/frontend/src/tabs/reports/components/ReportsView.tsx +++ b/frontend/src/tabs/reports/components/ReportsView.tsx @@ -130,6 +130,7 @@ export function ReportsView() { + @@ -145,6 +146,7 @@ export function ReportsView() { 관할 상태 수정 + 지도 다운로드 삭제 @@ -177,6 +179,12 @@ export function ReportsView() { {report.jurisdiction} {report.status} + + {(report.hasMapCapture || report.capturedMapImage) + ? 📷 + : + } + @@ -369,6 +377,14 @@ export function ReportsView() { previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`, ].filter(Boolean).join('\n') || '—'}
+ {previewReport.capturedMapImage && ( + 확산예측 지도 캡처 + )}
{/* 3. 초동조치 / 대응현황 */} diff --git a/frontend/src/tabs/reports/services/reportsApi.ts b/frontend/src/tabs/reports/services/reportsApi.ts index 33ee933..0bee564 100644 --- a/frontend/src/tabs/reports/services/reportsApi.ts +++ b/frontend/src/tabs/reports/services/reportsApi.ts @@ -62,6 +62,7 @@ export interface ApiReportListItem { authorName: string; regDtm: string; mdfcnDtm: string | null; + hasMapCapture?: boolean; } export interface ApiReportSectionData { @@ -74,6 +75,7 @@ export interface ApiReportSectionData { export interface ApiReportDetail extends ApiReportListItem { acdntSn: number | null; sections: ApiReportSectionData[]; + mapCaptureImg?: string | null; } export interface ApiReportListResponse { @@ -176,6 +178,7 @@ export async function createReportApi(input: { title: string; jrsdCd?: string; sttsCd?: string; + mapCaptureImg?: string; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; }): Promise<{ sn: number }> { const res = await api.post<{ sn: number }>('/reports', input); @@ -187,6 +190,7 @@ export async function updateReportApi(sn: number, input: { jrsdCd?: string; sttsCd?: string; acdntSn?: number | null; + mapCaptureImg?: string | null; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; }): Promise { await api.post(`/reports/${sn}/update`, input); @@ -239,6 +243,7 @@ export async function saveReport(data: OilSpillReportData): Promise { title: data.title || data.incident.name || '보고서', jrsdCd: data.jurisdiction, sttsCd, + mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined, sections, }); return existingSn; @@ -250,6 +255,7 @@ export async function saveReport(data: OilSpillReportData): Promise { title: data.title || data.incident.name || '보고서', jrsdCd: data.jurisdiction, sttsCd, + mapCaptureImg: data.capturedMapImage || undefined, sections, }); return result.sn; @@ -266,6 +272,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport analysisCategory: (item.ctgrCd ? CTGR_CODE_TO_CAT[item.ctgrCd] : '') || '', jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청', status: CODE_TO_STATUS[item.sttsCd] || '테스트', + hasMapCapture: item.hasMapCapture, // 목록에서는 섹션 데이터 없음 — 빈 기본값 incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' }, tide: [], weather: [], spread: [], @@ -337,6 +344,10 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa } } + if (detail.mapCaptureImg) { + reportData.capturedMapImage = detail.mapCaptureImg; + } + return reportData; } 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': {