kcg-monitoring/frontend/src/components/korea/KoreaMap.tsx
htlee 650c027013 feat: 한국 현황 위성지도/ENC 토글 + ENC 스타일 설정
- ENC 전자해도: gcnautical 벡터 타일 연동 (gc-wing-dev 이식)
- 상단 위성/ENC 토글 버튼 + ⚙ 드롭다운 설정 패널
- 12개 심볼 토글 + 8개 색상 수정 + 초기화
- mapMode/encSettings localStorage 영속화
- style.load 대기 패턴으로 스타일 전환 시 설정 자동 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:08:41 +09:00

1090 lines
46 KiB
TypeScript

import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import type { StyleSpecification } from 'maplibre-gl';
import { fetchEncStyle } from '../../features/encMap/encStyle';
import { useEncMapSettings } from '../../features/encMap/useEncMapSettings';
import type { EncMapSettings } from '../../features/encMap/types';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { useFontScale } from '../../hooks/useFontScale';
import { FONT_MONO } from '../../styles/fonts';
import type { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer as DeckLayer } from '@deck.gl/core';
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers';
import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
import { useGearReplayLayers } from '../../hooks/useGearReplayLayers';
import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers';
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useShipDeckLayers } from '../../hooks/useShipDeckLayers';
import { useShipDeckStore } from '../../stores/shipDeckStore';
import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay';
import { InfraLayer } from './InfraLayer';
import { SatelliteLayer } from '../layers/SatelliteLayer';
import { AircraftLayer } from '../layers/AircraftLayer';
import { SubmarineCableLayer } from './SubmarineCableLayer';
import { CctvLayer } from './CctvLayer';
// 정적 레이어들은 useStaticDeckLayers로 전환됨
import { OsintMapLayer } from './OsintMapLayer';
import { EezLayer } from './EezLayer';
// PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer,
// NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
import { StaticFacilityPopup } from './StaticFacilityPopup';
// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨
import { AnalysisOverlay } from './AnalysisOverlay';
import { FleetClusterLayer } from './FleetClusterLayer';
import type { SelectedGearGroupData, SelectedFleetData } from './FleetClusterLayer';
import { FishingZoneLayer } from './FishingZoneLayer';
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
import { fetchKoreaInfra } from '../../services/infra';
import type { PowerFacility } from '../../services/infra';
import type { Ship, Aircraft, SatellitePosition } from '../../types';
import type { OsintItem } from '../../services/osint';
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import 'maplibre-gl/dist/maplibre-gl.css';
export interface KoreaFiltersState {
illegalFishing: boolean;
illegalTransship: boolean;
darkVessel: boolean;
cableWatch: boolean;
dokdoWatch: boolean;
ferryWatch: boolean;
cnFishing: boolean;
}
interface Props {
ships: Ship[];
allShips?: Ship[];
aircraft: Aircraft[];
satellites: SatellitePosition[];
layers: Record<string, boolean>;
osintFeed: OsintItem[];
currentTime: number;
koreaFilters: KoreaFiltersState;
transshipSuspects: Set<string>;
cableWatchSuspects: Set<string>;
dokdoWatchSuspects: Set<string>;
cnFishingSuspects: Set<string>;
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
vesselAnalysis?: UseVesselAnalysisResult;
groupPolygons?: UseGroupPolygonsResult;
hiddenShipCategories?: Set<string>;
hiddenNationalities?: Set<string>;
externalFlyTo?: { lat: number; lng: number; zoom: number } | null;
onExternalFlyToDone?: () => void;
opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null;
mapMode: 'satellite' | 'enc';
encSettings: EncMapSettings;
}
// MarineTraffic-style: satellite + dark ocean + 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: '&copy; 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': '#0d1f3c' } },
{ id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.75, 'raster-saturation': -0.1, 'raster-contrast': 0.05 } },
{ id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.25 } },
{ id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.5 } },
],
};
// ═══ Sea routing — avoid Korean peninsula land mass ═══
const SEA_WAYPOINTS: [number, number][] = [
[124.5, 37.8], [124.0, 36.5], [124.5, 35.5], [125.0, 34.5],
[126.0, 33.5], [126.5, 33.2], [127.5, 33.0], [128.5, 33.5],
[129.0, 34.5], [129.5, 35.2], [129.8, 36.0], [130.0, 37.0],
[129.5, 37.8], [129.0, 38.5],
];
const LAND_BOXES = [
{ minLng: 125.5, maxLng: 129.5, minLat: 34.3, maxLat: 38.6 },
{ minLng: 126.1, maxLng: 126.9, minLat: 33.2, maxLat: 33.6 },
];
function segmentCrossesLand(lng1: number, lat1: number, lng2: number, lat2: number): boolean {
for (let i = 1; i < 10; i++) {
const t = i / 10;
const lng = lng1 + (lng2 - lng1) * t;
const lat = lat1 + (lat2 - lat1) * t;
for (const box of LAND_BOXES) {
if (lng >= box.minLng && lng <= box.maxLng && lat >= box.minLat && lat <= box.maxLat) return true;
}
}
return false;
}
function buildSeaRoute(from: { lat: number; lng: number }, to: { lat: number; lng: number }): [number, number][] {
if (!segmentCrossesLand(from.lng, from.lat, to.lng, to.lat)) {
return [[from.lng, from.lat], [to.lng, to.lat]];
}
const nearest = (lng: number, lat: number) => {
let best = 0, d = Infinity;
for (let i = 0; i < SEA_WAYPOINTS.length; i++) {
const dd = (SEA_WAYPOINTS[i][0] - lng) ** 2 + (SEA_WAYPOINTS[i][1] - lat) ** 2;
if (dd < d) { d = dd; best = i; }
}
return best;
};
const startWP = nearest(from.lng, from.lat);
const endWP = nearest(to.lng, to.lat);
const n = SEA_WAYPOINTS.length;
const cwPath: [number, number][] = [];
const ccwPath: [number, number][] = [];
for (let i = startWP; ; i = (i + 1) % n) {
cwPath.push(SEA_WAYPOINTS[i]);
if (i === endWP || cwPath.length > n) break;
}
for (let i = startWP; ; i = (i - 1 + n) % n) {
ccwPath.push(SEA_WAYPOINTS[i]);
if (i === endWP || ccwPath.length > n) break;
}
const waypoints = cwPath.length <= ccwPath.length ? cwPath : ccwPath;
return [[from.lng, from.lat], ...waypoints, [to.lng, to.lat]];
}
// Korea-centered view
const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 };
const KOREA_MAP_ZOOM = 6;
const FILTER_ICON: Record<string, string> = {
illegalFishing: '\u{1F6AB}\u{1F41F}',
illegalTransship: '\u2693',
darkVessel: '\u{1F47B}',
cableWatch: '\u{1F50C}',
dokdoWatch: '\u{1F3DD}\uFE0F',
ferryWatch: '\u{1F6A2}',
cnFishing: '\u{1F3A3}',
};
const FILTER_COLOR: Record<string, string> = {
illegalFishing: '#ef4444',
illegalTransship: '#f97316',
darkVessel: '#8b5cf6',
cableWatch: '#00e5ff',
dokdoWatch: '#22c55e',
ferryWatch: '#2196f3',
cnFishing: '#f59e0b',
};
const FILTER_I18N_KEY: Record<string, string> = {
illegalFishing: 'filters.illegalFishingMonitor',
illegalTransship: 'filters.illegalTransshipMonitor',
darkVessel: 'filters.darkVesselMonitor',
cableWatch: 'filters.cableWatchMonitor',
dokdoWatch: 'filters.dokdoWatchMonitor',
ferryWatch: 'filters.ferryWatchMonitor',
cnFishing: 'filters.cnFishingMonitor',
};
// [DEBUG] 개발용 도구 — DEV에서만 동적 로드, 프로덕션 번들에서 완전 제거
import { lazy, Suspense } from 'react';
const DebugTools = import.meta.env.DEV
? lazy(() => import('./debug'))
: null;
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute, mapMode, encSettings }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const maplibreRef = useRef<import('maplibre-gl').Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
// ENC 스타일 사전 로드
const [encStyle, setEncStyle] = useState<StyleSpecification | null>(null);
useEffect(() => {
const ctrl = new AbortController();
fetchEncStyle(ctrl.signal).then(setEncStyle).catch(() => {});
return () => ctrl.abort();
}, []);
const activeMapStyle = mapMode === 'enc' && encStyle ? encStyle : MAP_STYLE;
// ENC 설정 적용을 트리거하는 epoch — 맵 로드/스타일 전환 시 증가
const [encSyncEpoch, setEncSyncEpoch] = useState(0);
// ENC 설정 런타임 적용
useEncMapSettings(maplibreRef, mapMode, encSettings, encSyncEpoch);
const replayLayerRef = useRef<DeckLayer[]>([]);
const fleetClusterLayerRef = useRef<DeckLayer[]>([]);
const requestRenderRef = useRef<(() => void) | null>(null);
const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => {
fleetClusterLayerRef.current = layers;
requestRenderRef.current?.();
}, []);
const [infra, setInfra] = useState<PowerFacility[]>([]);
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
const { fontScale } = useFontScale();
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
const zoomRef = useRef(KOREA_MAP_ZOOM);
const handleZoom = useCallback((e: { viewState: { zoom: number } }) => {
const z = Math.floor(e.viewState.zoom);
if (z !== zoomRef.current) {
zoomRef.current = z;
setZoomLevel(z);
useShipDeckStore.getState().setZoomLevel(z);
}
}, []);
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []);
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
const [activeBadgeFilter, setActiveBadgeFilter] = useState<string | null>(null);
const replayFocusMode = useGearReplayStore(s => s.focusMode);
// ── deck.gl 레이어 (Zustand → imperative setProps, React 렌더 우회) ──
const reactLayersRef = useRef<DeckLayer[]>([]);
const shipLayerRef = useRef<DeckLayer[]>([]);
type ShipPos = { lng: number; lat: number; course?: number };
const shipsRef = useRef(new globalThis.Map<string, ShipPos>());
// live 선박 위치를 ref에 동기화 (리플레이 fallback용)
const allShipsList = allShips ?? ships;
const shipPosMap = new globalThis.Map<string, ShipPos>();
for (const s of allShipsList) shipPosMap.set(s.mmsi, { lng: s.lng, lat: s.lat, course: s.course });
shipsRef.current = shipPosMap;
const requestRender = useCallback(() => {
if (!overlayRef.current) return;
const focus = useGearReplayStore.getState().focusMode;
overlayRef.current.setProps({
layers: focus
? [...replayLayerRef.current]
: [...reactLayersRef.current, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current],
});
}, []);
requestRenderRef.current = requestRender;
useShipDeckLayers(shipLayerRef, requestRender);
useGearReplayLayers(replayLayerRef, requestRender, shipsRef);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
}, []);
// MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제)
const handleMapLoad = useCallback(() => {
maplibreRef.current = mapRef.current?.getMap() ?? null;
setEncSyncEpoch(v => v + 1);
}, []);
// ── shipDeckStore 동기화 ──
useEffect(() => {
useShipDeckStore.getState().setShips(allShipsList);
}, [allShipsList]);
useEffect(() => {
useShipDeckStore.getState().setFilters({
militaryOnly: layers.militaryOnly,
layerVisible: layers.ships,
hiddenShipCategories,
hiddenNationalities,
});
}, [layers.militaryOnly, layers.ships, hiddenShipCategories, hiddenNationalities]);
// Korea 탭에서는 한국선박 강조 OFF (Iran 탭 전용 기능)
// highlightKorean 기본값 false 유지
useEffect(() => {
if (flyToTarget && mapRef.current) {
mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom, duration: 1500 });
setFlyToTarget(null);
}
}, [flyToTarget]);
useEffect(() => {
if (externalFlyTo && mapRef.current) {
mapRef.current.flyTo({ center: [externalFlyTo.lng, externalFlyTo.lat], zoom: externalFlyTo.zoom, duration: 1500 });
onExternalFlyToDone?.();
}
}, [externalFlyTo, onExternalFlyToDone]);
useEffect(() => {
if (!selectedAnalysisMmsi) setTrackCoords(null);
}, [selectedAnalysisMmsi]);
const handleAnalysisShipSelect = useCallback((mmsi: string) => {
setSelectedAnalysisMmsi(mmsi);
const ship = (allShips ?? ships).find(s => s.mmsi === mmsi);
if (ship) setFlyToTarget({ lng: ship.lng, lat: ship.lat, zoom: 12 });
}, [allShips, ships]);
const handleTrackLoad = useCallback((_mmsi: string, coords: [number, number][]) => {
setTrackCoords(coords);
}, []);
const handleFleetZoom = useCallback((bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => {
mapRef.current?.fitBounds(
[[bounds.minLng, bounds.minLat], [bounds.maxLng, bounds.maxLat]],
{ padding: 60, duration: 1500, maxZoom: 10 },
);
}, []);
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
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]);
// 불법어선 강조 — deck.gl ScatterplotLayer + TextLayer
const illegalFishingData = useMemo(() => {
if (!koreaFilters.illegalFishing) return [];
return (allShips ?? ships).filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (mtCat !== 'fishing' || s.flag === 'KR') return false;
return classifyFishingZone(s.lat, s.lng).zone !== 'OUTSIDE';
}).slice(0, 200);
}, [koreaFilters.illegalFishing, allShips, ships]);
const illegalFishingLayer = useMemo(() => new ScatterplotLayer({
id: 'illegal-fishing-highlight',
data: illegalFishingData,
getPosition: (d) => [d.lng, d.lat],
getRadius: 800 * zoomScale,
getFillColor: [239, 68, 68, 40],
getLineColor: [239, 68, 68, 200],
getLineWidth: 2,
stroked: true,
filled: true,
radiusUnits: 'meters',
lineWidthUnits: 'pixels',
updateTriggers: { getRadius: [zoomScale] },
}), [illegalFishingData, zoomScale]);
const illegalFishingLabelLayer = useMemo(() => new TextLayer({
id: 'illegal-fishing-labels',
data: illegalFishingData,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name || d.mmsi,
getSize: 11 * zoomScale * fontScale.analysis,
getColor: [239, 68, 68, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 14],
fontFamily: FONT_MONO,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
updateTriggers: { getSize: [zoomScale, fontScale.analysis] },
}), [illegalFishingData, zoomScale, fontScale.analysis]);
// 수역 라벨 TextLayer — illegalFishing 또는 cnFishing 필터 활성 시 표시
const zoneLabelsLayer = useMemo(() => {
if (!koreaFilters.illegalFishing && !koreaFilters.cnFishing) return null;
const data = (fishingZonesData as GeoJSON.FeatureCollection).features.map(f => {
const geom = f.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon;
let sLng = 0, sLat = 0, n = 0;
const rings = geom.type === 'MultiPolygon'
? geom.coordinates.flatMap(poly => poly)
: geom.coordinates;
for (const ring of rings) {
for (const [lng, lat] of ring) {
sLng += lng; sLat += lat; n++;
}
}
return {
name: (f.properties as { name: string }).name,
lng: n > 0 ? sLng / n : 0,
lat: n > 0 ? sLat / n : 0,
};
});
return new TextLayer({
id: 'fishing-zone-labels',
data,
getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat],
getText: (d: { name: string }) => d.name,
getSize: 14 * zoomScale * fontScale.area,
getColor: [255, 255, 255, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: FONT_MONO,
fontWeight: 700,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
updateTriggers: { getSize: [zoomScale, fontScale.area] },
});
}, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale, fontScale.area]);
// 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등
const staticDeckLayers = useStaticDeckLayers({
ports: layers.ports ?? false,
coastGuard: layers.coastGuard ?? false,
windFarm: layers.windFarm ?? false,
militaryBases: layers.militaryBases ?? false,
govBuildings: layers.govBuildings ?? false,
airports: layers.airports ?? false,
navWarning: layers.navWarning ?? false,
nkLaunch: layers.nkLaunch ?? false,
nkMissile: layers.nkMissile ?? false,
piracy: layers.piracy ?? false,
infra: layers.infra ?? false,
infraFacilities: infra,
hazardTypes: [
...(layers.hazardPetrochemical ? ['petrochemical' as const] : []),
...(layers.hazardLng ? ['lng' as const] : []),
...(layers.hazardOilTank ? ['oilTank' as const] : []),
...(layers.hazardPort ? ['hazardPort' as const] : []),
...(layers.energyNuclear ? ['nuclear' as const] : []),
...(layers.energyThermal ? ['thermal' as const] : []),
...(layers.industryShipyard ? ['shipyard' as const] : []),
...(layers.industryWastewater ? ['wastewater' as const] : []),
...(layers.industryHeavy ? ['heavyIndustry' as const] : []),
],
cnPower: !!layers.cnPower,
cnMilitary: !!layers.cnMilitary,
jpPower: !!layers.jpPower,
jpMilitary: !!layers.jpMilitary,
onPick: handleStaticPick,
sizeScale: zoomScale,
});
// 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl)
const selectedGearLayers = useMemo(() => {
if (!selectedGearData || replayFocusMode) return [];
const { parent, gears, groupName } = selectedGearData;
const layers = [];
// 어구 위치 — 주황 원형 마커
layers.push(new ScatterplotLayer({
id: 'selected-gear-items',
data: gears,
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 6 * zoomScale,
getFillColor: [249, 115, 22, 180],
getLineColor: [255, 255, 255, 220],
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 1.5,
updateTriggers: { getRadius: [zoomScale] },
}));
// 어구 이름 라벨
layers.push(new TextLayer({
id: 'selected-gear-labels',
data: gears,
getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => d.name || d.mmsi,
getSize: 10 * zoomScale * fontScale.analysis,
getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
updateTriggers: { getSize: [zoomScale] },
}));
// 모선 강조 — 큰 원 + 라벨
if (parent) {
layers.push(new ScatterplotLayer({
id: 'selected-gear-parent',
data: [parent],
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 14 * zoomScale,
getFillColor: [249, 115, 22, 80],
getLineColor: [249, 115, 22, 255],
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 3,
updateTriggers: { getRadius: [zoomScale] },
}));
layers.push(new TextLayer({
id: 'selected-gear-parent-label',
data: [parent],
getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => `${d.name || groupName} (모선)`,
getSize: 11 * zoomScale * fontScale.analysis,
getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 18],
fontFamily: FONT_MONO,
fontWeight: 700,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
updateTriggers: { getSize: [zoomScale] },
}));
}
return layers;
}, [selectedGearData, zoomScale, fontScale.analysis, replayFocusMode]);
// 선택된 선단 소속 선박 강조 레이어 (deck.gl)
const selectedFleetLayers = useMemo(() => {
if (!selectedFleetData || replayFocusMode) return [];
const { ships: fleetShips, clusterId } = selectedFleetData;
if (fleetShips.length === 0) return [];
// HSL→RGB 인라인 변환 (선단 색상)
const hue = (clusterId * 137) % 360;
const h = hue / 360; const s = 0.7; const l = 0.6;
const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1; if (t > 1) t -= 1; return t < 1/6 ? p + (q-p)*6*t : t < 1/2 ? q : t < 2/3 ? p + (q-p)*(2/3-t)*6 : p; };
const q = l < 0.5 ? l * (1+s) : l + s - l*s; const p = 2*l - q;
const r = Math.round(hue2rgb(p, q, h + 1/3) * 255);
const g = Math.round(hue2rgb(p, q, h) * 255);
const b = Math.round(hue2rgb(p, q, h - 1/3) * 255);
const color: [number, number, number, number] = [r, g, b, 255];
const fillColor: [number, number, number, number] = [r, g, b, 80];
const result: DeckLayer[] = [];
// 소속 선박 — 강조 원형
result.push(new ScatterplotLayer({
id: 'selected-fleet-items',
data: fleetShips,
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 8 * zoomScale,
getFillColor: fillColor,
getLineColor: color,
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 2,
updateTriggers: { getRadius: [zoomScale] },
}));
// 소속 선박 이름 라벨
result.push(new TextLayer({
id: 'selected-fleet-labels',
data: fleetShips,
getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => {
const dto = vesselAnalysis?.analysisMap.get(d.mmsi);
const role = dto?.algorithms.fleetRole.role;
const prefix = role === 'LEADER' ? '★ ' : '';
return `${prefix}${d.name || d.mmsi}`;
},
getSize: 10 * zoomScale * fontScale.analysis,
getColor: color,
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 12],
fontFamily: FONT_MONO,
fontWeight: 600,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
updateTriggers: { getSize: [zoomScale], getText: [vesselAnalysis] },
}));
// 리더 선박 추가 강조 (큰 외곽 링)
const leaders = fleetShips.filter(s => {
const dto = vesselAnalysis?.analysisMap.get(s.mmsi);
return dto?.algorithms.fleetRole.isLeader;
});
if (leaders.length > 0) {
result.push(new ScatterplotLayer({
id: 'selected-fleet-leaders',
data: leaders,
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 16 * zoomScale,
getFillColor: [0, 0, 0, 0],
getLineColor: color,
stroked: true,
filled: false,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 3,
updateTriggers: { getRadius: [zoomScale] },
}));
}
return result;
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis, replayFocusMode]);
// 분석 결과 deck.gl 레이어
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
: koreaFilters.darkVessel ? 'darkVessel'
: koreaFilters.cnFishing ? 'cnFishing'
: null;
// shipDeckStore에 분석 상태 동기화
useEffect(() => {
useShipDeckStore.getState().setAnalysis(
vesselAnalysis?.analysisMap ?? null,
analysisActiveFilter,
);
}, [vesselAnalysis?.analysisMap, analysisActiveFilter]);
const analysisDeckLayers = useAnalysisDeckLayers(
vesselAnalysis?.analysisMap ?? (new globalThis.Map() as globalThis.Map<string, import('../../types').VesselAnalysisDto>),
allShips ?? ships,
analysisActiveFilter,
zoomScale,
);
return (
<Map
ref={mapRef}
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
style={{ width: '100%', height: '100%' }}
mapStyle={activeMapStyle}
onZoom={handleZoom}
onLoad={handleMapLoad}
>
<NavigationControl position="top-right" />
{/* [DEBUG] 개발용 도구 — 프로덕션 번들에서 완전 제거 */}
{DebugTools && <Suspense><DebugTools mapRef={mapRef} /></Suspense>}
<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>
{/* ShipLayer → deck.gl (useShipDeckLayers) 전환 완료 */}
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div
className="rounded-sm text-[9px] font-bold font-mono whitespace-nowrap animate-pulse"
style={{
background: 'rgba(249,115,22,0.9)', color: '#fff',
padding: '1px 5px',
border: '1px solid #f97316',
textShadow: '0 0 2px #000',
}}
>
{`\u26A0 ${t('korea.transshipSuspect')}`}
</div>
</Marker>
))}
{/* Cable watch suspect labels */}
{cableWatchSuspects.size > 0 && ships.filter(s => cableWatchSuspects.has(s.mmsi)).map(s => (
<Marker key={`cw-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div
className="rounded-sm text-[9px] font-bold font-mono whitespace-nowrap animate-pulse"
style={{
background: 'rgba(0,229,255,0.9)', color: '#000',
padding: '1px 5px',
border: '1px solid #00e5ff',
textShadow: '0 0 2px rgba(255,255,255,0.5)',
}}
>
{`\u{1F50C} ${t('korea.cableDanger')}`}
</div>
</Marker>
))}
{/* Dokdo watch labels (Japanese vessels) */}
{dokdoWatchSuspects.size > 0 && ships.filter(s => dokdoWatchSuspects.has(s.mmsi)).map(s => {
const dist = Math.round(Math.hypot(
(s.lng - 131.8647) * Math.cos((37.2417 * Math.PI) / 180),
s.lat - 37.2417,
) * 111);
const inTerritorial = dist < 22;
return (
<Marker key={`dk-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div
className="rounded-sm text-[9px] font-bold font-mono whitespace-nowrap animate-pulse"
style={{
background: inTerritorial ? 'rgba(239,68,68,0.95)' : 'rgba(234,179,8,0.9)',
color: '#fff',
padding: '2px 6px',
border: `1px solid ${inTerritorial ? '#ef4444' : '#eab308'}`,
textShadow: '0 0 2px #000',
}}
>
{inTerritorial ? '\u{1F6A8}' : '\u26A0'} {'\u{1F1EF}\u{1F1F5}'} {inTerritorial ? t('korea.dokdoIntrusion') : t('korea.dokdoApproach')} {`${dist}km`}
</div>
</Marker>
);
})}
{layers.infra && infra.length > 0 && <InfraLayer facilities={infra} />}
{layers.satellites && satellites.length > 0 && <SatelliteLayer satellites={satellites} />}
{layers.aircraft && aircraft.length > 0 && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.cables && <SubmarineCableLayer />}
{layers.cctv && <CctvLayer />}
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
{(koreaFilters.illegalFishing || koreaFilters.cnFishing) && <FishingZoneLayer />}
{koreaFilters.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
{/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */}
{koreaFilters.cnFishing && (
<FleetClusterLayer
ships={allShips ?? ships}
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
groupPolygons={groupPolygons}
zoomScale={zoomScale}
onDeckLayersChange={handleFleetDeckLayers}
onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom}
onSelectedGearChange={setSelectedGearData}
onSelectedFleetChange={setSelectedFleetData}
/>
)}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && (
<AnalysisOverlay
ships={allShips ?? ships}
analysisMap={vesselAnalysis.analysisMap}
clusters={vesselAnalysis.clusters}
activeFilter={analysisActiveFilter}
/>
)}
{/* analysisShipMarkers → deck.gl (useShipDeckLayers) 전환 완료 */}
{/* 선박 호버 툴팁 + 클릭 팝업 — React 오버레이 (MapLibre Popup 대체) */}
<ShipHoverTooltip />
<ShipPopupOverlay />
{/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */}
<DeckGLOverlay
overlayRef={overlayRef}
layers={(() => {
const base = replayFocusMode ? [] : [
...staticDeckLayers,
illegalFishingLayer,
illegalFishingLabelLayer,
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean) as DeckLayer[];
reactLayersRef.current = base;
return [...base, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current];
})()}
/>
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
{staticPickInfo && (
<StaticFacilityPopup pickInfo={staticPickInfo} onClose={() => setStaticPickInfo(null)} />
)}
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
{layers.eez && <EezLayer />}
{/* Filter Status Banner — 필터별 개별 탐지 카운트 */}
{(() => {
const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]);
if (active.length === 0) { if (activeBadgeFilter) setActiveBadgeFilter(null); return null; }
const all = allShips ?? ships;
const getShipsForFilter = (k: string): Ship[] => {
switch (k) {
case 'illegalFishing': return all.filter(s => s.mtCategory === 'fishing' && s.flag !== 'KR' && classifyFishingZone(s.lat, s.lng).zone !== 'OUTSIDE');
case 'illegalTransship': return all.filter(s => transshipSuspects.has(s.mmsi));
case 'darkVessel': return all.filter(s => { const dto = vesselAnalysis?.analysisMap.get(s.mmsi); return !!(dto?.algorithms.darkVessel.isDark) || (s.lastSeen != null && currentTime - s.lastSeen > 3600000); });
case 'cableWatch': return all.filter(s => cableWatchSuspects.has(s.mmsi));
case 'dokdoWatch': return all.filter(s => dokdoWatchSuspects.has(s.mmsi));
case 'ferryWatch': return all.filter(s => s.mtCategory === 'passenger');
case 'cnFishing': {
const gearRe = /^(.+?)_\d+_\d+_?$/;
const gears = all.filter(s => gearRe.test(s.name || ''));
const parentNames = new Set(gears.map(s => { const m = (s.name || '').match(gearRe); return m ? m[1].trim() : ''; }).filter(Boolean));
const parents = all.filter(s => parentNames.has((s.name || '').trim()) && !gearRe.test(s.name || ''));
return [...gears, ...parents];
}
default: return [];
}
};
const downloadCsv = (k: string) => {
const data = getShipsForFilter(k);
const bom = '\uFEFF';
const header = 'MMSI,Name,Flag,Category,Lat,Lng,Speed,Heading';
const rows = data.map(s => `${s.mmsi},"${(s.name || '').replace(/"/g, '""')}",${s.flag || ''},${s.mtCategory || ''},${s.lat.toFixed(4)},${s.lng.toFixed(4)},${s.speed},${s.heading}`);
const blob = new Blob([bom + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${k}_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const badgeShips = activeBadgeFilter ? getShipsForFilter(activeBadgeFilter) : [];
return (
<>
<div className="absolute top-2.5 left-1/2 -translate-x-1/2 z-20 flex gap-1.5 backdrop-blur-lg">
{active.map(k => {
const color = FILTER_COLOR[k];
const filterShips = getShipsForFilter(k);
const isOpen = activeBadgeFilter === k;
// cnFishing: 어구그룹 수(고유 모선명)로 표시, 나머지: 선박 수
let badgeLabel: string;
if (k === 'cnFishing') {
const groupNames = new Set(filterShips.map(s => (s.name || '').match(/^(.+?)_\d+/)?.[1]).filter(Boolean));
badgeLabel = `${groupNames.size}`;
} else {
badgeLabel = `${filterShips.length}`;
}
const badgeName = k === 'cnFishing' ? '중국 어구그룹 감시' : (FILTER_I18N_KEY[k] ? t(FILTER_I18N_KEY[k]) : k);
return (
<div
key={k}
className={`rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 cursor-pointer select-none whitespace-nowrap ${isOpen ? '' : 'animate-pulse'}`}
style={{
background: isOpen ? `${color}44` : `${color}22`,
border: `1px solid ${isOpen ? color : color + '88'}`,
color,
}}
onClick={() => setActiveBadgeFilter(prev => prev === k ? null : k)}
>
<span className="text-[13px]">{FILTER_ICON[k]}</span>
{badgeName}
<span className="ml-0.5 text-white/80">{badgeLabel}</span>
</div>
);
})}
</div>
{activeBadgeFilter && badgeShips.length > 0 && (
<div
className="absolute top-12 left-1/2 -translate-x-1/2 z-30 rounded-lg px-3 py-2 font-mono text-[11px] w-[440px] max-h-[320px] bg-kcg-overlay backdrop-blur-lg shadow-lg"
style={{ borderWidth: 1, borderStyle: 'solid', borderColor: (FILTER_COLOR[activeBadgeFilter] ?? '#888') + '88' }}
>
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-white">
{FILTER_ICON[activeBadgeFilter]} {FILTER_I18N_KEY[activeBadgeFilter] ? t(FILTER_I18N_KEY[activeBadgeFilter]) : activeBadgeFilter} {badgeShips.length}
</span>
<div className="flex gap-1.5">
<button type="button" onClick={() => downloadCsv(activeBadgeFilter)} className="text-[10px] px-2 py-0.5 rounded bg-white/10 hover:bg-white/20 text-white transition-colors">CSV</button>
<button type="button" onClick={() => setActiveBadgeFilter(null)} className="text-white/60 hover:text-white transition-colors"></button>
</div>
</div>
<div className="overflow-y-auto max-h-[260px]">
<table className="w-full text-[10px]">
<thead className="sticky top-0 bg-kcg-overlay">
<tr className="text-white/50 border-b border-white/10">
<th className="text-left py-1 px-1">MMSI</th>
<th className="text-left py-1 px-1">Name</th>
<th className="text-center py-1 px-1">Flag</th>
<th className="text-left py-1 px-1">Type</th>
<th className="text-right py-1 px-1">Speed</th>
</tr>
</thead>
<tbody>
{badgeShips.slice(0, 200).map(s => (
<tr
key={s.mmsi}
className="hover:bg-white/5 cursor-pointer border-b border-white/5"
onClick={() => setFlyToTarget({ lng: s.lng, lat: s.lat, zoom: 12 })}
>
<td className="py-0.5 px-1 text-white/60">{s.mmsi}</td>
<td className="py-0.5 px-1 text-white/90 truncate max-w-[140px]">{s.name || '-'}</td>
<td className="py-0.5 px-1 text-center text-white/60">{s.flag || '-'}</td>
<td className="py-0.5 px-1 text-white/50">{s.mtCategory || '-'}</td>
<td className="py-0.5 px-1 text-right text-white/50">{s.speed?.toFixed(1)}kn</td>
</tr>
))}
</tbody>
</table>
{badgeShips.length > 200 && <div className="text-center text-white/40 text-[10px] py-1">... {badgeShips.length - 200}</div>}
</div>
</div>
)}
</>
);
})()}
{/* Dokdo alert panel */}
{dokdoAlerts.length > 0 && koreaFilters.dokdoWatch && (
<div className="absolute top-2.5 right-[50px] z-20 rounded-lg border border-kcg-danger px-2.5 py-2 font-mono text-[11px] min-w-[220px] max-h-[200px] overflow-y-auto bg-kcg-overlay backdrop-blur-lg shadow-[0_0_20px_rgba(239,68,68,0.3)]">
<div className="font-bold text-[10px] text-kcg-danger mb-1.5 tracking-widest flex items-center gap-1">
{`\u{1F6A8} ${t('korea.dokdoAlerts')}`}
</div>
{dokdoAlerts.map((a, i) => (
<div key={`${a.mmsi}-${i}`} className="flex flex-col gap-0.5" style={{
padding: '4px 0', borderBottom: i < dokdoAlerts.length - 1 ? '1px solid #222' : 'none',
}}>
<div className="flex justify-between items-center">
<span className="font-bold text-[10px]" style={{ color: a.dist < 22 ? '#ef4444' : '#eab308' }}>
{a.dist < 22 ? `\u{1F6A8} ${t('korea.territorialIntrusion')}` : `\u26A0 ${t('korea.approachWarning')}`}
</span>
<span className="text-kcg-dim text-[9px]">
{new Date(a.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
<div className="text-kcg-text-secondary text-[10px]">
{`\u{1F1EF}\u{1F1F5} ${a.name} \u2014 ${t('korea.dokdoDistance', { dist: a.dist })}`}
</div>
</div>
))}
</div>
)}
{/* 선택된 분석 선박 항적 — tracks API 응답 기반 */}
{trackCoords && trackCoords.length > 1 && (
<Source id="analysis-trail" type="geojson" data={{
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: trackCoords,
},
}],
}}>
<Layer id="analysis-trail-line" type="line" paint={{
'line-color': '#00e5ff',
'line-width': 2.5,
'line-opacity': 0.8,
}} />
</Source>
)}
{/* AI Analysis Stats Panel — 항상 표시 */}
{vesselAnalysis && (
<AnalysisStatsPanel
stats={vesselAnalysis.stats}
lastUpdated={vesselAnalysis.lastUpdated}
isLoading={vesselAnalysis.isLoading}
analysisMap={vesselAnalysis.analysisMap}
ships={allShips ?? ships}
onShipSelect={handleAnalysisShipSelect}
onTrackLoad={handleTrackLoad}
onExpandedChange={setAnalysisPanelOpen}
/>
)}
{/* 작전가이드 임검침로 점선 — 해상 루트 (육지 우회) */}
{opsRoute && (() => {
const riskColor = opsRoute.riskLevel === 'CRITICAL' ? '#ef4444' : opsRoute.riskLevel === 'HIGH' ? '#f59e0b' : '#3b82f6';
const coords = buildSeaRoute(opsRoute.from, opsRoute.to);
const routeGeoJson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }],
};
const midIdx = Math.floor(coords.length / 2);
return (
<>
<Source id="ops-route-line" type="geojson" data={routeGeoJson}>
<Layer id="ops-route-dash" type="line" paint={{
'line-color': riskColor, 'line-width': 2.5,
'line-dasharray': [4, 4], 'line-opacity': 0.8,
}} />
</Source>
<Marker longitude={opsRoute.from.lng} latitude={opsRoute.from.lat} anchor="center">
<div style={{ fontSize: 18, filter: 'drop-shadow(0 0 4px rgba(0,0,0,0.8))' }}></div>
</Marker>
<Marker longitude={opsRoute.to.lng} latitude={opsRoute.to.lat} anchor="center">
<div style={{ width: 12, height: 12, borderRadius: '50%', background: riskColor, border: '2px solid #fff', boxShadow: `0 0 8px ${riskColor}` }} />
</Marker>
<Marker longitude={coords[midIdx][0]} latitude={coords[midIdx][1]} anchor="bottom">
<div style={{ background: 'rgba(0,0,0,0.8)', padding: '2px 6px', borderRadius: 3, border: `1px solid ${riskColor}`, fontSize: 9, color: '#fff', fontWeight: 700, whiteSpace: 'nowrap', textAlign: 'center' }}>
{opsRoute.distanceNM.toFixed(1)} NM
<div style={{ fontSize: 7, color: riskColor }}>{opsRoute.from.name} {opsRoute.to.name}</div>
</div>
</Marker>
</>
);
})()}
{/* ENC 설정 패널은 KoreaDashboard 헤더에서 렌더 */}
</Map>
);
}