Merge pull request 'release: 2026-03-23.3 (리팩토링)' (#157) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m53s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m53s
This commit is contained in:
커밋
a1c917108c
@ -4,6 +4,17 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-03-23.3]
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- App.tsx 분해: IranDashboard + KoreaDashboard 추출 (771줄→163줄)
|
||||||
|
- useStaticDeckLayers 분할: 레이어별 서브훅 4개 (1,086줄→85줄)
|
||||||
|
- StaticFacilityPopup 독립 컴포넌트 추출 (KoreaMap -200줄)
|
||||||
|
- geometry/shipClassification 유틸 추출
|
||||||
|
- SharedFilterContext + useSharedFilters (카테고리 필터 공유)
|
||||||
|
- API 클라이언트 래퍼 + usePoll 폴링 유틸 추가
|
||||||
|
- 줌 이벤트 ref 기반 디바운싱
|
||||||
|
|
||||||
## [2026-03-23.2]
|
## [2026-03-23.2]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -1,44 +1,15 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useLocalStorage, useLocalStorageSet } from './hooks/useLocalStorage';
|
|
||||||
import { ReplayMap } from './components/iran/ReplayMap';
|
|
||||||
import type { FlyToTarget } from './components/iran/ReplayMap';
|
|
||||||
import { GlobeMap } from './components/iran/GlobeMap';
|
|
||||||
import { SatelliteMap } from './components/iran/SatelliteMap';
|
|
||||||
import { KoreaMap } from './components/korea/KoreaMap';
|
|
||||||
import { TimelineSlider } from './components/common/TimelineSlider';
|
|
||||||
import { ReplayControls } from './components/common/ReplayControls';
|
|
||||||
import { LiveControls } from './components/common/LiveControls';
|
|
||||||
import { SensorChart } from './components/common/SensorChart';
|
|
||||||
import { EventLog } from './components/common/EventLog';
|
|
||||||
import { LayerPanel } from './components/common/LayerPanel';
|
|
||||||
import { useReplay } from './hooks/useReplay';
|
import { useReplay } from './hooks/useReplay';
|
||||||
import { useMonitor } from './hooks/useMonitor';
|
import { useMonitor } from './hooks/useMonitor';
|
||||||
import { useIranData } from './hooks/useIranData';
|
import type { AppMode } from './types';
|
||||||
import { useKoreaData } from './hooks/useKoreaData';
|
|
||||||
import { useKoreaFilters } from './hooks/useKoreaFilters';
|
|
||||||
import { useVesselAnalysis } from './hooks/useVesselAnalysis';
|
|
||||||
import type { GeoEvent, LayerVisibility, AppMode } from './types';
|
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { useAuth } from './hooks/useAuth';
|
import { useAuth } from './hooks/useAuth';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import LoginPage from './components/auth/LoginPage';
|
import LoginPage from './components/auth/LoginPage';
|
||||||
import CollectorMonitor from './components/common/CollectorMonitor';
|
import CollectorMonitor from './components/common/CollectorMonitor';
|
||||||
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
|
import { SharedFilterProvider } from './contexts/SharedFilterContext';
|
||||||
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
|
import { IranDashboard } from './components/iran/IranDashboard';
|
||||||
import { EAST_ASIA_PORTS } from './data/ports';
|
import { KoreaDashboard } from './components/korea/KoreaDashboard';
|
||||||
import { KOREAN_AIRPORTS } from './services/airports';
|
|
||||||
import { MILITARY_BASES } from './data/militaryBases';
|
|
||||||
import { GOV_BUILDINGS } from './data/govBuildings';
|
|
||||||
import { KOREA_WIND_FARMS } from './data/windFarms';
|
|
||||||
import { NK_LAUNCH_SITES } from './data/nkLaunchSites';
|
|
||||||
import { NK_MISSILE_EVENTS } from './data/nkMissileEvents';
|
|
||||||
import { COAST_GUARD_FACILITIES } from './services/coastGuard';
|
|
||||||
import { NAV_WARNINGS } from './services/navWarning';
|
|
||||||
import { PIRACY_ZONES } from './services/piracy';
|
|
||||||
import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
|
|
||||||
import { HAZARD_FACILITIES } from './data/hazardFacilities';
|
|
||||||
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities';
|
|
||||||
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities';
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -69,133 +40,18 @@ interface AuthenticatedAppProps {
|
|||||||
|
|
||||||
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||||
const [appMode, setAppMode] = useState<AppMode>('live');
|
const [appMode, setAppMode] = useState<AppMode>('live');
|
||||||
const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite');
|
const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran');
|
||||||
const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran');
|
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
||||||
const [layers, setLayers] = useLocalStorage<LayerVisibility>('iranLayers', {
|
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
||||||
events: true,
|
|
||||||
aircraft: true,
|
|
||||||
satellites: true,
|
|
||||||
ships: true,
|
|
||||||
koreanShips: true,
|
|
||||||
airports: true,
|
|
||||||
sensorCharts: false,
|
|
||||||
oilFacilities: true,
|
|
||||||
meFacilities: true,
|
|
||||||
militaryOnly: false,
|
|
||||||
overseasUS: false,
|
|
||||||
overseasUK: false,
|
|
||||||
overseasIran: false,
|
|
||||||
overseasUAE: false,
|
|
||||||
overseasSaudi: false,
|
|
||||||
overseasOman: false,
|
|
||||||
overseasQatar: false,
|
|
||||||
overseasKuwait: false,
|
|
||||||
overseasIraq: false,
|
|
||||||
overseasBahrain: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Korea tab layer visibility (lifted from KoreaMap)
|
|
||||||
const [koreaLayers, setKoreaLayers] = useLocalStorage<Record<string, boolean>>('koreaLayers', {
|
|
||||||
ships: true,
|
|
||||||
aircraft: true,
|
|
||||||
satellites: true,
|
|
||||||
infra: true,
|
|
||||||
cables: true,
|
|
||||||
cctv: true,
|
|
||||||
airports: true,
|
|
||||||
coastGuard: true,
|
|
||||||
navWarning: true,
|
|
||||||
osint: true,
|
|
||||||
eez: true,
|
|
||||||
piracy: true,
|
|
||||||
windFarm: true,
|
|
||||||
ports: true,
|
|
||||||
militaryBases: true,
|
|
||||||
govBuildings: true,
|
|
||||||
nkLaunch: true,
|
|
||||||
nkMissile: true,
|
|
||||||
cnFishing: false,
|
|
||||||
militaryOnly: false,
|
|
||||||
overseasChina: false,
|
|
||||||
overseasJapan: false,
|
|
||||||
cnPower: false,
|
|
||||||
cnMilitary: false,
|
|
||||||
jpPower: false,
|
|
||||||
jpMilitary: false,
|
|
||||||
hazardPetrochemical: false,
|
|
||||||
hazardLng: false,
|
|
||||||
hazardOilTank: false,
|
|
||||||
hazardPort: false,
|
|
||||||
energyNuclear: false,
|
|
||||||
energyThermal: false,
|
|
||||||
industryShipyard: false,
|
|
||||||
industryWastewater: false,
|
|
||||||
industryHeavy: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleKoreaLayer = useCallback((key: string) => {
|
|
||||||
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
}, [setKoreaLayers]);
|
|
||||||
|
|
||||||
// Category filter state (shared across tabs)
|
|
||||||
const [hiddenAcCategories, setHiddenAcCategories] = useLocalStorageSet('hiddenAcCategories', new Set());
|
|
||||||
const [hiddenShipCategories, setHiddenShipCategories] = useLocalStorageSet('hiddenShipCategories', new Set());
|
|
||||||
|
|
||||||
const toggleAcCategory = useCallback((cat: string) => {
|
|
||||||
setHiddenAcCategories(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [setHiddenAcCategories]);
|
|
||||||
|
|
||||||
const toggleShipCategory = useCallback((cat: string) => {
|
|
||||||
setHiddenShipCategories(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [setHiddenShipCategories]);
|
|
||||||
|
|
||||||
// Nationality filter state (Korea tab)
|
|
||||||
const [hiddenNationalities, setHiddenNationalities] = useLocalStorageSet('hiddenNationalities', new Set());
|
|
||||||
const toggleNationality = useCallback((nat: string) => {
|
|
||||||
setHiddenNationalities(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [setHiddenNationalities]);
|
|
||||||
|
|
||||||
// Fishing vessel nationality filter state
|
|
||||||
const [hiddenFishingNats, setHiddenFishingNats] = useLocalStorageSet('hiddenFishingNats', new Set());
|
|
||||||
const toggleFishingNat = useCallback((nat: string) => {
|
|
||||||
setHiddenFishingNats(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [setHiddenFishingNats]);
|
|
||||||
|
|
||||||
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
|
|
||||||
|
|
||||||
// 1시간마다 전체 데이터 강제 리프레시
|
// 1시간마다 전체 데이터 강제 리프레시
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const HOUR_MS = 3600_000;
|
const HOUR_MS = 3600_000;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => setRefreshKey(k => k + 1), HOUR_MS);
|
||||||
setRefreshKey(k => k + 1);
|
|
||||||
}, HOUR_MS);
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
|
||||||
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
|
|
||||||
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
|
||||||
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
|
|
||||||
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
|
|
||||||
const [seismicMarker, setSeismicMarker] = useState<{ lat: number; lng: number; magnitude: number; place: string } | null>(null);
|
|
||||||
|
|
||||||
const replay = useReplay();
|
const replay = useReplay();
|
||||||
const monitor = useMonitor();
|
const monitor = useMonitor();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
@ -205,56 +61,13 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
}, [i18n]);
|
}, [i18n]);
|
||||||
|
|
||||||
const isLive = appMode === 'live';
|
const isLive = appMode === 'live';
|
||||||
|
const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime;
|
||||||
// Unified time values based on current mode
|
|
||||||
const currentTime = appMode === 'live' ? monitor.state.currentTime : replay.state.currentTime;
|
|
||||||
|
|
||||||
// Iran data hook
|
|
||||||
const iranData = useIranData({
|
|
||||||
appMode,
|
|
||||||
currentTime,
|
|
||||||
isLive,
|
|
||||||
hiddenAcCategories,
|
|
||||||
hiddenShipCategories,
|
|
||||||
refreshKey,
|
|
||||||
dashboardTab,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Korea data hook
|
|
||||||
const koreaData = useKoreaData({
|
|
||||||
currentTime,
|
|
||||||
isLive,
|
|
||||||
hiddenAcCategories,
|
|
||||||
hiddenShipCategories,
|
|
||||||
hiddenNationalities,
|
|
||||||
refreshKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Vessel analysis (Python prediction 결과)
|
|
||||||
const vesselAnalysis = useVesselAnalysis(dashboardTab === 'korea');
|
|
||||||
|
|
||||||
// Korea filters hook
|
|
||||||
const koreaFiltersResult = useKoreaFilters(
|
|
||||||
koreaData.ships,
|
|
||||||
koreaData.visibleShips,
|
|
||||||
currentTime,
|
|
||||||
vesselAnalysis.analysisMap,
|
|
||||||
koreaLayers.cnFishing,
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
|
||||||
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
}, [setLayers]);
|
|
||||||
|
|
||||||
// Handle event card click from timeline: fly to location on map
|
|
||||||
const handleEventFlyTo = useCallback((event: GeoEvent) => {
|
|
||||||
setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SharedFilterProvider>
|
||||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
{/* Dashboard Tabs (replaces title) */}
|
{/* Dashboard Tabs */}
|
||||||
<div className="dash-tabs">
|
<div className="dash-tabs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -274,145 +87,11 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mode Toggle */}
|
{/* 탭별 모드/필터 영역 — 각 대시보드가 headerSlot으로 렌더링 */}
|
||||||
{dashboardTab === 'iran' && (
|
<div id="dashboard-header-slot" />
|
||||||
<div className="mode-toggle">
|
|
||||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
|
|
||||||
<span className="text-[13px]">⚔️</span>
|
|
||||||
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${appMode === 'live' ? 'active live' : ''}`}
|
|
||||||
onClick={() => setAppMode('live')}
|
|
||||||
>
|
|
||||||
<span className="mode-dot-icon" />
|
|
||||||
{t('mode.live')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
|
|
||||||
onClick={() => setAppMode('replay')}
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
|
||||||
{t('mode.replay')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dashboardTab === 'korea' && (
|
|
||||||
<div className="mode-toggle">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
|
|
||||||
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)}
|
|
||||||
title={t('filters.illegalFishing')}
|
|
||||||
>
|
|
||||||
<span className="text-[11px]">🚫🐟</span>
|
|
||||||
{t('filters.illegalFishing')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${koreaFiltersResult.filters.illegalTransship ? 'active live' : ''}`}
|
|
||||||
onClick={() => koreaFiltersResult.setFilter('illegalTransship', !koreaFiltersResult.filters.illegalTransship)}
|
|
||||||
title={t('filters.illegalTransship')}
|
|
||||||
>
|
|
||||||
<span className="text-[11px]">⚓</span>
|
|
||||||
{t('filters.illegalTransship')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${koreaFiltersResult.filters.darkVessel ? 'active live' : ''}`}
|
|
||||||
onClick={() => koreaFiltersResult.setFilter('darkVessel', !koreaFiltersResult.filters.darkVessel)}
|
|
||||||
title={t('filters.darkVessel')}
|
|
||||||
>
|
|
||||||
<span className="text-[11px]">👻</span>
|
|
||||||
{t('filters.darkVessel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${koreaFiltersResult.filters.cableWatch ? 'active live' : ''}`}
|
|
||||||
onClick={() => koreaFiltersResult.setFilter('cableWatch', !koreaFiltersResult.filters.cableWatch)}
|
|
||||||
title={t('filters.cableWatch')}
|
|
||||||
>
|
|
||||||
<span className="text-[11px]">🔌</span>
|
|
||||||
{t('filters.cableWatch')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${koreaFiltersResult.filters.dokdoWatch ? 'active live' : ''}`}
|
|
||||||
onClick={() => koreaFiltersResult.setFilter('dokdoWatch', !koreaFiltersResult.filters.dokdoWatch)}
|
|
||||||
title={t('filters.dokdoWatch')}
|
|
||||||
>
|
|
||||||
<span className="text-[11px]">🏝️</span>
|
|
||||||
{t('filters.dokdoWatch')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${koreaFiltersResult.filters.ferryWatch ? 'active live' : ''}`}
|
|
||||||
onClick={() => koreaFiltersResult.setFilter('ferryWatch', !koreaFiltersResult.filters.ferryWatch)}
|
|
||||||
title={t('filters.ferryWatch')}
|
|
||||||
>
|
|
||||||
<span className="text-[11px]">🚢</span>
|
|
||||||
{t('filters.ferryWatch')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${koreaLayers.cnFishing ? 'active live' : ''}`}
|
|
||||||
onClick={() => toggleKoreaLayer('cnFishing')}
|
|
||||||
title="중국어선감시"
|
|
||||||
>
|
|
||||||
<span className="text-[11px]">🎣</span>
|
|
||||||
중국어선감시
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mode-btn ${showFieldAnalysis ? 'active' : ''}`}
|
|
||||||
onClick={() => setShowFieldAnalysis(v => !v)}
|
|
||||||
title="현장분석"
|
|
||||||
>
|
|
||||||
<span className="text-[11px]">📊</span>
|
|
||||||
현장분석
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dashboardTab === 'iran' && (
|
|
||||||
<div className="map-mode-toggle">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`map-mode-btn ${mapMode === 'flat' ? 'active' : ''}`}
|
|
||||||
onClick={() => setMapMode('flat')}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
|
|
||||||
{t('mapMode.flat')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
|
|
||||||
onClick={() => setMapMode('globe')}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
|
|
||||||
{t('mapMode.globe')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
|
|
||||||
onClick={() => setMapMode('satellite')}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
|
|
||||||
{t('mapMode.satellite')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="header-info">
|
<div className="header-info">
|
||||||
<div className="header-counts">
|
<div id="dashboard-counts-slot" />
|
||||||
<span className="count-item ac-count">{dashboardTab === 'iran' ? iranData.aircraft.length : koreaData.aircraft.length} AC</span>
|
|
||||||
<span className="count-item mil-count">{dashboardTab === 'iran' ? iranData.militaryCount : koreaData.militaryCount} MIL</span>
|
|
||||||
<span className="count-item ship-count">{dashboardTab === 'iran' ? iranData.ships.length : koreaData.ships.length} SHIP</span>
|
|
||||||
<span className="count-item sat-count">{dashboardTab === 'iran' ? iranData.satPositions.length : koreaData.satPositions.length} SAT</span>
|
|
||||||
</div>
|
|
||||||
<div className="header-toggles">
|
<div className="header-toggles">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -447,326 +126,37 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════
|
|
||||||
IRAN DASHBOARD
|
|
||||||
═══════════════════════════════════════ */}
|
|
||||||
{dashboardTab === 'iran' && (
|
{dashboardTab === 'iran' && (
|
||||||
<>
|
<IranDashboard
|
||||||
<main className="app-main">
|
|
||||||
<div className="map-panel">
|
|
||||||
{mapMode === 'flat' ? (
|
|
||||||
<ReplayMap
|
|
||||||
key="map-iran"
|
|
||||||
events={isLive ? [] : iranData.mergedEvents}
|
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
aircraft={iranData.visibleAircraft}
|
|
||||||
satellites={iranData.satPositions}
|
|
||||||
ships={iranData.visibleShips}
|
|
||||||
layers={layers}
|
|
||||||
flyToTarget={flyToTarget}
|
|
||||||
onFlyToDone={() => setFlyToTarget(null)}
|
|
||||||
hoveredShipMmsi={hoveredShipMmsi}
|
|
||||||
focusShipMmsi={focusShipMmsi}
|
|
||||||
onFocusShipClear={() => setFocusShipMmsi(null)}
|
|
||||||
seismicMarker={seismicMarker}
|
|
||||||
/>
|
|
||||||
) : mapMode === 'globe' ? (
|
|
||||||
<GlobeMap
|
|
||||||
events={isLive ? [] : iranData.mergedEvents}
|
|
||||||
currentTime={currentTime}
|
|
||||||
aircraft={iranData.visibleAircraft}
|
|
||||||
satellites={iranData.satPositions}
|
|
||||||
ships={iranData.visibleShips}
|
|
||||||
layers={layers}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SatelliteMap
|
|
||||||
events={isLive ? [] : iranData.mergedEvents}
|
|
||||||
currentTime={currentTime}
|
|
||||||
aircraft={iranData.visibleAircraft}
|
|
||||||
satellites={iranData.satPositions}
|
|
||||||
ships={iranData.visibleShips}
|
|
||||||
layers={layers}
|
|
||||||
hoveredShipMmsi={hoveredShipMmsi}
|
|
||||||
focusShipMmsi={focusShipMmsi}
|
|
||||||
onFocusShipClear={() => setFocusShipMmsi(null)}
|
|
||||||
flyToTarget={flyToTarget}
|
|
||||||
onFlyToDone={() => setFlyToTarget(null)}
|
|
||||||
seismicMarker={seismicMarker}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="map-overlay-left">
|
|
||||||
<LayerPanel
|
|
||||||
layers={layers as unknown as Record<string, boolean>}
|
|
||||||
onToggle={toggleLayer as (key: string) => void}
|
|
||||||
aircraftByCategory={iranData.aircraftByCategory}
|
|
||||||
aircraftTotal={iranData.aircraft.length}
|
|
||||||
shipsByMtCategory={iranData.shipsByCategory}
|
|
||||||
shipTotal={iranData.ships.length}
|
|
||||||
satelliteCount={iranData.satPositions.length}
|
|
||||||
extraLayers={[
|
|
||||||
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
|
|
||||||
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.koreanShips.length },
|
|
||||||
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b' },
|
|
||||||
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' },
|
|
||||||
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
|
|
||||||
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
|
||||||
]}
|
|
||||||
overseasItems={[
|
|
||||||
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' },
|
|
||||||
{ key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' },
|
|
||||||
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' },
|
|
||||||
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' },
|
|
||||||
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' },
|
|
||||||
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' },
|
|
||||||
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' },
|
|
||||||
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' },
|
|
||||||
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' },
|
|
||||||
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' },
|
|
||||||
]}
|
|
||||||
hiddenAcCategories={hiddenAcCategories}
|
|
||||||
hiddenShipCategories={hiddenShipCategories}
|
|
||||||
onAcCategoryToggle={toggleAcCategory}
|
|
||||||
onShipCategoryToggle={toggleShipCategory}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="side-panel">
|
|
||||||
<EventLog
|
|
||||||
events={isLive ? [] : iranData.mergedEvents}
|
|
||||||
currentTime={currentTime}
|
|
||||||
totalShipCount={iranData.ships.length}
|
|
||||||
koreanShips={iranData.koreanShips}
|
|
||||||
koreanShipsByCategory={iranData.koreanShipsByCategory}
|
|
||||||
osintFeed={iranData.osintFeed}
|
|
||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
dashboardTab={dashboardTab}
|
refreshKey={refreshKey}
|
||||||
onTabChange={setDashboardTab}
|
replay={replay}
|
||||||
ships={iranData.ships}
|
monitor={monitor}
|
||||||
highlightKoreanShips={layers.koreanShips}
|
|
||||||
onToggleHighlightKorean={() => setLayers(prev => ({ ...prev, koreanShips: !prev.koreanShips }))}
|
|
||||||
onShipHover={setHoveredShipMmsi}
|
|
||||||
onShipClick={(mmsi) => {
|
|
||||||
setFocusShipMmsi(mmsi);
|
|
||||||
const ship = iranData.ships.find(s => s.mmsi === mmsi);
|
|
||||||
if (ship) setFlyToTarget({ lat: ship.lat, lng: ship.lng, zoom: 10 });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{layers.sensorCharts && (
|
|
||||||
<section className="charts-panel">
|
|
||||||
<SensorChart
|
|
||||||
seismicData={iranData.seismicData}
|
|
||||||
pressureData={iranData.pressureData}
|
|
||||||
currentTime={currentTime}
|
|
||||||
historyMinutes={monitor.state.historyMinutes}
|
|
||||||
onSeismicClick={(lat, lng, magnitude, place) => {
|
|
||||||
setFlyToTarget({ lat, lng, zoom: 8 });
|
|
||||||
setSeismicMarker({ lat, lng, magnitude, place });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<footer className="app-footer">
|
|
||||||
{isLive ? (
|
|
||||||
<LiveControls
|
|
||||||
currentTime={monitor.state.currentTime}
|
|
||||||
historyMinutes={monitor.state.historyMinutes}
|
|
||||||
onHistoryChange={monitor.setHistoryMinutes}
|
|
||||||
aircraftCount={iranData.aircraft.length}
|
|
||||||
shipCount={iranData.ships.length}
|
|
||||||
satelliteCount={iranData.satPositions.length}
|
|
||||||
timeZone={timeZone}
|
timeZone={timeZone}
|
||||||
onTimeZoneChange={setTimeZone}
|
onTimeZoneChange={setTimeZone}
|
||||||
|
appMode={appMode}
|
||||||
|
onAppModeChange={setAppMode}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ReplayControls
|
|
||||||
isPlaying={replay.state.isPlaying}
|
|
||||||
speed={replay.state.speed}
|
|
||||||
startTime={replay.state.startTime}
|
|
||||||
endTime={replay.state.endTime}
|
|
||||||
onPlay={replay.play}
|
|
||||||
onPause={replay.pause}
|
|
||||||
onReset={replay.reset}
|
|
||||||
onSpeedChange={replay.setSpeed}
|
|
||||||
onRangeChange={replay.setRange}
|
|
||||||
/>
|
|
||||||
<TimelineSlider
|
|
||||||
currentTime={replay.state.currentTime}
|
|
||||||
startTime={replay.state.startTime}
|
|
||||||
endTime={replay.state.endTime}
|
|
||||||
events={iranData.mergedEvents}
|
|
||||||
onSeek={replay.seek}
|
|
||||||
onEventFlyTo={handleEventFlyTo}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</footer>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════
|
|
||||||
KOREA DASHBOARD
|
|
||||||
═══════════════════════════════════════ */}
|
|
||||||
{dashboardTab === 'korea' && (
|
{dashboardTab === 'korea' && (
|
||||||
<>
|
<KoreaDashboard
|
||||||
<main className="app-main">
|
|
||||||
<div className="map-panel">
|
|
||||||
{showFieldAnalysis && (
|
|
||||||
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} />
|
|
||||||
)}
|
|
||||||
<KoreaMap
|
|
||||||
ships={koreaFiltersResult.filteredShips}
|
|
||||||
allShips={koreaData.visibleShips}
|
|
||||||
aircraft={koreaData.visibleAircraft}
|
|
||||||
satellites={koreaData.satPositions}
|
|
||||||
layers={koreaLayers}
|
|
||||||
osintFeed={koreaData.osintFeed}
|
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
koreaFilters={koreaFiltersResult.filters}
|
|
||||||
transshipSuspects={koreaFiltersResult.transshipSuspects}
|
|
||||||
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
|
|
||||||
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
|
||||||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
|
||||||
vesselAnalysis={vesselAnalysis}
|
|
||||||
/>
|
|
||||||
<div className="map-overlay-left">
|
|
||||||
<LayerPanel
|
|
||||||
layers={koreaLayers}
|
|
||||||
onToggle={toggleKoreaLayer}
|
|
||||||
aircraftByCategory={koreaData.aircraftByCategory}
|
|
||||||
aircraftTotal={koreaData.aircraft.length}
|
|
||||||
shipsByMtCategory={koreaData.shipsByCategory}
|
|
||||||
shipTotal={koreaData.ships.length}
|
|
||||||
satelliteCount={koreaData.satPositions.length}
|
|
||||||
extraLayers={[
|
|
||||||
// 해양안전
|
|
||||||
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length, group: '해양안전' },
|
|
||||||
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length, group: '해양안전' },
|
|
||||||
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length, group: '해양안전' },
|
|
||||||
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length, group: '해양안전' },
|
|
||||||
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1, group: '해양안전' },
|
|
||||||
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length, group: '해양안전' },
|
|
||||||
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length, group: '해양안전' },
|
|
||||||
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length, group: '해양안전' },
|
|
||||||
// 국가기관망
|
|
||||||
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length, group: '국가기관망' },
|
|
||||||
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length, group: '국가기관망' },
|
|
||||||
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length, group: '국가기관망' },
|
|
||||||
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length, group: '국가기관망' },
|
|
||||||
// 에너지/발전시설
|
|
||||||
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
|
|
||||||
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length, group: '에너지/발전시설' },
|
|
||||||
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length, group: '에너지/발전시설' },
|
|
||||||
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' },
|
|
||||||
// 위험시설
|
|
||||||
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' },
|
|
||||||
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' },
|
|
||||||
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' },
|
|
||||||
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' },
|
|
||||||
// 산업공정/제조시설
|
|
||||||
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' },
|
|
||||||
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' },
|
|
||||||
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' },
|
|
||||||
]}
|
|
||||||
overseasItems={[
|
|
||||||
{
|
|
||||||
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
|
|
||||||
count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length,
|
|
||||||
children: [
|
|
||||||
{ key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length },
|
|
||||||
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
|
|
||||||
count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length,
|
|
||||||
children: [
|
|
||||||
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
|
|
||||||
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
hiddenAcCategories={hiddenAcCategories}
|
|
||||||
hiddenShipCategories={hiddenShipCategories}
|
|
||||||
onAcCategoryToggle={toggleAcCategory}
|
|
||||||
onShipCategoryToggle={toggleShipCategory}
|
|
||||||
shipsByNationality={koreaData.shipsByNationality}
|
|
||||||
hiddenNationalities={hiddenNationalities}
|
|
||||||
onNationalityToggle={toggleNationality}
|
|
||||||
fishingByNationality={koreaData.fishingByNationality}
|
|
||||||
hiddenFishingNats={hiddenFishingNats}
|
|
||||||
onFishingNatToggle={toggleFishingNat}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="side-panel">
|
|
||||||
<EventLog
|
|
||||||
events={isLive ? [] : iranData.events}
|
|
||||||
currentTime={currentTime}
|
|
||||||
totalShipCount={koreaData.ships.length}
|
|
||||||
koreanShips={koreaData.koreaKoreanShips}
|
|
||||||
koreanShipsByCategory={koreaData.shipsByCategory}
|
|
||||||
chineseShips={koreaData.koreaChineseShips}
|
|
||||||
osintFeed={koreaData.osintFeed}
|
|
||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
dashboardTab={dashboardTab}
|
refreshKey={refreshKey}
|
||||||
onTabChange={setDashboardTab}
|
replay={replay}
|
||||||
ships={koreaData.ships}
|
monitor={monitor}
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="app-footer">
|
|
||||||
{isLive ? (
|
|
||||||
<LiveControls
|
|
||||||
currentTime={monitor.state.currentTime}
|
|
||||||
historyMinutes={monitor.state.historyMinutes}
|
|
||||||
onHistoryChange={monitor.setHistoryMinutes}
|
|
||||||
aircraftCount={koreaData.aircraft.length}
|
|
||||||
shipCount={koreaData.ships.length}
|
|
||||||
satelliteCount={koreaData.satPositions.length}
|
|
||||||
timeZone={timeZone}
|
timeZone={timeZone}
|
||||||
onTimeZoneChange={setTimeZone}
|
onTimeZoneChange={setTimeZone}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ReplayControls
|
|
||||||
isPlaying={replay.state.isPlaying}
|
|
||||||
speed={replay.state.speed}
|
|
||||||
startTime={replay.state.startTime}
|
|
||||||
endTime={replay.state.endTime}
|
|
||||||
onPlay={replay.play}
|
|
||||||
onPause={replay.pause}
|
|
||||||
onReset={replay.reset}
|
|
||||||
onSpeedChange={replay.setSpeed}
|
|
||||||
onRangeChange={replay.setRange}
|
|
||||||
/>
|
|
||||||
<TimelineSlider
|
|
||||||
currentTime={replay.state.currentTime}
|
|
||||||
startTime={replay.state.startTime}
|
|
||||||
endTime={replay.state.endTime}
|
|
||||||
events={iranData.mergedEvents}
|
|
||||||
onSeek={replay.seek}
|
|
||||||
onEventFlyTo={handleEventFlyTo}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</footer>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showCollectorMonitor && (
|
{showCollectorMonitor && (
|
||||||
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
|
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SharedFilterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
323
frontend/src/components/iran/IranDashboard.tsx
Normal file
323
frontend/src/components/iran/IranDashboard.tsx
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ReplayMap } from './ReplayMap';
|
||||||
|
import type { FlyToTarget } from './ReplayMap';
|
||||||
|
import { GlobeMap } from './GlobeMap';
|
||||||
|
import { SatelliteMap } from './SatelliteMap';
|
||||||
|
import { SensorChart } from '../common/SensorChart';
|
||||||
|
import { EventLog } from '../common/EventLog';
|
||||||
|
import { LayerPanel } from '../common/LayerPanel';
|
||||||
|
import { LiveControls } from '../common/LiveControls';
|
||||||
|
import { ReplayControls } from '../common/ReplayControls';
|
||||||
|
import { TimelineSlider } from '../common/TimelineSlider';
|
||||||
|
import { useIranData } from '../../hooks/useIranData';
|
||||||
|
import { useSharedFilters } from '../../hooks/useSharedFilters';
|
||||||
|
import type { GeoEvent, LayerVisibility, AppMode } from '../../types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface IranDashboardProps {
|
||||||
|
currentTime: number;
|
||||||
|
isLive: boolean;
|
||||||
|
refreshKey: number;
|
||||||
|
replay: {
|
||||||
|
state: {
|
||||||
|
isPlaying: boolean;
|
||||||
|
speed: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
currentTime: number;
|
||||||
|
};
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
setSpeed: (s: number) => void;
|
||||||
|
setRange: (s: number, e: number) => void;
|
||||||
|
seek: (t: number) => void;
|
||||||
|
};
|
||||||
|
monitor: {
|
||||||
|
state: { currentTime: number; historyMinutes: number };
|
||||||
|
setHistoryMinutes: (m: number) => void;
|
||||||
|
};
|
||||||
|
timeZone: 'KST' | 'UTC';
|
||||||
|
onTimeZoneChange: (tz: 'KST' | 'UTC') => void;
|
||||||
|
appMode: AppMode;
|
||||||
|
onAppModeChange: (mode: AppMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_LAYERS: LayerVisibility = {
|
||||||
|
events: true,
|
||||||
|
aircraft: true,
|
||||||
|
satellites: true,
|
||||||
|
ships: true,
|
||||||
|
koreanShips: true,
|
||||||
|
airports: true,
|
||||||
|
sensorCharts: false,
|
||||||
|
oilFacilities: true,
|
||||||
|
meFacilities: true,
|
||||||
|
militaryOnly: false,
|
||||||
|
overseasUS: false,
|
||||||
|
overseasUK: false,
|
||||||
|
overseasIran: false,
|
||||||
|
overseasUAE: false,
|
||||||
|
overseasSaudi: false,
|
||||||
|
overseasOman: false,
|
||||||
|
overseasQatar: false,
|
||||||
|
overseasKuwait: false,
|
||||||
|
overseasIraq: false,
|
||||||
|
overseasBahrain: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const IranDashboard = ({
|
||||||
|
currentTime,
|
||||||
|
isLive,
|
||||||
|
refreshKey,
|
||||||
|
replay,
|
||||||
|
monitor,
|
||||||
|
timeZone,
|
||||||
|
onTimeZoneChange,
|
||||||
|
appMode,
|
||||||
|
onAppModeChange,
|
||||||
|
}: IranDashboardProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite');
|
||||||
|
const [layers, setLayers] = useState<LayerVisibility>(INITIAL_LAYERS);
|
||||||
|
const [seismicMarker, setSeismicMarker] = useState<{
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
magnitude: number;
|
||||||
|
place: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
|
||||||
|
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
|
||||||
|
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
|
||||||
|
useSharedFilters();
|
||||||
|
|
||||||
|
const iranData = useIranData({
|
||||||
|
appMode,
|
||||||
|
currentTime,
|
||||||
|
isLive,
|
||||||
|
hiddenAcCategories,
|
||||||
|
hiddenShipCategories,
|
||||||
|
refreshKey,
|
||||||
|
dashboardTab: 'iran',
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
||||||
|
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEventFlyTo = useCallback((event: GeoEvent) => {
|
||||||
|
setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 헤더 슬롯 Portal — 이란 모드 토글 + 맵 모드 + 카운트
|
||||||
|
const headerSlot = document.getElementById('dashboard-header-slot');
|
||||||
|
const countsSlot = document.getElementById('dashboard-counts-slot');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{headerSlot && createPortal(
|
||||||
|
<>
|
||||||
|
<div className="mode-toggle">
|
||||||
|
<div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
|
||||||
|
<span className="text-[13px]">⚔️</span>
|
||||||
|
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={`mode-btn ${appMode === 'live' ? 'active live' : ''}`} onClick={() => onAppModeChange('live')}>
|
||||||
|
<span className="mode-dot-icon" />
|
||||||
|
{t('mode.live')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`} onClick={() => onAppModeChange('replay')}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
||||||
|
{t('mode.replay')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="map-mode-toggle">
|
||||||
|
<button type="button" className={`map-mode-btn ${mapMode === 'flat' ? 'active' : ''}`} onClick={() => setMapMode('flat')}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
|
||||||
|
{t('mapMode.flat')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`} onClick={() => setMapMode('globe')}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
|
||||||
|
{t('mapMode.globe')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`} onClick={() => setMapMode('satellite')}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
|
||||||
|
{t('mapMode.satellite')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
headerSlot,
|
||||||
|
)}
|
||||||
|
{countsSlot && createPortal(
|
||||||
|
<div className="header-counts">
|
||||||
|
<span className="count-item ac-count">{iranData.aircraft.length} AC</span>
|
||||||
|
<span className="count-item mil-count">{iranData.militaryCount} MIL</span>
|
||||||
|
<span className="count-item ship-count">{iranData.ships.length} SHIP</span>
|
||||||
|
<span className="count-item sat-count">{iranData.satPositions.length} SAT</span>
|
||||||
|
</div>,
|
||||||
|
countsSlot,
|
||||||
|
)}
|
||||||
|
<main className="app-main">
|
||||||
|
<div className="map-panel">
|
||||||
|
{mapMode === 'flat' ? (
|
||||||
|
<ReplayMap
|
||||||
|
key="map-iran"
|
||||||
|
events={isLive ? [] : iranData.mergedEvents}
|
||||||
|
currentTime={currentTime}
|
||||||
|
aircraft={iranData.visibleAircraft}
|
||||||
|
satellites={iranData.satPositions}
|
||||||
|
ships={iranData.visibleShips}
|
||||||
|
layers={layers}
|
||||||
|
flyToTarget={flyToTarget}
|
||||||
|
onFlyToDone={() => setFlyToTarget(null)}
|
||||||
|
hoveredShipMmsi={hoveredShipMmsi}
|
||||||
|
focusShipMmsi={focusShipMmsi}
|
||||||
|
onFocusShipClear={() => setFocusShipMmsi(null)}
|
||||||
|
seismicMarker={seismicMarker}
|
||||||
|
/>
|
||||||
|
) : mapMode === 'globe' ? (
|
||||||
|
<GlobeMap
|
||||||
|
events={isLive ? [] : iranData.mergedEvents}
|
||||||
|
currentTime={currentTime}
|
||||||
|
aircraft={iranData.visibleAircraft}
|
||||||
|
satellites={iranData.satPositions}
|
||||||
|
ships={iranData.visibleShips}
|
||||||
|
layers={layers}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SatelliteMap
|
||||||
|
events={isLive ? [] : iranData.mergedEvents}
|
||||||
|
currentTime={currentTime}
|
||||||
|
aircraft={iranData.visibleAircraft}
|
||||||
|
satellites={iranData.satPositions}
|
||||||
|
ships={iranData.visibleShips}
|
||||||
|
layers={layers}
|
||||||
|
hoveredShipMmsi={hoveredShipMmsi}
|
||||||
|
focusShipMmsi={focusShipMmsi}
|
||||||
|
onFocusShipClear={() => setFocusShipMmsi(null)}
|
||||||
|
flyToTarget={flyToTarget}
|
||||||
|
onFlyToDone={() => setFlyToTarget(null)}
|
||||||
|
seismicMarker={seismicMarker}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="map-overlay-left">
|
||||||
|
<LayerPanel
|
||||||
|
layers={layers as unknown as Record<string, boolean>}
|
||||||
|
onToggle={toggleLayer as (key: string) => void}
|
||||||
|
aircraftByCategory={iranData.aircraftByCategory}
|
||||||
|
aircraftTotal={iranData.aircraft.length}
|
||||||
|
shipsByMtCategory={iranData.shipsByCategory}
|
||||||
|
shipTotal={iranData.ships.length}
|
||||||
|
satelliteCount={iranData.satPositions.length}
|
||||||
|
extraLayers={[
|
||||||
|
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
|
||||||
|
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.koreanShips.length },
|
||||||
|
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b' },
|
||||||
|
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' },
|
||||||
|
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
|
||||||
|
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
||||||
|
]}
|
||||||
|
overseasItems={[
|
||||||
|
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' },
|
||||||
|
{ key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' },
|
||||||
|
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' },
|
||||||
|
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' },
|
||||||
|
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' },
|
||||||
|
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' },
|
||||||
|
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' },
|
||||||
|
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' },
|
||||||
|
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' },
|
||||||
|
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' },
|
||||||
|
]}
|
||||||
|
hiddenAcCategories={hiddenAcCategories}
|
||||||
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
|
onAcCategoryToggle={toggleAcCategory}
|
||||||
|
onShipCategoryToggle={toggleShipCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="side-panel">
|
||||||
|
<EventLog
|
||||||
|
events={isLive ? [] : iranData.mergedEvents}
|
||||||
|
currentTime={currentTime}
|
||||||
|
totalShipCount={iranData.ships.length}
|
||||||
|
koreanShips={iranData.koreanShips}
|
||||||
|
koreanShipsByCategory={iranData.koreanShipsByCategory}
|
||||||
|
osintFeed={iranData.osintFeed}
|
||||||
|
isLive={isLive}
|
||||||
|
dashboardTab="iran"
|
||||||
|
ships={iranData.ships}
|
||||||
|
highlightKoreanShips={layers.koreanShips}
|
||||||
|
onToggleHighlightKorean={() => setLayers(prev => ({ ...prev, koreanShips: !prev.koreanShips }))}
|
||||||
|
onShipHover={setHoveredShipMmsi}
|
||||||
|
onShipClick={(mmsi) => {
|
||||||
|
setFocusShipMmsi(mmsi);
|
||||||
|
const ship = iranData.ships.find(s => s.mmsi === mmsi);
|
||||||
|
if (ship) setFlyToTarget({ lat: ship.lat, lng: ship.lng, zoom: 10 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{layers.sensorCharts && (
|
||||||
|
<section className="charts-panel">
|
||||||
|
<SensorChart
|
||||||
|
seismicData={iranData.seismicData}
|
||||||
|
pressureData={iranData.pressureData}
|
||||||
|
currentTime={currentTime}
|
||||||
|
historyMinutes={monitor.state.historyMinutes}
|
||||||
|
onSeismicClick={(lat, lng, magnitude, place) => {
|
||||||
|
setFlyToTarget({ lat, lng, zoom: 8 });
|
||||||
|
setSeismicMarker({ lat, lng, magnitude, place });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
{isLive ? (
|
||||||
|
<LiveControls
|
||||||
|
currentTime={monitor.state.currentTime}
|
||||||
|
historyMinutes={monitor.state.historyMinutes}
|
||||||
|
onHistoryChange={monitor.setHistoryMinutes}
|
||||||
|
aircraftCount={iranData.aircraft.length}
|
||||||
|
shipCount={iranData.ships.length}
|
||||||
|
satelliteCount={iranData.satPositions.length}
|
||||||
|
timeZone={timeZone}
|
||||||
|
onTimeZoneChange={onTimeZoneChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ReplayControls
|
||||||
|
isPlaying={replay.state.isPlaying}
|
||||||
|
speed={replay.state.speed}
|
||||||
|
startTime={replay.state.startTime}
|
||||||
|
endTime={replay.state.endTime}
|
||||||
|
onPlay={replay.play}
|
||||||
|
onPause={replay.pause}
|
||||||
|
onReset={replay.reset}
|
||||||
|
onSpeedChange={replay.setSpeed}
|
||||||
|
onRangeChange={replay.setRange}
|
||||||
|
/>
|
||||||
|
<TimelineSlider
|
||||||
|
currentTime={replay.state.currentTime}
|
||||||
|
startTime={replay.state.startTime}
|
||||||
|
endTime={replay.state.endTime}
|
||||||
|
events={iranData.mergedEvents}
|
||||||
|
onSeek={replay.seek}
|
||||||
|
onEventFlyTo={handleEventFlyTo}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { IranDashboard };
|
||||||
|
export type { IranDashboardProps };
|
||||||
@ -6,6 +6,7 @@ import type { Ship, VesselAnalysisDto } from '../../types';
|
|||||||
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
|
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
|
||||||
import type { FleetCompany } from '../../services/vesselAnalysis';
|
import type { FleetCompany } from '../../services/vesselAnalysis';
|
||||||
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||||
|
import { convexHull, padPolygon, clusterColor } from '../../utils/geometry';
|
||||||
|
|
||||||
export interface SelectedGearGroupData {
|
export interface SelectedGearGroupData {
|
||||||
parent: Ship | null;
|
parent: Ship | null;
|
||||||
@ -29,59 +30,6 @@ interface Props {
|
|||||||
onSelectedFleetChange?: (data: SelectedFleetData | null) => void;
|
onSelectedFleetChange?: (data: SelectedFleetData | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별
|
|
||||||
function cross(o: [number, number], a: [number, number], b: [number, number]): number {
|
|
||||||
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graham scan 기반 볼록 껍질 (반시계 방향)
|
|
||||||
function convexHull(points: [number, number][]): [number, number][] {
|
|
||||||
const n = points.length;
|
|
||||||
if (n < 2) return points.slice();
|
|
||||||
const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
||||||
const lower: [number, number][] = [];
|
|
||||||
for (const p of sorted) {
|
|
||||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
|
|
||||||
lower.pop();
|
|
||||||
}
|
|
||||||
lower.push(p);
|
|
||||||
}
|
|
||||||
const upper: [number, number][] = [];
|
|
||||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
||||||
const p = sorted[i];
|
|
||||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
|
|
||||||
upper.pop();
|
|
||||||
}
|
|
||||||
upper.push(p);
|
|
||||||
}
|
|
||||||
// lower + upper (첫/끝 중복 제거)
|
|
||||||
lower.pop();
|
|
||||||
upper.pop();
|
|
||||||
return lower.concat(upper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 중심에서 각 꼭짓점 방향으로 padding 확장
|
|
||||||
function padPolygon(hull: [number, number][], padding: number): [number, number][] {
|
|
||||||
if (hull.length === 0) return hull;
|
|
||||||
const cx = hull.reduce((s, p) => s + p[0], 0) / hull.length;
|
|
||||||
const cy = hull.reduce((s, p) => s + p[1], 0) / hull.length;
|
|
||||||
return hull.map(([x, y]) => {
|
|
||||||
const dx = x - cx;
|
|
||||||
const dy = y - cy;
|
|
||||||
const len = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
if (len === 0) return [x + padding, y + padding] as [number, number];
|
|
||||||
const scale = (len + padding) / len;
|
|
||||||
return [cx + dx * scale, cy + dy * scale] as [number, number];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// cluster_id 해시 → HSL 색상
|
|
||||||
function clusterColor(id: number): string {
|
|
||||||
const h = (id * 137) % 360;
|
|
||||||
return `hsl(${h}, 80%, 55%)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HSL 문자열 → hex 근사 (MapLibre는 hsl() 지원하므로 직접 사용 가능)
|
|
||||||
// GeoJSON feature에 color 속성으로 주입
|
// GeoJSON feature에 color 속성으로 주입
|
||||||
interface ClusterPolygonFeature {
|
interface ClusterPolygonFeature {
|
||||||
type: 'Feature';
|
type: 'Feature';
|
||||||
|
|||||||
372
frontend/src/components/korea/KoreaDashboard.tsx
Normal file
372
frontend/src/components/korea/KoreaDashboard.tsx
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage';
|
||||||
|
import { KoreaMap } from './KoreaMap';
|
||||||
|
import { FieldAnalysisModal } from './FieldAnalysisModal';
|
||||||
|
import { LayerPanel } from '../common/LayerPanel';
|
||||||
|
import { EventLog } from '../common/EventLog';
|
||||||
|
import { LiveControls } from '../common/LiveControls';
|
||||||
|
import { ReplayControls } from '../common/ReplayControls';
|
||||||
|
import { TimelineSlider } from '../common/TimelineSlider';
|
||||||
|
import { useKoreaData } from '../../hooks/useKoreaData';
|
||||||
|
import { useVesselAnalysis } from '../../hooks/useVesselAnalysis';
|
||||||
|
import { useKoreaFilters } from '../../hooks/useKoreaFilters';
|
||||||
|
import { useSharedFilters } from '../../hooks/useSharedFilters';
|
||||||
|
import { EAST_ASIA_PORTS } from '../../data/ports';
|
||||||
|
import { KOREAN_AIRPORTS } from '../../services/airports';
|
||||||
|
import { MILITARY_BASES } from '../../data/militaryBases';
|
||||||
|
import { GOV_BUILDINGS } from '../../data/govBuildings';
|
||||||
|
import { KOREA_WIND_FARMS } from '../../data/windFarms';
|
||||||
|
import { NK_LAUNCH_SITES } from '../../data/nkLaunchSites';
|
||||||
|
import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents';
|
||||||
|
import { COAST_GUARD_FACILITIES } from '../../services/coastGuard';
|
||||||
|
import { NAV_WARNINGS } from '../../services/navWarning';
|
||||||
|
import { PIRACY_ZONES } from '../../services/piracy';
|
||||||
|
import { KOREA_SUBMARINE_CABLES } from '../../services/submarineCable';
|
||||||
|
import { HAZARD_FACILITIES } from '../../data/hazardFacilities';
|
||||||
|
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../../data/cnFacilities';
|
||||||
|
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../../data/jpFacilities';
|
||||||
|
|
||||||
|
type DashboardTab = 'iran' | 'korea';
|
||||||
|
|
||||||
|
interface ReplayState {
|
||||||
|
isPlaying: boolean;
|
||||||
|
speed: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
currentTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplayControls {
|
||||||
|
state: ReplayState;
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
setSpeed: (s: number) => void;
|
||||||
|
setRange: (s: number, e: number) => void;
|
||||||
|
seek: (t: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonitorState {
|
||||||
|
currentTime: number;
|
||||||
|
historyMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonitorControls {
|
||||||
|
state: MonitorState;
|
||||||
|
setHistoryMinutes: (m: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KoreaDashboardProps {
|
||||||
|
currentTime: number;
|
||||||
|
isLive: boolean;
|
||||||
|
refreshKey: number;
|
||||||
|
replay: ReplayControls;
|
||||||
|
monitor: MonitorControls;
|
||||||
|
timeZone: 'KST' | 'UTC';
|
||||||
|
onTimeZoneChange: (tz: 'KST' | 'UTC') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KoreaDashboard = ({
|
||||||
|
currentTime,
|
||||||
|
isLive,
|
||||||
|
refreshKey,
|
||||||
|
replay,
|
||||||
|
monitor,
|
||||||
|
timeZone,
|
||||||
|
onTimeZoneChange,
|
||||||
|
}: KoreaDashboardProps) => {
|
||||||
|
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
|
||||||
|
useSharedFilters();
|
||||||
|
|
||||||
|
const [koreaLayers, setKoreaLayers] = useLocalStorage<Record<string, boolean>>('koreaLayers', {
|
||||||
|
ships: true,
|
||||||
|
aircraft: true,
|
||||||
|
satellites: true,
|
||||||
|
infra: true,
|
||||||
|
cables: true,
|
||||||
|
cctv: true,
|
||||||
|
airports: true,
|
||||||
|
coastGuard: true,
|
||||||
|
navWarning: true,
|
||||||
|
osint: true,
|
||||||
|
eez: true,
|
||||||
|
piracy: true,
|
||||||
|
windFarm: true,
|
||||||
|
ports: true,
|
||||||
|
militaryBases: true,
|
||||||
|
govBuildings: true,
|
||||||
|
nkLaunch: true,
|
||||||
|
nkMissile: true,
|
||||||
|
cnFishing: false,
|
||||||
|
militaryOnly: false,
|
||||||
|
overseasChina: false,
|
||||||
|
overseasJapan: false,
|
||||||
|
cnPower: false,
|
||||||
|
cnMilitary: false,
|
||||||
|
jpPower: false,
|
||||||
|
jpMilitary: false,
|
||||||
|
hazardPetrochemical: false,
|
||||||
|
hazardLng: false,
|
||||||
|
hazardOilTank: false,
|
||||||
|
hazardPort: false,
|
||||||
|
energyNuclear: false,
|
||||||
|
energyThermal: false,
|
||||||
|
industryShipyard: false,
|
||||||
|
industryWastewater: false,
|
||||||
|
industryHeavy: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleKoreaLayer = useCallback((key: string) => {
|
||||||
|
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}, [setKoreaLayers]);
|
||||||
|
|
||||||
|
const [hiddenNationalities, setHiddenNationalities] = useLocalStorageSet('hiddenNationalities', new Set());
|
||||||
|
const toggleNationality = useCallback((nat: string) => {
|
||||||
|
setHiddenNationalities(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [setHiddenNationalities]);
|
||||||
|
|
||||||
|
const [hiddenFishingNats, setHiddenFishingNats] = useLocalStorageSet('hiddenFishingNats', new Set());
|
||||||
|
const toggleFishingNat = useCallback((nat: string) => {
|
||||||
|
setHiddenFishingNats(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [setHiddenFishingNats]);
|
||||||
|
|
||||||
|
const koreaData = useKoreaData({
|
||||||
|
currentTime,
|
||||||
|
isLive,
|
||||||
|
hiddenAcCategories,
|
||||||
|
hiddenShipCategories,
|
||||||
|
hiddenNationalities,
|
||||||
|
refreshKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vesselAnalysis = useVesselAnalysis(true);
|
||||||
|
|
||||||
|
const koreaFiltersResult = useKoreaFilters(
|
||||||
|
koreaData.ships,
|
||||||
|
koreaData.visibleShips,
|
||||||
|
currentTime,
|
||||||
|
vesselAnalysis.analysisMap,
|
||||||
|
koreaLayers.cnFishing,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback((_tab: DashboardTab) => {
|
||||||
|
// Tab switching is managed by parent (App.tsx); no-op here
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 헤더 슬롯 Portal — 한국 필터 버튼 + 카운트
|
||||||
|
const headerSlot = document.getElementById('dashboard-header-slot');
|
||||||
|
const countsSlot = document.getElementById('dashboard-counts-slot');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{headerSlot && createPortal(
|
||||||
|
<div className="mode-toggle">
|
||||||
|
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
|
||||||
|
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)} title={t('filters.illegalFishing')}>
|
||||||
|
<span className="text-[11px]">🚫🐟</span>{t('filters.illegalFishing')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalTransship ? 'active live' : ''}`}
|
||||||
|
onClick={() => koreaFiltersResult.setFilter('illegalTransship', !koreaFiltersResult.filters.illegalTransship)} title={t('filters.illegalTransship')}>
|
||||||
|
<span className="text-[11px]">⚓</span>{t('filters.illegalTransship')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.darkVessel ? 'active live' : ''}`}
|
||||||
|
onClick={() => koreaFiltersResult.setFilter('darkVessel', !koreaFiltersResult.filters.darkVessel)} title={t('filters.darkVessel')}>
|
||||||
|
<span className="text-[11px]">👻</span>{t('filters.darkVessel')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.cableWatch ? 'active live' : ''}`}
|
||||||
|
onClick={() => koreaFiltersResult.setFilter('cableWatch', !koreaFiltersResult.filters.cableWatch)} title={t('filters.cableWatch')}>
|
||||||
|
<span className="text-[11px]">🔌</span>{t('filters.cableWatch')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.dokdoWatch ? 'active live' : ''}`}
|
||||||
|
onClick={() => koreaFiltersResult.setFilter('dokdoWatch', !koreaFiltersResult.filters.dokdoWatch)} title={t('filters.dokdoWatch')}>
|
||||||
|
<span className="text-[11px]">🏝️</span>{t('filters.dokdoWatch')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.ferryWatch ? 'active live' : ''}`}
|
||||||
|
onClick={() => koreaFiltersResult.setFilter('ferryWatch', !koreaFiltersResult.filters.ferryWatch)} title={t('filters.ferryWatch')}>
|
||||||
|
<span className="text-[11px]">🚢</span>{t('filters.ferryWatch')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`mode-btn ${koreaLayers.cnFishing ? 'active live' : ''}`}
|
||||||
|
onClick={() => toggleKoreaLayer('cnFishing')} title="중국어선감시">
|
||||||
|
<span className="text-[11px]">🎣</span>중국어선감시
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`mode-btn ${showFieldAnalysis ? 'active' : ''}`}
|
||||||
|
onClick={() => setShowFieldAnalysis(v => !v)} title="현장분석">
|
||||||
|
<span className="text-[11px]">📊</span>현장분석
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
headerSlot,
|
||||||
|
)}
|
||||||
|
{countsSlot && createPortal(
|
||||||
|
<div className="header-counts">
|
||||||
|
<span className="count-item ac-count">{koreaData.aircraft.length} AC</span>
|
||||||
|
<span className="count-item mil-count">{koreaData.militaryCount} MIL</span>
|
||||||
|
<span className="count-item ship-count">{koreaData.ships.length} SHIP</span>
|
||||||
|
<span className="count-item sat-count">{koreaData.satPositions.length} SAT</span>
|
||||||
|
</div>,
|
||||||
|
countsSlot,
|
||||||
|
)}
|
||||||
|
<main className="app-main">
|
||||||
|
<div className="map-panel">
|
||||||
|
{showFieldAnalysis && (
|
||||||
|
<FieldAnalysisModal
|
||||||
|
ships={koreaData.ships}
|
||||||
|
vesselAnalysis={vesselAnalysis}
|
||||||
|
onClose={() => setShowFieldAnalysis(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<KoreaMap
|
||||||
|
ships={koreaFiltersResult.filteredShips}
|
||||||
|
allShips={koreaData.visibleShips}
|
||||||
|
aircraft={koreaData.visibleAircraft}
|
||||||
|
satellites={koreaData.satPositions}
|
||||||
|
layers={koreaLayers}
|
||||||
|
osintFeed={koreaData.osintFeed}
|
||||||
|
currentTime={currentTime}
|
||||||
|
koreaFilters={koreaFiltersResult.filters}
|
||||||
|
transshipSuspects={koreaFiltersResult.transshipSuspects}
|
||||||
|
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
|
||||||
|
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
||||||
|
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||||||
|
vesselAnalysis={vesselAnalysis}
|
||||||
|
/>
|
||||||
|
<div className="map-overlay-left">
|
||||||
|
<LayerPanel
|
||||||
|
layers={koreaLayers}
|
||||||
|
onToggle={toggleKoreaLayer}
|
||||||
|
aircraftByCategory={koreaData.aircraftByCategory}
|
||||||
|
aircraftTotal={koreaData.aircraft.length}
|
||||||
|
shipsByMtCategory={koreaData.shipsByCategory}
|
||||||
|
shipTotal={koreaData.ships.length}
|
||||||
|
satelliteCount={koreaData.satPositions.length}
|
||||||
|
extraLayers={[
|
||||||
|
// 해양안전
|
||||||
|
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length, group: '해양안전' },
|
||||||
|
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length, group: '해양안전' },
|
||||||
|
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length, group: '해양안전' },
|
||||||
|
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length, group: '해양안전' },
|
||||||
|
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1, group: '해양안전' },
|
||||||
|
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length, group: '해양안전' },
|
||||||
|
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length, group: '해양안전' },
|
||||||
|
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length, group: '해양안전' },
|
||||||
|
// 국가기관망
|
||||||
|
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length, group: '국가기관망' },
|
||||||
|
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length, group: '국가기관망' },
|
||||||
|
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length, group: '국가기관망' },
|
||||||
|
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length, group: '국가기관망' },
|
||||||
|
// 에너지/발전시설
|
||||||
|
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
|
||||||
|
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length, group: '에너지/발전시설' },
|
||||||
|
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length, group: '에너지/발전시설' },
|
||||||
|
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' },
|
||||||
|
// 위험시설
|
||||||
|
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' },
|
||||||
|
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' },
|
||||||
|
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' },
|
||||||
|
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' },
|
||||||
|
// 산업공정/제조시설
|
||||||
|
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' },
|
||||||
|
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' },
|
||||||
|
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' },
|
||||||
|
]}
|
||||||
|
overseasItems={[
|
||||||
|
{
|
||||||
|
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
|
||||||
|
count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length,
|
||||||
|
children: [
|
||||||
|
{ key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length },
|
||||||
|
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
|
||||||
|
count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length,
|
||||||
|
children: [
|
||||||
|
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
|
||||||
|
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
hiddenAcCategories={hiddenAcCategories}
|
||||||
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
|
onAcCategoryToggle={toggleAcCategory}
|
||||||
|
onShipCategoryToggle={toggleShipCategory}
|
||||||
|
shipsByNationality={koreaData.shipsByNationality}
|
||||||
|
hiddenNationalities={hiddenNationalities}
|
||||||
|
onNationalityToggle={toggleNationality}
|
||||||
|
fishingByNationality={koreaData.fishingByNationality}
|
||||||
|
hiddenFishingNats={hiddenFishingNats}
|
||||||
|
onFishingNatToggle={toggleFishingNat}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="side-panel">
|
||||||
|
<EventLog
|
||||||
|
events={[]}
|
||||||
|
currentTime={currentTime}
|
||||||
|
totalShipCount={koreaData.ships.length}
|
||||||
|
koreanShips={koreaData.koreaKoreanShips}
|
||||||
|
koreanShipsByCategory={koreaData.shipsByCategory}
|
||||||
|
chineseShips={koreaData.koreaChineseShips}
|
||||||
|
osintFeed={koreaData.osintFeed}
|
||||||
|
isLive={isLive}
|
||||||
|
dashboardTab="korea"
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
ships={koreaData.ships}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
{isLive ? (
|
||||||
|
<LiveControls
|
||||||
|
currentTime={monitor.state.currentTime}
|
||||||
|
historyMinutes={monitor.state.historyMinutes}
|
||||||
|
onHistoryChange={monitor.setHistoryMinutes}
|
||||||
|
aircraftCount={koreaData.aircraft.length}
|
||||||
|
shipCount={koreaData.ships.length}
|
||||||
|
satelliteCount={koreaData.satPositions.length}
|
||||||
|
timeZone={timeZone}
|
||||||
|
onTimeZoneChange={onTimeZoneChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ReplayControls
|
||||||
|
isPlaying={replay.state.isPlaying}
|
||||||
|
speed={replay.state.speed}
|
||||||
|
startTime={replay.state.startTime}
|
||||||
|
endTime={replay.state.endTime}
|
||||||
|
onPlay={replay.play}
|
||||||
|
onPause={replay.pause}
|
||||||
|
onReset={replay.reset}
|
||||||
|
onSpeedChange={replay.setSpeed}
|
||||||
|
onRangeChange={replay.setRange}
|
||||||
|
/>
|
||||||
|
<TimelineSlider
|
||||||
|
currentTime={replay.state.currentTime}
|
||||||
|
startTime={replay.state.startTime}
|
||||||
|
endTime={replay.state.endTime}
|
||||||
|
events={[]}
|
||||||
|
onSeek={replay.seek}
|
||||||
|
onEventFlyTo={() => undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Map, NavigationControl, Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
import type { MapRef } from 'react-map-gl/maplibre';
|
||||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||||
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
|
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
|
||||||
@ -20,6 +20,7 @@ import { EezLayer } from './EezLayer';
|
|||||||
// PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer,
|
// PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer,
|
||||||
// NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨
|
// NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨
|
||||||
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
||||||
|
import { StaticFacilityPopup } from './StaticFacilityPopup';
|
||||||
// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨
|
// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨
|
||||||
import { AnalysisOverlay } from './AnalysisOverlay';
|
import { AnalysisOverlay } from './AnalysisOverlay';
|
||||||
import { FleetClusterLayer } from './FleetClusterLayer';
|
import { FleetClusterLayer } from './FleetClusterLayer';
|
||||||
@ -142,6 +143,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
|
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
|
||||||
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
|
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
|
||||||
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
|
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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
||||||
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
|
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
|
||||||
|
|
||||||
@ -481,7 +490,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
|
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
mapStyle={MAP_STYLE}
|
mapStyle={MAP_STYLE}
|
||||||
onZoom={e => setZoomLevel(Math.floor(e.viewState.zoom))}
|
onZoom={handleZoom}
|
||||||
>
|
>
|
||||||
<NavigationControl position="top-right" />
|
<NavigationControl position="top-right" />
|
||||||
|
|
||||||
@ -643,203 +652,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
...(analysisPanelOpen ? analysisDeckLayers : []),
|
...(analysisPanelOpen ? analysisDeckLayers : []),
|
||||||
].filter(Boolean)} />
|
].filter(Boolean)} />
|
||||||
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
|
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
|
||||||
{staticPickInfo && (() => {
|
{staticPickInfo && (
|
||||||
const obj = staticPickInfo.object;
|
<StaticFacilityPopup pickInfo={staticPickInfo} onClose={() => setStaticPickInfo(null)} />
|
||||||
const kind = staticPickInfo.kind;
|
|
||||||
const lat = obj.lat ?? obj.launchLat ?? 0;
|
|
||||||
const lng = obj.lng ?? obj.launchLng ?? 0;
|
|
||||||
if (!lat || !lng) return null;
|
|
||||||
|
|
||||||
// ── kind + subType 기반 메타 결정 ──
|
|
||||||
const SUB_META: Record<string, Record<string, { icon: string; color: string; label: string }>> = {
|
|
||||||
hazard: {
|
|
||||||
petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지' },
|
|
||||||
lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지' },
|
|
||||||
oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크' },
|
|
||||||
hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물항만하역' },
|
|
||||||
nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소' },
|
|
||||||
thermal: { icon: '🔥', color: '#64748b', label: '화력발전소' },
|
|
||||||
shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소' },
|
|
||||||
wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장' },
|
|
||||||
heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소' },
|
|
||||||
},
|
|
||||||
overseas: {
|
|
||||||
nuclear: { icon: '☢️', color: '#ef4444', label: '핵발전소' },
|
|
||||||
thermal: { icon: '🔥', color: '#f97316', label: '화력발전소' },
|
|
||||||
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
|
|
||||||
airbase: { icon: '✈️', color: '#22d3ee', label: '공군기지' },
|
|
||||||
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
|
|
||||||
shipyard:{ icon: '🚢', color: '#94a3b8', label: '조선소' },
|
|
||||||
},
|
|
||||||
militaryBase: {
|
|
||||||
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
|
|
||||||
airforce:{ icon: '✈️', color: '#22d3ee', label: '공군기지' },
|
|
||||||
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
|
|
||||||
missile: { icon: '🚀', color: '#ef4444', label: '미사일기지' },
|
|
||||||
joint: { icon: '⭐', color: '#f59e0b', label: '합동사령부' },
|
|
||||||
},
|
|
||||||
govBuilding: {
|
|
||||||
executive: { icon: '🏛️', color: '#f59e0b', label: '행정부' },
|
|
||||||
legislature: { icon: '🏛️', color: '#3b82f6', label: '입법부' },
|
|
||||||
military_hq: { icon: '⭐', color: '#ef4444', label: '군사령부' },
|
|
||||||
intelligence: { icon: '🔍', color: '#8b5cf6', label: '정보기관' },
|
|
||||||
foreign: { icon: '🌐', color: '#06b6d4', label: '외교부' },
|
|
||||||
maritime: { icon: '⚓', color: '#0ea5e9', label: '해양기관' },
|
|
||||||
defense: { icon: '🛡️', color: '#dc2626', label: '국방부' },
|
|
||||||
},
|
|
||||||
nkLaunch: {
|
|
||||||
icbm: { icon: '🚀', color: '#dc2626', label: 'ICBM 발사장' },
|
|
||||||
irbm: { icon: '🚀', color: '#ef4444', label: 'IRBM/MRBM' },
|
|
||||||
srbm: { icon: '🎯', color: '#f97316', label: '단거리탄도미사일' },
|
|
||||||
slbm: { icon: '🔱', color: '#3b82f6', label: 'SLBM 발사' },
|
|
||||||
cruise: { icon: '✈️', color: '#8b5cf6', label: '순항미사일' },
|
|
||||||
artillery: { icon: '💥', color: '#eab308', label: '해안포/장사정포' },
|
|
||||||
mlrs: { icon: '💥', color: '#f59e0b', label: '방사포(MLRS)' },
|
|
||||||
},
|
|
||||||
coastGuard: {
|
|
||||||
hq: { icon: '🏢', color: '#3b82f6', label: '본청' },
|
|
||||||
regional: { icon: '🏢', color: '#60a5fa', label: '지방청' },
|
|
||||||
station: { icon: '🚔', color: '#22d3ee', label: '해양경찰서' },
|
|
||||||
substation: { icon: '🏠', color: '#94a3b8', label: '파출소' },
|
|
||||||
vts: { icon: '📡', color: '#f59e0b', label: 'VTS센터' },
|
|
||||||
navy: { icon: '⚓', color: '#3b82f6', label: '해군부대' },
|
|
||||||
},
|
|
||||||
airport: {
|
|
||||||
international: { icon: '✈️', color: '#a78bfa', label: '국제공항' },
|
|
||||||
domestic: { icon: '🛩️', color: '#60a5fa', label: '국내공항' },
|
|
||||||
military: { icon: '✈️', color: '#ef4444', label: '군용비행장' },
|
|
||||||
},
|
|
||||||
navWarning: {
|
|
||||||
danger: { icon: '⚠️', color: '#ef4444', label: '위험' },
|
|
||||||
caution: { icon: '⚠️', color: '#eab308', label: '주의' },
|
|
||||||
info: { icon: 'ℹ️', color: '#3b82f6', label: '정보' },
|
|
||||||
},
|
|
||||||
piracy: {
|
|
||||||
critical: { icon: '☠️', color: '#ef4444', label: '극고위험' },
|
|
||||||
high: { icon: '☠️', color: '#f97316', label: '고위험' },
|
|
||||||
moderate: { icon: '☠️', color: '#eab308', label: '주의' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const KIND_DEFAULT: Record<string, { icon: string; color: string; label: string }> = {
|
|
||||||
port: { icon: '⚓', color: '#3b82f6', label: '항구' },
|
|
||||||
windFarm: { icon: '🌀', color: '#00bcd4', label: '풍력단지' },
|
|
||||||
militaryBase: { icon: '🪖', color: '#ef4444', label: '군사시설' },
|
|
||||||
govBuilding: { icon: '🏛️', color: '#f59e0b', label: '정부기관' },
|
|
||||||
nkLaunch: { icon: '🚀', color: '#dc2626', label: '북한 발사장' },
|
|
||||||
nkMissile: { icon: '💣', color: '#ef4444', label: '미사일 낙하' },
|
|
||||||
coastGuard: { icon: '🚔', color: '#4dabf7', label: '해양경찰' },
|
|
||||||
airport: { icon: '✈️', color: '#a78bfa', label: '공항' },
|
|
||||||
navWarning: { icon: '⚠️', color: '#eab308', label: '항행경보' },
|
|
||||||
piracy: { icon: '☠️', color: '#ef4444', label: '해적위험' },
|
|
||||||
infra: { icon: '⚡', color: '#ffeb3b', label: '발전/변전' },
|
|
||||||
hazard: { icon: '⚠️', color: '#ef4444', label: '위험시설' },
|
|
||||||
cnFacility: { icon: '📍', color: '#ef4444', label: '중국시설' },
|
|
||||||
jpFacility: { icon: '📍', color: '#f472b6', label: '일본시설' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// subType 키 결정
|
|
||||||
const subKey = obj.type ?? obj.subType ?? obj.level ?? '';
|
|
||||||
const subGroup = kind === 'cnFacility' || kind === 'jpFacility' ? 'overseas' : kind;
|
|
||||||
const meta = SUB_META[subGroup]?.[subKey] ?? KIND_DEFAULT[kind] ?? { icon: '📍', color: '#64748b', label: kind };
|
|
||||||
|
|
||||||
// 국가 플래그
|
|
||||||
const COUNTRY_FLAG: Record<string, string> = { CN: '🇨🇳', JP: '🇯🇵', KP: '🇰🇵', KR: '🇰🇷', TW: '🇹🇼' };
|
|
||||||
const flag = kind === 'cnFacility' ? '🇨🇳' : kind === 'jpFacility' ? '🇯🇵' : COUNTRY_FLAG[obj.country] ?? '';
|
|
||||||
const countryName = kind === 'cnFacility' ? '중국' : kind === 'jpFacility' ? '일본'
|
|
||||||
: { CN: '중국', JP: '일본', KP: '북한', KR: '한국', TW: '대만' }[obj.country] ?? '';
|
|
||||||
|
|
||||||
// 이름 결정
|
|
||||||
const title = obj.nameKo || obj.name || obj.launchNameKo || obj.title || kind;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popup longitude={lng} latitude={lat} anchor="bottom"
|
|
||||||
onClose={() => setStaticPickInfo(null)} closeOnClick={false}
|
|
||||||
maxWidth="280px" className="gl-popup"
|
|
||||||
>
|
|
||||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
|
||||||
{/* 컬러 헤더 */}
|
|
||||||
<div className="popup-header" style={{ background: meta.color, color: '#000', gap: 6, padding: '4px 8px' }}>
|
|
||||||
<span>{meta.icon}</span> {title}
|
|
||||||
</div>
|
|
||||||
{/* 배지 행 */}
|
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{
|
|
||||||
background: meta.color, color: '#000',
|
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
|
||||||
}}>
|
|
||||||
{meta.label}
|
|
||||||
</span>
|
|
||||||
{flag && (
|
|
||||||
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
|
|
||||||
{flag} {countryName}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{kind === 'hazard' && (
|
|
||||||
<span style={{
|
|
||||||
background: 'rgba(239,68,68,0.15)', color: '#ef4444',
|
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 600,
|
|
||||||
border: '1px solid rgba(239,68,68,0.3)',
|
|
||||||
}}>⚠️ 위험시설</span>
|
|
||||||
)}
|
|
||||||
{kind === 'port' && (
|
|
||||||
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
|
|
||||||
{obj.type === 'major' ? '주요항' : '중소항'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{kind === 'airport' && obj.intl && (
|
|
||||||
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>국제선</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* 설명 */}
|
|
||||||
{obj.description && (
|
|
||||||
<div style={{ fontSize: 10, color: '#999', marginBottom: 4, lineHeight: 1.5 }}>{obj.description}</div>
|
|
||||||
)}
|
|
||||||
{obj.detail && (
|
|
||||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.detail}</div>
|
|
||||||
)}
|
|
||||||
{obj.note && (
|
|
||||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.note}</div>
|
|
||||||
)}
|
|
||||||
{/* 필드 그리드 */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
{obj.operator && <div><span className="popup-label">운영: </span>{obj.operator}</div>}
|
|
||||||
{obj.capacity && <div><span className="popup-label">규모: </span><strong>{obj.capacity}</strong></div>}
|
|
||||||
{obj.output && <div><span className="popup-label">출력: </span><strong>{obj.output}</strong></div>}
|
|
||||||
{obj.source && <div><span className="popup-label">연료: </span>{obj.source}</div>}
|
|
||||||
{obj.capacityMW && <div><span className="popup-label">용량: </span><strong>{obj.capacityMW}MW</strong></div>}
|
|
||||||
{obj.turbines && <div><span className="popup-label">터빈: </span>{obj.turbines}기</div>}
|
|
||||||
{obj.status && <div><span className="popup-label">상태: </span>{obj.status}</div>}
|
|
||||||
{obj.year && <div><span className="popup-label">연도: </span>{obj.year}년</div>}
|
|
||||||
{obj.region && <div><span className="popup-label">지역: </span>{obj.region}</div>}
|
|
||||||
{obj.org && <div><span className="popup-label">기관: </span>{obj.org}</div>}
|
|
||||||
{obj.area && <div><span className="popup-label">해역: </span>{obj.area}</div>}
|
|
||||||
{obj.altitude && <div><span className="popup-label">고도: </span>{obj.altitude}</div>}
|
|
||||||
{obj.address && <div><span className="popup-label">주소: </span>{obj.address}</div>}
|
|
||||||
{obj.recentUse && <div><span className="popup-label">최근 사용: </span>{obj.recentUse}</div>}
|
|
||||||
{obj.recentIncidents != null && <div><span className="popup-label">최근 1년: </span><strong>{obj.recentIncidents}건</strong></div>}
|
|
||||||
{obj.icao && <div><span className="popup-label">ICAO: </span>{obj.icao}</div>}
|
|
||||||
{kind === 'nkMissile' && (
|
|
||||||
<>
|
|
||||||
{obj.typeKo && <div><span className="popup-label">미사일: </span>{obj.typeKo}</div>}
|
|
||||||
{obj.date && <div><span className="popup-label">발사일: </span>{obj.date} {obj.time}</div>}
|
|
||||||
{obj.distanceKm && <div><span className="popup-label">사거리: </span>{obj.distanceKm}km</div>}
|
|
||||||
{obj.altitudeKm && <div><span className="popup-label">최고고도: </span>{obj.altitudeKm}km</div>}
|
|
||||||
{obj.flightMin && <div><span className="popup-label">비행시간: </span>{obj.flightMin}분</div>}
|
|
||||||
{obj.launchNameKo && <div><span className="popup-label">발사지: </span>{obj.launchNameKo}</div>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{obj.name && obj.nameKo && obj.name !== obj.nameKo && (
|
|
||||||
<div><span className="popup-label">영문: </span>{obj.name}</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
|
|
||||||
{lat.toFixed(4)}°N, {lng.toFixed(4)}°E
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
|
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
|
||||||
{layers.eez && <EezLayer />}
|
{layers.eez && <EezLayer />}
|
||||||
|
|
||||||
|
|||||||
207
frontend/src/components/korea/StaticFacilityPopup.tsx
Normal file
207
frontend/src/components/korea/StaticFacilityPopup.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import { Popup } from 'react-map-gl/maplibre';
|
||||||
|
import type { StaticPickInfo } from '../../hooks/layers/types';
|
||||||
|
|
||||||
|
interface StaticFacilityPopupProps {
|
||||||
|
pickInfo: StaticPickInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StaticFacilityPopup = ({ pickInfo, onClose }: StaticFacilityPopupProps) => {
|
||||||
|
const obj = pickInfo.object as any; // eslint-disable-line @typescript-eslint/no-explicit-any -- StaticPickedObject union requires loose access
|
||||||
|
const kind = pickInfo.kind;
|
||||||
|
const lat = obj.lat ?? obj.launchLat ?? 0;
|
||||||
|
const lng = obj.lng ?? obj.launchLng ?? 0;
|
||||||
|
if (!lat || !lng) return null;
|
||||||
|
|
||||||
|
// ── kind + subType 기반 메타 결정 ──
|
||||||
|
const SUB_META: Record<string, Record<string, { icon: string; color: string; label: string }>> = {
|
||||||
|
hazard: {
|
||||||
|
petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지' },
|
||||||
|
lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지' },
|
||||||
|
oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크' },
|
||||||
|
hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물항만하역' },
|
||||||
|
nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소' },
|
||||||
|
thermal: { icon: '🔥', color: '#64748b', label: '화력발전소' },
|
||||||
|
shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소' },
|
||||||
|
wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장' },
|
||||||
|
heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소' },
|
||||||
|
},
|
||||||
|
overseas: {
|
||||||
|
nuclear: { icon: '☢️', color: '#ef4444', label: '핵발전소' },
|
||||||
|
thermal: { icon: '🔥', color: '#f97316', label: '화력발전소' },
|
||||||
|
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
|
||||||
|
airbase: { icon: '✈️', color: '#22d3ee', label: '공군기지' },
|
||||||
|
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
|
||||||
|
shipyard:{ icon: '🚢', color: '#94a3b8', label: '조선소' },
|
||||||
|
},
|
||||||
|
militaryBase: {
|
||||||
|
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
|
||||||
|
airforce:{ icon: '✈️', color: '#22d3ee', label: '공군기지' },
|
||||||
|
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
|
||||||
|
missile: { icon: '🚀', color: '#ef4444', label: '미사일기지' },
|
||||||
|
joint: { icon: '⭐', color: '#f59e0b', label: '합동사령부' },
|
||||||
|
},
|
||||||
|
govBuilding: {
|
||||||
|
executive: { icon: '🏛️', color: '#f59e0b', label: '행정부' },
|
||||||
|
legislature: { icon: '🏛️', color: '#3b82f6', label: '입법부' },
|
||||||
|
military_hq: { icon: '⭐', color: '#ef4444', label: '군사령부' },
|
||||||
|
intelligence: { icon: '🔍', color: '#8b5cf6', label: '정보기관' },
|
||||||
|
foreign: { icon: '🌐', color: '#06b6d4', label: '외교부' },
|
||||||
|
maritime: { icon: '⚓', color: '#0ea5e9', label: '해양기관' },
|
||||||
|
defense: { icon: '🛡️', color: '#dc2626', label: '국방부' },
|
||||||
|
},
|
||||||
|
nkLaunch: {
|
||||||
|
icbm: { icon: '🚀', color: '#dc2626', label: 'ICBM 발사장' },
|
||||||
|
irbm: { icon: '🚀', color: '#ef4444', label: 'IRBM/MRBM' },
|
||||||
|
srbm: { icon: '🎯', color: '#f97316', label: '단거리탄도미사일' },
|
||||||
|
slbm: { icon: '🔱', color: '#3b82f6', label: 'SLBM 발사' },
|
||||||
|
cruise: { icon: '✈️', color: '#8b5cf6', label: '순항미사일' },
|
||||||
|
artillery: { icon: '💥', color: '#eab308', label: '해안포/장사정포' },
|
||||||
|
mlrs: { icon: '💥', color: '#f59e0b', label: '방사포(MLRS)' },
|
||||||
|
},
|
||||||
|
coastGuard: {
|
||||||
|
hq: { icon: '🏢', color: '#3b82f6', label: '본청' },
|
||||||
|
regional: { icon: '🏢', color: '#60a5fa', label: '지방청' },
|
||||||
|
station: { icon: '🚔', color: '#22d3ee', label: '해양경찰서' },
|
||||||
|
substation: { icon: '🏠', color: '#94a3b8', label: '파출소' },
|
||||||
|
vts: { icon: '📡', color: '#f59e0b', label: 'VTS센터' },
|
||||||
|
navy: { icon: '⚓', color: '#3b82f6', label: '해군부대' },
|
||||||
|
},
|
||||||
|
airport: {
|
||||||
|
international: { icon: '✈️', color: '#a78bfa', label: '국제공항' },
|
||||||
|
domestic: { icon: '🛩️', color: '#60a5fa', label: '국내공항' },
|
||||||
|
military: { icon: '✈️', color: '#ef4444', label: '군용비행장' },
|
||||||
|
},
|
||||||
|
navWarning: {
|
||||||
|
danger: { icon: '⚠️', color: '#ef4444', label: '위험' },
|
||||||
|
caution: { icon: '⚠️', color: '#eab308', label: '주의' },
|
||||||
|
info: { icon: 'ℹ️', color: '#3b82f6', label: '정보' },
|
||||||
|
},
|
||||||
|
piracy: {
|
||||||
|
critical: { icon: '☠️', color: '#ef4444', label: '극고위험' },
|
||||||
|
high: { icon: '☠️', color: '#f97316', label: '고위험' },
|
||||||
|
moderate: { icon: '☠️', color: '#eab308', label: '주의' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_DEFAULT: Record<string, { icon: string; color: string; label: string }> = {
|
||||||
|
port: { icon: '⚓', color: '#3b82f6', label: '항구' },
|
||||||
|
windFarm: { icon: '🌀', color: '#00bcd4', label: '풍력단지' },
|
||||||
|
militaryBase: { icon: '🪖', color: '#ef4444', label: '군사시설' },
|
||||||
|
govBuilding: { icon: '🏛️', color: '#f59e0b', label: '정부기관' },
|
||||||
|
nkLaunch: { icon: '🚀', color: '#dc2626', label: '북한 발사장' },
|
||||||
|
nkMissile: { icon: '💣', color: '#ef4444', label: '미사일 낙하' },
|
||||||
|
coastGuard: { icon: '🚔', color: '#4dabf7', label: '해양경찰' },
|
||||||
|
airport: { icon: '✈️', color: '#a78bfa', label: '공항' },
|
||||||
|
navWarning: { icon: '⚠️', color: '#eab308', label: '항행경보' },
|
||||||
|
piracy: { icon: '☠️', color: '#ef4444', label: '해적위험' },
|
||||||
|
infra: { icon: '⚡', color: '#ffeb3b', label: '발전/변전' },
|
||||||
|
hazard: { icon: '⚠️', color: '#ef4444', label: '위험시설' },
|
||||||
|
cnFacility: { icon: '📍', color: '#ef4444', label: '중국시설' },
|
||||||
|
jpFacility: { icon: '📍', color: '#f472b6', label: '일본시설' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// subType 키 결정
|
||||||
|
const subKey = obj.type ?? obj.subType ?? obj.level ?? '';
|
||||||
|
const subGroup = kind === 'cnFacility' || kind === 'jpFacility' ? 'overseas' : kind;
|
||||||
|
const meta = SUB_META[subGroup]?.[subKey] ?? KIND_DEFAULT[kind] ?? { icon: '📍', color: '#64748b', label: kind };
|
||||||
|
|
||||||
|
// 국가 플래그
|
||||||
|
const COUNTRY_FLAG: Record<string, string> = { CN: '🇨🇳', JP: '🇯🇵', KP: '🇰🇵', KR: '🇰🇷', TW: '🇹🇼' };
|
||||||
|
const flag = kind === 'cnFacility' ? '🇨🇳' : kind === 'jpFacility' ? '🇯🇵' : COUNTRY_FLAG[obj.country] ?? '';
|
||||||
|
const countryName = kind === 'cnFacility' ? '중국' : kind === 'jpFacility' ? '일본'
|
||||||
|
: { CN: '중국', JP: '일본', KP: '북한', KR: '한국', TW: '대만' }[obj.country] ?? '';
|
||||||
|
|
||||||
|
// 이름 결정
|
||||||
|
const title = obj.nameKo || obj.name || obj.launchNameKo || obj.title || kind;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup longitude={lng} latitude={lat} anchor="bottom"
|
||||||
|
onClose={onClose} closeOnClick={false}
|
||||||
|
maxWidth="280px" className="gl-popup"
|
||||||
|
>
|
||||||
|
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||||
|
{/* 컬러 헤더 */}
|
||||||
|
<div className="popup-header" style={{ background: meta.color, color: '#000', gap: 6, padding: '4px 8px' }}>
|
||||||
|
<span>{meta.icon}</span> {title}
|
||||||
|
</div>
|
||||||
|
{/* 배지 행 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{
|
||||||
|
background: meta.color, color: '#000',
|
||||||
|
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
{flag && (
|
||||||
|
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
|
||||||
|
{flag} {countryName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{kind === 'hazard' && (
|
||||||
|
<span style={{
|
||||||
|
background: 'rgba(239,68,68,0.15)', color: '#ef4444',
|
||||||
|
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 600,
|
||||||
|
border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
}}>⚠️ 위험시설</span>
|
||||||
|
)}
|
||||||
|
{kind === 'port' && (
|
||||||
|
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
|
||||||
|
{obj.type === 'major' ? '주요항' : '중소항'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{kind === 'airport' && obj.intl && (
|
||||||
|
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>국제선</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 설명 */}
|
||||||
|
{obj.description && (
|
||||||
|
<div style={{ fontSize: 10, color: '#999', marginBottom: 4, lineHeight: 1.5 }}>{obj.description}</div>
|
||||||
|
)}
|
||||||
|
{obj.detail && (
|
||||||
|
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.detail}</div>
|
||||||
|
)}
|
||||||
|
{obj.note && (
|
||||||
|
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.note}</div>
|
||||||
|
)}
|
||||||
|
{/* 필드 그리드 */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{obj.operator && <div><span className="popup-label">운영: </span>{obj.operator}</div>}
|
||||||
|
{obj.capacity && <div><span className="popup-label">규모: </span><strong>{obj.capacity}</strong></div>}
|
||||||
|
{obj.output && <div><span className="popup-label">출력: </span><strong>{obj.output}</strong></div>}
|
||||||
|
{obj.source && <div><span className="popup-label">연료: </span>{obj.source}</div>}
|
||||||
|
{obj.capacityMW && <div><span className="popup-label">용량: </span><strong>{obj.capacityMW}MW</strong></div>}
|
||||||
|
{obj.turbines && <div><span className="popup-label">터빈: </span>{obj.turbines}기</div>}
|
||||||
|
{obj.status && <div><span className="popup-label">상태: </span>{obj.status}</div>}
|
||||||
|
{obj.year && <div><span className="popup-label">연도: </span>{obj.year}년</div>}
|
||||||
|
{obj.region && <div><span className="popup-label">지역: </span>{obj.region}</div>}
|
||||||
|
{obj.org && <div><span className="popup-label">기관: </span>{obj.org}</div>}
|
||||||
|
{obj.area && <div><span className="popup-label">해역: </span>{obj.area}</div>}
|
||||||
|
{obj.altitude && <div><span className="popup-label">고도: </span>{obj.altitude}</div>}
|
||||||
|
{obj.address && <div><span className="popup-label">주소: </span>{obj.address}</div>}
|
||||||
|
{obj.recentUse && <div><span className="popup-label">최근 사용: </span>{obj.recentUse}</div>}
|
||||||
|
{obj.recentIncidents != null && <div><span className="popup-label">최근 1년: </span><strong>{obj.recentIncidents}건</strong></div>}
|
||||||
|
{obj.icao && <div><span className="popup-label">ICAO: </span>{obj.icao}</div>}
|
||||||
|
{kind === 'nkMissile' && (
|
||||||
|
<>
|
||||||
|
{obj.typeKo && <div><span className="popup-label">미사일: </span>{obj.typeKo}</div>}
|
||||||
|
{obj.date && <div><span className="popup-label">발사일: </span>{obj.date} {obj.time}</div>}
|
||||||
|
{obj.distanceKm && <div><span className="popup-label">사거리: </span>{obj.distanceKm}km</div>}
|
||||||
|
{obj.altitudeKm && <div><span className="popup-label">최고고도: </span>{obj.altitudeKm}km</div>}
|
||||||
|
{obj.flightMin && <div><span className="popup-label">비행시간: </span>{obj.flightMin}분</div>}
|
||||||
|
{obj.launchNameKo && <div><span className="popup-label">발사지: </span>{obj.launchNameKo}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{obj.name && obj.nameKo && obj.name !== obj.nameKo && (
|
||||||
|
<div><span className="popup-label">영문: </span>{obj.name}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
|
||||||
|
{lat.toFixed(4)}°N, {lng.toFixed(4)}°E
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { StaticFacilityPopup };
|
||||||
@ -1,8 +1,9 @@
|
|||||||
import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Ship, ShipCategory, VesselAnalysisDto } from '../../types';
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import { MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ships: Ship[];
|
ships: Ship[];
|
||||||
@ -14,100 +15,6 @@ interface Props {
|
|||||||
analysisMap?: Map<string, VesselAnalysisDto>;
|
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
|
|
||||||
const MT_TYPE_COLORS: Record<string, string> = {
|
|
||||||
cargo: 'var(--kcg-ship-cargo)',
|
|
||||||
tanker: 'var(--kcg-ship-tanker)',
|
|
||||||
passenger: 'var(--kcg-ship-passenger)',
|
|
||||||
fishing: 'var(--kcg-ship-fishing)',
|
|
||||||
fishing_gear: '#f97316',
|
|
||||||
pleasure: 'var(--kcg-ship-pleasure)',
|
|
||||||
military: 'var(--kcg-ship-military)',
|
|
||||||
tug_special: 'var(--kcg-ship-tug)',
|
|
||||||
other: 'var(--kcg-ship-other)',
|
|
||||||
unknown: 'var(--kcg-ship-unknown)',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolved hex colors for MapLibre paint (which cannot use CSS vars)
|
|
||||||
const MT_TYPE_HEX: Record<string, string> = {
|
|
||||||
cargo: '#f0a830',
|
|
||||||
tanker: '#e74c3c',
|
|
||||||
passenger: '#4caf50',
|
|
||||||
fishing: '#42a5f5',
|
|
||||||
fishing_gear: '#f97316',
|
|
||||||
pleasure: '#e91e8c',
|
|
||||||
military: '#d32f2f',
|
|
||||||
tug_special: '#2e7d32',
|
|
||||||
other: '#5c6bc0',
|
|
||||||
unknown: '#9e9e9e',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map our internal ShipCategory + typecode → MT visual type
|
|
||||||
function getMTType(ship: Ship): string {
|
|
||||||
const tc = (ship.typecode || '').toUpperCase();
|
|
||||||
const cat = ship.category;
|
|
||||||
|
|
||||||
// Military first
|
|
||||||
if (cat === 'carrier' || cat === 'destroyer' || cat === 'warship' || cat === 'submarine' || cat === 'patrol') return 'military';
|
|
||||||
if (tc === 'DDG' || tc === 'DDH' || tc === 'CVN' || tc === 'FFG' || tc === 'LCS' || tc === 'MCM' || tc === 'PC' || tc === 'LPH') return 'military';
|
|
||||||
|
|
||||||
// Tanker
|
|
||||||
if (cat === 'tanker') return 'tanker';
|
|
||||||
if (tc === 'VLCC' || tc === 'LNG' || tc === 'LPG') return 'tanker';
|
|
||||||
if (tc.startsWith('A1')) return 'tanker';
|
|
||||||
|
|
||||||
// Cargo
|
|
||||||
if (cat === 'cargo') return 'cargo';
|
|
||||||
if (tc === 'CONT' || tc === 'BULK') return 'cargo';
|
|
||||||
if (tc.startsWith('A2') || tc.startsWith('A3')) return 'cargo';
|
|
||||||
|
|
||||||
// Passenger
|
|
||||||
if (tc === 'PASS' || tc.startsWith('B')) return 'passenger';
|
|
||||||
|
|
||||||
// Fishing
|
|
||||||
if (tc.startsWith('C')) return 'fishing';
|
|
||||||
|
|
||||||
// Tug / Special
|
|
||||||
if (tc.startsWith('D') || tc.startsWith('E')) return 'tug_special';
|
|
||||||
|
|
||||||
// Pleasure
|
|
||||||
if (tc === 'SAIL' || tc === 'YACHT') return 'pleasure';
|
|
||||||
|
|
||||||
if (cat === 'civilian') return 'other';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy navy flag colors (for popup header accent only)
|
|
||||||
const NAVY_COLORS: Record<string, string> = {
|
|
||||||
US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff',
|
|
||||||
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FLAG_EMOJI: Record<string, string> = {
|
|
||||||
US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}',
|
|
||||||
KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}',
|
|
||||||
AU: '\u{1F1E6}\u{1F1FA}', DE: '\u{1F1E9}\u{1F1EA}', IN: '\u{1F1EE}\u{1F1F3}',
|
|
||||||
CN: '\u{1F1E8}\u{1F1F3}', PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}',
|
|
||||||
MH: '\u{1F1F2}\u{1F1ED}', HK: '\u{1F1ED}\u{1F1F0}', SG: '\u{1F1F8}\u{1F1EC}',
|
|
||||||
BZ: '\u{1F1E7}\u{1F1FF}', OM: '\u{1F1F4}\u{1F1F2}', AE: '\u{1F1E6}\u{1F1EA}',
|
|
||||||
SA: '\u{1F1F8}\u{1F1E6}', BH: '\u{1F1E7}\u{1F1ED}', QA: '\u{1F1F6}\u{1F1E6}',
|
|
||||||
};
|
|
||||||
|
|
||||||
// icon-size multiplier (symbol layer, base=64px)
|
|
||||||
const SIZE_MAP: Record<ShipCategory, number> = {
|
|
||||||
carrier: 0.32, destroyer: 0.22, warship: 0.22, submarine: 0.18, patrol: 0.16,
|
|
||||||
tanker: 0.16, cargo: 0.16, fishing: 0.14, civilian: 0.14, unknown: 0.12,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIL_CATEGORIES: ShipCategory[] = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol'];
|
|
||||||
|
|
||||||
function isMilitary(category: ShipCategory): boolean {
|
|
||||||
return MIL_CATEGORIES.includes(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getShipColor(ship: Ship): string {
|
|
||||||
return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getShipHex(ship: Ship): string {
|
function getShipHex(ship: Ship): string {
|
||||||
return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown;
|
return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown;
|
||||||
|
|||||||
32
frontend/src/contexts/SharedFilterContext.tsx
Normal file
32
frontend/src/contexts/SharedFilterContext.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { SharedFilterContext } from './sharedFilterState';
|
||||||
|
|
||||||
|
export { SharedFilterContext } from './sharedFilterState';
|
||||||
|
export type { SharedFilterState } from './sharedFilterState';
|
||||||
|
|
||||||
|
export function SharedFilterProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [hiddenAcCategories, setHiddenAcCategories] = useState<Set<string>>(new Set());
|
||||||
|
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleAcCategory = useCallback((cat: string) => {
|
||||||
|
setHiddenAcCategories(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleShipCategory = useCallback((cat: string) => {
|
||||||
|
setHiddenShipCategories(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SharedFilterContext.Provider value={{ hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory }}>
|
||||||
|
{children}
|
||||||
|
</SharedFilterContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/contexts/sharedFilterState.ts
Normal file
10
frontend/src/contexts/sharedFilterState.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
export interface SharedFilterState {
|
||||||
|
hiddenAcCategories: Set<string>;
|
||||||
|
hiddenShipCategories: Set<string>;
|
||||||
|
toggleAcCategory: (cat: string) => void;
|
||||||
|
toggleShipCategory: (cat: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SharedFilterContext = createContext<SharedFilterState | null>(null);
|
||||||
310
frontend/src/hooks/layers/createFacilityLayers.ts
Normal file
310
frontend/src/hooks/layers/createFacilityLayers.ts
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||||
|
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||||
|
import { HAZARD_FACILITIES } from '../../data/hazardFacilities';
|
||||||
|
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../../data/cnFacilities';
|
||||||
|
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../../data/jpFacilities';
|
||||||
|
import {
|
||||||
|
hexToRgb,
|
||||||
|
type LayerFactoryConfig,
|
||||||
|
type Layer,
|
||||||
|
type PickingInfo,
|
||||||
|
type PowerFacility,
|
||||||
|
type HazardFacility,
|
||||||
|
type HazardType,
|
||||||
|
type CnFacility,
|
||||||
|
type JpFacility,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ─── Infra SVG ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const INFRA_SOURCE_COLOR: Record<string, string> = {
|
||||||
|
nuclear: '#e040fb',
|
||||||
|
coal: '#795548',
|
||||||
|
gas: '#ff9800',
|
||||||
|
oil: '#5d4037',
|
||||||
|
hydro: '#2196f3',
|
||||||
|
solar: '#ffc107',
|
||||||
|
wind: '#00bcd4',
|
||||||
|
biomass: '#4caf50',
|
||||||
|
};
|
||||||
|
const INFRA_SUBSTATION_COLOR = '#ffeb3b';
|
||||||
|
|
||||||
|
const WIND_COLOR = '#00bcd4';
|
||||||
|
|
||||||
|
function windTurbineSvg(size: number): string {
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="12" y1="10" x2="11" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||||
|
<line x1="12" y1="10" x2="13" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||||
|
<circle cx="12" cy="9" r="1.8" fill="${WIND_COLOR}"/>
|
||||||
|
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||||
|
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||||
|
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function infraColor(f: PowerFacility): string {
|
||||||
|
if (f.type === 'substation') return INFRA_SUBSTATION_COLOR;
|
||||||
|
return INFRA_SOURCE_COLOR[f.source ?? ''] ?? '#9e9e9e';
|
||||||
|
}
|
||||||
|
|
||||||
|
function infraSvg(f: PowerFacility): string {
|
||||||
|
const color = infraColor(f);
|
||||||
|
if (f.source === 'wind') {
|
||||||
|
return windTurbineSvg(14).replace(`stroke="${WIND_COLOR}"`, `stroke="${color}"`).replace(new RegExp(`fill="${WIND_COLOR}"`, 'g'), `fill="${color}"`);
|
||||||
|
}
|
||||||
|
const size = f.type === 'substation' ? 7 : 12;
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0.5" y="0.5" width="${size - 1}" height="${size - 1}" rx="1" fill="#111" stroke="${color}" stroke-width="1"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createFacilityLayers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createFacilityLayers(
|
||||||
|
config: {
|
||||||
|
infra: boolean;
|
||||||
|
infraFacilities: PowerFacility[];
|
||||||
|
hazardTypes: HazardType[];
|
||||||
|
cnPower: boolean;
|
||||||
|
cnMilitary: boolean;
|
||||||
|
jpPower: boolean;
|
||||||
|
jpMilitary: boolean;
|
||||||
|
},
|
||||||
|
fc: LayerFactoryConfig,
|
||||||
|
): Layer[] {
|
||||||
|
const layers: Layer[] = [];
|
||||||
|
const sc = fc.sc;
|
||||||
|
const onPick = fc.onPick;
|
||||||
|
|
||||||
|
// ── Infra ──────────────────────────────────────────────────────────────
|
||||||
|
if (config.infra && config.infraFacilities.length > 0) {
|
||||||
|
const infraIconCache = new Map<string, string>();
|
||||||
|
function getInfraIconUrl(f: PowerFacility): string {
|
||||||
|
const key = `${f.type}-${f.source ?? ''}`;
|
||||||
|
if (!infraIconCache.has(key)) {
|
||||||
|
infraIconCache.set(key, svgToDataUri(infraSvg(f)));
|
||||||
|
}
|
||||||
|
return infraIconCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plants = config.infraFacilities.filter(f => f.type === 'plant');
|
||||||
|
const substations = config.infraFacilities.filter(f => f.type === 'substation');
|
||||||
|
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<PowerFacility>({
|
||||||
|
id: 'static-infra-substation',
|
||||||
|
data: substations,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }),
|
||||||
|
getSize: 7 * sc,
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<PowerFacility>) => {
|
||||||
|
if (info.object) onPick({ kind: 'infra', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new IconLayer<PowerFacility>({
|
||||||
|
id: 'static-infra-plant',
|
||||||
|
data: plants,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
||||||
|
getSize: 12 * sc,
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<PowerFacility>) => {
|
||||||
|
if (info.object) onPick({ kind: 'infra', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<PowerFacility>({
|
||||||
|
id: 'static-infra-plant-label',
|
||||||
|
data: plants,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||||
|
getSize: 8 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 8],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 600,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hazard Facilities ──────────────────────────────────────────────────
|
||||||
|
if (config.hazardTypes.length > 0) {
|
||||||
|
const hazardTypeSet = new Set(config.hazardTypes);
|
||||||
|
const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type));
|
||||||
|
|
||||||
|
const HAZARD_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
|
||||||
|
petrochemical: { icon: '🏭', color: [249, 115, 22, 255] },
|
||||||
|
lng: { icon: '🔵', color: [6, 182, 212, 255] },
|
||||||
|
oilTank: { icon: '🛢️', color: [234, 179, 8, 255] },
|
||||||
|
hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] },
|
||||||
|
nuclear: { icon: '☢️', color: [168, 85, 247, 255] },
|
||||||
|
thermal: { icon: '🔥', color: [100, 116, 139, 255] },
|
||||||
|
shipyard: { icon: '🚢', color: [14, 165, 233, 255] },
|
||||||
|
wastewater: { icon: '💧', color: [16, 185, 129, 255] },
|
||||||
|
heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hazardData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<HazardFacility>({
|
||||||
|
id: 'static-hazard-emoji',
|
||||||
|
data: hazardData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️',
|
||||||
|
getSize: 16 * sc,
|
||||||
|
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'center',
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<HazardFacility>) => {
|
||||||
|
if (info.object) onPick({ kind: 'hazard', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<HazardFacility>({
|
||||||
|
id: 'static-hazard-label',
|
||||||
|
data: hazardData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||||
|
getSize: 9 * sc,
|
||||||
|
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 12],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 600,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CN Facilities ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const CN_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
|
||||||
|
nuclear: { icon: '☢️', color: [239, 68, 68, 255] },
|
||||||
|
thermal: { icon: '🔥', color: [249, 115, 22, 255] },
|
||||||
|
naval: { icon: '⚓', color: [59, 130, 246, 255] },
|
||||||
|
airbase: { icon: '✈️', color: [34, 211, 238, 255] },
|
||||||
|
army: { icon: '🪖', color: [34, 197, 94, 255] },
|
||||||
|
shipyard: { icon: '🚢', color: [148, 163, 184, 255] },
|
||||||
|
};
|
||||||
|
const cnData: CnFacility[] = [
|
||||||
|
...(config.cnPower ? CN_POWER_PLANTS : []),
|
||||||
|
...(config.cnMilitary ? CN_MILITARY_FACILITIES : []),
|
||||||
|
];
|
||||||
|
if (cnData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<CnFacility>({
|
||||||
|
id: 'static-cn-emoji',
|
||||||
|
data: cnData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => CN_META[d.subType]?.icon ?? '📍',
|
||||||
|
getSize: 16 * sc,
|
||||||
|
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'center',
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<CnFacility>) => {
|
||||||
|
if (info.object) onPick({ kind: 'cnFacility', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<CnFacility>({
|
||||||
|
id: 'static-cn-label',
|
||||||
|
data: cnData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => d.name,
|
||||||
|
getSize: 9 * sc,
|
||||||
|
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 12],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 600,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JP Facilities ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const JP_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
|
||||||
|
nuclear: { icon: '☢️', color: [239, 68, 68, 255] },
|
||||||
|
thermal: { icon: '🔥', color: [249, 115, 22, 255] },
|
||||||
|
naval: { icon: '⚓', color: [59, 130, 246, 255] },
|
||||||
|
airbase: { icon: '✈️', color: [34, 211, 238, 255] },
|
||||||
|
army: { icon: '🪖', color: [34, 197, 94, 255] },
|
||||||
|
};
|
||||||
|
const jpData: JpFacility[] = [
|
||||||
|
...(config.jpPower ? JP_POWER_PLANTS : []),
|
||||||
|
...(config.jpMilitary ? JP_MILITARY_FACILITIES : []),
|
||||||
|
];
|
||||||
|
if (jpData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<JpFacility>({
|
||||||
|
id: 'static-jp-emoji',
|
||||||
|
data: jpData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => JP_META[d.subType]?.icon ?? '📍',
|
||||||
|
getSize: 16 * sc,
|
||||||
|
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'center',
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<JpFacility>) => {
|
||||||
|
if (info.object) onPick({ kind: 'jpFacility', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<JpFacility>({
|
||||||
|
id: 'static-jp-label',
|
||||||
|
data: jpData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => d.name,
|
||||||
|
getSize: 9 * sc,
|
||||||
|
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 12],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 600,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
272
frontend/src/hooks/layers/createMilitaryLayers.ts
Normal file
272
frontend/src/hooks/layers/createMilitaryLayers.ts
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import { IconLayer, TextLayer, PathLayer } from '@deck.gl/layers';
|
||||||
|
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||||
|
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||||
|
import { MILITARY_BASES } from '../../data/militaryBases';
|
||||||
|
import type { MilitaryBase } from '../../data/militaryBases';
|
||||||
|
import { GOV_BUILDINGS } from '../../data/govBuildings';
|
||||||
|
import type { GovBuilding } from '../../data/govBuildings';
|
||||||
|
import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites';
|
||||||
|
import type { NKLaunchSite } from '../../data/nkLaunchSites';
|
||||||
|
import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents';
|
||||||
|
import type { NKMissileEvent } from '../../data/nkMissileEvents';
|
||||||
|
import { hexToRgb } from './types';
|
||||||
|
import type { LayerFactoryConfig } from './types';
|
||||||
|
|
||||||
|
// ─── NKMissile SVG ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getMissileColor(type: string): string {
|
||||||
|
if (type.includes('ICBM')) return '#dc2626';
|
||||||
|
if (type.includes('IRBM')) return '#ef4444';
|
||||||
|
if (type.includes('SLBM')) return '#3b82f6';
|
||||||
|
return '#f97316';
|
||||||
|
}
|
||||||
|
|
||||||
|
function missileLaunchSvg(color: string): string {
|
||||||
|
return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<polygon points="12,2 22,20 2,20" fill="${color}" stroke="#fff" stroke-width="1"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function missileImpactSvg(color: string): string {
|
||||||
|
return `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.5"/>
|
||||||
|
<line x1="7" y1="7" x2="17" y2="17" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<line x1="17" y1="7" x2="7" y2="17" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMilitaryLayers(
|
||||||
|
config: { militaryBases: boolean; govBuildings: boolean; nkLaunch: boolean; nkMissile: boolean },
|
||||||
|
fc: LayerFactoryConfig,
|
||||||
|
): Layer[] {
|
||||||
|
const layers: Layer[] = [];
|
||||||
|
const sc = fc.sc;
|
||||||
|
const onPick = fc.onPick;
|
||||||
|
|
||||||
|
// ── Military Bases — TextLayer (이모지) ───────────────────────────────
|
||||||
|
if (config.militaryBases) {
|
||||||
|
const TYPE_COLOR: Record<string, string> = {
|
||||||
|
naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e',
|
||||||
|
missile: '#ef4444', joint: '#a78bfa',
|
||||||
|
};
|
||||||
|
const TYPE_ICON: Record<string, string> = {
|
||||||
|
naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐',
|
||||||
|
};
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<MilitaryBase>({
|
||||||
|
id: 'static-militarybase-emoji',
|
||||||
|
data: MILITARY_BASES,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => TYPE_ICON[d.type] ?? '⭐',
|
||||||
|
getSize: 14 * sc,
|
||||||
|
getColor: [255, 255, 255, 220],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'center',
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<MilitaryBase>) => {
|
||||||
|
if (info.object) onPick({ kind: 'militaryBase', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<MilitaryBase>({
|
||||||
|
id: 'static-militarybase-label',
|
||||||
|
data: MILITARY_BASES,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
|
||||||
|
getSize: 8 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 9],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gov Buildings — TextLayer (이모지) ─────────────────────────────────
|
||||||
|
if (config.govBuildings) {
|
||||||
|
const GOV_TYPE_COLOR: Record<string, string> = {
|
||||||
|
executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444',
|
||||||
|
intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626',
|
||||||
|
};
|
||||||
|
const GOV_TYPE_ICON: Record<string, string> = {
|
||||||
|
executive: '🏛', legislature: '🏛', military_hq: '⭐',
|
||||||
|
intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡',
|
||||||
|
};
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<GovBuilding>({
|
||||||
|
id: 'static-govbuilding-emoji',
|
||||||
|
data: GOV_BUILDINGS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛',
|
||||||
|
getSize: 12 * sc,
|
||||||
|
getColor: [255, 255, 255, 220],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'center',
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<GovBuilding>) => {
|
||||||
|
if (info.object) onPick({ kind: 'govBuilding', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<GovBuilding>({
|
||||||
|
id: 'static-govbuilding-label',
|
||||||
|
data: GOV_BUILDINGS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||||
|
getSize: 8 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 8],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NK Launch Sites — TextLayer (이모지) ──────────────────────────────
|
||||||
|
if (config.nkLaunch) {
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<NKLaunchSite>({
|
||||||
|
id: 'static-nklaunch-emoji',
|
||||||
|
data: NK_LAUNCH_SITES,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀',
|
||||||
|
getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc,
|
||||||
|
getColor: [255, 255, 255, 220],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'center',
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<NKLaunchSite>) => {
|
||||||
|
if (info.object) onPick({ kind: 'nkLaunch', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<NKLaunchSite>({
|
||||||
|
id: 'static-nklaunch-label',
|
||||||
|
data: NK_LAUNCH_SITES,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||||
|
getSize: 8 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 8],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NK Missile Events — IconLayer ─────────────────────────────────────
|
||||||
|
if (config.nkMissile) {
|
||||||
|
const launchIconCache = new Map<string, string>();
|
||||||
|
function getLaunchIconUrl(type: string): string {
|
||||||
|
if (!launchIconCache.has(type)) {
|
||||||
|
launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type))));
|
||||||
|
}
|
||||||
|
return launchIconCache.get(type)!;
|
||||||
|
}
|
||||||
|
const impactIconCache = new Map<string, string>();
|
||||||
|
function getImpactIconUrl(type: string): string {
|
||||||
|
if (!impactIconCache.has(type)) {
|
||||||
|
impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type))));
|
||||||
|
}
|
||||||
|
return impactIconCache.get(type)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LaunchPoint { ev: NKMissileEvent; lat: number; lng: number }
|
||||||
|
interface ImpactPoint { ev: NKMissileEvent; lat: number; lng: number }
|
||||||
|
|
||||||
|
const launchData: LaunchPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.launchLat, lng: ev.launchLng }));
|
||||||
|
const impactData: ImpactPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.impactLat, lng: ev.impactLng }));
|
||||||
|
|
||||||
|
// 발사→착탄 궤적선
|
||||||
|
const trajectoryData = NK_MISSILE_EVENTS.map(ev => ({
|
||||||
|
path: [[ev.launchLng, ev.launchLat], [ev.impactLng, ev.impactLat]] as [number, number][],
|
||||||
|
color: hexToRgb(getMissileColor(ev.type)),
|
||||||
|
}));
|
||||||
|
layers.push(
|
||||||
|
new PathLayer<{ path: [number, number][]; color: [number, number, number] }>({
|
||||||
|
id: 'static-nkmissile-trajectory',
|
||||||
|
data: trajectoryData,
|
||||||
|
getPath: (d) => d.path,
|
||||||
|
getColor: (d) => [...d.color, 150] as [number, number, number, number],
|
||||||
|
getWidth: 2,
|
||||||
|
widthUnits: 'pixels',
|
||||||
|
getDashArray: [6, 3],
|
||||||
|
dashJustified: true,
|
||||||
|
extensions: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<LaunchPoint>({
|
||||||
|
id: 'static-nkmissile-launch',
|
||||||
|
data: launchData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
||||||
|
getSize: 12 * sc,
|
||||||
|
getColor: (d) => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
||||||
|
return [255, 255, 255, today ? 255 : 90] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new IconLayer<ImpactPoint>({
|
||||||
|
id: 'static-nkmissile-impact',
|
||||||
|
data: impactData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }),
|
||||||
|
getSize: 16 * sc,
|
||||||
|
getColor: (d) => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
||||||
|
return [255, 255, 255, today ? 255 : 100] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<ImpactPoint>) => {
|
||||||
|
if (info.object) onPick({ kind: 'nkMissile', object: info.object.ev });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<ImpactPoint>({
|
||||||
|
id: 'static-nkmissile-label',
|
||||||
|
data: impactData,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`,
|
||||||
|
getSize: 8 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 10],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
332
frontend/src/hooks/layers/createNavigationLayers.ts
Normal file
332
frontend/src/hooks/layers/createNavigationLayers.ts
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||||
|
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||||
|
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||||
|
import { COAST_GUARD_FACILITIES } from '../../services/coastGuard';
|
||||||
|
import type { CoastGuardFacility, CoastGuardType } from '../../services/coastGuard';
|
||||||
|
import { KOREAN_AIRPORTS } from '../../services/airports';
|
||||||
|
import type { KoreanAirport } from '../../services/airports';
|
||||||
|
import { NAV_WARNINGS } from '../../services/navWarning';
|
||||||
|
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../../services/navWarning';
|
||||||
|
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../../services/piracy';
|
||||||
|
import type { PiracyZone } from '../../services/piracy';
|
||||||
|
import { hexToRgb } from './types';
|
||||||
|
import type { LayerFactoryConfig } from './types';
|
||||||
|
|
||||||
|
// ─── CoastGuard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CG_TYPE_COLOR: Record<CoastGuardType, string> = {
|
||||||
|
hq: '#ff6b6b',
|
||||||
|
regional: '#ffa94d',
|
||||||
|
station: '#4dabf7',
|
||||||
|
substation: '#69db7c',
|
||||||
|
vts: '#da77f2',
|
||||||
|
navy: '#3b82f6',
|
||||||
|
};
|
||||||
|
|
||||||
|
function coastGuardSvg(type: CoastGuardType, size: number): string {
|
||||||
|
const color = CG_TYPE_COLOR[type];
|
||||||
|
if (type === 'navy') {
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 16 Q12 20 22 16 L22 12 Q12 8 2 12 Z" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||||
|
<line x1="12" y1="4" x2="12" y2="12" stroke="${color}" stroke-width="1.5"/>
|
||||||
|
<circle cx="12" cy="4" r="2" fill="${color}"/>
|
||||||
|
<line x1="8" y1="12" x2="16" y2="12" stroke="${color}" stroke-width="1"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
if (type === 'vts') {
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="10" stroke="${color}" stroke-width="1.5"/>
|
||||||
|
<circle cx="12" cy="9" r="2" fill="none" stroke="${color}" stroke-width="1"/>
|
||||||
|
<path d="M7 7 Q12 3 17 7" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.6"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z" fill="rgba(0,0,0,0.6)" stroke="${color}" stroke-width="1.2"/>
|
||||||
|
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" stroke-width="1"/>
|
||||||
|
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" stroke-width="1"/>
|
||||||
|
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" stroke-width="1"/>
|
||||||
|
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" stroke-width="0.8"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CG_TYPE_SIZE: Record<CoastGuardType, number> = {
|
||||||
|
hq: 24,
|
||||||
|
regional: 20,
|
||||||
|
station: 16,
|
||||||
|
substation: 13,
|
||||||
|
vts: 14,
|
||||||
|
navy: 18,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Airport ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AP_COUNTRY_COLOR: Record<string, { intl: string; domestic: string }> = {
|
||||||
|
KR: { intl: '#a78bfa', domestic: '#7c8aaa' },
|
||||||
|
CN: { intl: '#ef4444', domestic: '#b91c1c' },
|
||||||
|
JP: { intl: '#f472b6', domestic: '#9d174d' },
|
||||||
|
KP: { intl: '#f97316', domestic: '#c2410c' },
|
||||||
|
TW: { intl: '#10b981', domestic: '#059669' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function apColor(ap: KoreanAirport): string {
|
||||||
|
const cc = AP_COUNTRY_COLOR[ap.country ?? 'KR'] ?? AP_COUNTRY_COLOR.KR;
|
||||||
|
return ap.intl ? cc.intl : cc.domestic;
|
||||||
|
}
|
||||||
|
|
||||||
|
function airportSvg(color: string, size: number): string {
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||||
|
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||||
|
fill="${color}" stroke="#fff" stroke-width="0.3"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NavWarning ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NW_ORG_COLOR: Record<TrainingOrg, string> = {
|
||||||
|
'해군': '#8b5cf6',
|
||||||
|
'해병대': '#22c55e',
|
||||||
|
'공군': '#f97316',
|
||||||
|
'육군': '#ef4444',
|
||||||
|
'해경': '#3b82f6',
|
||||||
|
'국과연': '#eab308',
|
||||||
|
};
|
||||||
|
|
||||||
|
function navWarningSvg(level: NavWarningLevel, org: TrainingOrg, size: number): string {
|
||||||
|
const color = NW_ORG_COLOR[org];
|
||||||
|
if (level === 'danger') {
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 3 L22 20 L2 20 Z" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.5"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="14" stroke="${color}" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<circle cx="12" cy="17" r="1" fill="${color}"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="13" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="12" cy="16" r="1" fill="${color}"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Piracy ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function piracySvg(color: string, size: number): string {
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke="${color}" stroke-width="1.5"/>
|
||||||
|
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill="${color}" opacity="0.9"/>
|
||||||
|
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill="${color}" opacity="0.9"/>
|
||||||
|
<path d="M11 13 L12 14.5 L13 13" stroke="${color}" stroke-width="1" fill="none"/>
|
||||||
|
<path d="M7 17 Q12 21 17 17" stroke="${color}" stroke-width="1.2" fill="none"/>
|
||||||
|
<line x1="4" y1="20" x2="20" y2="24" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<line x1="20" y1="20" x2="4" y2="24" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNavigationLayers(
|
||||||
|
config: { coastGuard: boolean; airports: boolean; navWarning: boolean; piracy: boolean },
|
||||||
|
fc: LayerFactoryConfig,
|
||||||
|
): Layer[] {
|
||||||
|
const layers: Layer[] = [];
|
||||||
|
const sc = fc.sc;
|
||||||
|
const onPick = fc.onPick;
|
||||||
|
|
||||||
|
// ── Coast Guard ────────────────────────────────────────────────────────
|
||||||
|
if (config.coastGuard) {
|
||||||
|
const cgIconCache = new Map<CoastGuardType, string>();
|
||||||
|
function getCgIconUrl(type: CoastGuardType): string {
|
||||||
|
if (!cgIconCache.has(type)) {
|
||||||
|
const size = CG_TYPE_SIZE[type];
|
||||||
|
cgIconCache.set(type, svgToDataUri(coastGuardSvg(type, size * 2)));
|
||||||
|
}
|
||||||
|
return cgIconCache.get(type)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<CoastGuardFacility>({
|
||||||
|
id: 'static-coastguard-icon',
|
||||||
|
data: COAST_GUARD_FACILITIES,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: (d) => {
|
||||||
|
const sz = CG_TYPE_SIZE[d.type] * 2;
|
||||||
|
return { url: getCgIconUrl(d.type), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||||
|
},
|
||||||
|
getSize: (d) => CG_TYPE_SIZE[d.type] * sc,
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<CoastGuardFacility>) => {
|
||||||
|
if (info.object) onPick({ kind: 'coastGuard', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<CoastGuardFacility>({
|
||||||
|
id: 'static-coastguard-label',
|
||||||
|
data: COAST_GUARD_FACILITIES.filter(f => f.type === 'hq' || f.type === 'regional' || f.type === 'navy' || f.type === 'vts'),
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => {
|
||||||
|
if (d.type === 'vts') return 'VTS';
|
||||||
|
if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8);
|
||||||
|
return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청';
|
||||||
|
},
|
||||||
|
getSize: 8 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 8],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Airports ───────────────────────────────────────────────────────────
|
||||||
|
if (config.airports) {
|
||||||
|
const apIconCache = new Map<string, string>();
|
||||||
|
function getApIconUrl(ap: KoreanAirport): string {
|
||||||
|
const color = apColor(ap);
|
||||||
|
const size = ap.intl ? 40 : 32;
|
||||||
|
const key = `${color}-${size}`;
|
||||||
|
if (!apIconCache.has(key)) {
|
||||||
|
apIconCache.set(key, svgToDataUri(airportSvg(color, size)));
|
||||||
|
}
|
||||||
|
return apIconCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<KoreanAirport>({
|
||||||
|
id: 'static-airports-icon',
|
||||||
|
data: KOREAN_AIRPORTS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: (d) => {
|
||||||
|
const sz = d.intl ? 40 : 32;
|
||||||
|
return { url: getApIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||||
|
},
|
||||||
|
getSize: (d) => (d.intl ? 20 : 16) * sc,
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<KoreanAirport>) => {
|
||||||
|
if (info.object) onPick({ kind: 'airport', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<KoreanAirport>({
|
||||||
|
id: 'static-airports-label',
|
||||||
|
data: KOREAN_AIRPORTS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''),
|
||||||
|
getSize: 9 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 10],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NavWarning ─────────────────────────────────────────────────────────
|
||||||
|
if (config.navWarning) {
|
||||||
|
const nwIconCache = new Map<string, string>();
|
||||||
|
function getNwIconUrl(w: NavWarning): string {
|
||||||
|
const key = `${w.level}-${w.org}`;
|
||||||
|
if (!nwIconCache.has(key)) {
|
||||||
|
const size = w.level === 'danger' ? 32 : 28;
|
||||||
|
nwIconCache.set(key, svgToDataUri(navWarningSvg(w.level, w.org, size)));
|
||||||
|
}
|
||||||
|
return nwIconCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<NavWarning>({
|
||||||
|
id: 'static-navwarning-icon',
|
||||||
|
data: NAV_WARNINGS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: (d) => {
|
||||||
|
const sz = d.level === 'danger' ? 32 : 28;
|
||||||
|
return { url: getNwIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||||
|
},
|
||||||
|
getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc,
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<NavWarning>) => {
|
||||||
|
if (info.object) onPick({ kind: 'navWarning', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<NavWarning>({
|
||||||
|
id: 'static-navwarning-label',
|
||||||
|
data: NAV_WARNINGS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => d.id,
|
||||||
|
getSize: 8 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 9],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Piracy ─────────────────────────────────────────────────────────────
|
||||||
|
if (config.piracy) {
|
||||||
|
const piracyIconCache = new Map<string, string>();
|
||||||
|
function getPiracyIconUrl(zone: PiracyZone): string {
|
||||||
|
const key = zone.level;
|
||||||
|
if (!piracyIconCache.has(key)) {
|
||||||
|
const color = PIRACY_LEVEL_COLOR[zone.level];
|
||||||
|
const size = zone.level === 'critical' ? 56 : zone.level === 'high' ? 48 : 40;
|
||||||
|
piracyIconCache.set(key, svgToDataUri(piracySvg(color, size)));
|
||||||
|
}
|
||||||
|
return piracyIconCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<PiracyZone>({
|
||||||
|
id: 'static-piracy-icon',
|
||||||
|
data: PIRACY_ZONES,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: (d) => {
|
||||||
|
const sz = d.level === 'critical' ? 56 : d.level === 'high' ? 48 : 40;
|
||||||
|
return { url: getPiracyIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||||
|
},
|
||||||
|
getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc,
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<PiracyZone>) => {
|
||||||
|
if (info.object) onPick({ kind: 'piracy', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<PiracyZone>({
|
||||||
|
id: 'static-piracy-label',
|
||||||
|
data: PIRACY_ZONES,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => d.nameKo,
|
||||||
|
getSize: 9 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 14],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
145
frontend/src/hooks/layers/createPortLayers.ts
Normal file
145
frontend/src/hooks/layers/createPortLayers.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||||
|
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||||
|
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||||
|
import { EAST_ASIA_PORTS } from '../../data/ports';
|
||||||
|
import type { Port } from '../../data/ports';
|
||||||
|
import { KOREA_WIND_FARMS } from '../../data/windFarms';
|
||||||
|
import type { WindFarm } from '../../data/windFarms';
|
||||||
|
import { hexToRgb } from './types';
|
||||||
|
import type { LayerFactoryConfig } from './types';
|
||||||
|
|
||||||
|
// ─── Port colors ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PORT_COUNTRY_COLOR: Record<string, string> = {
|
||||||
|
KR: '#3b82f6',
|
||||||
|
CN: '#ef4444',
|
||||||
|
JP: '#f472b6',
|
||||||
|
KP: '#f97316',
|
||||||
|
TW: '#10b981',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Port SVG ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function portSvg(color: string, size: number): string {
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="5" r="2.5" stroke="${color}" stroke-width="1.5" fill="none"/>
|
||||||
|
<line x1="12" y1="7.5" x2="12" y2="21" stroke="${color}" stroke-width="1.5"/>
|
||||||
|
<line x1="7" y1="12" x2="17" y2="12" stroke="${color}" stroke-width="1.5"/>
|
||||||
|
<path d="M5 18 Q8 21 12 21 Q16 21 19 18" stroke="${color}" stroke-width="1.5" fill="none"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wind Turbine SVG ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const WIND_COLOR = '#00bcd4';
|
||||||
|
|
||||||
|
function windTurbineSvg(size: number): string {
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="12" y1="10" x2="11" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||||
|
<line x1="12" y1="10" x2="13" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||||
|
<circle cx="12" cy="9" r="1.8" fill="${WIND_COLOR}"/>
|
||||||
|
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||||
|
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||||
|
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPortLayers(
|
||||||
|
config: { ports: boolean; windFarm: boolean },
|
||||||
|
fc: LayerFactoryConfig,
|
||||||
|
): Layer[] {
|
||||||
|
const layers: Layer[] = [];
|
||||||
|
const sc = fc.sc;
|
||||||
|
const onPick = fc.onPick;
|
||||||
|
|
||||||
|
// ── Ports ───────────────────────────────────────────────────────────────
|
||||||
|
if (config.ports) {
|
||||||
|
const portIconCache = new Map<string, string>();
|
||||||
|
function getPortIconUrl(p: Port): string {
|
||||||
|
const key = `${p.country}-${p.type}`;
|
||||||
|
if (!portIconCache.has(key)) {
|
||||||
|
const color = PORT_COUNTRY_COLOR[p.country] ?? PORT_COUNTRY_COLOR.KR;
|
||||||
|
const size = p.type === 'major' ? 32 : 24;
|
||||||
|
portIconCache.set(key, svgToDataUri(portSvg(color, size)));
|
||||||
|
}
|
||||||
|
return portIconCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<Port>({
|
||||||
|
id: 'static-ports-icon',
|
||||||
|
data: EAST_ASIA_PORTS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: (d) => ({
|
||||||
|
url: getPortIconUrl(d),
|
||||||
|
width: d.type === 'major' ? 32 : 24,
|
||||||
|
height: d.type === 'major' ? 32 : 24,
|
||||||
|
anchorX: d.type === 'major' ? 16 : 12,
|
||||||
|
anchorY: d.type === 'major' ? 16 : 12,
|
||||||
|
}),
|
||||||
|
getSize: (d) => (d.type === 'major' ? 16 : 12) * sc,
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<Port>) => {
|
||||||
|
if (info.object) onPick({ kind: 'port', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<Port>({
|
||||||
|
id: 'static-ports-label',
|
||||||
|
data: EAST_ASIA_PORTS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => d.nameKo.replace('항', ''),
|
||||||
|
getSize: 9 * sc,
|
||||||
|
getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 8],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wind Farms ─────────────────────────────────────────────────────────
|
||||||
|
if (config.windFarm) {
|
||||||
|
const windUrl = svgToDataUri(windTurbineSvg(36));
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<WindFarm>({
|
||||||
|
id: 'static-windfarm-icon',
|
||||||
|
data: KOREA_WIND_FARMS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getIcon: () => ({ url: windUrl, width: 36, height: 36, anchorX: 18, anchorY: 18 }),
|
||||||
|
getSize: 18 * sc,
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: PickingInfo<WindFarm>) => {
|
||||||
|
if (info.object) onPick({ kind: 'windFarm', object: info.object });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new TextLayer<WindFarm>({
|
||||||
|
id: 'static-windfarm-label',
|
||||||
|
data: KOREA_WIND_FARMS,
|
||||||
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
|
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||||
|
getSize: 9 * sc,
|
||||||
|
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
getAlignmentBaseline: 'top',
|
||||||
|
getPixelOffset: [0, 10],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [0, 0, 0, 200],
|
||||||
|
billboard: false,
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
49
frontend/src/hooks/layers/types.ts
Normal file
49
frontend/src/hooks/layers/types.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||||
|
import type { Port } from '../../data/ports';
|
||||||
|
import type { WindFarm } from '../../data/windFarms';
|
||||||
|
import type { MilitaryBase } from '../../data/militaryBases';
|
||||||
|
import type { GovBuilding } from '../../data/govBuildings';
|
||||||
|
import type { NKLaunchSite } from '../../data/nkLaunchSites';
|
||||||
|
import type { NKMissileEvent } from '../../data/nkMissileEvents';
|
||||||
|
import type { CoastGuardFacility } from '../../services/coastGuard';
|
||||||
|
import type { KoreanAirport } from '../../services/airports';
|
||||||
|
import type { NavWarning } from '../../services/navWarning';
|
||||||
|
import type { PiracyZone } from '../../services/piracy';
|
||||||
|
import type { PowerFacility } from '../../services/infra';
|
||||||
|
import type { HazardFacility, HazardType } from '../../data/hazardFacilities';
|
||||||
|
import type { CnFacility } from '../../data/cnFacilities';
|
||||||
|
import type { JpFacility } from '../../data/jpFacilities';
|
||||||
|
|
||||||
|
export type StaticPickedObject =
|
||||||
|
| Port | WindFarm | MilitaryBase | GovBuilding
|
||||||
|
| NKLaunchSite | NKMissileEvent | CoastGuardFacility | KoreanAirport
|
||||||
|
| NavWarning | PiracyZone | PowerFacility | HazardFacility
|
||||||
|
| CnFacility | JpFacility;
|
||||||
|
|
||||||
|
export type StaticLayerKind =
|
||||||
|
| 'port' | 'windFarm' | 'militaryBase' | 'govBuilding'
|
||||||
|
| 'nkLaunch' | 'nkMissile' | 'coastGuard' | 'airport'
|
||||||
|
| 'navWarning' | 'piracy' | 'infra' | 'hazard'
|
||||||
|
| 'cnFacility' | 'jpFacility';
|
||||||
|
|
||||||
|
export interface StaticPickInfo {
|
||||||
|
kind: StaticLayerKind;
|
||||||
|
object: StaticPickedObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayerFactoryConfig {
|
||||||
|
sc: number; // sizeScale
|
||||||
|
onPick: (info: StaticPickInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PickingInfo, Layer };
|
||||||
|
export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent };
|
||||||
|
export type { CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone };
|
||||||
|
export type { PowerFacility, HazardFacility, HazardType, CnFacility, JpFacility };
|
||||||
|
|
||||||
|
export function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return [r, g, b];
|
||||||
|
}
|
||||||
34
frontend/src/hooks/usePoll.ts
Normal file
34
frontend/src/hooks/usePoll.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공통 폴링 훅 — 주기적으로 fetchFn을 호출하고 결과를 onData로 전달.
|
||||||
|
* enabled가 false면 폴링 중지.
|
||||||
|
*/
|
||||||
|
export function usePoll<T>(
|
||||||
|
fetchFn: () => Promise<T>,
|
||||||
|
onData: (data: T) => void,
|
||||||
|
intervalMs: number,
|
||||||
|
enabled = true,
|
||||||
|
): void {
|
||||||
|
const onDataRef = useRef(onData);
|
||||||
|
onDataRef.current = onData;
|
||||||
|
|
||||||
|
const fetchRef = useRef(fetchFn);
|
||||||
|
fetchRef.current = fetchFn;
|
||||||
|
|
||||||
|
const doFetch = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchRef.current();
|
||||||
|
onDataRef.current(data);
|
||||||
|
} catch {
|
||||||
|
// graceful — 기존 데이터 유지
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
doFetch();
|
||||||
|
const t = setInterval(doFetch, intervalMs);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [enabled, intervalMs, doFetch]);
|
||||||
|
}
|
||||||
11
frontend/src/hooks/useSharedFilters.ts
Normal file
11
frontend/src/hooks/useSharedFilters.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { SharedFilterContext } from '../contexts/sharedFilterState';
|
||||||
|
import type { SharedFilterState } from '../contexts/sharedFilterState';
|
||||||
|
|
||||||
|
export type { SharedFilterState };
|
||||||
|
|
||||||
|
export function useSharedFilters(): SharedFilterState {
|
||||||
|
const ctx = useContext(SharedFilterContext);
|
||||||
|
if (!ctx) throw new Error('useSharedFilters must be inside SharedFilterProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
30
frontend/src/services/apiClient.ts
Normal file
30
frontend/src/services/apiClient.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
const BASE_PREFIX = '/api/kcg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KCG 백엔드 API 호출 래퍼.
|
||||||
|
* - 자동 credentials: 'include'
|
||||||
|
* - JSON 파싱
|
||||||
|
* - 에러 시 null 반환 (graceful degradation)
|
||||||
|
*/
|
||||||
|
export async function kcgFetch<T>(path: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_PREFIX}${path}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json() as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API 호출 래퍼 (CORS 프록시 경유).
|
||||||
|
*/
|
||||||
|
export async function externalFetch<T>(url: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json() as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
frontend/src/utils/geometry.ts
Normal file
50
frontend/src/utils/geometry.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/** 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별 */
|
||||||
|
function cross(o: [number, number], a: [number, number], b: [number, number]): number {
|
||||||
|
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Graham scan 기반 볼록 껍질 (반시계 방향) */
|
||||||
|
export function convexHull(points: [number, number][]): [number, number][] {
|
||||||
|
const n = points.length;
|
||||||
|
if (n < 2) return points.slice();
|
||||||
|
const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||||
|
const lower: [number, number][] = [];
|
||||||
|
for (const p of sorted) {
|
||||||
|
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
|
||||||
|
lower.pop();
|
||||||
|
}
|
||||||
|
lower.push(p);
|
||||||
|
}
|
||||||
|
const upper: [number, number][] = [];
|
||||||
|
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||||
|
const p = sorted[i];
|
||||||
|
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
|
||||||
|
upper.pop();
|
||||||
|
}
|
||||||
|
upper.push(p);
|
||||||
|
}
|
||||||
|
lower.pop();
|
||||||
|
upper.pop();
|
||||||
|
return lower.concat(upper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 중심에서 각 꼭짓점 방향으로 padding 확장 */
|
||||||
|
export function padPolygon(hull: [number, number][], padding: number): [number, number][] {
|
||||||
|
if (hull.length === 0) return hull;
|
||||||
|
const cx = hull.reduce((s, p) => s + p[0], 0) / hull.length;
|
||||||
|
const cy = hull.reduce((s, p) => s + p[1], 0) / hull.length;
|
||||||
|
return hull.map(([x, y]) => {
|
||||||
|
const dx = x - cx;
|
||||||
|
const dy = y - cy;
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (len === 0) return [x + padding, y + padding] as [number, number];
|
||||||
|
const scale = (len + padding) / len;
|
||||||
|
return [cx + dx * scale, cy + dy * scale] as [number, number];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cluster_id 해시 → HSL 색상 */
|
||||||
|
export function clusterColor(id: number): string {
|
||||||
|
const h = (id * 137) % 360;
|
||||||
|
return `hsl(${h}, 80%, 55%)`;
|
||||||
|
}
|
||||||
84
frontend/src/utils/shipClassification.ts
Normal file
84
frontend/src/utils/shipClassification.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import type { Ship, ShipCategory } from '../types';
|
||||||
|
|
||||||
|
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
|
||||||
|
export const MT_TYPE_COLORS: Record<string, string> = {
|
||||||
|
cargo: 'var(--kcg-ship-cargo)',
|
||||||
|
tanker: 'var(--kcg-ship-tanker)',
|
||||||
|
passenger: 'var(--kcg-ship-passenger)',
|
||||||
|
fishing: 'var(--kcg-ship-fishing)',
|
||||||
|
fishing_gear: '#f97316',
|
||||||
|
pleasure: 'var(--kcg-ship-pleasure)',
|
||||||
|
military: 'var(--kcg-ship-military)',
|
||||||
|
tug_special: 'var(--kcg-ship-tug)',
|
||||||
|
other: 'var(--kcg-ship-other)',
|
||||||
|
unknown: 'var(--kcg-ship-unknown)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolved hex colors for MapLibre paint (which cannot use CSS vars)
|
||||||
|
export const MT_TYPE_HEX: Record<string, string> = {
|
||||||
|
cargo: '#f0a830',
|
||||||
|
tanker: '#e74c3c',
|
||||||
|
passenger: '#4caf50',
|
||||||
|
fishing: '#42a5f5',
|
||||||
|
fishing_gear: '#f97316',
|
||||||
|
pleasure: '#e91e8c',
|
||||||
|
military: '#d32f2f',
|
||||||
|
tug_special: '#2e7d32',
|
||||||
|
other: '#5c6bc0',
|
||||||
|
unknown: '#9e9e9e',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMTType(ship: Ship): string {
|
||||||
|
const tc = (ship.typecode || '').toUpperCase();
|
||||||
|
const cat = ship.category;
|
||||||
|
|
||||||
|
if (cat === 'carrier' || cat === 'destroyer' || cat === 'warship' || cat === 'submarine' || cat === 'patrol') return 'military';
|
||||||
|
if (tc === 'DDG' || tc === 'DDH' || tc === 'CVN' || tc === 'FFG' || tc === 'LCS' || tc === 'MCM' || tc === 'PC' || tc === 'LPH') return 'military';
|
||||||
|
|
||||||
|
if (cat === 'tanker') return 'tanker';
|
||||||
|
if (tc === 'VLCC' || tc === 'LNG' || tc === 'LPG') return 'tanker';
|
||||||
|
if (tc.startsWith('A1')) return 'tanker';
|
||||||
|
|
||||||
|
if (cat === 'cargo') return 'cargo';
|
||||||
|
if (tc === 'CONT' || tc === 'BULK') return 'cargo';
|
||||||
|
if (tc.startsWith('A2') || tc.startsWith('A3')) return 'cargo';
|
||||||
|
|
||||||
|
if (tc === 'PASS' || tc.startsWith('B')) return 'passenger';
|
||||||
|
if (tc.startsWith('C')) return 'fishing';
|
||||||
|
if (tc.startsWith('D') || tc.startsWith('E')) return 'tug_special';
|
||||||
|
if (tc === 'SAIL' || tc === 'YACHT') return 'pleasure';
|
||||||
|
|
||||||
|
if (cat === 'civilian') return 'other';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy navy flag colors (for popup header accent only)
|
||||||
|
export const NAVY_COLORS: Record<string, string> = {
|
||||||
|
US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff',
|
||||||
|
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FLAG_EMOJI: Record<string, string> = {
|
||||||
|
US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}',
|
||||||
|
KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}',
|
||||||
|
AU: '\u{1F1E6}\u{1F1FA}', DE: '\u{1F1E9}\u{1F1EA}', IN: '\u{1F1EE}\u{1F1F3}',
|
||||||
|
CN: '\u{1F1E8}\u{1F1F3}', PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}',
|
||||||
|
MH: '\u{1F1F2}\u{1F1ED}', HK: '\u{1F1ED}\u{1F1F0}', SG: '\u{1F1F8}\u{1F1EC}',
|
||||||
|
BZ: '\u{1F1E7}\u{1F1FF}', OM: '\u{1F1F4}\u{1F1F2}', AE: '\u{1F1E6}\u{1F1EA}',
|
||||||
|
SA: '\u{1F1F8}\u{1F1E6}', BH: '\u{1F1E7}\u{1F1ED}', QA: '\u{1F1F6}\u{1F1E6}',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SIZE_MAP: Record<ShipCategory, number> = {
|
||||||
|
carrier: 0.32, destroyer: 0.22, warship: 0.22, submarine: 0.18, patrol: 0.16,
|
||||||
|
tanker: 0.16, cargo: 0.16, fishing: 0.14, civilian: 0.14, unknown: 0.12,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MIL_CATEGORIES: ShipCategory[] = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol'];
|
||||||
|
|
||||||
|
export function isMilitary(category: ShipCategory): boolean {
|
||||||
|
return MIL_CATEGORIES.includes(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipColor(ship: Ship): string {
|
||||||
|
return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown;
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user