feat(aerial): 실시간 드론 RTSP→HLS 스트림 연동 + 드론 모델명 표시
This commit is contained in:
부모
5b734d3cf1
커밋
df01fd1b1d
@ -1,4 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
listMedia,
|
listMedia,
|
||||||
createMedia,
|
createMedia,
|
||||||
@ -9,6 +10,10 @@ import {
|
|||||||
isValidSatStatus,
|
isValidSatStatus,
|
||||||
requestOilInference,
|
requestOilInference,
|
||||||
checkInferenceHealth,
|
checkInferenceHealth,
|
||||||
|
listDroneStreams,
|
||||||
|
startDroneStream,
|
||||||
|
stopDroneStream,
|
||||||
|
getHlsDirectory,
|
||||||
} from './aerialService.js';
|
} from './aerialService.js';
|
||||||
import { isValidNumber } from '../middleware/security.js';
|
import { isValidNumber } from '../middleware/security.js';
|
||||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
||||||
@ -162,6 +167,87 @@ router.get('/cctv/stream-proxy', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DRONE STREAM 라우트 (RTSP → HLS)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// GET /api/aerial/drone/streams — 드론 스트림 목록 + 상태
|
||||||
|
router.get('/drone/streams', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const streams = listDroneStreams();
|
||||||
|
res.json(streams);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 드론 스트림 목록 오류:', err);
|
||||||
|
res.status(500).json({ error: '드론 스트림 목록 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/aerial/drone/streams/:id/start — 드론 스트림 시작 (RTSP→HLS 변환)
|
||||||
|
router.post('/drone/streams/:id/start', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = startDroneStream(req.params.id as string);
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({ error: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 드론 스트림 시작 오류:', err);
|
||||||
|
res.status(500).json({ error: '드론 스트림 시작 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/aerial/drone/streams/:id/stop — 드론 스트림 중지
|
||||||
|
router.post('/drone/streams/:id/stop', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = stopDroneStream(req.params.id as string);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] 드론 스트림 중지 오류:', err);
|
||||||
|
res.status(500).json({ error: '드론 스트림 중지 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/aerial/drone/hls/:id/* — HLS 정적 파일 서빙 (.m3u8, .ts)
|
||||||
|
router.get('/drone/hls/:id/*', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const hlsDir = getHlsDirectory(id);
|
||||||
|
if (!hlsDir) {
|
||||||
|
res.status(404).json({ error: '스트림을 찾을 수 없습니다' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// wildcard: req.params[0] contains the rest of the path
|
||||||
|
// Cast through unknown because @types/express v5 types the wildcard key as string[]
|
||||||
|
const rawParams = req.params as unknown as Record<string, string | string[]>;
|
||||||
|
const wildcardRaw = rawParams['0'] ?? '';
|
||||||
|
const wildcardParam = Array.isArray(wildcardRaw) ? wildcardRaw.join('/') : wildcardRaw;
|
||||||
|
const filePath = path.join(hlsDir, wildcardParam);
|
||||||
|
|
||||||
|
// Security: prevent path traversal
|
||||||
|
if (!filePath.startsWith(hlsDir)) {
|
||||||
|
res.status(403).json({ error: '접근 거부' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const contentType = ext === '.m3u8' ? 'application/vnd.apple.mpegurl'
|
||||||
|
: ext === '.ts' ? 'video/mp2t'
|
||||||
|
: 'application/octet-stream';
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
});
|
||||||
|
res.sendFile(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] HLS 파일 서빙 오류:', err);
|
||||||
|
res.status(404).json({ error: 'HLS 파일을 찾을 수 없습니다' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SAT_REQUEST 라우트
|
// SAT_REQUEST 라우트
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { wingPool } from '../db/wingDb.js';
|
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
|
// AERIAL_MEDIA
|
||||||
@ -398,3 +402,175 @@ export async function checkInferenceHealth(): Promise<{ status: string; device?:
|
|||||||
return { status: 'unavailable' };
|
return { status: 'unavailable' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DRONE STREAM (RTSP → HLS via FFmpeg)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface DroneStreamConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shipName: string;
|
||||||
|
droneModel: string;
|
||||||
|
ip: string;
|
||||||
|
rtspUrl: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DroneStreamStatus extends DroneStreamConfig {
|
||||||
|
status: 'idle' | 'starting' | 'streaming' | 'error';
|
||||||
|
hlsUrl: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRONE_STREAMS: DroneStreamConfig[] = [
|
||||||
|
{ id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산' },
|
||||||
|
{ id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천' },
|
||||||
|
{ id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HLS_OUTPUT_DIR = '/tmp/wing-drone-hls';
|
||||||
|
const activeProcesses = new Map<string, { process: ChildProcess; status: 'starting' | 'streaming' | 'error'; error: string | null }>();
|
||||||
|
|
||||||
|
function getHlsDir(id: string): string {
|
||||||
|
return path.join(HLS_OUTPUT_DIR, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFfmpeg(): boolean {
|
||||||
|
try {
|
||||||
|
execSync('which ffmpeg', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDroneStreams(): DroneStreamStatus[] {
|
||||||
|
return DRONE_STREAMS.map(ds => {
|
||||||
|
const active = activeProcesses.get(ds.id);
|
||||||
|
return {
|
||||||
|
...ds,
|
||||||
|
status: active?.status ?? 'idle',
|
||||||
|
hlsUrl: active?.status === 'streaming' ? `/api/aerial/drone/hls/${ds.id}/stream.m3u8` : null,
|
||||||
|
error: active?.error ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startDroneStream(id: string): { success: boolean; error?: string; hlsUrl?: string } {
|
||||||
|
const config = DRONE_STREAMS.find(d => d.id === id);
|
||||||
|
if (!config) return { success: false, error: '알 수 없는 드론 스트림 ID' };
|
||||||
|
|
||||||
|
if (activeProcesses.has(id)) {
|
||||||
|
const existing = activeProcesses.get(id)!;
|
||||||
|
if (existing.status === 'streaming' || existing.status === 'starting') {
|
||||||
|
return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkFfmpeg()) {
|
||||||
|
return { success: false, error: 'FFmpeg가 설치되어 있지 않습니다. 서버에 FFmpeg를 설치하세요.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hlsDir = getHlsDir(id);
|
||||||
|
if (!existsSync(hlsDir)) {
|
||||||
|
mkdirSync(hlsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = path.join(hlsDir, 'stream.m3u8');
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', [
|
||||||
|
'-rtsp_transport', 'tcp',
|
||||||
|
'-i', config.rtspUrl,
|
||||||
|
'-c:v', 'copy',
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-f', 'hls',
|
||||||
|
'-hls_time', '2',
|
||||||
|
'-hls_list_size', '5',
|
||||||
|
'-hls_flags', 'delete_segments',
|
||||||
|
'-y',
|
||||||
|
outputPath,
|
||||||
|
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
|
const entry = { process: ffmpeg, status: 'starting' as const, error: null as string | null };
|
||||||
|
activeProcesses.set(id, entry);
|
||||||
|
|
||||||
|
// Monitor for m3u8 file creation to confirm streaming
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (existsSync(outputPath)) {
|
||||||
|
const e = activeProcesses.get(id);
|
||||||
|
if (e && e.status === 'starting') {
|
||||||
|
e.status = 'streaming';
|
||||||
|
}
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Timeout after 15 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
const e = activeProcesses.get(id);
|
||||||
|
if (e && e.status === 'starting') {
|
||||||
|
e.status = 'error';
|
||||||
|
e.error = 'RTSP 연결 시간 초과 — 내부망에서만 접속 가능합니다.';
|
||||||
|
ffmpeg.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
let stderrBuf = '';
|
||||||
|
ffmpeg.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
stderrBuf += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
const e = activeProcesses.get(id);
|
||||||
|
if (e) {
|
||||||
|
if (e.status !== 'error') {
|
||||||
|
e.status = 'error';
|
||||||
|
e.error = code !== 0
|
||||||
|
? `FFmpeg 종료 (코드: ${code})${stderrBuf.includes('Connection refused') ? ' — RTSP 연결 거부됨' : ''}`
|
||||||
|
: '스트림 종료';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[drone] FFmpeg 종료 (${id}): code=${code}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (err) => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
const e = activeProcesses.get(id);
|
||||||
|
if (e) {
|
||||||
|
e.status = 'error';
|
||||||
|
e.error = `FFmpeg 실행 오류: ${err.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, hlsUrl: `/api/aerial/drone/hls/${id}/stream.m3u8` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopDroneStream(id: string): { success: boolean } {
|
||||||
|
const entry = activeProcesses.get(id);
|
||||||
|
if (!entry) return { success: true };
|
||||||
|
|
||||||
|
entry.process.kill('SIGTERM');
|
||||||
|
activeProcesses.delete(id);
|
||||||
|
|
||||||
|
// Cleanup HLS files
|
||||||
|
const hlsDir = getHlsDir(id);
|
||||||
|
try {
|
||||||
|
if (existsSync(hlsDir)) {
|
||||||
|
rmSync(hlsDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[drone] HLS 디렉토리 정리 실패 (${id}):`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHlsDirectory(id: string): string | null {
|
||||||
|
const config = DRONE_STREAMS.find(d => d.id === id);
|
||||||
|
if (!config) return null;
|
||||||
|
const dir = getHlsDir(id);
|
||||||
|
return existsSync(dir) ? dir : null;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,625 +1,354 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Map, useControl } from '@vis.gl/react-maplibre'
|
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import type { DroneStreamItem } from '../services/aerialApi'
|
||||||
import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers'
|
import { CCTVPlayer } from './CCTVPlayer'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
import type { CCTVPlayerHandle } from './CCTVPlayer'
|
||||||
import type { PickingInfo } from '@deck.gl/core'
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
|
||||||
|
|
||||||
// ── 지도 스타일 ─────────────────────────────────────────
|
|
||||||
const BASE_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,
|
|
||||||
attribution: '© OSM © CARTO',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
|
||||||
const overlay = useControl<MapboxOverlay>(() => 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() {
|
export function RealtimeDrone() {
|
||||||
const [reconProgress, setReconProgress] = useState(0)
|
const [streams, setStreams] = useState<DroneStreamItem[]>([])
|
||||||
const [reconDone, setReconDone] = useState(false)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedDrone, setSelectedDrone] = useState<string | null>(null)
|
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null)
|
||||||
const [animFrame, setAnimFrame] = useState(0)
|
const [gridMode, setGridMode] = useState(1)
|
||||||
|
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
|
||||||
|
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
||||||
|
|
||||||
// 3D 재구성 진행률
|
const loadStreams = useCallback(async () => {
|
||||||
useEffect(() => {
|
try {
|
||||||
if (reconDone) return
|
const items = await fetchDroneStreams()
|
||||||
const timer = setInterval(() => {
|
setStreams(items)
|
||||||
setReconProgress(prev => {
|
// Update selected stream and active cells with latest status
|
||||||
if (prev >= 100) {
|
setSelectedStream(prev => prev ? items.find(s => s.id === prev.id) ?? prev : prev)
|
||||||
clearInterval(timer)
|
setActiveCells(prev => prev.map(cell => items.find(s => s.id === cell.id) ?? cell))
|
||||||
setReconDone(true)
|
} catch {
|
||||||
return 100
|
// Fallback: show configured streams as idle
|
||||||
}
|
setStreams([
|
||||||
return prev + 2
|
{ 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 },
|
||||||
}, 300)
|
{ 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 },
|
||||||
return () => clearInterval(timer)
|
])
|
||||||
}, [reconDone])
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
// 애니메이션 루프 (~20fps)
|
|
||||||
useEffect(() => {
|
|
||||||
let frame = 0
|
|
||||||
let raf: number
|
|
||||||
const tick = () => {
|
|
||||||
frame++
|
|
||||||
if (frame % 3 === 0) setAnimFrame(f => f + 1)
|
|
||||||
raf = requestAnimationFrame(tick)
|
|
||||||
}
|
}
|
||||||
raf = requestAnimationFrame(tick)
|
|
||||||
return () => cancelAnimationFrame(raf)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const activeDrones = useMemo(() => drones.filter(d => d.status !== 'charging'), [])
|
useEffect(() => {
|
||||||
|
loadStreams()
|
||||||
|
}, [loadStreams])
|
||||||
|
|
||||||
// ── deck.gl 레이어 ──────────────────────────────────
|
// Poll status every 3 seconds when any stream is starting
|
||||||
const deckLayers = useMemo(() => {
|
useEffect(() => {
|
||||||
const t = animFrame * 0.05
|
const hasStarting = streams.some(s => s.status === 'starting')
|
||||||
|
if (!hasStarting) return
|
||||||
|
const timer = setInterval(loadStreams, 3000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [streams, loadStreams])
|
||||||
|
|
||||||
// 탐색 구역 (반투명 원 + 테두리)
|
const handleStartStream = async (id: string) => {
|
||||||
const zoneFillLayer = new ScatterplotLayer<ZoneInfo>({
|
try {
|
||||||
id: 'search-zones-fill',
|
await startDroneStreamApi(id)
|
||||||
data: searchZones,
|
// Immediately update to 'starting' state
|
||||||
getPosition: d => [d.lon, d.lat],
|
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'starting' as const, error: null } : s))
|
||||||
getRadius: d => d.radius + Math.sin(t + searchZones.indexOf(d)) * 100,
|
// Poll for status update
|
||||||
getFillColor: d => [...d.color, 15],
|
setTimeout(loadStreams, 2000)
|
||||||
getLineColor: d => [...d.color, 80],
|
} catch {
|
||||||
getLineWidth: 2,
|
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s))
|
||||||
filled: true,
|
}
|
||||||
stroked: true,
|
}
|
||||||
radiusUnits: 'meters',
|
|
||||||
radiusMinPixels: 30,
|
|
||||||
lineWidthMinPixels: 1.5,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 구역 라벨
|
const handleStopStream = async (id: string) => {
|
||||||
const zoneLabels = new TextLayer<ZoneInfo>({
|
try {
|
||||||
id: 'zone-labels',
|
await stopDroneStreamApi(id)
|
||||||
data: searchZones,
|
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s))
|
||||||
getPosition: d => [d.lon, d.lat + 0.025],
|
setActiveCells(prev => prev.filter(c => c.id !== id))
|
||||||
getText: d => `${d.id}구역`,
|
} catch {
|
||||||
getColor: d => [...d.color, 180],
|
// ignore
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 유류 확산 (동심원 3개)
|
const handleSelectStream = (stream: DroneStreamItem) => {
|
||||||
const oilRings = [0, 1, 2].map(ring =>
|
setSelectedStream(stream)
|
||||||
new ScatterplotLayer({
|
if (stream.status === 'streaming' && stream.hlsUrl) {
|
||||||
id: `oil-spill-${ring}`,
|
if (gridMode === 1) {
|
||||||
data: [oilSpill],
|
setActiveCells([stream])
|
||||||
getPosition: () => [oilSpill.lon, oilSpill.lat],
|
} else {
|
||||||
getRadius: 800 + ring * 500 + Math.sin(t * 0.5 + ring) * 80,
|
setActiveCells(prev => {
|
||||||
getFillColor: [249, 115, 22, Math.max(4, 20 - ring * 6)],
|
if (prev.length < gridMode && !prev.find(c => c.id === stream.id)) return [...prev, stream]
|
||||||
getLineColor: [249, 115, 22, ring === 0 ? 120 : 40],
|
return prev
|
||||||
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<VesselInfo>({
|
|
||||||
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<VesselInfo>({
|
|
||||||
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<VesselInfo>({
|
|
||||||
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 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<DroneInfo>({
|
|
||||||
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<DroneInfo>({
|
|
||||||
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<DroneInfo>) => {
|
|
||||||
if (info.object) setSelectedDrone(info.object.id)
|
|
||||||
},
|
|
||||||
updateTriggers: {
|
|
||||||
getPosition: [animFrame],
|
|
||||||
getRadius: [selectedDrone],
|
|
||||||
getLineWidth: [selectedDrone],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 드론 라벨
|
|
||||||
const droneLabels = new TextLayer<DroneInfo>({
|
|
||||||
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) =>
|
const statusInfo = (status: string) => {
|
||||||
t === 'danger' ? 'border-l-status-red bg-[rgba(239,68,68,0.05)]'
|
switch (status) {
|
||||||
: t === 'warning' ? 'border-l-status-orange bg-[rgba(249,115,22,0.05)]'
|
case 'streaming': return { label: '송출중', color: 'var(--green)', bg: 'rgba(34,197,94,.12)' }
|
||||||
: 'border-l-primary-blue bg-[rgba(59,130,246,0.05)]'
|
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 (
|
return (
|
||||||
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||||
{/* 지도 영역 */}
|
{/* 좌측: 드론 스트림 목록 */}
|
||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex flex-col overflow-hidden bg-bg-1 border-r border-border w-[260px] min-w-[260px]">
|
||||||
<Map
|
{/* 헤더 */}
|
||||||
initialViewState={{
|
<div className="p-3 pb-2.5 border-b border-border shrink-0 bg-bg-2">
|
||||||
longitude: 128.75,
|
<div className="flex items-center justify-between mb-2">
|
||||||
latitude: 34.64,
|
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
|
||||||
zoom: 10,
|
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: streams.some(s => s.status === 'streaming') ? 'var(--green)' : 'var(--t3)' }} />
|
||||||
}}
|
실시간 드론 영상
|
||||||
mapStyle={BASE_STYLE}
|
|
||||||
className="w-full h-full"
|
|
||||||
attributionControl={false}
|
|
||||||
>
|
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
|
||||||
</Map>
|
|
||||||
|
|
||||||
{/* 오버레이 통계 */}
|
|
||||||
<div className="absolute top-2.5 left-2.5 flex gap-1.5 z-[2] pointer-events-none">
|
|
||||||
{[
|
|
||||||
{ 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) => (
|
|
||||||
<div key={i} className="bg-[rgba(15,21,36,0.9)] backdrop-blur-sm rounded-sm px-2.5 py-1.5 border border-border">
|
|
||||||
<div className="text-[7px] text-text-3">{s.label}</div>
|
|
||||||
<div>
|
|
||||||
<span className={`font-mono font-bold text-base ${s.color}`}>{s.value}</span>
|
|
||||||
<span className="text-[7px] text-text-3 ml-0.5">{s.unit}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<button
|
||||||
|
onClick={loadStreams}
|
||||||
|
className="px-2 py-0.5 text-[9px] font-korean bg-bg-3 border border-border rounded text-text-2 cursor-pointer hover:bg-bg-hover transition-colors"
|
||||||
|
>새로고침</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-text-3 font-korean">ViewLink RTSP 스트림 · 내부망 전용</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3D 재구성 진행률 */}
|
{/* 드론 스트림 카드 */}
|
||||||
<div className="absolute bottom-2.5 right-2.5 bg-[rgba(15,21,36,0.9)] rounded-sm px-3 py-2 border z-[3] min-w-[175px] cursor-pointer transition-colors hover:border-primary-cyan/40" style={{ borderColor: 'rgba(6,182,212,0.18)' }}>
|
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||||
<div className="flex items-center justify-between mb-1">
|
{loading ? (
|
||||||
<span className="text-[9px] font-bold text-primary-cyan">🧊 3D 재구성</span>
|
<div className="px-3.5 py-4 text-[11px] text-text-3 font-korean">불러오는 중...</div>
|
||||||
<span className="font-mono font-bold text-[13px] text-primary-cyan">{reconProgress}%</span>
|
) : streams.map(stream => {
|
||||||
</div>
|
const si = statusInfo(stream.status)
|
||||||
<div className="w-full h-[3px] bg-white/[0.06] rounded-sm mb-1">
|
const isSelected = selectedStream?.id === stream.id
|
||||||
<div className="h-full rounded-sm transition-all duration-500" style={{ width: `${reconProgress}%`, background: 'linear-gradient(90deg, var(--cyan), var(--blue))' }} />
|
return (
|
||||||
</div>
|
<div
|
||||||
{!reconDone ? (
|
key={stream.id}
|
||||||
<div className="text-[7px] text-text-3">D-01~D-03 다각도 영상 융합중...</div>
|
onClick={() => handleSelectStream(stream)}
|
||||||
) : (
|
className="px-3.5 py-3 border-b cursor-pointer transition-colors"
|
||||||
<div className="text-[8px] font-bold text-status-green mt-0.5 animate-pulse-dot">✅ 완료 — 클릭하여 정밀분석</div>
|
style={{
|
||||||
)}
|
borderColor: 'rgba(255,255,255,.04)',
|
||||||
</div>
|
background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||||
|
}}
|
||||||
{/* 실시간 영상 패널 */}
|
>
|
||||||
{selectedDrone && (() => {
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
const drone = drones.find(d => d.id === selectedDrone)
|
<div className="flex items-center gap-2">
|
||||||
if (!drone) return null
|
<div className="text-sm">🚁</div>
|
||||||
return (
|
<div>
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-[rgba(15,21,36,0.95)] z-[5] border-t" style={{ borderColor: 'rgba(59,130,246,0.2)', height: 190 }}>
|
<div className="text-[11px] font-semibold text-text-1 font-korean">{stream.shipName} <span className="text-[9px] text-text-3 font-normal">({stream.droneModel})</span></div>
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
|
<div className="text-[9px] text-text-3 font-mono">{stream.ip}</div>
|
||||||
<div className="text-[10px] font-bold flex items-center gap-1.5" style={{ color: drone.color }}>
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full animate-pulse-dot" style={{ background: drone.color }} />
|
|
||||||
{drone.id} 실시간 영상
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setSelectedDrone(null)} className="w-5 h-5 rounded bg-white/5 border border-border text-text-3 text-[11px] flex items-center justify-center cursor-pointer hover:text-text-1">✕</button>
|
|
||||||
</div>
|
|
||||||
<div className="grid h-[calc(100%-30px)]" style={{ gridTemplateColumns: '1fr 180px' }}>
|
|
||||||
<div className="relative overflow-hidden" style={{ background: 'radial-gradient(ellipse at center, #0c1a2e, #060c18)' }}>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="text-text-3/20 text-2xl font-mono">LIVE FEED</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-1.5 left-2 z-[2]">
|
|
||||||
<span className="text-[11px] font-bold" style={{ color: drone.color }}>{drone.id}</span>
|
|
||||||
<span className="text-[7px] px-1 py-px rounded bg-white/[0.08] ml-1">{drone.sensor}</span>
|
|
||||||
<div className="text-[7px] text-text-3 font-mono mt-0.5">{drone.lat.toFixed(2)}°N, {drone.lon.toFixed(2)}°E</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-1.5 right-2 z-[2] flex items-center gap-1 text-[8px] font-bold text-status-red">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-status-red" />REC
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-1 left-2 z-[2] text-[7px] text-text-3">
|
|
||||||
ALT {drone.altitude}m · SPD {drone.speed}m/s · HDG 045°
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 overflow-auto text-[9px] border-l border-border">
|
|
||||||
<div className="font-bold text-text-2 mb-1.5 font-korean">비행 정보</div>
|
|
||||||
{[
|
|
||||||
['드론 ID', drone.id],
|
|
||||||
['기체', drone.name],
|
|
||||||
['배터리', `${drone.battery}%`],
|
|
||||||
['고도', `${drone.altitude}m`],
|
|
||||||
['속도', `${drone.speed}m/s`],
|
|
||||||
['센서', drone.sensor],
|
|
||||||
['상태', statusLabel(drone.status).text],
|
|
||||||
].map(([k, v], i) => (
|
|
||||||
<div key={i} className="flex justify-between py-0.5">
|
|
||||||
<span className="text-text-3 font-korean">{k}</span>
|
|
||||||
<span className="font-mono font-semibold text-text-1">{v}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
|
||||||
|
style={{ background: si.bg, color: si.color }}
|
||||||
|
>{si.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[8px] text-text-3 font-korean px-1.5 py-0.5 rounded bg-bg-3">{stream.region}</span>
|
||||||
|
<span className="text-[8px] text-text-3 font-mono px-1.5 py-0.5 rounded bg-bg-3">RTSP :554</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stream.error && (
|
||||||
|
<div className="mt-1.5 text-[8px] text-status-red font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
|
||||||
|
{stream.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 시작/중지 버튼 */}
|
||||||
|
<div className="mt-2 flex gap-1.5">
|
||||||
|
{stream.status === 'idle' || stream.status === 'error' ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleStartStream(stream.id) }}
|
||||||
|
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
||||||
|
style={{ background: 'rgba(34,197,94,.1)', borderColor: 'rgba(34,197,94,.3)', color: 'var(--green)' }}
|
||||||
|
>▶ 스트림 시작</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleStopStream(stream.id) }}
|
||||||
|
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
||||||
|
style={{ background: 'rgba(239,68,68,.1)', borderColor: 'rgba(239,68,68,.3)', color: 'var(--red)' }}
|
||||||
|
>■ 스트림 중지</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})}
|
||||||
})()}
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 안내 */}
|
||||||
|
<div className="px-3 py-2 border-t border-border bg-bg-2 shrink-0">
|
||||||
|
<div className="text-[8px] text-text-3 font-korean leading-relaxed">
|
||||||
|
RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다.
|
||||||
|
ViewLink 프로그램과 연동됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측 사이드바 */}
|
{/* 중앙: 영상 뷰어 */}
|
||||||
<div className="w-[260px] bg-[rgba(15,21,36,0.88)] border-l border-border flex flex-col overflow-auto">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-[#04070f]">
|
||||||
{/* 군집 드론 현황 */}
|
{/* 툴바 */}
|
||||||
<div className="p-2.5 px-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-2 shrink-0 gap-2.5">
|
||||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">군집 드론 현황 · {activeDrones.length}/{drones.length} 운용</div>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||||
{drones.map(d => {
|
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
|
||||||
const st = statusLabel(d.status)
|
</div>
|
||||||
return (
|
{selectedStream?.status === 'streaming' && (
|
||||||
<div
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(34,197,94,.14)', border: '1px solid rgba(34,197,94,.35)', color: 'var(--green)' }}>
|
||||||
key={d.id}
|
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--green)' }} />LIVE
|
||||||
onClick={() => d.status !== 'charging' && setSelectedDrone(d.id)}
|
</div>
|
||||||
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?.status === 'starting' && (
|
||||||
}`}
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(6,182,212,.14)', border: '1px solid rgba(6,182,212,.35)', color: 'var(--cyan)' }}>
|
||||||
>
|
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--cyan)' }} />연결중
|
||||||
<div className="w-2 h-2 rounded-full" style={{ background: d.color }} />
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
)}
|
||||||
<div className="text-[9px] font-bold" style={{ color: d.color }}>{d.id}</div>
|
</div>
|
||||||
<div className="text-[7px] text-text-3 truncate">{d.name}</div>
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{/* 분할 모드 */}
|
||||||
|
<div className="flex border border-border rounded-[5px] overflow-hidden">
|
||||||
|
{[
|
||||||
|
{ mode: 1, icon: '▣', label: '1화면' },
|
||||||
|
{ mode: 4, icon: '⊞', label: '4분할' },
|
||||||
|
].map(g => (
|
||||||
|
<button
|
||||||
|
key={g.mode}
|
||||||
|
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
|
||||||
|
title={g.label}
|
||||||
|
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
|
||||||
|
style={gridMode === g.mode
|
||||||
|
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
|
||||||
|
: { background: 'var(--bg3)', color: 'var(--t2)' }
|
||||||
|
}
|
||||||
|
>{g.icon}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => playerRefs.current.forEach(r => r?.capture())}
|
||||||
|
className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors"
|
||||||
|
>📷 캡처</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 영상 그리드 */}
|
||||||
|
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
|
||||||
|
}}>
|
||||||
|
{Array.from({ length: totalCells }).map((_, i) => {
|
||||||
|
const stream = activeCells[i]
|
||||||
|
return (
|
||||||
|
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
||||||
|
{stream && stream.status === 'streaming' && stream.hlsUrl ? (
|
||||||
|
<CCTVPlayer
|
||||||
|
ref={el => { playerRefs.current[i] = el }}
|
||||||
|
cameraNm={stream.shipName}
|
||||||
|
streamUrl={stream.hlsUrl}
|
||||||
|
sttsCd="LIVE"
|
||||||
|
coordDc={`${stream.ip} · RTSP`}
|
||||||
|
sourceNm="ViewLink"
|
||||||
|
cellIndex={i}
|
||||||
|
/>
|
||||||
|
) : stream && stream.status === 'starting' ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<div className="text-lg opacity-40 animate-pulse">🚁</div>
|
||||||
|
<div className="text-[10px] text-primary-cyan font-korean animate-pulse">RTSP 스트림 연결 중...</div>
|
||||||
|
<div className="text-[8px] text-text-3 font-mono">{stream.ip}:554</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
) : stream && stream.status === 'error' ? (
|
||||||
<div className={`text-[8px] font-semibold ${st.cls}`}>{st.text}</div>
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
<div className="text-[7px] font-mono text-text-3">{d.battery}%</div>
|
<div className="text-lg opacity-30">⚠️</div>
|
||||||
|
<div className="text-[10px] text-status-red font-korean">연결 실패</div>
|
||||||
|
<div className="text-[8px] text-text-3 font-korean max-w-[200px] text-center">{stream.error}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartStream(stream.id)}
|
||||||
|
className="mt-1 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors"
|
||||||
|
>재시도</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-text-3 font-korean opacity-40">
|
||||||
|
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 정보 바 */}
|
||||||
|
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">선택: <b className="text-text-1">{selectedStream?.shipName ?? '–'}</b></div>
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">IP: <span className="text-primary-cyan font-mono text-[9px]">{selectedStream?.ip ?? '–'}</span></div>
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">지역: <span className="text-text-2">{selectedStream?.region ?? '–'}</span></div>
|
||||||
|
<div className="ml-auto text-[9px] text-text-3 font-korean">RTSP → HLS · ViewLink 연동</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 정보 패널 */}
|
||||||
|
<div className="flex flex-col overflow-hidden bg-bg-1 border-l border-border w-[220px] min-w-[220px]">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-3 py-2 border-b border-border text-[11px] font-bold text-text-1 font-korean bg-bg-2 shrink-0">
|
||||||
|
📋 스트림 정보
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 py-2.5" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||||
|
{selectedStream ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{[
|
||||||
|
['함정명', selectedStream.shipName],
|
||||||
|
['드론명', selectedStream.name],
|
||||||
|
['기체모델', selectedStream.droneModel],
|
||||||
|
['IP 주소', selectedStream.ip],
|
||||||
|
['RTSP 포트', '554'],
|
||||||
|
['지역', selectedStream.region],
|
||||||
|
['프로토콜', 'RTSP → HLS'],
|
||||||
|
['상태', statusInfo(selectedStream.status).label],
|
||||||
|
].map(([k, v], i) => (
|
||||||
|
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded text-[9px]">
|
||||||
|
<span className="text-text-3 font-korean">{k}</span>
|
||||||
|
<span className="font-mono text-text-1">{v}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
{selectedStream.hlsUrl && (
|
||||||
</div>
|
<div className="px-2 py-1 bg-bg-0 rounded text-[8px]">
|
||||||
</div>
|
<div className="text-text-3 font-korean mb-0.5">HLS URL</div>
|
||||||
|
<div className="font-mono text-primary-cyan break-all">{selectedStream.hlsUrl}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-text-3 font-korean">드론 스트림을 선택하세요</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 다각화 분석 */}
|
{/* 연동 시스템 */}
|
||||||
<div className="p-2.5 px-3 border-b border-border">
|
<div className="mt-3 pt-2.5 border-t border-border">
|
||||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">다각화 분석</div>
|
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">🔗 연동 시스템</div>
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="flex flex-col gap-1.5">
|
||||||
{[
|
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(6,182,212,.2)' }}>
|
||||||
{ icon: '🎯', label: '다시점 융합', value: '28건', sub: '360° 식별' },
|
<span className="text-[9px] text-text-2 font-korean">ViewLink 3.5</span>
|
||||||
{ icon: '🧊', label: '3D 재구성', value: '12건', sub: '선박+오염원' },
|
<span className="text-[9px] font-bold" style={{ color: 'var(--cyan)' }}>● RTSP</span>
|
||||||
{ icon: '📡', label: '다센서 융합', value: '45건', sub: '광학+IR+SAR' },
|
|
||||||
{ icon: '🛢️', label: '오염원 3D', value: '3건', sub: '유류+HNS' },
|
|
||||||
].map((a, i) => (
|
|
||||||
<div key={i} className="bg-white/[0.02] rounded-sm px-1.5 py-1.5 border border-white/[0.03]">
|
|
||||||
<div className="text-[10px] mb-px">{a.icon}</div>
|
|
||||||
<div className="text-[7px] text-text-3">{a.label}</div>
|
|
||||||
<div className="text-xs font-bold font-mono text-primary-cyan my-px">{a.value}</div>
|
|
||||||
<div className="text-[6px] text-text-3">{a.sub}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
|
||||||
|
<span className="text-[9px] text-text-2 font-korean">FFmpeg 변환</span>
|
||||||
|
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>RTSP→HLS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 실시간 경보 */}
|
{/* 전체 상태 요약 */}
|
||||||
<div className="p-2.5 px-3 flex-1 overflow-auto">
|
<div className="mt-3 pt-2.5 border-t border-border">
|
||||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">실시간 경보</div>
|
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📊 스트림 현황</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{alerts.map((a, i) => (
|
{[
|
||||||
<div key={i} className={`px-2 py-1.5 border-l-2 rounded-sm text-[9px] font-korean ${alertColor(a.type)}`}>
|
{ label: '전체', value: streams.length, color: 'text-text-1' },
|
||||||
<span className="font-mono text-text-3 mr-1.5">{a.time}</span>
|
{ label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-status-green' },
|
||||||
<span className="text-text-2">{a.message}</span>
|
{ label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-primary-cyan' },
|
||||||
</div>
|
{ label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-status-red' },
|
||||||
))}
|
].map((item, i) => (
|
||||||
|
<div key={i} className="px-2 py-1.5 bg-bg-0 rounded text-center">
|
||||||
|
<div className="text-[8px] text-text-3 font-korean">{item.label}</div>
|
||||||
|
<div className={`text-sm font-bold font-mono ${item.color}`}>{item.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -103,3 +103,32 @@ export async function createSatRequest(
|
|||||||
const response = await api.post<{ satReqSn: number }>('/aerial/sat-requests', input);
|
const response = await api.post<{ satReqSn: number }>('/aerial/sat-requests', input);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === DRONE STREAM ===
|
||||||
|
export interface DroneStreamItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shipName: string;
|
||||||
|
droneModel: string;
|
||||||
|
ip: string;
|
||||||
|
rtspUrl: string;
|
||||||
|
region: string;
|
||||||
|
status: 'idle' | 'starting' | 'streaming' | 'error';
|
||||||
|
hlsUrl: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDroneStreams(): Promise<DroneStreamItem[]> {
|
||||||
|
const response = await api.get<DroneStreamItem[]>('/aerial/drone/streams');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startDroneStreamApi(id: string): Promise<{ success: boolean; hlsUrl?: string; error?: string }> {
|
||||||
|
const response = await api.post<{ success: boolean; hlsUrl?: string; error?: string }>(`/aerial/drone/streams/${id}/start`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopDroneStreamApi(id: string): Promise<{ success: boolean }> {
|
||||||
|
const response = await api.post<{ success: boolean }>(`/aerial/drone/streams/${id}/stop`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user