kcg-monitoring/frontend/src/components/iran/ReplayMap.tsx
htlee 44aa449b03 feat: 지도 글꼴 크기 커스텀 시스템 (4개 그룹 슬라이더)
- FontScaleContext + FontScalePanel: 시설/선박/분석/지역 4그룹 × 0.5~2.0 범위
- LAYERS 패널 하단 슬라이더 UI, localStorage 영속화
- Korea static 14개 + Iran 4개 + 분석 3개 + KoreaMap 5개 TextLayer 적용
- MapLibre 선박 라벨/국가명 실시간 반영
- 모든 useMemo deps + updateTriggers에 fontScale 포함
2026-03-24 09:27:11 +09:00

637 lines
27 KiB
TypeScript

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<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 ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, 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(
() => 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 (
<Map
ref={mapRef}
initialViewState={{ longitude: initialCenter?.lng ?? 44, latitude: initialCenter?.lat ?? 31.5, zoom: initialZoom ?? 5 }}
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
onZoom={handleZoom}
>
<NavigationControl position="top-right" />
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer
id="country-label-lg"
type="symbol"
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 15 * fontScale.area,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 6,
}}
paint={{
'text-color': '#e2e8f0',
'text-halo-color': '#000000',
'text-halo-width': 2,
'text-opacity': 0.9,
}}
/>
<Layer
id="country-label-md"
type="symbol"
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-size': 12 * fontScale.area,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 4,
}}
paint={{
'text-color': '#94a3b8',
'text-halo-color': '#000000',
'text-halo-width': 1.5,
'text-opacity': 0.85,
}}
/>
<Layer
id="country-label-sm"
type="symbol"
filter={['==', ['get', 'rank'], 3]}
minzoom={5}
layout={{
'text-field': ['get', 'name'],
'text-size': 10 * fontScale.area,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 2,
}}
paint={{
'text-color': '#64748b',
'text-halo-color': '#000000',
'text-halo-width': 1,
'text-opacity': 0.75,
}}
/>
</Source>
{layers.events && (
<>
{trajectoryData.features.length > 0 && (
<Source id="trajectories" type="geojson" data={trajectoryData}>
<Layer
id="trajectory-lines"
type="line"
paint={{
'line-color': '#eab308',
'line-width': 1.5,
'line-opacity': 0.4,
'line-dasharray': [8, 4],
}}
/>
</Source>
)}
{newEvents.map(event => {
const color = getEventColor(event);
const size = EVENT_RADIUS[event.type] * 5;
return (
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-pulse-ring rounded-full pointer-events-none" style={{
width: size, height: size,
border: `2px solid ${color}`,
}} />
</Marker>
);
})}
{justActivated.map(event => {
const color = getEventColor(event);
const size = event.type === 'impact' ? 100 : 70;
return (
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-shockwave rounded-full pointer-events-none" style={{
width: size, height: size,
border: `3px solid ${color}`,
}} />
</Marker>
);
})}
{justActivated.map(event => {
const color = getEventColor(event);
const size = event.type === 'impact' ? 40 : 30;
return (
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-strike-flash rounded-full opacity-60 pointer-events-none" style={{
width: size, height: size,
background: color,
}} />
</Marker>
);
})}
{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 (
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
<div
className={`cursor-pointer ${isNew ? 'gl-event-flash' : ''}`}
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle
cx={r} cy={r} r={r - 1}
fill={color} fillOpacity={isNew ? 0.9 : isRecent ? opacity * 0.8 : opacity * 0.4}
stroke={color} strokeWidth={isNew ? 3 : isRecent ? 2.5 : 1}
opacity={isNew ? 1 : opacity}
/>
</svg>
</div>
</Marker>
);
})}
{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 (
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="relative cursor-pointer" style={{ opacity: impactOpacity }}
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}>
<svg viewBox={`0 0 ${s} ${s}`} width={s} height={s}>
<circle cx={c} cy={c} r={c * 0.77} fill="none" stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
<circle cx={c} cy={c} r={c * 0.32} fill="none" stroke="#ff0000" strokeWidth={sw * 0.7} opacity={0.9} />
<circle cx={c} cy={c} r={c * 0.14} fill="#ff0000" opacity={1} />
<line x1={c} y1={0.5} x2={c} y2={c * 0.23} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
<line x1={c} y1={s - 0.5} x2={c} y2={s - c * 0.23} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
<line x1={0.5} y1={c} x2={c * 0.23} y2={c} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
<line x1={s - 0.5} y1={c} x2={s - c * 0.23} y2={c} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
</svg>
<div className="gl-impact-label">
{isRecent && <span className="gl-new-badge">NEW</span>}
{event.label}
</div>
</div>
</Marker>
);
})}
{selectedEvent && (
<Popup
longitude={selectedEvent.lng}
latitude={selectedEvent.lat}
onClose={() => setSelectedEventId(null)}
closeOnClick={false}
anchor="bottom"
maxWidth="320px"
className="gl-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><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]">{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>
)}
{selectedEvent.type === 'impact' && (
<div className="text-[10px] text-kcg-muted mt-1.5">
{selectedEvent.lat.toFixed(4)}&deg;N, {selectedEvent.lng.toFixed(4)}&deg;E
</div>
)}
</div>
</Popup>
)}
</>
)}
{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} />}
{layers.ships && <DamagedShipLayer currentTime={currentTime} />}
{seismicMarker && <SeismicMarker {...seismicMarker} />}
<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)}&deg;{mePickedFacility.lat >= 0 ? 'N' : 'S'}, {mePickedFacility.lng.toFixed(4)}&deg;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)}&deg;{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}&deg;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)}&deg;{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}&deg;{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)}&deg;{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}&deg;E
</div>
</div>
</Popup>
);
}
return null;
})()}
</Map>
);
}