- GeoEvent.type에 'sea_attack' 추가 + SEA ATK 배지 (#0ea5e9) - damagedShips → GeoEvent 변환, mergedEvents에 합류 - 더미↔API 토글 UI (ReplayControls 배속 우측) - useIranData: dataSource 분기 (dummy=sampleData, api=Backend DB) - API 모드: events/aircraft/osint 시점 범위 조회 (3월1일~오늘) - 중복 방지: API 모드에서 damageEvents 프론트 병합 건너뜀 - fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수
357 lines
16 KiB
TypeScript
357 lines
16 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||
import { createPortal } from 'react-dom';
|
||
import { IRAN_OIL_COUNT } from './createIranOilLayers';
|
||
import { IRAN_AIRPORT_COUNT } from './createIranAirportLayers';
|
||
import { ME_FACILITY_COUNT } from './createMEFacilityLayers';
|
||
import { ME_ENERGY_HAZARD_FACILITIES } from '../../data/meEnergyHazardFacilities';
|
||
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, type LayerTreeNode } from '../common/LayerPanel';
|
||
import { LiveControls } from '../common/LiveControls';
|
||
import { ReplayControls, type DataSource } 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,
|
||
overseasIsrael: 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 [dataSource, setDataSource] = useLocalStorage<DataSource>('iranDataSource', 'dummy');
|
||
|
||
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
|
||
useSharedFilters();
|
||
|
||
const iranData = useIranData({
|
||
appMode,
|
||
currentTime,
|
||
isLive,
|
||
hiddenAcCategories,
|
||
hiddenShipCategories,
|
||
refreshKey,
|
||
dashboardTab: 'iran',
|
||
dataSource,
|
||
});
|
||
|
||
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
||
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||
}, []);
|
||
|
||
const batchToggleLayer = useCallback((keys: string[], value: boolean) => {
|
||
setLayers(prev => {
|
||
const next = { ...prev } as Record<string, boolean>;
|
||
for (const k of keys) next[k] = value;
|
||
return next as LayerVisibility;
|
||
});
|
||
}, []);
|
||
|
||
const handleEventFlyTo = useCallback((event: GeoEvent) => {
|
||
setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 });
|
||
}, []);
|
||
|
||
const meCountByCountry = useCallback((ck: string) => ME_ENERGY_HAZARD_FACILITIES.filter(f => f.countryKey === ck).length, []);
|
||
|
||
const layerTree = useMemo((): LayerTreeNode[] => [
|
||
{ key: 'ships', label: t('layers.ships'), color: '#fb923c', count: iranData.ships.length, specialRenderer: 'shipCategories' },
|
||
{
|
||
key: 'aviation', label: '항공망', color: '#22d3ee',
|
||
children: [
|
||
{ key: 'aircraft', label: t('layers.aircraft'), color: '#22d3ee', count: iranData.aircraft.length, specialRenderer: 'aircraftCategories' },
|
||
{ key: 'satellites', label: t('layers.satellites'), color: '#ef4444', count: iranData.satPositions.length },
|
||
],
|
||
},
|
||
{ 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', count: IRAN_AIRPORT_COUNT },
|
||
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706', count: IRAN_OIL_COUNT },
|
||
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: ME_FACILITY_COUNT },
|
||
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
||
{
|
||
key: 'overseas', label: '해외시설', color: '#f97316',
|
||
children: [
|
||
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6', count: meCountByCountry('us') },
|
||
{ key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#dc2626', count: meCountByCountry('il') },
|
||
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e', count: meCountByCountry('ir') },
|
||
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b', count: meCountByCountry('ae') },
|
||
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16', count: meCountByCountry('sa') },
|
||
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48', count: meCountByCountry('om') },
|
||
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6', count: meCountByCountry('qa') },
|
||
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316', count: meCountByCountry('kw') },
|
||
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d', count: meCountByCountry('iq') },
|
||
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48', count: meCountByCountry('bh') },
|
||
],
|
||
},
|
||
], [iranData, t, meCountByCountry]);
|
||
|
||
// 헤더 슬롯 Portal — 이란 모드 토글 + 맵 모드 + 카운트
|
||
const headerSlot = document.getElementById('dashboard-header-slot');
|
||
const countsSlot = document.getElementById('dashboard-counts-slot');
|
||
|
||
return (
|
||
<>
|
||
{headerSlot && createPortal(
|
||
<>
|
||
<div className="mode-toggle mode-toggle-left">
|
||
<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}
|
||
onBatchToggle={batchToggleLayer}
|
||
tree={layerTree}
|
||
aircraftByCategory={iranData.aircraftByCategory}
|
||
aircraftTotal={iranData.aircraft.length}
|
||
shipsByMtCategory={iranData.shipsByCategory}
|
||
shipTotal={iranData.ships.length}
|
||
satelliteCount={iranData.satPositions.length}
|
||
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}
|
||
dataSource={dataSource}
|
||
onDataSourceChange={setDataSource}
|
||
/>
|
||
<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 };
|