kcg-monitoring/frontend/src/components/iran/IranDashboard.tsx
htlee 3f2052a46e feat: 웹폰트 내장 + 이란 시설물 색상/가독성 개선
- @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅
- 전체 font-family 통일 (CSS, deck.gl, 인라인 스타일)
- 이란 시설물 색상 사막 대비 고채도 팔레트로 교체
- 이란 라벨 fontWeight 600→700, alpha 200→255
- 접힘 패널 상하 패딩 균일화
2026-03-24 10:11:59 +09:00

357 lines
16 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 { 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: '#38bdf8', count: IRAN_AIRPORT_COUNT },
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#fb7185', count: IRAN_OIL_COUNT },
{ key: 'meFacilities', label: '주요시설/군사', color: '#f87171', count: ME_FACILITY_COUNT },
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
{
key: 'overseas', label: '해외시설', color: '#c084fc',
children: [
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#60a5fa', count: meCountByCountry('us') },
{ key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#ef4444', count: meCountByCountry('il') },
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#34d399', count: meCountByCountry('ir') },
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#38bdf8', count: meCountByCountry('ae') },
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#a3e635', count: meCountByCountry('sa') },
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#fb7185', count: meCountByCountry('om') },
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#a78bfa', count: meCountByCountry('qa') },
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f472b6', count: meCountByCountry('kw') },
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#86efac', count: meCountByCountry('iq') },
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#f87171', 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 };