826 lines
35 KiB
TypeScript
826 lines
35 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
|
||
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';
|
||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||
import { useMapStore } from '@common/store/mapStore';
|
||
|
||
/** 함정 위치 + 드론 비행 위치 */
|
||
const DRONE_POSITIONS: Record<
|
||
string,
|
||
{ ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }
|
||
> = {
|
||
'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.11, lon: 129.11 } },
|
||
'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.48, lon: 126.56 } },
|
||
'mokpo-3015': { ship: { lat: 34.778, lon: 126.378 }, drone: { lat: 34.805, lon: 126.41 } },
|
||
};
|
||
|
||
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 currentMapStyle = useBaseMapStyle();
|
||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||
|
||
const showMap = activeCells.length === 0;
|
||
|
||
const loadStreams = useCallback(async () => {
|
||
try {
|
||
const items = await fetchDroneStreams();
|
||
setStreams(items);
|
||
// Update selected stream and active cells with latest status
|
||
setSelectedStream((prev) => (prev ? (items.find((s) => s.id === prev.id) ?? prev) : prev));
|
||
setActiveCells((prev) => prev.map((cell) => items.find((s) => s.id === cell.id) ?? cell));
|
||
} catch {
|
||
// Fallback: show configured streams as idle
|
||
setStreams([
|
||
{
|
||
id: 'busan-1501',
|
||
name: '1501함 드론',
|
||
shipName: '부산서 1501함',
|
||
droneModel: 'DJI M300 RTK',
|
||
ip: '10.26.7.213',
|
||
rtspUrl: 'rtsp://10.26.7.213:554/stream0',
|
||
region: '부산',
|
||
status: 'idle',
|
||
hlsUrl: null,
|
||
error: null,
|
||
},
|
||
{
|
||
id: 'incheon-3008',
|
||
name: '3008함 드론',
|
||
shipName: '인천서 3008함',
|
||
droneModel: 'DJI M30T',
|
||
ip: '10.26.5.21',
|
||
rtspUrl: 'rtsp://10.26.5.21:554/stream0',
|
||
region: '인천',
|
||
status: 'idle',
|
||
hlsUrl: null,
|
||
error: null,
|
||
},
|
||
{
|
||
id: 'mokpo-3015',
|
||
name: '3015함 드론',
|
||
shipName: '목포서 3015함',
|
||
droneModel: 'DJI Mavic 3E',
|
||
ip: '10.26.7.85',
|
||
rtspUrl: 'rtsp://10.26.7.85:554/stream0',
|
||
region: '목포',
|
||
status: 'idle',
|
||
hlsUrl: null,
|
||
error: null,
|
||
},
|
||
]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadStreams();
|
||
}, [loadStreams]);
|
||
|
||
// Poll status every 3 seconds when any stream is starting
|
||
useEffect(() => {
|
||
const hasStarting = streams.some((s) => s.status === 'starting');
|
||
if (!hasStarting) return;
|
||
const timer = setInterval(loadStreams, 3000);
|
||
return () => clearInterval(timer);
|
||
}, [streams, loadStreams]);
|
||
|
||
const handleStartStream = async (id: string) => {
|
||
try {
|
||
await startDroneStreamApi(id);
|
||
// Immediately update to 'starting' state
|
||
setStreams((prev) =>
|
||
prev.map((s) => (s.id === id ? { ...s, status: 'starting' as const, error: null } : s)),
|
||
);
|
||
// Poll for status update
|
||
setTimeout(loadStreams, 2000);
|
||
} catch {
|
||
setStreams((prev) =>
|
||
prev.map((s) =>
|
||
s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s,
|
||
),
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleStopStream = async (id: string) => {
|
||
try {
|
||
await stopDroneStreamApi(id);
|
||
setStreams((prev) =>
|
||
prev.map((s) =>
|
||
s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s,
|
||
),
|
||
);
|
||
setActiveCells((prev) => prev.filter((c) => c.id !== id));
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
const handleSelectStream = (stream: DroneStreamItem) => {
|
||
setSelectedStream(stream);
|
||
if (stream.status === 'streaming' && stream.hlsUrl) {
|
||
if (gridMode === 1) {
|
||
setActiveCells([stream]);
|
||
} else {
|
||
setActiveCells((prev) => {
|
||
if (prev.length < gridMode && !prev.find((c) => c.id === stream.id))
|
||
return [...prev, stream];
|
||
return prev;
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
const statusInfo = (status: string) => {
|
||
switch (status) {
|
||
case 'streaming':
|
||
return {
|
||
label: '송출중',
|
||
color: 'var(--color-success)',
|
||
bg: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
|
||
};
|
||
case 'starting':
|
||
return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' };
|
||
case 'error':
|
||
return {
|
||
label: '오류',
|
||
color: 'var(--color-danger)',
|
||
bg: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
|
||
};
|
||
default:
|
||
return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' };
|
||
}
|
||
};
|
||
|
||
const gridCols = gridMode === 1 ? 1 : 2;
|
||
const totalCells = gridMode;
|
||
|
||
return (
|
||
<div
|
||
className="flex h-full overflow-hidden"
|
||
style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}
|
||
>
|
||
{/* 좌측: 드론 스트림 목록 */}
|
||
<div className="flex flex-col overflow-hidden bg-bg-surface border-r border-stroke w-[260px] min-w-[260px]">
|
||
{/* 헤더 */}
|
||
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-caption font-bold text-fg font-korean flex items-center gap-1.5">
|
||
<span
|
||
className="w-[7px] h-[7px] rounded-full inline-block"
|
||
style={{
|
||
background: streams.some((s) => s.status === 'streaming')
|
||
? 'var(--color-success)'
|
||
: 'var(--fg-disabled)',
|
||
}}
|
||
/>
|
||
실시간 드론 영상
|
||
</div>
|
||
<button
|
||
onClick={loadStreams}
|
||
className="px-2 py-0.5 text-caption font-korean bg-bg-card border border-stroke rounded text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||
>
|
||
새로고침
|
||
</button>
|
||
</div>
|
||
<div className="text-caption text-fg-disabled font-korean">
|
||
ViewLink RTSP 스트림 · 내부망 전용
|
||
</div>
|
||
</div>
|
||
|
||
{/* 드론 스트림 카드 */}
|
||
<div
|
||
className="flex-1 overflow-y-auto"
|
||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
||
>
|
||
{loading ? (
|
||
<div className="px-3.5 py-4 text-label-2 text-fg-disabled font-korean">
|
||
불러오는 중...
|
||
</div>
|
||
) : (
|
||
streams.map((stream) => {
|
||
const si = statusInfo(stream.status);
|
||
const isSelected = selectedStream?.id === stream.id;
|
||
return (
|
||
<div
|
||
key={stream.id}
|
||
onClick={() => handleSelectStream(stream)}
|
||
className="px-3.5 py-3 border-b cursor-pointer transition-colors"
|
||
style={{
|
||
borderColor: 'rgba(255,255,255,.04)',
|
||
background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent',
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<div className="text-body-2">🚁</div>
|
||
<div>
|
||
<div className="text-label-2 font-normal text-fg font-korean">
|
||
{stream.shipName}{' '}
|
||
<span className="text-caption font-normal">({stream.droneModel})</span>
|
||
</div>
|
||
<div className="text-caption text-fg-disabled font-mono">{stream.ip}</div>
|
||
</div>
|
||
</div>
|
||
<span
|
||
className="text-caption font-bold px-1.5 py-0.5 rounded-full"
|
||
style={{ background: si.bg, color: si.color }}
|
||
>
|
||
{si.label}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-caption text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">
|
||
{stream.region}
|
||
</span>
|
||
<span className="text-caption text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">
|
||
RTSP :554
|
||
</span>
|
||
</div>
|
||
|
||
{stream.error && (
|
||
<div className="mt-1.5 text-caption text-color-danger font-korean px-1.5 py-1 rounded bg-[color-mix(in srgb, var(--color-danger) 6%, transparent)]">
|
||
{stream.error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 시작/중지 버튼 */}
|
||
<div className="mt-2 flex gap-1.5">
|
||
{stream.status === 'idle' || stream.status === 'error' ? (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleStartStream(stream.id);
|
||
}}
|
||
className="flex-1 px-2 py-1 text-caption font-bold font-korean rounded border cursor-pointer transition-colors bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-fg hover:bg-[rgba(6,182,212,0.15)]"
|
||
>
|
||
<span className="text-color-success">▶</span> 스트림 시작
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleStopStream(stream.id);
|
||
}}
|
||
className="flex-1 px-2 py-1 text-caption font-bold font-korean rounded border cursor-pointer transition-colors bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-fg hover:bg-[rgba(6,182,212,0.15)]"
|
||
>
|
||
<span className="text-color-danger">■</span> 스트림 중지
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* 하단 안내 */}
|
||
<div className="px-3 py-2 border-t border-stroke bg-bg-elevated shrink-0">
|
||
<div className="text-caption text-fg-disabled font-korean leading-relaxed">
|
||
RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다. ViewLink 프로그램과 연동됩니다.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 중앙: 영상 뷰어 */}
|
||
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-bg-base">
|
||
{/* 툴바 */}
|
||
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<div className="text-caption font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
|
||
</div>
|
||
{selectedStream?.status === 'streaming' && (
|
||
<div
|
||
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-caption font-bold shrink-0"
|
||
style={{
|
||
background: 'color-mix(in srgb, var(--color-success) 14%, transparent)',
|
||
border: '1px solid color-mix(in srgb, var(--color-success) 35%, transparent)',
|
||
color: 'var(--color-success)',
|
||
}}
|
||
>
|
||
<span
|
||
className="w-[5px] h-[5px] rounded-full inline-block animate-pulse"
|
||
style={{ background: 'var(--color-success)' }}
|
||
/>
|
||
LIVE
|
||
</div>
|
||
)}
|
||
{selectedStream?.status === 'starting' && (
|
||
<div
|
||
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-caption font-bold shrink-0"
|
||
style={{
|
||
background: 'rgba(6,182,212,.14)',
|
||
border: '1px solid rgba(6,182,212,.35)',
|
||
color: 'var(--color-accent)',
|
||
}}
|
||
>
|
||
<span
|
||
className="w-[5px] h-[5px] rounded-full inline-block animate-pulse"
|
||
style={{ background: 'var(--color-accent)' }}
|
||
/>
|
||
연결중
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
{/* 분할 모드 */}
|
||
<div className="flex border border-stroke rounded-[5px] overflow-hidden">
|
||
{[
|
||
{ mode: 1, icon: '▣', label: '1화면' },
|
||
{ mode: 4, icon: '⊞', label: '4분할' },
|
||
].map((g) => (
|
||
<button
|
||
key={g.mode}
|
||
onClick={() => {
|
||
setGridMode(g.mode);
|
||
setActiveCells((prev) => prev.slice(0, g.mode));
|
||
}}
|
||
title={g.label}
|
||
className="px-2 py-1 text-label-2 cursor-pointer border-none transition-colors"
|
||
style={
|
||
gridMode === g.mode
|
||
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
|
||
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
|
||
}
|
||
>
|
||
{g.icon}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={() => playerRefs.current.forEach((r) => r?.capture())}
|
||
className="px-2.5 py-1 bg-bg-card border border-stroke rounded-[5px] text-fg-sub text-label-1 font-semibold cursor-pointer font-korean hover:bg-bg-surface-hover transition-colors"
|
||
>
|
||
📷 캡처
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 드론 위치 지도 또는 영상 그리드 */}
|
||
{showMap ? (
|
||
<div className="flex-1 overflow-hidden relative">
|
||
<Map
|
||
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
||
mapStyle={currentMapStyle}
|
||
style={{ width: '100%', height: '100%' }}
|
||
attributionControl={false}
|
||
>
|
||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||
{streams.map((stream) => {
|
||
const pos = DRONE_POSITIONS[stream.id];
|
||
if (!pos) return null;
|
||
const statusColor =
|
||
stream.status === 'streaming'
|
||
? 'var(--color-success)'
|
||
: stream.status === 'starting'
|
||
? 'var(--color-accent)'
|
||
: stream.status === 'error'
|
||
? 'var(--color-danger)'
|
||
: 'var(--fg-disabled)';
|
||
return (
|
||
<Marker
|
||
key={stream.id}
|
||
longitude={(pos.ship.lon + pos.drone.lon) / 2}
|
||
latitude={(pos.ship.lat + pos.drone.lat) / 2}
|
||
anchor="center"
|
||
onClick={(e) => {
|
||
e.originalEvent.stopPropagation();
|
||
setMapPopup(stream);
|
||
}}
|
||
>
|
||
<div className="cursor-pointer group" title={stream.shipName}>
|
||
<svg
|
||
width="130"
|
||
height="85"
|
||
viewBox="0 0 130 85"
|
||
fill="none"
|
||
className="drop-shadow-lg transition-transform group-hover:scale-105"
|
||
style={{ overflow: 'visible' }}
|
||
>
|
||
{/* 연결선 (점선) */}
|
||
<line
|
||
x1="28"
|
||
y1="52"
|
||
x2="88"
|
||
y2="30"
|
||
stroke={statusColor}
|
||
strokeWidth="1.2"
|
||
strokeDasharray="4 3"
|
||
opacity="0.5"
|
||
/>
|
||
|
||
{/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */}
|
||
<polygon points="28,38 18,58 38,58" fill={statusColor} opacity="0.85" />
|
||
<polygon
|
||
points="28,38 18,58 38,58"
|
||
fill="none"
|
||
stroke="var(--static-white)"
|
||
strokeWidth="0.8"
|
||
opacity="0.5"
|
||
/>
|
||
{/* 함정명 라벨 */}
|
||
<rect x="3" y="61" width="50" height="13" rx="3" fill="rgba(0,0,0,.75)" />
|
||
<text
|
||
x="28"
|
||
y="70.5"
|
||
textAnchor="middle"
|
||
fill="var(--static-white)"
|
||
fontSize="7"
|
||
fontFamily="var(--font-korean)"
|
||
fontWeight="bold"
|
||
>
|
||
{stream.shipName.replace(/서 /, ' ')}
|
||
</text>
|
||
|
||
{/* ── 드론: 쿼드콥터 아이콘 ── */}
|
||
{/* 외곽 원 */}
|
||
<circle
|
||
cx="88"
|
||
cy="30"
|
||
r="18"
|
||
fill="rgba(10,14,24,.7)"
|
||
stroke={statusColor}
|
||
strokeWidth="1.5"
|
||
/>
|
||
{/* X자 팔 */}
|
||
<line
|
||
x1="76"
|
||
y1="18"
|
||
x2="100"
|
||
y2="42"
|
||
stroke={statusColor}
|
||
strokeWidth="1.2"
|
||
opacity="0.5"
|
||
/>
|
||
<line
|
||
x1="100"
|
||
y1="18"
|
||
x2="76"
|
||
y2="42"
|
||
stroke={statusColor}
|
||
strokeWidth="1.2"
|
||
opacity="0.5"
|
||
/>
|
||
{/* 프로펠러 4개 (회전 애니메이션) */}
|
||
<ellipse cx="76" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||
<animateTransform
|
||
attributeName="transform"
|
||
type="rotate"
|
||
from="0 76 18"
|
||
to="360 76 18"
|
||
dur="1.5s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</ellipse>
|
||
<ellipse cx="100" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||
<animateTransform
|
||
attributeName="transform"
|
||
type="rotate"
|
||
from="0 100 18"
|
||
to="-360 100 18"
|
||
dur="1.5s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</ellipse>
|
||
<ellipse cx="76" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||
<animateTransform
|
||
attributeName="transform"
|
||
type="rotate"
|
||
from="0 76 42"
|
||
to="-360 76 42"
|
||
dur="1.5s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</ellipse>
|
||
<ellipse cx="100" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||
<animateTransform
|
||
attributeName="transform"
|
||
type="rotate"
|
||
from="0 100 42"
|
||
to="360 100 42"
|
||
dur="1.5s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</ellipse>
|
||
{/* 본체 */}
|
||
<circle cx="88" cy="30" r="6" fill={statusColor} opacity="0.8" />
|
||
{/* 카메라 렌즈 */}
|
||
<circle cx="88" cy="30" r="3" fill="var(--static-white)" opacity="0.9" />
|
||
<circle cx="88" cy="30" r="1.5" fill={statusColor} />
|
||
{/* 송출중 REC LED */}
|
||
{stream.status === 'streaming' && (
|
||
<circle cx="100" cy="16" r="3" fill="var(--color-danger)">
|
||
<animate
|
||
attributeName="opacity"
|
||
values="1;0.2;1"
|
||
dur="1s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</circle>
|
||
)}
|
||
{/* 드론 모델명 */}
|
||
<rect x="65" y="51" width="46" height="12" rx="3" fill="rgba(0,0,0,.75)" />
|
||
<text
|
||
x="88"
|
||
y="60"
|
||
textAnchor="middle"
|
||
fill={statusColor}
|
||
fontSize="7"
|
||
fontFamily="var(--font-korean)"
|
||
fontWeight="bold"
|
||
>
|
||
{stream.droneModel.split(' ').slice(-1)[0]}
|
||
</text>
|
||
</svg>
|
||
</div>
|
||
</Marker>
|
||
);
|
||
})}
|
||
{/* 드론 클릭 팝업 */}
|
||
{mapPopup && DRONE_POSITIONS[mapPopup.id] && (
|
||
<Popup
|
||
longitude={DRONE_POSITIONS[mapPopup.id].drone.lon}
|
||
latitude={DRONE_POSITIONS[mapPopup.id].drone.lat}
|
||
anchor="bottom"
|
||
onClose={() => setMapPopup(null)}
|
||
closeOnClick={false}
|
||
offset={36}
|
||
className="cctv-dark-popup"
|
||
>
|
||
<div
|
||
className="p-2.5"
|
||
style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}
|
||
>
|
||
<div className="flex items-center gap-1.5 mb-1">
|
||
<span className="text-body-2">🚁</span>
|
||
<div className="text-label-2 font-bold text-fg">{mapPopup.shipName}</div>
|
||
</div>
|
||
<div className="text-caption text-fg-disabled mb-0.5">
|
||
{mapPopup.droneModel}
|
||
</div>
|
||
<div className="text-caption text-fg-disabled font-mono mb-2">
|
||
{mapPopup.ip} · {mapPopup.region}
|
||
</div>
|
||
<div className="flex items-center gap-1.5 mb-2">
|
||
<span
|
||
className="text-caption 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-label-1 font-bold cursor-pointer border transition-colors"
|
||
style={{
|
||
background: 'color-mix(in srgb, var(--color-success) 15%, transparent)',
|
||
borderColor: 'color-mix(in srgb, var(--color-success) 30%, transparent)',
|
||
color: 'var(--color-success)',
|
||
}}
|
||
>
|
||
▶ 스트림 시작
|
||
</button>
|
||
) : mapPopup.status === 'streaming' ? (
|
||
<button
|
||
onClick={() => {
|
||
handleSelectStream(mapPopup);
|
||
setMapPopup(null);
|
||
}}
|
||
className="w-full px-2 py-1.5 rounded text-label-1 font-bold cursor-pointer border transition-colors"
|
||
style={{
|
||
background: 'rgba(6,182,212,.15)',
|
||
borderColor: 'rgba(6,182,212,.3)',
|
||
color: 'var(--color-accent)',
|
||
}}
|
||
>
|
||
▶ 영상 보기
|
||
</button>
|
||
) : (
|
||
<div className="text-caption text-color-accent 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-label-1 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-bg-base"
|
||
style={{ border: '1px solid var(--stroke-light)' }}
|
||
>
|
||
{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-label-1 text-color-accent font-korean animate-pulse">
|
||
RTSP 스트림 연결 중...
|
||
</div>
|
||
<div className="text-caption text-fg-disabled 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-label-1 text-color-danger font-korean">연결 실패</div>
|
||
<div className="text-caption text-fg-disabled 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-caption font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||
>
|
||
재시도
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="text-label-1 text-fg-disabled 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-stroke bg-bg-elevated shrink-0">
|
||
<div className="text-label-1 text-fg-disabled font-korean">
|
||
선택: <b className="text-fg">{selectedStream?.shipName ?? '–'}</b>
|
||
</div>
|
||
<div className="text-label-1 text-fg-disabled font-korean">
|
||
IP:{' '}
|
||
<span className="text-color-accent font-mono text-caption">
|
||
{selectedStream?.ip ?? '–'}
|
||
</span>
|
||
</div>
|
||
<div className="text-label-1 text-fg-disabled font-korean">
|
||
지역: <span className="text-fg-sub">{selectedStream?.region ?? '–'}</span>
|
||
</div>
|
||
<div className="ml-auto text-caption text-fg-disabled font-korean">
|
||
RTSP → HLS · ViewLink 연동
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 우측: 정보 패널 */}
|
||
<div className="flex flex-col overflow-hidden bg-bg-surface border-l border-stroke w-[220px] min-w-[220px]">
|
||
{/* 헤더 */}
|
||
<div className="px-3 py-2 border-b border-stroke text-label-2 font-bold text-fg font-korean bg-bg-elevated shrink-0">
|
||
📋 스트림 정보
|
||
</div>
|
||
|
||
<div
|
||
className="flex-1 overflow-y-auto px-3 py-2.5"
|
||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
||
>
|
||
{selectedStream ? (
|
||
<div className="flex flex-col gap-1.5">
|
||
{[
|
||
['함정명', selectedStream.shipName],
|
||
['드론명', selectedStream.name],
|
||
['기체모델', selectedStream.droneModel],
|
||
['IP 주소', selectedStream.ip],
|
||
['RTSP 포트', '554'],
|
||
['지역', selectedStream.region],
|
||
['프로토콜', 'RTSP → HLS'],
|
||
['상태', statusInfo(selectedStream.status).label],
|
||
].map(([k, v], i) => (
|
||
<div
|
||
key={i}
|
||
className="flex justify-between px-2 py-1 bg-bg-base rounded text-caption"
|
||
>
|
||
<span className="text-fg-disabled font-korean">{k}</span>
|
||
<span className="font-mono text-fg">{v}</span>
|
||
</div>
|
||
))}
|
||
{selectedStream.hlsUrl && (
|
||
<div className="px-2 py-1 bg-bg-base rounded text-caption">
|
||
<div className="text-fg-disabled font-korean mb-0.5">HLS URL</div>
|
||
<div className="font-mono text-color-accent break-all">
|
||
{selectedStream.hlsUrl}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-label-1 text-fg-disabled font-korean">
|
||
드론 스트림을 선택하세요
|
||
</div>
|
||
)}
|
||
|
||
{/* 연동 시스템 */}
|
||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
||
<div className="text-label-1 font-bold text-fg-sub font-korean mb-2">
|
||
🔗 연동 시스템
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<div
|
||
className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]"
|
||
style={{ border: '1px solid rgba(6,182,212,.2)' }}
|
||
>
|
||
<span className="text-caption text-fg-sub font-korean">ViewLink 3.5</span>
|
||
<span className="text-caption text-fg">● RTSP</span>
|
||
</div>
|
||
<div
|
||
className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]"
|
||
style={{ border: '1px solid rgba(6,182,212,.2)' }}
|
||
>
|
||
<span className="text-caption text-fg-sub font-korean">FFmpeg 변환</span>
|
||
<span className="text-caption text-fg">RTSP→HLS</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 전체 상태 요약 */}
|
||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
||
<div className="text-label-1 font-bold text-fg-sub font-korean mb-2">
|
||
📊 스트림 현황
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{[
|
||
{ label: '전체', value: streams.length },
|
||
{
|
||
label: '송출중',
|
||
value: streams.filter((s) => s.status === 'streaming').length,
|
||
},
|
||
{
|
||
label: '연결중',
|
||
value: streams.filter((s) => s.status === 'starting').length,
|
||
},
|
||
{
|
||
label: '오류',
|
||
value: streams.filter((s) => s.status === 'error').length,
|
||
},
|
||
].map((item, i) => (
|
||
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
|
||
<div className="text-caption text-fg-disabled font-korean">{item.label}</div>
|
||
<div className="text-body-2 font-mono text-fg">{item.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|