feat(aerial): 드론 지도 아이콘 개선 — 함정 삼각형 + 연결선 + 드론 원형

- 함정: 삼각형 아이콘 + 함정명 라벨 (좌하단)
- 드론: 원형 아이콘 (십자 프로펠러 + 본체 + 카메라 렌즈) (우상단)
- 함정↔드론 점선 연결선으로 소속 관계 표시
- 상태별 색상: 송출중(초록), 연결중(시안), 오류(빨강), 대기(회색)
- 송출중 드론 빨간 LED 깜빡임 유지
- 드론 모델명 라벨 (M300/M30T/Mavic3E)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-16 09:51:30 +09:00
부모 c4728be7a1
커밋 615f7f9277

파일 보기

@ -7,11 +7,11 @@ 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 }> = { const DRONE_POSITIONS: Record<string, { ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }> = {
'busan-1501': { lat: 35.0796, lon: 129.0756 }, 'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.1100, lon: 129.1100 } },
'incheon-3008': { lat: 37.4541, lon: 126.5986 }, 'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.4800, lon: 126.5600 } },
'mokpo-3015': { lat: 34.7780, lon: 126.3780 }, 'mokpo-3015': { ship: { lat: 34.7780, lon: 126.3780 }, drone: { lat: 34.8050, lon: 126.4100 } },
} }
const DRONE_MAP_STYLE: StyleSpecification = { const DRONE_MAP_STYLE: StyleSpecification = {
@ -266,55 +266,61 @@ export function RealtimeDrone() {
attributionControl={false} attributionControl={false}
> >
{streams.map(stream => { {streams.map(stream => {
const coord = DRONE_COORDS[stream.id] const pos = DRONE_POSITIONS[stream.id]
if (!coord) return null if (!pos) return null
const si = statusInfo(stream.status) const statusColor = stream.status === 'streaming' ? '#22c55e' : stream.status === 'starting' ? '#06b6d4' : stream.status === 'error' ? '#ef4444' : '#94a3b8'
return ( return (
<Marker <Marker
key={stream.id} key={stream.id}
longitude={coord.lon} longitude={(pos.ship.lon + pos.drone.lon) / 2}
latitude={coord.lat} latitude={(pos.ship.lat + pos.drone.lat) / 2}
anchor="bottom" anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }} onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }}
> >
<div className="flex flex-col items-center cursor-pointer group" title={stream.shipName}> <div className="cursor-pointer group" title={stream.shipName}>
{/* 드론 SVG 아이콘 */} <svg width="120" height="80" viewBox="0 0 120 80" fill="none" className="drop-shadow-lg transition-transform group-hover:scale-105" style={{ overflow: 'visible' }}>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" className="drop-shadow-lg transition-transform group-hover:scale-110"> {/* 연결선 (점선) */}
<line x1="30" y1="55" x2="85" y2="28" stroke={statusColor} strokeWidth="1.5" strokeDasharray="4 3" opacity="0.6" />
{/* 함정 삼각형 (좌하단) */}
<polygon points="30,45 20,65 40,65" fill="rgba(0,0,0,.5)" stroke={statusColor} strokeWidth="1.5" />
<text x="30" y="59" textAnchor="middle" fill="#fff" fontSize="7" fontWeight="bold"></text>
{/* 함정명 */}
<rect x="5" y="67" width="50" height="13" rx="3" fill="rgba(0,0,0,.7)" />
<text x="30" y="76" textAnchor="middle" fill="#fff" fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.shipName.replace(/서 /, ' ')}</text>
{/* 드론 원형 아이콘 (우상단) */}
<circle cx="85" cy="28" r="16" fill="rgba(0,0,0,.6)" stroke={statusColor} strokeWidth="2" />
{/* 드론 내부 — 십자 프로펠러 */}
<line x1="73" y1="28" x2="97" y2="28" stroke={statusColor} strokeWidth="1" opacity="0.4" />
<line x1="85" y1="16" x2="85" y2="40" stroke={statusColor} strokeWidth="1" opacity="0.4" />
{/* 프로펠러 4개 */}
<circle cx="73" cy="28" r="4" fill={statusColor} opacity="0.25" />
<circle cx="97" cy="28" r="4" fill={statusColor} opacity="0.25" />
<circle cx="85" cy="16" r="4" fill={statusColor} opacity="0.25" />
<circle cx="85" cy="40" r="4" fill={statusColor} opacity="0.25" />
{/* 본체 */} {/* 본체 */}
<ellipse cx="16" cy="16" rx="5" ry="3" fill={si.color} opacity="0.9" /> <circle cx="85" cy="28" r="5" fill={statusColor} opacity="0.8" />
{/* 팔 4개 */} <circle cx="85" cy="28" r="2.5" fill="#fff" opacity="0.9" />
<line x1="11" y1="13" x2="5" y2="7" stroke={si.color} strokeWidth="1.5" /> {/* 송출중 LED */}
<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' && ( {stream.status === 'streaming' && (
<circle cx="16" cy="10" r="2" fill="#ef4444"> <circle cx="95" cy="18" r="3" fill="#ef4444">
<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" /> <animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" />
</circle> </circle>
)} )}
{/* 드론 이름 */}
<rect x="62" y="46" width="46" height="12" rx="3" fill="rgba(0,0,0,.7)" />
<text x="85" y="55" textAnchor="middle" fill={statusColor} fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.droneModel.split(' ').slice(-1)[0]}</text>
</svg> </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> </div>
</Marker> </Marker>
) )
})} })}
{/* 드론 클릭 팝업 */} {/* 드론 클릭 팝업 */}
{mapPopup && DRONE_COORDS[mapPopup.id] && ( {mapPopup && DRONE_POSITIONS[mapPopup.id] && (
<Popup <Popup
longitude={DRONE_COORDS[mapPopup.id].lon} longitude={DRONE_POSITIONS[mapPopup.id].drone.lon}
latitude={DRONE_COORDS[mapPopup.id].lat} latitude={DRONE_POSITIONS[mapPopup.id].drone.lat}
anchor="bottom" anchor="bottom"
onClose={() => setMapPopup(null)} onClose={() => setMapPopup(null)}
closeOnClick={false} closeOnClick={false}