kcg-monitoring/frontend/src/components/korea/KoreaDashboard.tsx

417 lines
18 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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