kcg-monitoring/frontend/src/components/iran/GlobeMap.tsx
htlee 6d4ac4d3fe feat(frontend): 이란 리플레이 실데이터 전환 + 피격선박 이벤트 통합
- GeoEvent.type에 'sea_attack' 추가 + SEA ATK 배지 (#0ea5e9)
- damagedShips → GeoEvent 변환, mergedEvents에 합류
- 더미↔API 토글 UI (ReplayControls 배속 우측)
- useIranData: dataSource 분기 (dummy=sampleData, api=Backend DB)
- API 모드: events/aircraft/osint 시점 범위 조회 (3월1일~오늘)
- 중복 방지: API 모드에서 damageEvents 프론트 병합 건너뜀
- fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수
2026-03-24 07:52:22 +09:00

233 lines
7.6 KiB
TypeScript

import { useRef, useEffect } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
interface Props {
events: GeoEvent[];
currentTime: number;
aircraft: Aircraft[];
satellites: SatellitePosition[];
ships: Ship[];
layers: LayerVisibility;
}
const EVENT_COLORS: Record<string, string> = {
airstrike: '#ef4444',
explosion: '#f97316',
missile_launch: '#eab308',
intercept: '#3b82f6',
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
sea_attack: '#0ea5e9',
};
// Navy flag-based colors for military vessels
const NAVY_COLORS: Record<string, string> = {
US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff',
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261',
};
const SHIP_COLORS: Record<string, string> = {
carrier: '#ef4444',
destroyer: '#f97316',
warship: '#fb923c',
patrol: '#fbbf24',
submarine: '#8b5cf6',
tanker: '#22d3ee',
cargo: '#94a3b8',
civilian: '#64748b',
};
const MIL_SHIP_CATS = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol'];
function getGlobeShipColor(cat: string, flag?: string): string {
if (MIL_SHIP_CATS.includes(cat) && flag && NAVY_COLORS[flag]) return NAVY_COLORS[flag];
return SHIP_COLORS[cat] || '#64748b';
}
const AC_COLORS: Record<string, string> = {
fighter: '#ef4444',
bomber: '#dc2626',
surveillance: '#f59e0b',
tanker: '#22d3ee',
transport: '#10b981',
cargo: '#6366f1',
helicopter: '#a855f7',
civilian: '#64748b',
unknown: '#475569',
};
export function GlobeMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const markersRef = useRef<maplibregl.Marker[]>([]);
// Initialize map
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
sources: {
'dark-tiles': {
type: 'raster',
tiles: ['https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'],
tileSize: 256,
attribution: '&copy; OpenStreetMap',
},
},
layers: [
{
id: 'background',
type: 'background',
paint: { 'background-color': '#0a0a1a' },
},
{
id: 'dark-tiles',
type: 'raster',
source: 'dark-tiles',
},
],
projection: { type: 'globe' },
} as maplibregl.StyleSpecification,
center: [44, 31.5],
zoom: 3,
pitch: 20,
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() });
map.addLayer({
id: 'country-label-lg', type: 'symbol', source: 'country-labels',
filter: ['==', ['get', 'rank'], 1],
layout: {
'text-field': ['get', 'name'], 'text-size': 14,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false, 'text-padding': 6,
},
paint: { 'text-color': '#e2e8f0', 'text-halo-color': '#000', 'text-halo-width': 2, 'text-opacity': 0.9 },
});
map.addLayer({
id: 'country-label-md', type: 'symbol', source: 'country-labels',
filter: ['==', ['get', 'rank'], 2],
layout: {
'text-field': ['get', 'name'], 'text-size': 11,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false, 'text-padding': 4,
},
paint: { 'text-color': '#94a3b8', 'text-halo-color': '#000', 'text-halo-width': 1.5, 'text-opacity': 0.85 },
});
map.addLayer({
id: 'country-label-sm', type: 'symbol', source: 'country-labels',
filter: ['==', ['get', 'rank'], 3], minzoom: 5,
layout: {
'text-field': ['get', 'name'], 'text-size': 10,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false, 'text-padding': 2,
},
paint: { 'text-color': '#64748b', 'text-halo-color': '#000', 'text-halo-width': 1, 'text-opacity': 0.75 },
});
});
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, []);
// Update markers — DOM direct manipulation, inline styles intentionally kept
useEffect(() => {
const map = mapRef.current;
if (!map) return;
// Clear old markers
for (const m of markersRef.current) m.remove();
markersRef.current = [];
const addMarker = (lng: number, lat: number, color: string, size: number, tooltip: string) => {
const el = document.createElement('div');
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.borderRadius = '50%';
el.style.background = color;
el.style.border = `1.5px solid ${color}`;
el.style.boxShadow = `0 0 ${size}px ${color}80`;
el.style.cursor = 'pointer';
el.title = tooltip;
const marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(map);
markersRef.current.push(marker);
};
const addTriangle = (lng: number, lat: number, color: string, size: number, heading: number, tooltip: string) => {
const el = document.createElement('div');
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.transform = `rotate(${heading}deg)`;
el.style.cursor = 'pointer';
el.title = tooltip;
el.innerHTML = `<svg viewBox="0 0 10 10" width="${size}" height="${size}">
<polygon points="5,0 0,10 10,10" fill="${color}" stroke="#fff" stroke-width="0.5" opacity="0.9"/>
</svg>`;
const marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(map);
markersRef.current.push(marker);
};
// Events
if (layers.events) {
const visible = events.filter(e => e.timestamp <= currentTime);
for (const e of visible) {
const color = EVENT_COLORS[e.type] || '#888';
const size = e.type === 'impact' ? 14 : 8;
addMarker(e.lng, e.lat, color, size, `${e.label}\n${new Date(e.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST`);
}
}
// Aircraft
if (layers.aircraft) {
const filtered = layers.militaryOnly
? aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown')
: aircraft;
for (const ac of filtered) {
const color = AC_COLORS[ac.category] || '#64748b';
addTriangle(ac.lng, ac.lat, color, 10, ac.heading || 0,
`${ac.callsign || ac.icao24} [${ac.category}]\nAlt: ${ac.altitude?.toFixed(0) || '?'}ft`);
}
}
// Satellites
if (layers.satellites) {
for (const sat of satellites) {
addMarker(sat.lng, sat.lat, '#ef4444', 5, `${sat.name}\nAlt: ${sat.altitude?.toFixed(0)}km`);
}
}
// Ships
if (layers.ships) {
const filtered = layers.militaryOnly
? ships.filter(s => !['civilian', 'cargo', 'tanker'].includes(s.category))
: ships;
for (const s of filtered) {
const color = getGlobeShipColor(s.category, s.flag);
addTriangle(s.lng, s.lat, color, 10, s.heading || 0,
`${s.name} [${s.category}]\n${s.flag || ''}`);
}
}
}, [events, currentTime, aircraft, satellites, ships, layers]);
return (
<div ref={containerRef} className="w-full h-full" />
);
}