feat(aerial): 실시간 드론 지도 뷰 — 드론 위치 아이콘 + 클릭 스트림 연결

- 드론 미선택 시 MapLibre 지도에 드론 위치 표시 (부산/인천/목포)
- 드론 SVG 아이콘 (본체+팔4개+프로펠러+카메라, 상태별 색상)
- 송출중 드론은 빨간 LED 깜빡임 애니메이션
- 드론 클릭 → 다크 팝업 (함정명, 드론모델, IP, 상태)
  대기중: "스트림 시작" 버튼 / 송출중: "영상 보기" 버튼
- 스트림 선택 시 자동으로 영상 그리드로 전환

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-16 09:28:08 +09:00
부모 9386c1e29a
커밋 c4728be7a1

파일 보기

@ -1,17 +1,46 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi' import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'
import type { DroneStreamItem } from '../services/aerialApi' import type { DroneStreamItem } from '../services/aerialApi'
import { CCTVPlayer } from './CCTVPlayer' import { CCTVPlayer } from './CCTVPlayer'
import type { CCTVPlayerHandle } from './CCTVPlayer' import type { CCTVPlayerHandle } from './CCTVPlayer'
/** 드론 위치 좌표 (함정 모항 기준) */
const DRONE_COORDS: Record<string, { lat: number; lon: number }> = {
'busan-1501': { lat: 35.0796, lon: 129.0756 },
'incheon-3008': { lat: 37.4541, lon: 126.5986 },
'mokpo-3015': { lat: 34.7780, lon: 126.3780 },
}
const DRONE_MAP_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }],
}
export function RealtimeDrone() { export function RealtimeDrone() {
const [streams, setStreams] = useState<DroneStreamItem[]>([]) const [streams, setStreams] = useState<DroneStreamItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null) const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null)
const [gridMode, setGridMode] = useState(1) const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([]) const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null)
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
const showMap = activeCells.length === 0
const loadStreams = useCallback(async () => { const loadStreams = useCallback(async () => {
try { try {
const items = await fetchDroneStreams() const items = await fetchDroneStreams()
@ -227,51 +256,154 @@ export function RealtimeDrone() {
</div> </div>
</div> </div>
{/* 영상 그리드 */} {/* 드론 위치 지도 또는 영상 그리드 */}
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black" {showMap ? (
style={{ <div className="flex-1 overflow-hidden relative">
gridTemplateColumns: `repeat(${gridCols}, 1fr)`, <Map
gridTemplateRows: `repeat(${gridCols}, 1fr)`, initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
}}> mapStyle={DRONE_MAP_STYLE}
{Array.from({ length: totalCells }).map((_, i) => { style={{ width: '100%', height: '100%' }}
const stream = activeCells[i] attributionControl={false}
return ( >
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}> {streams.map(stream => {
{stream && stream.status === 'streaming' && stream.hlsUrl ? ( const coord = DRONE_COORDS[stream.id]
<CCTVPlayer if (!coord) return null
ref={el => { playerRefs.current[i] = el }} const si = statusInfo(stream.status)
cameraNm={stream.shipName} return (
streamUrl={stream.hlsUrl} <Marker
sttsCd="LIVE" key={stream.id}
coordDc={`${stream.ip} · RTSP`} longitude={coord.lon}
sourceNm="ViewLink" latitude={coord.lat}
cellIndex={i} anchor="bottom"
/> onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }}
) : stream && stream.status === 'starting' ? ( >
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center cursor-pointer group" title={stream.shipName}>
<div className="text-lg opacity-40 animate-pulse">🚁</div> {/* 드론 SVG 아이콘 */}
<div className="text-[10px] text-primary-cyan font-korean animate-pulse">RTSP ...</div> <svg width="32" height="32" viewBox="0 0 32 32" fill="none" className="drop-shadow-lg transition-transform group-hover:scale-110">
<div className="text-[8px] text-text-3 font-mono">{stream.ip}:554</div> {/* 본체 */}
<ellipse cx="16" cy="16" rx="5" ry="3" fill={si.color} opacity="0.9" />
{/* 팔 4개 */}
<line x1="11" y1="13" x2="5" y2="7" stroke={si.color} strokeWidth="1.5" />
<line x1="21" y1="13" x2="27" y2="7" stroke={si.color} strokeWidth="1.5" />
<line x1="11" y1="19" x2="5" y2="25" stroke={si.color} strokeWidth="1.5" />
<line x1="21" y1="19" x2="27" y2="25" stroke={si.color} strokeWidth="1.5" />
{/* 프로펠러 */}
<circle cx="5" cy="7" r="3" fill={si.color} opacity="0.3" />
<circle cx="27" cy="7" r="3" fill={si.color} opacity="0.3" />
<circle cx="5" cy="25" r="3" fill={si.color} opacity="0.3" />
<circle cx="27" cy="25" r="3" fill={si.color} opacity="0.3" />
{/* 카메라 */}
<circle cx="16" cy="16" r="1.5" fill="#fff" />
{/* 송출중 표시 */}
{stream.status === 'streaming' && (
<circle cx="16" cy="10" r="2" fill="#ef4444">
<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" />
</circle>
)}
</svg>
{/* 라벨 */}
<div className="px-1.5 py-px rounded text-[8px] font-bold font-korean whitespace-nowrap mt-0.5"
style={{ background: 'rgba(0,0,0,.7)', color: '#fff' }}>
{stream.shipName}
</div>
</div>
</Marker>
)
})}
{/* 드론 클릭 팝업 */}
{mapPopup && DRONE_COORDS[mapPopup.id] && (
<Popup
longitude={DRONE_COORDS[mapPopup.id].lon}
latitude={DRONE_COORDS[mapPopup.id].lat}
anchor="bottom"
onClose={() => setMapPopup(null)}
closeOnClick={false}
offset={36}
className="cctv-dark-popup"
>
<div className="p-2.5" style={{ minWidth: 170, background: '#1a1f2e', borderRadius: 6 }}>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-sm">🚁</span>
<div className="text-[11px] font-bold text-white">{mapPopup.shipName}</div>
</div>
<div className="text-[9px] text-gray-400 mb-0.5">{mapPopup.droneModel}</div>
<div className="text-[8px] text-gray-500 font-mono mb-2">{mapPopup.ip} · {mapPopup.region}</div>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
style={{ background: statusInfo(mapPopup.status).bg, color: statusInfo(mapPopup.status).color }}
> {statusInfo(mapPopup.status).label}</span>
</div>
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? (
<button
onClick={() => { handleStartStream(mapPopup.id); handleSelectStream(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{ background: 'rgba(34,197,94,.15)', borderColor: 'rgba(34,197,94,.3)', color: '#4ade80' }}
> </button>
) : mapPopup.status === 'streaming' ? (
<button
onClick={() => { handleSelectStream(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
> </button>
) : (
<div className="text-[9px] text-primary-cyan font-korean text-center animate-pulse"> ...</div>
)}
</div> </div>
) : stream && stream.status === 'error' ? ( </Popup>
<div className="flex flex-col items-center justify-center gap-2"> )}
<div className="text-lg opacity-30"></div> </Map>
<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> <div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
<button style={{ background: 'rgba(0,0,0,.7)', color: 'rgba(255,255,255,.7)', backdropFilter: 'blur(4px)' }}>
onClick={() => handleStartStream(stream.id)} 🚁 ({streams.length})
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" </div>
></button> </div>
</div> ) : (
) : ( <div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
<div className="text-[10px] text-text-3 font-korean opacity-40"> style={{
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'} gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
</div> gridTemplateRows: `repeat(${gridCols}, 1fr)`,
)} }}>
</div> {Array.from({ length: totalCells }).map((_, i) => {
) const stream = activeCells[i]
})} return (
</div> <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>
) : 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="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">