- 위험도 버튼 클릭 → 해당 레벨 선박 목록 펼침 (최대 50척) - 선박 행 클릭 → 지도 중심이동(flyTo) + 근거 상세 펼침 - 근거: 위치/활동/다크/GPS/선단 정보 표시 - 선택 선박 항적: trail 데이터를 GeoJSON LineString으로 렌더링 - KoreaMap flyTo 기능 구현 (mapRef.flyTo) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
436 lines
17 KiB
TypeScript
436 lines
17 KiB
TypeScript
import { useRef, useState, useEffect, useCallback } 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 { ShipLayer } from '../layers/ShipLayer';
|
|
import { InfraLayer } from './InfraLayer';
|
|
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
|
import { AircraftLayer } from '../layers/AircraftLayer';
|
|
import { SubmarineCableLayer } from './SubmarineCableLayer';
|
|
import { CctvLayer } from './CctvLayer';
|
|
import { KoreaAirportLayer } from './KoreaAirportLayer';
|
|
import { CoastGuardLayer } from './CoastGuardLayer';
|
|
import { NavWarningLayer } from './NavWarningLayer';
|
|
import { OsintMapLayer } from './OsintMapLayer';
|
|
import { EezLayer } from './EezLayer';
|
|
import { PiracyLayer } from './PiracyLayer';
|
|
import { WindFarmLayer } from './WindFarmLayer';
|
|
import { PortLayer } from './PortLayer';
|
|
import { MilitaryBaseLayer } from './MilitaryBaseLayer';
|
|
import { GovBuildingLayer } from './GovBuildingLayer';
|
|
import { NKLaunchLayer } from './NKLaunchLayer';
|
|
import { NKMissileEventLayer } from './NKMissileEventLayer';
|
|
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
|
import { AnalysisOverlay } from './AnalysisOverlay';
|
|
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 { countryLabelsGeoJSON } from '../../data/countryLabels';
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
|
|
export interface KoreaFiltersState {
|
|
illegalFishing: boolean;
|
|
illegalTransship: boolean;
|
|
darkVessel: boolean;
|
|
cableWatch: boolean;
|
|
dokdoWatch: boolean;
|
|
ferryWatch: 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>;
|
|
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
|
vesselAnalysis?: UseVesselAnalysisResult;
|
|
}
|
|
|
|
// 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 } },
|
|
],
|
|
};
|
|
|
|
// 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}',
|
|
};
|
|
|
|
const FILTER_COLOR: Record<string, string> = {
|
|
illegalFishing: '#ef4444',
|
|
illegalTransship: '#f97316',
|
|
darkVessel: '#8b5cf6',
|
|
cableWatch: '#00e5ff',
|
|
dokdoWatch: '#22c55e',
|
|
ferryWatch: '#2196f3',
|
|
};
|
|
|
|
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',
|
|
};
|
|
|
|
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) {
|
|
const { t } = useTranslation();
|
|
const mapRef = useRef<MapRef>(null);
|
|
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
|
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
|
|
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchKoreaInfra().then(setInfra).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (flyToTarget && mapRef.current) {
|
|
mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom, duration: 1500 });
|
|
setFlyToTarget(null);
|
|
}
|
|
}, [flyToTarget]);
|
|
|
|
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]);
|
|
|
|
return (
|
|
<Map
|
|
ref={mapRef}
|
|
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
|
|
style={{ width: '100%', height: '100%' }}
|
|
mapStyle={MAP_STYLE}
|
|
>
|
|
<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,
|
|
'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,
|
|
'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,
|
|
'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.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
|
|
{/* Illegal fishing vessel markers — allShips(라이브 위치) 기반 */}
|
|
{koreaFilters.illegalFishing && (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).map(s => (
|
|
<Marker key={`illegal-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="center">
|
|
<div style={{
|
|
width: 20, height: 20, borderRadius: '50%',
|
|
border: '2px solid #ef4444',
|
|
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
|
animation: 'pulse 2s infinite',
|
|
pointerEvents: 'none',
|
|
}} />
|
|
<div style={{
|
|
fontSize: 8, fontWeight: 700, color: '#ef4444',
|
|
textShadow: '0 0 2px #000, 0 0 2px #000',
|
|
textAlign: 'center', marginTop: -2,
|
|
whiteSpace: 'nowrap', pointerEvents: 'none',
|
|
}}>
|
|
{s.name || s.mmsi}
|
|
</div>
|
|
</Marker>
|
|
))}
|
|
{/* 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 />}
|
|
{layers.windFarm && <WindFarmLayer />}
|
|
{layers.ports && <PortLayer />}
|
|
{layers.militaryBases && <MilitaryBaseLayer />}
|
|
{layers.govBuildings && <GovBuildingLayer />}
|
|
{layers.nkLaunch && <NKLaunchLayer />}
|
|
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
|
|
{koreaFilters.illegalFishing && <FishingZoneLayer />}
|
|
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
|
|
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
|
|
<AnalysisOverlay
|
|
ships={allShips ?? ships}
|
|
analysisMap={vesselAnalysis.analysisMap}
|
|
clusters={vesselAnalysis.clusters}
|
|
activeFilter={
|
|
koreaFilters.illegalFishing ? 'illegalFishing'
|
|
: koreaFilters.darkVessel ? 'darkVessel'
|
|
: layers.cnFishing ? 'cnFishing'
|
|
: null
|
|
}
|
|
/>
|
|
)}
|
|
{layers.airports && <KoreaAirportLayer />}
|
|
{layers.coastGuard && <CoastGuardLayer />}
|
|
{layers.navWarning && <NavWarningLayer />}
|
|
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
|
|
{layers.eez && <EezLayer />}
|
|
{layers.piracy && <PiracyLayer />}
|
|
|
|
{/* Filter Status Banner */}
|
|
{(() => {
|
|
const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]);
|
|
if (active.length === 0) return null;
|
|
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];
|
|
return (
|
|
<div
|
|
key={k}
|
|
className="rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 animate-pulse"
|
|
style={{
|
|
background: `${color}22`, border: `1px solid ${color}88`,
|
|
color,
|
|
}}
|
|
>
|
|
<span className="text-[13px]">{FILTER_ICON[k]}</span>
|
|
{t(FILTER_I18N_KEY[k])}
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="rounded-lg px-3 py-1.5 font-mono text-xs font-bold flex items-center bg-kcg-glass border border-kcg-border-light text-white">
|
|
{t('korea.detected', { count: ships.length })}
|
|
</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>
|
|
)}
|
|
|
|
{/* 선택된 분석 선박 항적 */}
|
|
{selectedAnalysisMmsi && (() => {
|
|
const ship = (allShips ?? ships).find(s => s.mmsi === selectedAnalysisMmsi);
|
|
if (!ship?.trail || ship.trail.length < 2) return null;
|
|
const trailGeoJson = {
|
|
type: 'FeatureCollection' as const,
|
|
features: [{
|
|
type: 'Feature' as const,
|
|
properties: {},
|
|
geometry: {
|
|
type: 'LineString' as const,
|
|
coordinates: ship.trail.map(([lat, lng]) => [lng, lat]),
|
|
},
|
|
}],
|
|
};
|
|
return (
|
|
<Source id="analysis-trail" type="geojson" data={trailGeoJson}>
|
|
<Layer id="analysis-trail-line" type="line" paint={{
|
|
'line-color': '#00e5ff',
|
|
'line-width': 2.5,
|
|
'line-opacity': 0.8,
|
|
'line-dasharray': [2, 1],
|
|
}} />
|
|
</Source>
|
|
);
|
|
})()}
|
|
|
|
{/* AI Analysis Stats Panel — 항상 표시 */}
|
|
{vesselAnalysis && (
|
|
<AnalysisStatsPanel
|
|
stats={vesselAnalysis.stats}
|
|
lastUpdated={vesselAnalysis.lastUpdated}
|
|
isLoading={vesselAnalysis.isLoading}
|
|
analysisMap={vesselAnalysis.analysisMap}
|
|
ships={allShips ?? ships}
|
|
onShipSelect={handleAnalysisShipSelect}
|
|
/>
|
|
)}
|
|
</Map>
|
|
);
|
|
}
|