refactor: Phase 1 기반 컴포넌트 생성 — IranDashboard, KoreaDashboard, SharedFilterContext
- SharedFilterContext + SharedFilterProvider: 카테고리 필터 공유 상태 - useSharedFilters: Context 소비 훅 (react-refresh 호환 분리) - IranDashboard: 이란 전용 상태/JSX 추출 (274줄) - KoreaDashboard: 한국 전용 상태/JSX 추출 (323줄) - App.tsx 통합은 후속 커밋 (헤더 상태 참조 리팩토링 필요)
This commit is contained in:
부모
83c0281710
커밋
d6de826d1d
274
frontend/src/components/iran/IranDashboard.tsx
Normal file
274
frontend/src/components/iran/IranDashboard.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
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 appModeFromIsLive = (isLive: boolean): AppMode => (isLive ? 'live' : 'replay');
|
||||
|
||||
const IranDashboard = ({
|
||||
currentTime,
|
||||
isLive,
|
||||
refreshKey,
|
||||
replay,
|
||||
monitor,
|
||||
timeZone,
|
||||
onTimeZoneChange,
|
||||
}: 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: appModeFromIsLive(isLive),
|
||||
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 });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 };
|
||||
323
frontend/src/components/korea/KoreaDashboard.tsx
Normal file
323
frontend/src/components/korea/KoreaDashboard.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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;
|
||||
showFieldAnalysis: boolean;
|
||||
onFieldAnalysisClose: () => void;
|
||||
}
|
||||
|
||||
const KoreaDashboard = ({
|
||||
currentTime,
|
||||
isLive,
|
||||
refreshKey,
|
||||
replay,
|
||||
monitor,
|
||||
timeZone,
|
||||
onTimeZoneChange,
|
||||
showFieldAnalysis,
|
||||
onFieldAnalysisClose,
|
||||
}: KoreaDashboardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
|
||||
useSharedFilters();
|
||||
|
||||
const [koreaLayers, setKoreaLayers] = useState<Record<string, boolean>>({
|
||||
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] }));
|
||||
}, []);
|
||||
|
||||
const [hiddenNationalities, setHiddenNationalities] = useState<Set<string>>(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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [hiddenFishingNats, setHiddenFishingNats] = useState<Set<string>>(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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const koreaData = useKoreaData({
|
||||
currentTime,
|
||||
isLive,
|
||||
hiddenAcCategories,
|
||||
hiddenShipCategories,
|
||||
hiddenNationalities,
|
||||
refreshKey,
|
||||
});
|
||||
|
||||
const vesselAnalysis = useVesselAnalysis(true);
|
||||
|
||||
const koreaFiltersResult = useKoreaFilters(
|
||||
koreaData.ships,
|
||||
koreaData.visibleShips,
|
||||
currentTime,
|
||||
vesselAnalysis.analysisMap,
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback((_tab: DashboardTab) => {
|
||||
// Tab switching is managed by parent (App.tsx); no-op here
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="app-main">
|
||||
<div className="map-panel">
|
||||
{showFieldAnalysis && (
|
||||
<FieldAnalysisModal
|
||||
ships={koreaData.ships}
|
||||
vesselAnalysis={vesselAnalysis}
|
||||
onClose={onFieldAnalysisClose}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default KoreaDashboard;
|
||||
32
frontend/src/contexts/SharedFilterContext.tsx
Normal file
32
frontend/src/contexts/SharedFilterContext.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SharedFilterContext } from './sharedFilterState';
|
||||
|
||||
export { SharedFilterContext } from './sharedFilterState';
|
||||
export type { SharedFilterState } from './sharedFilterState';
|
||||
|
||||
export function SharedFilterProvider({ children }: { children: React.ReactNode }) {
|
||||
const [hiddenAcCategories, setHiddenAcCategories] = useState<Set<string>>(new Set());
|
||||
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleAcCategory = useCallback((cat: string) => {
|
||||
setHiddenAcCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleShipCategory = useCallback((cat: string) => {
|
||||
setHiddenShipCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SharedFilterContext.Provider value={{ hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory }}>
|
||||
{children}
|
||||
</SharedFilterContext.Provider>
|
||||
);
|
||||
}
|
||||
10
frontend/src/contexts/sharedFilterState.ts
Normal file
10
frontend/src/contexts/sharedFilterState.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export interface SharedFilterState {
|
||||
hiddenAcCategories: Set<string>;
|
||||
hiddenShipCategories: Set<string>;
|
||||
toggleAcCategory: (cat: string) => void;
|
||||
toggleShipCategory: (cat: string) => void;
|
||||
}
|
||||
|
||||
export const SharedFilterContext = createContext<SharedFilterState | null>(null);
|
||||
11
frontend/src/hooks/useSharedFilters.ts
Normal file
11
frontend/src/hooks/useSharedFilters.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react';
|
||||
import { SharedFilterContext } from '../contexts/sharedFilterState';
|
||||
import type { SharedFilterState } from '../contexts/sharedFilterState';
|
||||
|
||||
export type { SharedFilterState };
|
||||
|
||||
export function useSharedFilters(): SharedFilterState {
|
||||
const ctx = useContext(SharedFilterContext);
|
||||
if (!ctx) throw new Error('useSharedFilters must be inside SharedFilterProvider');
|
||||
return ctx;
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user