kcg-monitoring/frontend/src/components/iran/SatelliteMap.tsx
htlee 5e55a495bc refactor(frontend): 패키지 구조 리팩토링 — 공통/탭별 분리 + 데이터 훅 추출
- components/ 서브디렉토리 재배치: common/, layers/, iran/, korea/
- App.tsx God Component 분해: 1,179줄 → 588줄 (50% 감소)
- useIranData: 이란 데이터 로딩 + propagation + OSINT 병합
- useKoreaData: 한국 데이터 로딩 + propagation
- useKoreaFilters: 감시 로직 (환적/다크베셀/케이블/독도) 분리
- getMarineTrafficCategory → utils/marineTraffic.ts 추출

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 07:25:35 +09:00

258 lines
8.7 KiB
TypeScript

import { 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 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;
}
// 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: '&copy; 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',
};
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,
};
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
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}
>
<NavigationControl position="top-right" />
{/* 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,
'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,
'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,
'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)}&deg;N, {selectedEvent.lng.toFixed(4)}&deg;E
</div>
</div>
</Popup>
)}
{/* 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} />}
<DamagedShipLayer currentTime={currentTime} />
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
</Map>
);
}