From c4728be7a132b4e1e0f324dd50503cc271fbc470 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Mon, 16 Mar 2026 09:28:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(aerial):=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=93=9C=EB=A1=A0=20=EC=A7=80=EB=8F=84=20=EB=B7=B0=20=E2=80=94?= =?UTF-8?q?=20=EB=93=9C=EB=A1=A0=20=EC=9C=84=EC=B9=98=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20+=20=ED=81=B4=EB=A6=AD=20=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 드론 미선택 시 MapLibre 지도에 드론 위치 표시 (부산/인천/목포) - 드론 SVG 아이콘 (본체+팔4개+프로펠러+카메라, 상태별 색상) - 송출중 드론은 빨간 LED 깜빡임 애니메이션 - 드론 클릭 → 다크 팝업 (함정명, 드론모델, IP, 상태) 대기중: "스트림 시작" 버튼 / 송출중: "영상 보기" 버튼 - 스트림 선택 시 자동으로 영상 그리드로 전환 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tabs/aerial/components/RealtimeDrone.tsx | 220 ++++++++++++++---- 1 file changed, 176 insertions(+), 44 deletions(-) diff --git a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx index f2aeca1..3c01228 100644 --- a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx +++ b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx @@ -1,17 +1,46 @@ 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 type { DroneStreamItem } from '../services/aerialApi' import { CCTVPlayer } from './CCTVPlayer' import type { CCTVPlayerHandle } from './CCTVPlayer' +/** 드론 위치 좌표 (함정 모항 기준) */ +const DRONE_COORDS: Record = { + '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() { const [streams, setStreams] = useState([]) const [loading, setLoading] = useState(true) const [selectedStream, setSelectedStream] = useState(null) const [gridMode, setGridMode] = useState(1) const [activeCells, setActiveCells] = useState([]) + const [mapPopup, setMapPopup] = useState(null) const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) + const showMap = activeCells.length === 0 + const loadStreams = useCallback(async () => { try { const items = await fetchDroneStreams() @@ -227,51 +256,154 @@ export function RealtimeDrone() { - {/* 영상 그리드 */} -
- {Array.from({ length: totalCells }).map((_, i) => { - const stream = activeCells[i] - return ( -
- {stream && stream.status === 'streaming' && stream.hlsUrl ? ( - { playerRefs.current[i] = el }} - cameraNm={stream.shipName} - streamUrl={stream.hlsUrl} - sttsCd="LIVE" - coordDc={`${stream.ip} · RTSP`} - sourceNm="ViewLink" - cellIndex={i} - /> - ) : stream && stream.status === 'starting' ? ( -
-
🚁
-
RTSP 스트림 연결 중...
-
{stream.ip}:554
+ {/* 드론 위치 지도 또는 영상 그리드 */} + {showMap ? ( +
+ + {streams.map(stream => { + const coord = DRONE_COORDS[stream.id] + if (!coord) return null + const si = statusInfo(stream.status) + return ( + { e.originalEvent.stopPropagation(); setMapPopup(stream) }} + > +
+ {/* 드론 SVG 아이콘 */} + + {/* 본체 */} + + {/* 팔 4개 */} + + + + + {/* 프로펠러 */} + + + + + {/* 카메라 */} + + {/* 송출중 표시 */} + {stream.status === 'streaming' && ( + + + + )} + + {/* 라벨 */} +
+ {stream.shipName} +
+
+
+ ) + })} + {/* 드론 클릭 팝업 */} + {mapPopup && DRONE_COORDS[mapPopup.id] && ( + setMapPopup(null)} + closeOnClick={false} + offset={36} + className="cctv-dark-popup" + > +
+
+ 🚁 +
{mapPopup.shipName}
+
+
{mapPopup.droneModel}
+
{mapPopup.ip} · {mapPopup.region}
+
+ ● {statusInfo(mapPopup.status).label} +
+ {mapPopup.status === 'idle' || mapPopup.status === 'error' ? ( + + ) : mapPopup.status === 'streaming' ? ( + + ) : ( +
연결 중...
+ )}
- ) : stream && stream.status === 'error' ? ( -
-
⚠️
-
연결 실패
-
{stream.error}
- -
- ) : ( -
- {streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'} -
- )} -
- ) - })} -
+ + )} + + {/* 지도 위 안내 배지 */} +
+ 🚁 드론 위치를 클릭하여 스트림을 시작하세요 ({streams.length}대) +
+
+ ) : ( +
+ {Array.from({ length: totalCells }).map((_, i) => { + const stream = activeCells[i] + return ( +
+ {stream && stream.status === 'streaming' && stream.hlsUrl ? ( + { playerRefs.current[i] = el }} + cameraNm={stream.shipName} + streamUrl={stream.hlsUrl} + sttsCd="LIVE" + coordDc={`${stream.ip} · RTSP`} + sourceNm="ViewLink" + cellIndex={i} + /> + ) : stream && stream.status === 'starting' ? ( +
+
🚁
+
RTSP 스트림 연결 중...
+
{stream.ip}:554
+
+ ) : stream && stream.status === 'error' ? ( +
+
⚠️
+
연결 실패
+
{stream.error}
+ +
+ ) : ( +
+ {streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'} +
+ )} +
+ ) + })} +
+ )} {/* 하단 정보 바 */}