feat(aerial): 실시간 드론 지도 뷰 — 드론 위치 아이콘 + 클릭 스트림 연결
- 드론 미선택 시 MapLibre 지도에 드론 위치 표시 (부산/인천/목포) - 드론 SVG 아이콘 (본체+팔4개+프로펠러+카메라, 상태별 색상) - 송출중 드론은 빨간 LED 깜빡임 애니메이션 - 드론 클릭 → 다크 팝업 (함정명, 드론모델, IP, 상태) 대기중: "스트림 시작" 버튼 / 송출중: "영상 보기" 버튼 - 스트림 선택 시 자동으로 영상 그리드로 전환 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
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,7 +256,109 @@ export function RealtimeDrone() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 영상 그리드 */}
|
{/* 드론 위치 지도 또는 영상 그리드 */}
|
||||||
|
{showMap ? (
|
||||||
|
<div className="flex-1 overflow-hidden relative">
|
||||||
|
<Map
|
||||||
|
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
||||||
|
mapStyle={DRONE_MAP_STYLE}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
{streams.map(stream => {
|
||||||
|
const coord = DRONE_COORDS[stream.id]
|
||||||
|
if (!coord) return null
|
||||||
|
const si = statusInfo(stream.status)
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={stream.id}
|
||||||
|
longitude={coord.lon}
|
||||||
|
latitude={coord.lat}
|
||||||
|
anchor="bottom"
|
||||||
|
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center cursor-pointer group" title={stream.shipName}>
|
||||||
|
{/* 드론 SVG 아이콘 */}
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" className="drop-shadow-lg transition-transform group-hover:scale-110">
|
||||||
|
{/* 본체 */}
|
||||||
|
<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>
|
||||||
|
</Popup>
|
||||||
|
)}
|
||||||
|
</Map>
|
||||||
|
{/* 지도 위 안내 배지 */}
|
||||||
|
<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"
|
||||||
|
style={{ background: 'rgba(0,0,0,.7)', color: 'rgba(255,255,255,.7)', backdropFilter: 'blur(4px)' }}>
|
||||||
|
🚁 드론 위치를 클릭하여 스트림을 시작하세요 ({streams.length}대)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
|
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
|
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
|
||||||
@ -272,6 +403,7 @@ export function RealtimeDrone() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</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">
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user