import { useState, useEffect, useMemo } from 'react' import { Map, useControl } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers' import type { StyleSpecification } from 'maplibre-gl' import type { PickingInfo } from '@deck.gl/core' import 'maplibre-gl/dist/maplibre-gl.css' // ── 지도 스타일 ───────────────────────────────────────── const BASE_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, attribution: '© OSM © CARTO', }, }, layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], } // eslint-disable-next-line @typescript-eslint/no-explicit-any function DeckGLOverlay({ layers }: { layers: any[] }) { const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) overlay.setProps({ layers }) return null } // ── Mock 데이터 ───────────────────────────────────────── interface DroneInfo { id: string name: string status: 'active' | 'returning' | 'standby' | 'charging' battery: number altitude: number speed: number sensor: string color: string lon: number lat: number } const drones: DroneInfo[] = [ { id: 'D-01', name: 'DJI M300 #1', status: 'active', battery: 78, altitude: 150, speed: 12, sensor: '광학 4K', color: '#3b82f6', lon: 128.68, lat: 34.72 }, { id: 'D-02', name: 'DJI M300 #2', status: 'active', battery: 65, altitude: 200, speed: 8, sensor: 'IR 열화상', color: '#ef4444', lon: 128.74, lat: 34.68 }, { id: 'D-03', name: 'Mavic 3E', status: 'active', battery: 82, altitude: 120, speed: 15, sensor: '광학 4K', color: '#a855f7', lon: 128.88, lat: 34.60 }, { id: 'D-04', name: 'DJI M30T', status: 'active', battery: 45, altitude: 180, speed: 10, sensor: '다중센서', color: '#22c55e', lon: 128.62, lat: 34.56 }, { id: 'D-05', name: 'DJI M300 #3', status: 'returning', battery: 12, altitude: 80, speed: 18, sensor: '광학 4K', color: '#f97316', lon: 128.80, lat: 34.75 }, { id: 'D-06', name: 'Mavic 3E #2', status: 'charging', battery: 35, altitude: 0, speed: 0, sensor: '광학 4K', color: '#6b7280', lon: 128.70, lat: 34.65 }, ] interface VesselInfo { name: string lon: number lat: number aisOff: boolean } const vessels: VesselInfo[] = [ { name: '영풍호', lon: 128.72, lat: 34.74, aisOff: false }, { name: '불명-A', lon: 128.82, lat: 34.65, aisOff: true }, { name: '금성호', lon: 128.60, lat: 34.62, aisOff: false }, { name: '불명-B', lon: 128.92, lat: 34.70, aisOff: true }, { name: '태양호', lon: 128.66, lat: 34.58, aisOff: false }, ] interface ZoneInfo { id: string lon: number lat: number radius: number color: [number, number, number] } const searchZones: ZoneInfo[] = [ { id: 'A', lon: 128.70, lat: 34.72, radius: 3000, color: [6, 182, 212] }, { id: 'B', lon: 128.88, lat: 34.60, radius: 2500, color: [249, 115, 22] }, { id: 'C', lon: 128.62, lat: 34.56, radius: 2000, color: [234, 179, 8] }, ] const oilSpill = { lon: 128.85, lat: 34.58 } const hnsPoint = { lon: 128.58, lat: 34.52 } interface AlertItem { time: string type: 'warning' | 'info' | 'danger' message: string } const alerts: AlertItem[] = [ { time: '15:42', type: 'danger', message: 'D-05 배터리 부족 — 자동 복귀' }, { time: '15:38', type: 'warning', message: '오염원 신규 탐지 (34.82°N)' }, { time: '15:35', type: 'info', message: 'D-01~D-03 다시점 융합 완료' }, { time: '15:30', type: 'warning', message: 'AIS OFF 선박 2척 추가 탐지' }, { time: '15:25', type: 'info', message: 'D-04 센서 데이터 수집 시작' }, { time: '15:20', type: 'danger', message: '유류오염 확산 속도 증가 감지' }, { time: '15:15', type: 'info', message: '3D 재구성 시작 (불명선박-B)' }, ] // ── 유틸 ──────────────────────────────────────────────── function hexToRgba(hex: string): [number, number, number, number] { const r = parseInt(hex.slice(1, 3), 16) const g = parseInt(hex.slice(3, 5), 16) const b = parseInt(hex.slice(5, 7), 16) return [r, g, b, 255] } // ── 컴포넌트 ──────────────────────────────────────────── export function RealtimeDrone() { const [reconProgress, setReconProgress] = useState(0) const [reconDone, setReconDone] = useState(false) const [selectedDrone, setSelectedDrone] = useState(null) const [animFrame, setAnimFrame] = useState(0) // 3D 재구성 진행률 useEffect(() => { if (reconDone) return const timer = setInterval(() => { setReconProgress(prev => { if (prev >= 100) { clearInterval(timer) setReconDone(true) return 100 } return prev + 2 }) }, 300) return () => clearInterval(timer) }, [reconDone]) // 애니메이션 루프 (~20fps) useEffect(() => { let frame = 0 let raf: number const tick = () => { frame++ if (frame % 3 === 0) setAnimFrame(f => f + 1) raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) }, []) const activeDrones = useMemo(() => drones.filter(d => d.status !== 'charging'), []) // ── deck.gl 레이어 ────────────────────────────────── const deckLayers = useMemo(() => { const t = animFrame * 0.05 // 탐색 구역 (반투명 원 + 테두리) const zoneFillLayer = new ScatterplotLayer({ id: 'search-zones-fill', data: searchZones, getPosition: d => [d.lon, d.lat], getRadius: d => d.radius + Math.sin(t + searchZones.indexOf(d)) * 100, getFillColor: d => [...d.color, 15], getLineColor: d => [...d.color, 80], getLineWidth: 2, filled: true, stroked: true, radiusUnits: 'meters', radiusMinPixels: 30, lineWidthMinPixels: 1.5, }) // 구역 라벨 const zoneLabels = new TextLayer({ id: 'zone-labels', data: searchZones, getPosition: d => [d.lon, d.lat + 0.025], getText: d => `${d.id}구역`, getColor: d => [...d.color, 180], getSize: 12, fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', fontWeight: 'bold', characterSet: 'auto', outlineWidth: 2, outlineColor: [15, 21, 36, 180], getTextAnchor: 'middle', getAlignmentBaseline: 'center', billboard: false, }) // 유류 확산 (동심원 3개) const oilRings = [0, 1, 2].map(ring => new ScatterplotLayer({ id: `oil-spill-${ring}`, data: [oilSpill], getPosition: () => [oilSpill.lon, oilSpill.lat], getRadius: 800 + ring * 500 + Math.sin(t * 0.5 + ring) * 80, getFillColor: [249, 115, 22, Math.max(4, 20 - ring * 6)], getLineColor: [249, 115, 22, ring === 0 ? 120 : 40], getLineWidth: ring === 0 ? 2 : 1, filled: true, stroked: true, radiusUnits: 'meters', radiusMinPixels: 15, lineWidthMinPixels: ring === 0 ? 1.5 : 0.8, }), ) // 유류 확산 라벨 const oilLabel = new TextLayer({ id: 'oil-label', data: [oilSpill], getPosition: () => [oilSpill.lon, oilSpill.lat - 0.015], getText: () => '유류확산', getColor: [249, 115, 22, 200], getSize: 11, fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', fontWeight: 'bold', characterSet: 'auto', outlineWidth: 2, outlineColor: [15, 21, 36, 180], getTextAnchor: 'middle', getAlignmentBaseline: 'center', billboard: false, }) // HNS 의심 const hnsLayer = new ScatterplotLayer({ id: 'hns-point', data: [hnsPoint], getPosition: () => [hnsPoint.lon, hnsPoint.lat], getRadius: 400 + Math.sin(t * 1.2) * 80, getFillColor: [234, 179, 8, 50], getLineColor: [234, 179, 8, 100], getLineWidth: 1.5, filled: true, stroked: true, radiusUnits: 'meters', radiusMinPixels: 6, lineWidthMinPixels: 1, }) const hnsCore = new ScatterplotLayer({ id: 'hns-core', data: [hnsPoint], getPosition: () => [hnsPoint.lon, hnsPoint.lat], getRadius: 150, getFillColor: [234, 179, 8, 200], filled: true, radiusUnits: 'meters', radiusMinPixels: 4, }) const hnsLabel = new TextLayer({ id: 'hns-label', data: [hnsPoint], getPosition: () => [hnsPoint.lon, hnsPoint.lat - 0.008], getText: () => 'HNS 의심', getColor: [234, 179, 8, 180], getSize: 10, fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', fontWeight: 'bold', characterSet: 'auto', outlineWidth: 2, outlineColor: [15, 21, 36, 180], getTextAnchor: 'middle', getAlignmentBaseline: 'center', billboard: false, }) // 선박 — AIS OFF는 red 경고 원 const vesselAlertLayer = new ScatterplotLayer({ id: 'vessel-alert', data: vessels.filter(v => v.aisOff), getPosition: d => [d.lon, d.lat], getRadius: 600 + Math.sin(t * 1.5) * 150, getFillColor: [239, 68, 68, 20], getLineColor: [239, 68, 68, 60], getLineWidth: 1, filled: true, stroked: true, radiusUnits: 'meters', radiusMinPixels: 10, lineWidthMinPixels: 0.8, }) const vesselLayer = new ScatterplotLayer({ id: 'vessels', data: vessels, getPosition: d => [d.lon, d.lat], getRadius: 200, getFillColor: d => d.aisOff ? [239, 68, 68, 255] : [96, 165, 250, 255], getLineColor: d => d.aisOff ? [239, 68, 68, 120] : [96, 165, 250, 80], getLineWidth: 1.5, filled: true, stroked: true, radiusUnits: 'meters', radiusMinPixels: 5, lineWidthMinPixels: 1, }) const vesselLabels = new TextLayer({ id: 'vessel-labels', data: vessels, getPosition: d => [d.lon, d.lat + 0.005], getText: d => d.name, getColor: [255, 255, 255, 190], getSize: 11, fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', fontWeight: 'bold', characterSet: 'auto', outlineWidth: 2, outlineColor: [15, 21, 36, 180], getTextAnchor: 'middle', getAlignmentBaseline: 'center', billboard: false, }) // 드론 간 메시 링크 const droneLinks: { path: [number, number][] }[] = [] for (let i = 0; i < activeDrones.length; i++) { for (let j = i + 1; j < activeDrones.length; j++) { const a = activeDrones[i] const b = activeDrones[j] droneLinks.push({ path: [ [a.lon + Math.sin(t + i * 2) * 0.002, a.lat + Math.cos(t * 0.8 + i) * 0.001], [b.lon + Math.sin(t + j * 2) * 0.002, b.lat + Math.cos(t * 0.8 + j) * 0.001], ], }) } } const linkLayer = new PathLayer({ id: 'drone-links', data: droneLinks, getPath: d => d.path, getColor: [77, 208, 225, 35], getWidth: 1, getDashArray: [6, 8], dashJustified: true, widthMinPixels: 0.7, }) // 드론 글로우 (뒤쪽 큰 원) const droneGlowLayer = new ScatterplotLayer({ id: 'drone-glow', data: activeDrones, getPosition: d => { const i = activeDrones.indexOf(d) return [ d.lon + Math.sin(t + i * 2) * 0.002, d.lat + Math.cos(t * 0.8 + i) * 0.001, ] }, getRadius: d => selectedDrone === d.id ? 500 : 350, getFillColor: d => { const [r, g, b] = hexToRgba(d.color) return [r, g, b, selectedDrone === d.id ? 40 : 20] }, filled: true, radiusUnits: 'meters', radiusMinPixels: selectedDrone ? 12 : 8, updateTriggers: { getPosition: [animFrame], getFillColor: [selectedDrone], getRadius: [selectedDrone], }, }) // 드론 본체 const droneLayer = new ScatterplotLayer({ id: 'drones', data: activeDrones, getPosition: d => { const i = activeDrones.indexOf(d) return [ d.lon + Math.sin(t + i * 2) * 0.002, d.lat + Math.cos(t * 0.8 + i) * 0.001, ] }, getRadius: d => selectedDrone === d.id ? 200 : 150, getFillColor: d => hexToRgba(d.color), getLineColor: [255, 255, 255, 200], getLineWidth: d => selectedDrone === d.id ? 2 : 1, filled: true, stroked: true, radiusUnits: 'meters', radiusMinPixels: selectedDrone ? 6 : 4, lineWidthMinPixels: 1, pickable: true, onClick: (info: PickingInfo) => { if (info.object) setSelectedDrone(info.object.id) }, updateTriggers: { getPosition: [animFrame], getRadius: [selectedDrone], getLineWidth: [selectedDrone], }, }) // 드론 라벨 const droneLabels = new TextLayer({ id: 'drone-labels', data: activeDrones, getPosition: d => { const i = activeDrones.indexOf(d) return [ d.lon + Math.sin(t + i * 2) * 0.002, d.lat + Math.cos(t * 0.8 + i) * 0.001 + 0.006, ] }, getText: d => d.id, getColor: d => { const [r, g, b] = hexToRgba(d.color) return selectedDrone === d.id ? [255, 255, 255, 255] : [r, g, b, 230] }, getSize: d => selectedDrone === d.id ? 13 : 10, fontFamily: 'Outfit, monospace', fontWeight: 'bold', characterSet: 'auto', outlineWidth: 2, outlineColor: [15, 21, 36, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'center', billboard: false, updateTriggers: { getPosition: [animFrame], getColor: [selectedDrone], getSize: [selectedDrone], }, }) return [ zoneFillLayer, zoneLabels, ...oilRings, oilLabel, hnsLayer, hnsCore, hnsLabel, vesselAlertLayer, vesselLayer, vesselLabels, linkLayer, droneGlowLayer, droneLayer, droneLabels, ] }, [animFrame, selectedDrone, activeDrones]) // ── UI 유틸 ─────────────────────────────────────────── const statusLabel = (s: string) => { if (s === 'active') return { text: '비행중', cls: 'text-status-green' } if (s === 'returning') return { text: '복귀중', cls: 'text-status-orange' } if (s === 'charging') return { text: '충전중', cls: 'text-text-3' } return { text: '대기', cls: 'text-text-3' } } const alertColor = (t: string) => t === 'danger' ? 'border-l-status-red bg-[rgba(239,68,68,0.05)]' : t === 'warning' ? 'border-l-status-orange bg-[rgba(249,115,22,0.05)]' : 'border-l-primary-blue bg-[rgba(59,130,246,0.05)]' return (
{/* 지도 영역 */}
{/* 오버레이 통계 */}
{[ { label: '탐지 객체', value: '847', unit: '건', color: 'text-primary-blue' }, { label: '식별 선박', value: '312', unit: '척', color: 'text-primary-cyan' }, { label: 'AIS OFF', value: '14', unit: '척', color: 'text-status-red' }, { label: '오염 탐지', value: '3', unit: '건', color: 'text-status-orange' }, ].map((s, i) => (
{s.label}
{s.value} {s.unit}
))}
{/* 3D 재구성 진행률 */}
🧊 3D 재구성 {reconProgress}%
{!reconDone ? (
D-01~D-03 다각도 영상 융합중...
) : (
✅ 완료 — 클릭하여 정밀분석
)}
{/* 실시간 영상 패널 */} {selectedDrone && (() => { const drone = drones.find(d => d.id === selectedDrone) if (!drone) return null return (
{drone.id} 실시간 영상
LIVE FEED
{drone.id} {drone.sensor}
{drone.lat.toFixed(2)}°N, {drone.lon.toFixed(2)}°E
REC
ALT {drone.altitude}m · SPD {drone.speed}m/s · HDG 045°
비행 정보
{[ ['드론 ID', drone.id], ['기체', drone.name], ['배터리', `${drone.battery}%`], ['고도', `${drone.altitude}m`], ['속도', `${drone.speed}m/s`], ['센서', drone.sensor], ['상태', statusLabel(drone.status).text], ].map(([k, v], i) => (
{k} {v}
))}
) })()}
{/* 우측 사이드바 */}
{/* 군집 드론 현황 */}
군집 드론 현황 · {activeDrones.length}/{drones.length} 운용
{drones.map(d => { const st = statusLabel(d.status) return (
d.status !== 'charging' && setSelectedDrone(d.id)} className={`flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer transition-colors ${ selectedDrone === d.id ? 'bg-[rgba(6,182,212,0.08)] border border-primary-cyan/20' : 'hover:bg-white/[0.02] border border-transparent' }`} >
{d.id}
{d.name}
{st.text}
{d.battery}%
) })}
{/* 다각화 분석 */}
다각화 분석
{[ { icon: '🎯', label: '다시점 융합', value: '28건', sub: '360° 식별' }, { icon: '🧊', label: '3D 재구성', value: '12건', sub: '선박+오염원' }, { icon: '📡', label: '다센서 융합', value: '45건', sub: '광학+IR+SAR' }, { icon: '🛢️', label: '오염원 3D', value: '3건', sub: '유류+HNS' }, ].map((a, i) => (
{a.icon}
{a.label}
{a.value}
{a.sub}
))}
{/* 실시간 경보 */}
실시간 경보
{alerts.map((a, i) => (
{a.time} {a.message}
))}
) }