refactor: 프론트엔드 구조 리팩토링 Phase 1~6 (#155)

This commit is contained in:
htlee 2026-03-23 11:14:49 +09:00
부모 8ca89487e9
커밋 2c566041ca
20개의 변경된 파일2400개의 추가작업 그리고 2080개의 파일을 삭제

파일 보기

@ -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>
);
}

파일 보기

@ -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';

파일 보기

@ -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 />}

파일 보기

@ -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;

파일 보기

@ -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>
);
}

파일 보기

@ -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);

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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];
}

파일 보기

@ -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]);
}

파일 보기

@ -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

파일 보기

@ -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;
}
}

파일 보기

@ -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%)`;
}

파일 보기

@ -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;
}