feat: 보고서 지도캡처 + 드론/CCTV/확산예측 UI 기능 개선 #91

병합
jhkang feature/function_develop 에서 develop 로 27 commits 를 머지했습니다 2026-03-16 18:30:04 +09:00
Showing only changes of commit c4728be7a1 - Show all commits

파일 보기

@ -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">