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; osintFeed: OsintItem[]; currentTime: number; koreaFilters: KoreaFiltersState; transshipSuspects: Set; cableWatchSuspects: Set; dokdoWatchSuspects: Set; cnFishingSuspects: Set; dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[]; vesselAnalysis?: UseVesselAnalysisResult; groupPolygons?: UseGroupPolygonsResult; hiddenShipCategories?: Set; hiddenNationalities?: Set; 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: '© 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 = { 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 = { illegalFishing: '#ef4444', illegalTransship: '#f97316', darkVessel: '#8b5cf6', cableWatch: '#00e5ff', dokdoWatch: '#22c55e', ferryWatch: '#2196f3', cnFishing: '#f59e0b', }; const FILTER_I18N_KEY: Record = { 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(null); const maplibreRef = useRef(null); const overlayRef = useRef(null); // ENC 스타일 사전 로드 const [encStyle, setEncStyle] = useState(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([]); const fleetClusterLayerRef = useRef([]); const requestRenderRef = useRef<(() => void) | null>(null); const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => { fleetClusterLayerRef.current = layers; requestRenderRef.current?.(); }, []); const [infra, setInfra] = useState([]); const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null); const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState(null); const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null); const [selectedGearData, setSelectedGearData] = useState(null); const [selectedFleetData, setSelectedFleetData] = useState(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(null); const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []); const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false); const [activeBadgeFilter, setActiveBadgeFilter] = useState(null); const replayFocusMode = useGearReplayStore(s => s.focusMode); // ── deck.gl 레이어 (Zustand → imperative setProps, React 렌더 우회) ── const reactLayersRef = useRef([]); const shipLayerRef = useRef([]); type ShipPos = { lng: number; lat: number; course?: number }; const shipsRef = useRef(new globalThis.Map()); // live 선박 위치를 ref에 동기화 (리플레이 fallback용) const allShipsList = allShips ?? ships; const shipPosMap = new globalThis.Map(); 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), allShips ?? ships, analysisActiveFilter, zoomScale, ); return ( {/* [DEBUG] 개발용 도구 — 프로덕션 번들에서 완전 제거 */} {DebugTools && } {/* 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 => (
{`\u26A0 ${t('korea.transshipSuspect')}`}
))} {/* Cable watch suspect labels */} {cableWatchSuspects.size > 0 && ships.filter(s => cableWatchSuspects.has(s.mmsi)).map(s => (
{`\u{1F50C} ${t('korea.cableDanger')}`}
))} {/* 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 (
{inTerritorial ? '\u{1F6A8}' : '\u26A0'} {'\u{1F1EF}\u{1F1F5}'} {inTerritorial ? t('korea.dokdoIntrusion') : t('korea.dokdoApproach')} {`${dist}km`}
); })} {layers.infra && infra.length > 0 && } {layers.satellites && satellites.length > 0 && } {layers.aircraft && aircraft.length > 0 && } {layers.cables && } {layers.cctv && } {/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */} {(koreaFilters.illegalFishing || koreaFilters.cnFishing) && } {koreaFilters.cnFishing && } {/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */} {koreaFilters.cnFishing && ( )} {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && ( )} {/* analysisShipMarkers → deck.gl (useShipDeckLayers) 전환 완료 */} {/* 선박 호버 툴팁 + 클릭 팝업 — React 오버레이 (MapLibre Popup 대체) */} {/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */} { 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 && ( setStaticPickInfo(null)} /> )} {layers.osint && } {layers.eez && } {/* 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 ( <>
{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 (
setActiveBadgeFilter(prev => prev === k ? null : k)} > {FILTER_ICON[k]} {badgeName} {badgeLabel}
); })}
{activeBadgeFilter && badgeShips.length > 0 && (
{FILTER_ICON[activeBadgeFilter]} {FILTER_I18N_KEY[activeBadgeFilter] ? t(FILTER_I18N_KEY[activeBadgeFilter]) : activeBadgeFilter} — {badgeShips.length}척
{badgeShips.slice(0, 200).map(s => ( setFlyToTarget({ lng: s.lng, lat: s.lat, zoom: 12 })} > ))}
MMSI Name Flag Type Speed
{s.mmsi} {s.name || '-'} {s.flag || '-'} {s.mtCategory || '-'} {s.speed?.toFixed(1)}kn
{badgeShips.length > 200 &&
...외 {badgeShips.length - 200}척
}
)} ); })()} {/* Dokdo alert panel */} {dokdoAlerts.length > 0 && koreaFilters.dokdoWatch && (
{`\u{1F6A8} ${t('korea.dokdoAlerts')}`}
{dokdoAlerts.map((a, i) => (
{a.dist < 22 ? `\u{1F6A8} ${t('korea.territorialIntrusion')}` : `\u26A0 ${t('korea.approachWarning')}`} {new Date(a.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
{`\u{1F1EF}\u{1F1F5} ${a.name} \u2014 ${t('korea.dokdoDistance', { dist: a.dist })}`}
))}
)} {/* 선택된 분석 선박 항적 — tracks API 응답 기반 */} {trackCoords && trackCoords.length > 1 && ( )} {/* AI Analysis Stats Panel — 항상 표시 */} {vesselAnalysis && ( )} {/* 작전가이드 임검침로 점선 — 해상 루트 (육지 우회) */} {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 ( <>
{opsRoute.distanceNM.toFixed(1)} NM
{opsRoute.from.name} → {opsRoute.to.name}
); })()} {/* ENC 설정 패널은 KoreaDashboard 헤더에서 렌더 */} ); }