import { useEffect, useMemo, useState, useRef, useCallback } 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 { SeismicMarker } from '../layers/SeismicMarker'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; import { useFontScale } from '../../hooks/useFontScale'; import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer'; import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities'; import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities'; import { createIranOilLayers } from './createIranOilLayers'; import type { OilFacility } from './createIranOilLayers'; import { createIranAirportLayers } from './createIranAirportLayers'; import type { Airport } from './createIranAirportLayers'; import { createMEFacilityLayers } from './createMEFacilityLayers'; import type { MEFacility } from './createMEFacilityLayers'; 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; hoveredShipMmsi?: string | null; focusShipMmsi?: string | null; onFocusShipClear?: () => void; seismicMarker?: { lat: number; lng: number; magnitude: number; place: string } | null; } // 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', sea_attack: '#0ea5e9', }; 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, }; type IranPickedFacility = | { kind: 'oil'; data: OilFacility } | { kind: 'airport'; data: Airport } | { kind: 'meFacility'; data: MEFacility }; export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, seismicMarker }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [selectedEventId, setSelectedEventId] = useState(null); const [mePickedFacility, setMePickedFacility] = useState(null); const [iranPickedFacility, setIranPickedFacility] = useState(null); const { fontScale } = useFontScale(); const [zoomLevel, setZoomLevel] = useState(5); const zoomRef = useRef(5); const handleZoom = useCallback((e: { viewState: { zoom: number } }) => { const z = Math.floor(e.viewState.zoom); if (z !== zoomRef.current) { zoomRef.current = z; setZoomLevel(z); } }, []); const zoomScale = useMemo(() => { if (zoomLevel <= 4) return 0.8; if (zoomLevel <= 5) return 0.9; if (zoomLevel <= 6) return 1.0; if (zoomLevel <= 7) return 1.2; if (zoomLevel <= 8) return 1.5; if (zoomLevel <= 9) return 1.8; if (zoomLevel <= 10) return 2.2; if (zoomLevel <= 11) return 2.5; if (zoomLevel <= 12) return 2.8; if (zoomLevel <= 13) return 3.5; return 4.2; }, [zoomLevel]); const iranDeckLayers = useMemo(() => [ ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }), ], [layers, zoomScale, fontScale.facility]); 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 && } {seismicMarker && } {mePickedFacility && (() => { const meta = SUB_TYPE_META[mePickedFacility.subType]; return ( setMePickedFacility(null)} closeOnClick={false} anchor="bottom" maxWidth="320px" className="gl-popup" >
{meta.icon} {mePickedFacility.nameKo}
{meta.label} {mePickedFacility.country} {mePickedFacility.capacityMW !== undefined && ( {mePickedFacility.capacityMW.toLocaleString()} MW )}
{mePickedFacility.description}
{mePickedFacility.name}
{mePickedFacility.lat.toFixed(4)}°{mePickedFacility.lat >= 0 ? 'N' : 'S'}, {mePickedFacility.lng.toFixed(4)}°E
); })()} {iranPickedFacility && (() => { const { kind, data } = iranPickedFacility; if (kind === 'oil') { const OIL_TYPE_COLORS: Record = { refinery: '#fb7185', oilfield: '#34d399', gasfield: '#818cf8', terminal: '#c084fc', petrochemical: '#f472b6', desalination: '#22d3ee', }; const color = OIL_TYPE_COLORS[data.type] ?? '#888'; return ( setIranPickedFacility(null)} closeOnClick={false} anchor="bottom" maxWidth="300px" className="gl-popup">
{data.nameKo}
{data.type} {data.operator && ( {data.operator} )}
{data.description && (
{data.description}
)}
{data.name}
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E
); } if (kind === 'airport') { const isMil = data.type === 'military'; const color = isMil ? '#f87171' : '#38bdf8'; return ( setIranPickedFacility(null)} closeOnClick={false} anchor="bottom" maxWidth="300px" className="gl-popup">
{data.nameKo ?? data.name}
{data.type === 'military' ? 'Military Airbase' : data.type === 'large' ? 'International Airport' : 'Airport'} {data.country}
{data.iata && <>IATA{data.iata}} ICAO{data.icao} {data.city && <>City{data.city}}
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°{data.lng >= 0 ? 'E' : 'W'}
); } if (kind === 'meFacility') { const meta = { naval: { label: 'Naval Base', color: '#60a5fa', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#f87171', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#ef4444', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#a78bfa', icon: '🔍' }, government: { label: 'Government', color: '#c084fc', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#22d3ee', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' }; return ( setIranPickedFacility(null)} closeOnClick={false} anchor="bottom" maxWidth="300px" className="gl-popup">
{data.flag} {meta.icon} {data.nameKo}
{meta.label} {data.country}
{data.description}
{data.name}
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E
); } return null; })()} ); }