import { useEffect, useMemo, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; import { AircraftLayer } from '../layers/AircraftLayer'; import { SatelliteLayer } from '../layers/SatelliteLayer'; import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { OilFacilityLayer } from './OilFacilityLayer'; import { AirportLayer } from './AirportLayer'; import { iranOilFacilities } from '../data/oilFacilities'; import { middleEastAirports } from '../data/airports'; import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types'; import { countryLabelsGeoJSON } from '../data/countryLabels'; import 'maplibre-gl/dist/maplibre-gl.css'; export interface FlyToTarget { lat: number; lng: number; zoom?: number; } interface Props { events: GeoEvent[]; currentTime: number; aircraft: Aircraft[]; satellites: SatellitePosition[]; ships: Ship[]; layers: LayerVisibility; flyToTarget?: FlyToTarget | null; onFlyToDone?: () => void; initialCenter?: { lng: number; lat: number }; initialZoom?: number; } // MarineTraffic-style: dark ocean + satellite land + nautical overlay const MAP_STYLE = { version: 8 as const, sources: { 'satellite': { type: 'raster' as const, tiles: [ 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', ], tileSize: 256, maxzoom: 19, attribution: '© Esri, Maxar', }, 'carto-dark': { type: 'raster' as const, tiles: [ 'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', 'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', 'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', ], tileSize: 256, }, 'opensea': { type: 'raster' as const, tiles: [ 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', ], tileSize: 256, maxzoom: 18, }, }, layers: [ { id: 'background', type: 'background' as const, paint: { 'background-color': '#0b1526' } }, { id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.45, 'raster-saturation': -0.3, 'raster-contrast': 0.1 } }, { id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.55 } }, { id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.6 } }, ], }; const EVENT_COLORS: Record = { airstrike: '#ef4444', explosion: '#f97316', missile_launch: '#eab308', intercept: '#3b82f6', alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', }; const SOURCE_COLORS: Record = { US: '#ef4444', IL: '#22c55e', IR: '#ff0000', proxy: '#f59e0b', }; function getEventColor(event: GeoEvent): string { if (event.type === 'impact') return '#ff0000'; if (event.source && SOURCE_COLORS[event.source]) return SOURCE_COLORS[event.source]; return EVENT_COLORS[event.type]; } const EVENT_RADIUS: Record = { airstrike: 12, explosion: 10, missile_launch: 8, intercept: 7, alert: 6, impact: 14, osint: 8, }; export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [selectedEventId, setSelectedEventId] = useState(null); useEffect(() => { if (flyToTarget && mapRef.current) { mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom ?? 8, duration: 1200, }); onFlyToDone?.(); } }, [flyToTarget, onFlyToDone]); const visibleEvents = useMemo( () => events.filter(e => e.timestamp <= currentTime), [events, currentTime], ); const impactEvents = useMemo( () => visibleEvents.filter(e => e.type === 'impact'), [visibleEvents], ); const otherEvents = useMemo( () => visibleEvents.filter(e => e.type !== 'impact'), [visibleEvents], ); const newEvents = useMemo( () => visibleEvents.filter(e => { const age = currentTime - e.timestamp; return age >= 0 && age < 600_000; }), [visibleEvents, currentTime], ); const justActivated = useMemo( () => visibleEvents.filter(e => { const age = currentTime - e.timestamp; return age >= 0 && age < 120_000 && (e.type === 'airstrike' || e.type === 'impact' || e.type === 'explosion'); }), [visibleEvents, currentTime], ); const trajectoryData = useMemo(() => { const launches = visibleEvents.filter(e => e.type === 'missile_launch'); const targets = visibleEvents.filter(e => e.type === 'impact' || e.type === 'airstrike' || e.type === 'explosion'); if (launches.length === 0 || targets.length === 0) { return { type: 'FeatureCollection' as const, features: [] as GeoJSON.Feature[] }; } return { type: 'FeatureCollection' as const, features: launches.map(launch => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: [[launch.lng, launch.lat], [targets[0].lng, targets[0].lat]], }, })), }; }, [visibleEvents]); const selectedEvent = selectedEventId ? visibleEvents.find(e => e.id === selectedEventId) ?? null : null; return ( {layers.events && ( <> {trajectoryData.features.length > 0 && ( )} {newEvents.map(event => { const color = getEventColor(event); const size = EVENT_RADIUS[event.type] * 5; return (
); })} {justActivated.map(event => { const color = getEventColor(event); const size = event.type === 'impact' ? 100 : 70; return (
); })} {justActivated.map(event => { const color = getEventColor(event); const size = event.type === 'impact' ? 40 : 30; return (
); })} {otherEvents.map(event => { const ageMs = currentTime - event.timestamp; const ageHours = ageMs / 3600_000; const DAY_H = 24; const isRecent = ageHours <= DAY_H; const opacity = isRecent ? Math.max(0.85, 1 - ageHours * 0.006) : Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005); const color = getEventColor(event); const isNew = ageMs >= 0 && ageMs < 600_000; const baseR = EVENT_RADIUS[event.type]; const r = isNew ? baseR * 1.3 : isRecent ? baseR * 1.1 : baseR * 0.85; const size = r * 2; return (
{ e.stopPropagation(); setSelectedEventId(event.id); }} >
); })} {impactEvents.map(event => { const ageMs = currentTime - event.timestamp; const ageHours = ageMs / 3600_000; const isRecent = ageHours <= 24; const impactOpacity = isRecent ? Math.max(0.8, 1 - ageHours * 0.008) : Math.max(0.2, 0.45 - (ageHours - 24) * 0.005); const s = isRecent ? 22 : 16; const c = s / 2; const sw = isRecent ? 1.5 : 1; return (
{ e.stopPropagation(); setSelectedEventId(event.id); }}>
{isRecent && NEW} {event.label}
); })} {selectedEvent && ( setSelectedEventId(null)} closeOnClick={false} anchor="bottom" maxWidth="320px" className="gl-popup" >
{selectedEvent.source && ( {t(`source.${selectedEvent.source}`)} )} {selectedEvent.type === 'impact' && (
{t('popup.impactSite')}
)}
{selectedEvent.label}
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST {selectedEvent.description && (

{selectedEvent.description}

)} {selectedEvent.imageUrl && (
{selectedEvent.imageCaption { (e.target as HTMLImageElement).style.display = 'none'; }} /> {selectedEvent.imageCaption && (
{selectedEvent.imageCaption}
)}
)} {selectedEvent.type === 'impact' && (
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
)}
)} )} {layers.aircraft && } {layers.satellites && } {layers.ships && } {layers.ships && } {layers.airports && } {layers.oilFacilities && } ); }