- FontScaleContext + FontScalePanel: 시설/선박/분석/지역 4그룹 × 0.5~2.0 범위 - LAYERS 패널 하단 슬라이더 UI, localStorage 영속화 - Korea static 14개 + Iran 4개 + 분석 3개 + KoreaMap 5개 TextLayer 적용 - MapLibre 선박 라벨/국가명 실시간 반영 - 모든 useMemo deps + updateTriggers에 fontScale 포함
474 lines
20 KiB
TypeScript
474 lines
20 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } 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 maplibregl from 'maplibre-gl';
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
|
|
interface Props {
|
|
events: GeoEvent[];
|
|
currentTime: number;
|
|
aircraft: Aircraft[];
|
|
satellites: SatellitePosition[];
|
|
ships: Ship[];
|
|
layers: LayerVisibility;
|
|
hoveredShipMmsi?: string | null;
|
|
focusShipMmsi?: string | null;
|
|
onFocusShipClear?: () => void;
|
|
flyToTarget?: { lat: number; lng: number; zoom?: number } | null;
|
|
onFlyToDone?: () => void;
|
|
seismicMarker?: { lat: number; lng: number; magnitude: number; place: string } | null;
|
|
}
|
|
|
|
// ESRI World Imagery + ESRI boundaries overlay
|
|
const SATELLITE_STYLE = {
|
|
version: 8 as const,
|
|
sources: {
|
|
'esri-satellite': {
|
|
type: 'raster' as const,
|
|
tiles: [
|
|
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
|
],
|
|
tileSize: 256,
|
|
attribution: '© Esri, Maxar, Earthstar Geographics',
|
|
maxzoom: 19,
|
|
},
|
|
'esri-boundaries': {
|
|
type: 'raster' as const,
|
|
tiles: [
|
|
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
|
],
|
|
tileSize: 256,
|
|
maxzoom: 19,
|
|
},
|
|
},
|
|
layers: [
|
|
{ id: 'background', type: 'background' as const, paint: { 'background-color': '#000811' } },
|
|
{ id: 'satellite', type: 'raster' as const, source: 'esri-satellite' },
|
|
{ id: 'boundaries', type: 'raster' as const, source: 'esri-boundaries', paint: { 'raster-opacity': 0.65 } },
|
|
],
|
|
};
|
|
|
|
const EVENT_COLORS: Record<GeoEvent['type'], string> = {
|
|
airstrike: '#ef4444',
|
|
explosion: '#f97316',
|
|
missile_launch: '#eab308',
|
|
intercept: '#3b82f6',
|
|
alert: '#a855f7',
|
|
impact: '#ff0000',
|
|
osint: '#06b6d4',
|
|
sea_attack: '#0ea5e9',
|
|
};
|
|
|
|
const SOURCE_COLORS: Record<string, string> = {
|
|
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<GeoEvent['type'], number> = {
|
|
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 SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, flyToTarget, onFlyToDone, seismicMarker }: Props) {
|
|
const { t } = useTranslation();
|
|
const mapRef = useRef<MapRef>(null);
|
|
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
|
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
|
|
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(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<string, boolean>, 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(() => {
|
|
if (!layers.events) return [];
|
|
return events.filter(e => e.timestamp <= currentTime);
|
|
}, [events, currentTime, layers.events]);
|
|
|
|
const selectedEvent = useMemo(
|
|
() => visibleEvents.find(e => e.id === selectedEventId) || null,
|
|
[visibleEvents, selectedEventId],
|
|
);
|
|
|
|
const countryLabels = useMemo(() => countryLabelsGeoJSON(), []);
|
|
|
|
return (
|
|
<Map
|
|
ref={mapRef}
|
|
initialViewState={{
|
|
longitude: 53.0,
|
|
latitude: 32.0,
|
|
zoom: 5.5,
|
|
}}
|
|
style={{ width: '100%', height: '100%' }}
|
|
mapStyle={SATELLITE_STYLE as maplibregl.StyleSpecification}
|
|
attributionControl={false}
|
|
onZoom={handleZoom}
|
|
>
|
|
<NavigationControl position="top-right" />
|
|
<DeckGLOverlay layers={iranDeckLayers} />
|
|
|
|
{mePickedFacility && (() => {
|
|
const meta = SUB_TYPE_META[mePickedFacility.subType];
|
|
return (
|
|
<Popup
|
|
longitude={mePickedFacility.lng}
|
|
latitude={mePickedFacility.lat}
|
|
onClose={() => setMePickedFacility(null)}
|
|
closeOnClick={false}
|
|
anchor="bottom"
|
|
maxWidth="320px"
|
|
className="gl-popup"
|
|
>
|
|
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
|
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
|
<strong style={{ fontSize: 13 }}>{meta.icon} {mePickedFacility.nameKo}</strong>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
|
{meta.label}
|
|
</span>
|
|
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
|
{mePickedFacility.country}
|
|
</span>
|
|
{mePickedFacility.capacityMW !== undefined && (
|
|
<span style={{ background: '#222', color: '#94a3b8', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
|
{mePickedFacility.capacityMW.toLocaleString()} MW
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
|
{mePickedFacility.description}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
|
{mePickedFacility.name}
|
|
</div>
|
|
<div style={{ fontSize: 10, color: '#999' }}>
|
|
{mePickedFacility.lat.toFixed(4)}°{mePickedFacility.lat >= 0 ? 'N' : 'S'}, {mePickedFacility.lng.toFixed(4)}°E
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
);
|
|
})()}
|
|
|
|
{/* Korean country labels */}
|
|
<Source id="country-labels" type="geojson" data={countryLabels}>
|
|
<Layer
|
|
id="country-label-lg"
|
|
type="symbol"
|
|
filter={['==', ['get', 'rank'], 1]}
|
|
layout={{
|
|
'text-field': ['get', 'name'],
|
|
'text-font': ['Open Sans Bold'],
|
|
'text-size': 15 * fontScale.area,
|
|
'text-allow-overlap': false,
|
|
'text-ignore-placement': false,
|
|
}}
|
|
paint={{
|
|
'text-color': '#1a1a2e',
|
|
'text-halo-color': 'rgba(255,255,255,0.7)',
|
|
'text-halo-width': 2,
|
|
}}
|
|
/>
|
|
<Layer
|
|
id="country-label-md"
|
|
type="symbol"
|
|
filter={['==', ['get', 'rank'], 2]}
|
|
layout={{
|
|
'text-field': ['get', 'name'],
|
|
'text-font': ['Open Sans Bold'],
|
|
'text-size': 12 * fontScale.area,
|
|
'text-allow-overlap': false,
|
|
}}
|
|
paint={{
|
|
'text-color': '#2a2a3e',
|
|
'text-halo-color': 'rgba(255,255,255,0.6)',
|
|
'text-halo-width': 1.5,
|
|
}}
|
|
/>
|
|
<Layer
|
|
id="country-label-sm"
|
|
type="symbol"
|
|
filter={['==', ['get', 'rank'], 3]}
|
|
minzoom={4}
|
|
layout={{
|
|
'text-field': ['get', 'name'],
|
|
'text-font': ['Open Sans Bold'],
|
|
'text-size': 10 * fontScale.area,
|
|
'text-allow-overlap': false,
|
|
}}
|
|
paint={{
|
|
'text-color': '#333350',
|
|
'text-halo-color': 'rgba(255,255,255,0.5)',
|
|
'text-halo-width': 1.5,
|
|
}}
|
|
/>
|
|
</Source>
|
|
|
|
{/* Event markers */}
|
|
{visibleEvents.map(ev => (
|
|
<Marker
|
|
key={ev.id}
|
|
longitude={ev.lng}
|
|
latitude={ev.lat}
|
|
anchor="center"
|
|
onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }}
|
|
>
|
|
<div
|
|
className="rounded-full cursor-pointer"
|
|
style={{
|
|
width: EVENT_RADIUS[ev.type],
|
|
height: EVENT_RADIUS[ev.type],
|
|
background: getEventColor(ev),
|
|
border: '2px solid rgba(255,255,255,0.8)',
|
|
boxShadow: `0 0 8px ${getEventColor(ev)}`,
|
|
}}
|
|
/>
|
|
</Marker>
|
|
))}
|
|
|
|
{/* Popup */}
|
|
{selectedEvent && (
|
|
<Popup
|
|
longitude={selectedEvent.lng}
|
|
latitude={selectedEvent.lat}
|
|
anchor="bottom"
|
|
onClose={() => setSelectedEventId(null)}
|
|
closeOnClick={false}
|
|
maxWidth="320px"
|
|
className="event-popup"
|
|
>
|
|
<div className="min-w-[200px] max-w-[320px]">
|
|
{selectedEvent.source && (
|
|
<span
|
|
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
|
|
style={{ background: getEventColor(selectedEvent) }}
|
|
>
|
|
{t(`source.${selectedEvent.source}`)}
|
|
</span>
|
|
)}
|
|
{selectedEvent.type === 'impact' && (
|
|
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
|
|
{t('popup.impactSite')}
|
|
</div>
|
|
)}
|
|
<div className="text-kcg-text"><strong>{selectedEvent.label}</strong></div>
|
|
<span className="text-xs text-kcg-muted">
|
|
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
|
</span>
|
|
{selectedEvent.description && (
|
|
<p className="mt-1.5 mb-0 text-[13px] text-kcg-text-secondary">{selectedEvent.description}</p>
|
|
)}
|
|
{selectedEvent.imageUrl && (
|
|
<div className="mt-2">
|
|
<img
|
|
src={selectedEvent.imageUrl}
|
|
alt={selectedEvent.imageCaption || selectedEvent.label}
|
|
className="w-full rounded max-h-[180px] object-cover"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
/>
|
|
{selectedEvent.imageCaption && (
|
|
<div className="text-[10px] text-kcg-muted mt-[3px]">{selectedEvent.imageCaption}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="text-[10px] text-kcg-dim mt-1.5">
|
|
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
|
|
{iranPickedFacility && (() => {
|
|
const { kind, data } = iranPickedFacility;
|
|
if (kind === 'oil') {
|
|
const OIL_TYPE_COLORS: Record<string, string> = {
|
|
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1',
|
|
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
|
|
};
|
|
const color = OIL_TYPE_COLORS[data.type] ?? '#888';
|
|
return (
|
|
<Popup longitude={data.lng} latitude={data.lat}
|
|
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
|
|
anchor="bottom" maxWidth="300px" className="gl-popup">
|
|
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
|
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
|
<strong style={{ fontSize: 13 }}>{data.nameKo}</strong>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
|
{data.type}
|
|
</span>
|
|
{data.operator && (
|
|
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
|
{data.operator}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{data.description && (
|
|
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
|
{data.description}
|
|
</div>
|
|
)}
|
|
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
|
{data.name}
|
|
</div>
|
|
<div style={{ fontSize: 10, color: '#999' }}>
|
|
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
);
|
|
}
|
|
if (kind === 'airport') {
|
|
const isMil = data.type === 'military';
|
|
const color = isMil ? '#ef4444' : '#f59e0b';
|
|
return (
|
|
<Popup longitude={data.lng} latitude={data.lat}
|
|
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
|
|
anchor="bottom" maxWidth="300px" className="gl-popup">
|
|
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
|
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#92400e', color: '#fff', gap: 6, padding: '6px 10px' }}>
|
|
<strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
|
{data.type === 'military' ? 'Military Airbase' : data.type === 'large' ? 'International Airport' : 'Airport'}
|
|
</span>
|
|
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
|
{data.country}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px', fontSize: 11, marginBottom: 6 }}>
|
|
{data.iata && <><span style={{ color: '#888' }}>IATA</span><strong>{data.iata}</strong></>}
|
|
<span style={{ color: '#888' }}>ICAO</span><strong>{data.icao}</strong>
|
|
{data.city && <><span style={{ color: '#888' }}>City</span><span>{data.city}</span></>}
|
|
</div>
|
|
<div style={{ fontSize: 10, color: '#999' }}>
|
|
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°{data.lng >= 0 ? 'E' : 'W'}
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
);
|
|
}
|
|
if (kind === 'meFacility') {
|
|
const meta = { naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' };
|
|
return (
|
|
<Popup longitude={data.lng} latitude={data.lat}
|
|
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
|
|
anchor="bottom" maxWidth="300px" className="gl-popup">
|
|
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
|
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
|
<span style={{ fontSize: 16 }}>{data.flag}</span>
|
|
<strong style={{ fontSize: 13 }}>{meta.icon} {data.nameKo}</strong>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
|
{meta.label}
|
|
</span>
|
|
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
|
{data.country}
|
|
</span>
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
|
{data.description}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
|
{data.name}
|
|
</div>
|
|
<div style={{ fontSize: 10, color: '#999' }}>
|
|
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
|
|
{/* Overlay layers */}
|
|
{layers.aircraft && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
|
|
{layers.satellites && <SatelliteLayer satellites={satellites} />}
|
|
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} hoveredMmsi={hoveredShipMmsi} focusMmsi={focusShipMmsi} onFocusClear={onFocusShipClear} />}
|
|
<DamagedShipLayer currentTime={currentTime} />
|
|
{seismicMarker && <SeismicMarker {...seismicMarker} />}
|
|
</Map>
|
|
);
|
|
}
|