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]
|
||||
|
||||
## [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]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -1,44 +1,15 @@
|
||||
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 { useMonitor } from './hooks/useMonitor';
|
||||
import { useIranData } from './hooks/useIranData';
|
||||
import { useKoreaData } from './hooks/useKoreaData';
|
||||
import { useKoreaFilters } from './hooks/useKoreaFilters';
|
||||
import { useVesselAnalysis } from './hooks/useVesselAnalysis';
|
||||
import type { GeoEvent, LayerVisibility, AppMode } from './types';
|
||||
import type { AppMode } from './types';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginPage from './components/auth/LoginPage';
|
||||
import CollectorMonitor from './components/common/CollectorMonitor';
|
||||
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
|
||||
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
|
||||
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';
|
||||
import { SharedFilterProvider } from './contexts/SharedFilterContext';
|
||||
import { IranDashboard } from './components/iran/IranDashboard';
|
||||
import { KoreaDashboard } from './components/korea/KoreaDashboard';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@ -69,133 +40,18 @@ interface AuthenticatedAppProps {
|
||||
|
||||
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
const [appMode, setAppMode] = useState<AppMode>('live');
|
||||
const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite');
|
||||
const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran');
|
||||
const [layers, setLayers] = useLocalStorage<LayerVisibility>('iranLayers', {
|
||||
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);
|
||||
const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran');
|
||||
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
||||
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
||||
|
||||
// 1시간마다 전체 데이터 강제 리프레시
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
useEffect(() => {
|
||||
const HOUR_MS = 3600_000;
|
||||
const interval = setInterval(() => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, HOUR_MS);
|
||||
const interval = setInterval(() => setRefreshKey(k => k + 1), HOUR_MS);
|
||||
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 monitor = useMonitor();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
@ -205,568 +61,102 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
}, [i18n]);
|
||||
|
||||
const isLive = appMode === 'live';
|
||||
|
||||
// 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 });
|
||||
}, []);
|
||||
const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime;
|
||||
|
||||
return (
|
||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||
<header className="app-header">
|
||||
{/* Dashboard Tabs (replaces title) */}
|
||||
<div className="dash-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`dash-tab ${dashboardTab === 'iran' ? 'active' : ''}`}
|
||||
onClick={() => setDashboardTab('iran')}
|
||||
>
|
||||
<span className="dash-tab-flag">🇮🇷</span>
|
||||
{t('tabs.iran')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
|
||||
onClick={() => setDashboardTab('korea')}
|
||||
>
|
||||
<span className="dash-tab-flag">🇰🇷</span>
|
||||
{t('tabs.korea')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode Toggle */}
|
||||
{dashboardTab === 'iran' && (
|
||||
<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>
|
||||
<SharedFilterProvider>
|
||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||
<header className="app-header">
|
||||
{/* Dashboard Tabs */}
|
||||
<div className="dash-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${appMode === 'live' ? 'active live' : ''}`}
|
||||
onClick={() => setAppMode('live')}
|
||||
className={`dash-tab ${dashboardTab === 'iran' ? 'active' : ''}`}
|
||||
onClick={() => setDashboardTab('iran')}
|
||||
>
|
||||
<span className="mode-dot-icon" />
|
||||
{t('mode.live')}
|
||||
<span className="dash-tab-flag">🇮🇷</span>
|
||||
{t('tabs.iran')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
|
||||
onClick={() => setAppMode('replay')}
|
||||
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
|
||||
onClick={() => setDashboardTab('korea')}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
||||
{t('mode.replay')}
|
||||
<span className="dash-tab-flag">🇰🇷</span>
|
||||
{t('tabs.korea')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 탭별 모드/필터 영역 — 각 대시보드가 headerSlot으로 렌더링 */}
|
||||
<div id="dashboard-header-slot" />
|
||||
|
||||
<div className="header-info">
|
||||
<div id="dashboard-counts-slot" />
|
||||
<div className="header-toggles">
|
||||
<button
|
||||
type="button"
|
||||
className="header-toggle-btn"
|
||||
onClick={() => setShowCollectorMonitor(v => !v)}
|
||||
title="수집기 모니터링"
|
||||
>
|
||||
MON
|
||||
</button>
|
||||
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
|
||||
{i18n.language === 'ko' ? 'KO' : 'EN'}
|
||||
</button>
|
||||
<button type="button" className="header-toggle-btn" onClick={toggleTheme} title="Theme">
|
||||
{theme === 'dark' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="header-status">
|
||||
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
|
||||
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
|
||||
</div>
|
||||
{user && (
|
||||
<div className="header-user">
|
||||
{user.picture && (
|
||||
<img src={user.picture} alt="" className="header-user-avatar" referrerPolicy="no-referrer" />
|
||||
)}
|
||||
<span className="header-user-name">{user.name}</span>
|
||||
<button type="button" className="header-toggle-btn" onClick={onLogout} title="Logout">
|
||||
⏻
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{dashboardTab === 'iran' && (
|
||||
<IranDashboard
|
||||
currentTime={currentTime}
|
||||
isLive={isLive}
|
||||
refreshKey={refreshKey}
|
||||
replay={replay}
|
||||
monitor={monitor}
|
||||
timeZone={timeZone}
|
||||
onTimeZoneChange={setTimeZone}
|
||||
appMode={appMode}
|
||||
onAppModeChange={setAppMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<KoreaDashboard
|
||||
currentTime={currentTime}
|
||||
isLive={isLive}
|
||||
refreshKey={refreshKey}
|
||||
replay={replay}
|
||||
monitor={monitor}
|
||||
timeZone={timeZone}
|
||||
onTimeZoneChange={setTimeZone}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{showCollectorMonitor && (
|
||||
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
|
||||
)}
|
||||
|
||||
<div className="header-info">
|
||||
<div className="header-counts">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="header-toggle-btn"
|
||||
onClick={() => setShowCollectorMonitor(v => !v)}
|
||||
title="수집기 모니터링"
|
||||
>
|
||||
MON
|
||||
</button>
|
||||
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
|
||||
{i18n.language === 'ko' ? 'KO' : 'EN'}
|
||||
</button>
|
||||
<button type="button" className="header-toggle-btn" onClick={toggleTheme} title="Theme">
|
||||
{theme === 'dark' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="header-status">
|
||||
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
|
||||
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
|
||||
</div>
|
||||
{user && (
|
||||
<div className="header-user">
|
||||
{user.picture && (
|
||||
<img src={user.picture} alt="" className="header-user-avatar" referrerPolicy="no-referrer" />
|
||||
)}
|
||||
<span className="header-user-name">{user.name}</span>
|
||||
<button type="button" className="header-toggle-btn" onClick={onLogout} title="Logout">
|
||||
⏻
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ═══════════════════════════════════════
|
||||
IRAN DASHBOARD
|
||||
═══════════════════════════════════════ */}
|
||||
{dashboardTab === 'iran' && (
|
||||
<>
|
||||
<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={dashboardTab}
|
||||
onTabChange={setDashboardTab}
|
||||
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={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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════
|
||||
KOREA DASHBOARD
|
||||
═══════════════════════════════════════ */}
|
||||
{dashboardTab === 'korea' && (
|
||||
<>
|
||||
<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={isLive ? [] : iranData.events}
|
||||
currentTime={currentTime}
|
||||
totalShipCount={koreaData.ships.length}
|
||||
koreanShips={koreaData.koreaKoreanShips}
|
||||
koreanShipsByCategory={koreaData.shipsByCategory}
|
||||
chineseShips={koreaData.koreaChineseShips}
|
||||
osintFeed={koreaData.osintFeed}
|
||||
isLive={isLive}
|
||||
dashboardTab={dashboardTab}
|
||||
onTabChange={setDashboardTab}
|
||||
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={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 && (
|
||||
<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 type { FleetCompany } from '../../services/vesselAnalysis';
|
||||
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||
import { convexHull, padPolygon, clusterColor } from '../../utils/geometry';
|
||||
|
||||
export interface SelectedGearGroupData {
|
||||
parent: Ship | null;
|
||||
@ -29,59 +30,6 @@ interface Props {
|
||||
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 속성으로 주입
|
||||
interface ClusterPolygonFeature {
|
||||
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 { 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 { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
|
||||
@ -20,6 +20,7 @@ import { EezLayer } from './EezLayer';
|
||||
// PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer,
|
||||
// NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨
|
||||
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
||||
import { StaticFacilityPopup } from './StaticFacilityPopup';
|
||||
// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨
|
||||
import { AnalysisOverlay } from './AnalysisOverlay';
|
||||
import { FleetClusterLayer } from './FleetClusterLayer';
|
||||
@ -142,6 +143,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
|
||||
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
|
||||
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 [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 }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
onZoom={e => setZoomLevel(Math.floor(e.viewState.zoom))}
|
||||
onZoom={handleZoom}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
@ -643,203 +652,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
...(analysisPanelOpen ? analysisDeckLayers : []),
|
||||
].filter(Boolean)} />
|
||||
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
|
||||
{staticPickInfo && (() => {
|
||||
const obj = staticPickInfo.object;
|
||||
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>
|
||||
);
|
||||
})()}
|
||||
{staticPickInfo && (
|
||||
<StaticFacilityPopup pickInfo={staticPickInfo} onClose={() => setStaticPickInfo(null)} />
|
||||
)}
|
||||
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
|
||||
{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 { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Ship, ShipCategory, VesselAnalysisDto } from '../../types';
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
@ -14,100 +15,6 @@ interface Props {
|
||||
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 {
|
||||
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