feat: 보고서 지도캡처 + 드론/CCTV/확산예측 UI 기능 개선 #91
@ -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<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() {
|
||||
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 [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(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() {
|
||||
</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>
|
||||
{/* 드론 위치 지도 또는 영상 그리드 */}
|
||||
{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>
|
||||
) : 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>
|
||||
</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"
|
||||
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>
|
||||
) : 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">
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user