@ -1,625 +1,354 @@
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 type { StyleSpecification } from 'maplibre-gl'
import type { PickingInfo } from '@deck.gl/core'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useState , useEffect , useCallback , useRef } from 'react'
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 = {
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() {
const [ reconProgress , setReconProgress ] = useState ( 0 )
const [ reconDone , setReconDone ] = useState ( false )
const [ selectedDrone , setSelectedDrone ] = useState < string | null > ( null )
const [ animFrame , setAnimFrame ] = useState ( 0 )
const [ streams , setStreams ] = useState < DroneStreamItem [ ] > ( [ ] )
const [ loading , setLoading ] = useState ( true )
const [ selectedStream , setSelectedStream ] = useState < DroneStreamItem | null > ( null )
const [ gridMode , setGridMode ] = useState ( 1 )
const [ activeCells , setActiveCells ] = useState < DroneStreamItem [ ] > ( [ ] )
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 ] )
// 애니메이션 루프 (~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 < ZoneInfo > ( {
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 < ZoneInfo > ( {
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 < 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 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 < 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 ) = >
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 (
< div className = "flex h-full overflow-hidden" style = { { margin : '-20px -24px' , height : 'calc(100% + 40px)' } } >
{ /* 지도 영역 */ }
< div className = "flex-1 relative overflow-hidden" >
< Map
initialViewState = { {
longitude : 128.75 ,
latitude : 34.64 ,
zoom : 10 ,
} }
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 className = "flex flex-col overflow-hidden bg-bg-1 border-r border-border w-[260px] min-w-[260px]" >
{ /* 헤더 */ }
< div className = "p-3 pb-2.5 border-b border-border shrink-0 bg-bg-2" >
< div className = "flex items-center justify-between mb-2" >
< div className = "text-xs font-bold text-text-1 font-korean flex items-center gap-1.5" >
< span className = "w-[7px] h-[7px] rounded-full inline-block" style = { { background : streams.some ( s = > s . status === 'streaming' ) ? 'var(--green)' : 'var(--t3)' } } / >
실 시 간 드 론 영 상
< / 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 >
{ /* 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 items-center justify-between mb-1" >
< span className = "text-[9px] font-bold text-primary-cyan" > 🧊 3 D 재 구 성 < / span >
< span className = "font-mono font-bold text-[13px] text-primary-cyan" > { reconProgress } % < / span >
< / div >
< div className = "w-full h-[3px] bg-white/[0.06] rounded-sm mb-1" >
< div className = "h-full rounded-sm transition-all duration-500" style = { { width : ` ${ reconProgress } % ` , background : 'linear-gradient(90deg, var(--cyan), var(--blue))' } } / >
< / div >
{ ! reconDone ? (
< div className = "text-[7px] text-text-3" > D - 01 ~ D - 03 다 각 도 영 상 융 합 중 . . . < / div >
) : (
< div className = "text-[8px] font-bold text-status-green mt-0.5 animate-pulse-dot" > ✅ 완 료 — 클 릭 하 여 정 밀 분 석 < / div >
) }
< / div >
{ /* 실시간 영상 패널 */ }
{ selectedDrone && ( ( ) = > {
const drone = drones . find ( d = > d . id === selectedDrone )
if ( ! drone ) return null
return (
< 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 = "flex items-center justify-between px-3 py-1.5 border-b border-border" >
< 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 className = "flex-1 overflow-y-auto" style = { { scrollbarWidth : 'thin' , scrollbarColor : 'var(--bdL) transparent' } } >
{ loading ? (
< div className = "px-3.5 py-4 text-[11px] text-text-3 font-korean" > 불 러 오 는 중 . . . < / div >
) : streams . map ( stream = > {
const si = statusInfo ( stream . status )
const isSelected = selectedStream ? . id === stream . id
return (
< div
key = { stream . id }
onClick = { ( ) = > 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' ,
} }
>
< div className = "flex items-center justify-between mb-1.5" >
< div className = "flex items-center gap-2" >
< div className = "text-sm" > 🚁 < / div >
< div >
< 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 = "text-[9px] text-text-3 font-mono" > { stream . ip } < / 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 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 className = "w-[260px] bg-[rgba(15,21,36,0.88)] border-l border-border flex flex-col overflow-auto" >
{ /* 군집 드론 현황 */ }
< div className = "p-2.5 px-3 border-b border-border" >
< div className = "text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider" > 군 집 드 론 현 황 · { activeDrones . length } / { drones . length } 운 용 < / div >
< div className = "flex flex-col gap-1" >
{ drones . map ( d = > {
const st = statusLabel ( d . status )
return (
< div
key = { d . id }
onClick = { ( ) = > 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'
} ` }
>
< div className = "w-2 h-2 rounded-full" style = { { background : d.color } } / >
< div className = "flex-1 min-w-0" >
< div className = "text-[9px] font-bold" style = { { color : d.color } } > { d . id } < / div >
< div className = "text-[7px] text-text-3 truncate" > { d . name } < / div >
{ /* 중앙: 영상 뷰어 */ }
< div className = "flex-1 flex flex-col overflow-hidden min-w-0 bg-[#04070f]" >
{ /* 툴바 */ }
< 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 = "flex items-center gap-2 min-w-0" >
< div className = "text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis" >
{ selectedStream ? ` 🚁 ${ selectedStream . shipName } ` : '🚁 드론 스트림을 선택하세요' }
< / div >
{ selectedStream ? . status === 'streaming' && (
< 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)' } } >
< span className = "w-[5px] h-[5px] rounded-full inline-block animate-pulse" style = { { background : 'var(--green)' } } / > LIVE
< / div >
) }
{ 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 >
) }
< / 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 className = "text-right" >
< div className = { ` text-[8px] font-semibold ${ st . cls } ` } > { st . text } < / div >
< div className = "text-[7px] font-mono text-text-3" > { d . battery } % < / div >
) : stream && stream . status === 'error' ? (
< div className = "flex flex-col items-center justify-center gap-2" >
< 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 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 >
< / div >
) ) }
{ selectedStream . hlsUrl && (
< div className = "px-2 py-1 bg-bg-0 rounded text-[8px]" >
< 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 = "text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider" > 다 각 화 분 석 < / div >
< div className = "grid grid-cols-2 gap-1" >
{ [
{ 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 ) = > (
< 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 className = "mt-3 pt-2.5 border-t border-border" >
< div className = "text-[10px] font-bold text-text-2 font-korean mb-2" > 🔗 연 동 시 스 템 < / div >
< 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)' } } >
< span className = "text-[9px] text-text-2 font-korean" > ViewLink 3.5 < / span >
< span className = "text-[9px] font-bold" style = { { color : 'var(--cyan)' } } > ● RTSP < / span >
< / 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 className = "p-2.5 px-3 flex-1 overflow-auto" >
< div className = "text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider" > 실 시 간 경 보 < / div >
< div className = "flex flex-col gap-1" >
{ 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 ) } ` } >
< span className = "font-mono text-text-3 mr-1.5" > { a . time } < / span >
< span className = "text-text-2" > { a . message } < / span >
< / div >
) ) }
{ /* 전체 상태 요약 */ }
< div className = "mt-3 pt-2.5 border-t border-border" >
< div className = "text-[10px] font-bold text-text-2 font-korean mb-2" > 📊 스 트 림 현 황 < / div >
< div className = "grid grid-cols-2 gap-1.5" >
{ [
{ 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 ) = > (
< 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 >