417 lines
18 KiB
TypeScript
417 lines
18 KiB
TypeScript
import { useState, useMemo, 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, type LayerTreeNode } 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 batchToggleKoreaLayer = useCallback((keys: string[], value: boolean) => {
|
||
setKoreaLayers(prev => {
|
||
const next = { ...prev };
|
||
for (const k of keys) next[k] = value;
|
||
return next;
|
||
});
|
||
}, [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,
|
||
vesselAnalysis.analysisMap,
|
||
);
|
||
|
||
const handleTabChange = useCallback((_tab: DashboardTab) => {
|
||
// Tab switching is managed by parent (App.tsx); no-op here
|
||
}, []);
|
||
|
||
const layerTree = useMemo((): LayerTreeNode[] => [
|
||
{ key: 'ships', label: t('layers.ships'), color: '#fb923c', count: koreaData.ships.length, specialRenderer: 'shipCategories' },
|
||
{ key: 'nationality', label: '국적 분류', color: '#8b5cf6', count: koreaData.ships.length, specialRenderer: 'nationalityCategories' },
|
||
{
|
||
key: 'aviation', label: '항공망', color: '#22d3ee',
|
||
children: [
|
||
{ key: 'aircraft', label: t('layers.aircraft'), color: '#22d3ee', count: koreaData.aircraft.length, specialRenderer: 'aircraftCategories' },
|
||
{ key: 'satellites', label: t('layers.satellites'), color: '#ef4444', count: koreaData.satPositions.length },
|
||
],
|
||
},
|
||
{
|
||
key: 'maritime-safety', label: '해양안전', color: '#3b82f6',
|
||
children: [
|
||
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length },
|
||
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length },
|
||
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length },
|
||
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length },
|
||
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1 },
|
||
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length },
|
||
{ key: 'nkMissile', label: '미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length },
|
||
{ key: 'nkLaunch', label: '발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length },
|
||
],
|
||
},
|
||
{
|
||
key: 'govt-infra', label: '국가기관망', color: '#f59e0b',
|
||
children: [
|
||
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length },
|
||
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length },
|
||
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length },
|
||
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length },
|
||
],
|
||
},
|
||
{
|
||
key: 'energy', label: '에너지/발전시설', color: '#a855f7',
|
||
children: [
|
||
{ key: 'infra', label: t('layers.infra'), color: '#ffc107' },
|
||
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length },
|
||
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length },
|
||
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length },
|
||
],
|
||
},
|
||
{
|
||
key: 'hazard', label: '위험시설', color: '#ef4444',
|
||
children: [
|
||
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length },
|
||
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length },
|
||
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length },
|
||
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length },
|
||
],
|
||
},
|
||
{
|
||
key: 'industry', label: '산업공정/제조시설', color: '#0ea5e9',
|
||
children: [
|
||
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length },
|
||
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length },
|
||
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length },
|
||
],
|
||
},
|
||
{
|
||
key: 'overseas', label: '해외시설', color: '#f97316',
|
||
children: [
|
||
{
|
||
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
|
||
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',
|
||
children: [
|
||
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
|
||
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
], [koreaData, t]);
|
||
|
||
// 헤더 슬롯 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 ${koreaFiltersResult.filters.cnFishing ? 'active live' : ''}`}
|
||
onClick={() => koreaFiltersResult.setFilter('cnFishing', !koreaFiltersResult.filters.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.ships}
|
||
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}
|
||
cnFishingSuspects={koreaFiltersResult.cnFishingSuspects}
|
||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||
vesselAnalysis={vesselAnalysis}
|
||
hiddenShipCategories={hiddenShipCategories}
|
||
hiddenNationalities={hiddenNationalities}
|
||
/>
|
||
<div className="map-overlay-left">
|
||
<LayerPanel
|
||
layers={koreaLayers}
|
||
onToggle={toggleKoreaLayer}
|
||
onBatchToggle={batchToggleKoreaLayer}
|
||
tree={layerTree}
|
||
aircraftByCategory={koreaData.aircraftByCategory}
|
||
aircraftTotal={koreaData.aircraft.length}
|
||
shipsByMtCategory={koreaData.shipsByCategory}
|
||
shipTotal={koreaData.ships.length}
|
||
satelliteCount={koreaData.satPositions.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>
|
||
</>
|
||
);
|
||
};
|
||
|