- 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 서비스 함수
233 lines
7.6 KiB
TypeScript
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: '© 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" />
|
|
);
|
|
}
|