- 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 서비스 함수
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
import { fetchEvents, fetchEventsByRange } from '../services/api';
|
|
import { fetchAircraftFromBackend, fetchAircraftByRange } from '../services/aircraftApi';
|
|
import { getSampleAircraft } from '../data/sampleAircraft';
|
|
import { fetchSatelliteTLE, propagateAll } from '../services/celestrak';
|
|
import { fetchShips } from '../services/ships';
|
|
import { fetchOsintFeed, fetchOsintByRange } from '../services/osint';
|
|
import type { OsintItem } from '../services/osint';
|
|
import { fetchSeismic, fetchPressure } from '../services/sensorApi';
|
|
import type { SeismicDto, PressureDto } from '../services/sensorApi';
|
|
import { propagateAircraft, propagateShips } from '../services/propagation';
|
|
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
|
import type { GeoEvent, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types';
|
|
import { damagedShips } from '../data/damagedShips';
|
|
|
|
type DataSource = 'dummy' | 'api';
|
|
|
|
interface UseIranDataArgs {
|
|
appMode: AppMode;
|
|
currentTime: number;
|
|
isLive: boolean;
|
|
hiddenAcCategories: Set<string>;
|
|
hiddenShipCategories: Set<string>;
|
|
refreshKey: number;
|
|
dashboardTab: 'iran' | 'korea';
|
|
dataSource?: DataSource;
|
|
}
|
|
|
|
interface UseIranDataResult {
|
|
aircraft: Aircraft[];
|
|
ships: Ship[];
|
|
visibleAircraft: Aircraft[];
|
|
visibleShips: Ship[];
|
|
satPositions: SatellitePosition[];
|
|
events: GeoEvent[];
|
|
mergedEvents: GeoEvent[];
|
|
seismicData: SeismicDto[];
|
|
pressureData: PressureDto[];
|
|
osintFeed: OsintItem[];
|
|
aircraftByCategory: Record<string, number>;
|
|
militaryCount: number;
|
|
shipsByCategory: Record<string, number>;
|
|
koreanShips: Ship[];
|
|
koreanShipsByCategory: Record<string, number>;
|
|
}
|
|
|
|
const SENSOR_POLL_INTERVAL = 600_000; // 10 min
|
|
const SHIP_POLL_INTERVAL = 300_000; // 5 min
|
|
const SHIP_STALE_MS = 3_600_000; // 60 min
|
|
|
|
export function useIranData({
|
|
appMode,
|
|
currentTime,
|
|
isLive,
|
|
hiddenAcCategories,
|
|
hiddenShipCategories,
|
|
refreshKey,
|
|
dashboardTab,
|
|
dataSource = 'dummy',
|
|
}: UseIranDataArgs): UseIranDataResult {
|
|
const IRAN_T0 = '2026-03-01T00:00:00Z';
|
|
const isApi = dataSource === 'api';
|
|
const [events, setEvents] = useState<GeoEvent[]>([]);
|
|
const [seismicData, setSeismicData] = useState<SeismicDto[]>([]);
|
|
const [pressureData, setPressureData] = useState<PressureDto[]>([]);
|
|
const [baseAircraft, setBaseAircraft] = useState<Aircraft[]>([]);
|
|
const [baseShips, setBaseShips] = useState<Ship[]>([]);
|
|
const [satellites, setSatellites] = useState<Satellite[]>([]);
|
|
const [satPositions, setSatPositions] = useState<SatellitePosition[]>([]);
|
|
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
|
|
|
|
const satTimeRef = useRef(0);
|
|
const sensorInitRef = useRef(false);
|
|
const shipMapRef = useRef<Map<string, Ship>>(new Map());
|
|
|
|
// Load initial data (events + satellites)
|
|
useEffect(() => {
|
|
if (isApi) {
|
|
fetchEventsByRange(IRAN_T0, new Date().toISOString()).then(setEvents).catch(() => {});
|
|
} else {
|
|
fetchEvents().then(setEvents).catch(() => {});
|
|
}
|
|
fetchSatelliteTLE().then(setSatellites).catch(() => {});
|
|
}, [refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Sensor data: initial full 48h load + 10min polling (incremental merge)
|
|
useEffect(() => {
|
|
const loadFull = async () => {
|
|
try {
|
|
const [seismic, pressure] = await Promise.all([
|
|
fetchSeismic(), // default 2880 min = 48h
|
|
fetchPressure(),
|
|
]);
|
|
setSeismicData(seismic);
|
|
setPressureData(pressure);
|
|
sensorInitRef.current = true;
|
|
} catch { /* keep previous */ }
|
|
};
|
|
|
|
const loadIncremental = async () => {
|
|
if (!sensorInitRef.current) return;
|
|
try {
|
|
const [seismic, pressure] = await Promise.all([
|
|
fetchSeismic(11), // 11 min window (overlap)
|
|
fetchPressure(11),
|
|
]);
|
|
setSeismicData(prev => mergeSensor(prev, seismic, s => s.usgsId, 2880));
|
|
setPressureData(prev => mergeSensor(prev, pressure, p => `${p.station}-${p.timestamp}`, 2880));
|
|
} catch { /* keep previous */ }
|
|
};
|
|
|
|
loadFull();
|
|
const interval = setInterval(loadIncremental, SENSOR_POLL_INTERVAL);
|
|
return () => clearInterval(interval);
|
|
}, [refreshKey]);
|
|
|
|
// Fetch base aircraft data (LIVE: backend, REPLAY: sample or API)
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
if (appMode === 'live') {
|
|
const result = await fetchAircraftFromBackend('iran');
|
|
if (result.length > 0) setBaseAircraft(result);
|
|
} else if (isApi) {
|
|
const result = await fetchAircraftByRange('iran', IRAN_T0, new Date().toISOString());
|
|
if (result.length > 0) setBaseAircraft(result);
|
|
} else {
|
|
setBaseAircraft(getSampleAircraft());
|
|
}
|
|
};
|
|
load();
|
|
const interval = setInterval(load, appMode === 'live' ? 60_000 : 300_000);
|
|
return () => clearInterval(interval);
|
|
}, [appMode, refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Fetch Iran ship data: initial 60min, then 5min polling with 6min window + merge + stale cleanup
|
|
const mergeShips = useCallback((newShips: Ship[]) => {
|
|
const map = shipMapRef.current;
|
|
for (const s of newShips) {
|
|
map.set(s.mmsi, s);
|
|
}
|
|
// Remove stale ships (lastSeen > 60 min ago)
|
|
const cutoff = Date.now() - SHIP_STALE_MS;
|
|
for (const [mmsi, ship] of map) {
|
|
if (ship.lastSeen < cutoff) map.delete(mmsi);
|
|
}
|
|
setBaseShips(Array.from(map.values()));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let initialDone = false;
|
|
const loadInitial = async () => {
|
|
try {
|
|
const data = await fetchShips(60); // 초기: 60분 데이터
|
|
if (data.length > 0) {
|
|
shipMapRef.current = new Map(data.map(s => [s.mmsi, s]));
|
|
setBaseShips(data);
|
|
initialDone = true;
|
|
}
|
|
} catch { /* keep previous */ }
|
|
};
|
|
|
|
const loadIncremental = async () => {
|
|
if (!initialDone) return;
|
|
try {
|
|
const data = await fetchShips(6); // polling: 6분 데이터
|
|
if (data.length > 0) mergeShips(data);
|
|
} catch { /* keep previous */ }
|
|
};
|
|
|
|
loadInitial();
|
|
const interval = setInterval(loadIncremental, SHIP_POLL_INTERVAL);
|
|
return () => clearInterval(interval);
|
|
}, [appMode, refreshKey, mergeShips]);
|
|
|
|
// Fetch OSINT feed — live mode (both tabs) + replay mode (iran tab)
|
|
useEffect(() => {
|
|
const shouldFetch = isLive || dashboardTab === 'iran';
|
|
if (!shouldFetch) { setOsintFeed([]); return; }
|
|
const load = async () => {
|
|
try {
|
|
let data: OsintItem[];
|
|
if (isApi && !isLive) {
|
|
data = await fetchOsintByRange('iran', IRAN_T0, new Date().toISOString());
|
|
} else {
|
|
data = await fetchOsintFeed('iran');
|
|
}
|
|
if (data.length > 0) setOsintFeed(data);
|
|
} catch { /* keep previous */ }
|
|
};
|
|
setOsintFeed([]);
|
|
load();
|
|
const interval = setInterval(load, isApi ? 300_000 : 120_000);
|
|
return () => clearInterval(interval);
|
|
}, [isLive, dashboardTab, refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Propagate satellite positions — throttle to every 2s of real time
|
|
useEffect(() => {
|
|
if (satellites.length === 0) return;
|
|
const now = Date.now();
|
|
if (now - satTimeRef.current < 2000) return;
|
|
satTimeRef.current = now;
|
|
const positions = propagateAll(satellites, new Date(currentTime));
|
|
setSatPositions(positions);
|
|
}, [satellites, currentTime]);
|
|
|
|
// Propagate aircraft positions based on current time
|
|
const aircraft = useMemo(
|
|
() => propagateAircraft(baseAircraft, currentTime),
|
|
[baseAircraft, currentTime],
|
|
);
|
|
|
|
// Propagate ship positions based on current time
|
|
const ships = useMemo(
|
|
() => propagateShips(baseShips, currentTime, isLive),
|
|
[baseShips, currentTime, isLive],
|
|
);
|
|
|
|
// Category-filtered data for map rendering
|
|
const visibleAircraft = useMemo(
|
|
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
|
|
[aircraft, hiddenAcCategories],
|
|
);
|
|
|
|
const visibleShips = useMemo(
|
|
() => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
|
|
[ships, hiddenShipCategories],
|
|
);
|
|
|
|
// OSINT → GeoEvent 변환
|
|
const osintEvents = useMemo((): GeoEvent[] => {
|
|
if (dashboardTab !== 'iran' || osintFeed.length === 0) return [];
|
|
|
|
const STRIKE_PATTERN = /strike|attack|bomb|airstrike|hit|destroy|blast|공습|타격|폭격|파괴|피격/i;
|
|
const MISSILE_PATTERN = /missile|launch|drone|발사|미사일|드론/i;
|
|
const EXPLOSION_PATTERN = /explo|blast|deton|fire|폭발|화재|폭파/i;
|
|
const INTERCEPT_PATTERN = /intercept|shoot.*down|defense|요격|격추|방어/i;
|
|
const IMPACT_PATTERN = /impact|hit|struck|damage|casualt|피격|타격|피해|사상/i;
|
|
|
|
const categoryToType: Record<string, GeoEvent['type']> = {
|
|
military: 'osint',
|
|
shipping: 'osint',
|
|
oil: 'osint',
|
|
nuclear: 'osint',
|
|
diplomacy: 'osint',
|
|
};
|
|
|
|
return osintFeed
|
|
.filter(item => {
|
|
if (!item.lat || !item.lng) return false;
|
|
return item.category in categoryToType;
|
|
})
|
|
.map((item): GeoEvent => {
|
|
let eventType: GeoEvent['type'] = 'osint';
|
|
const title = item.title;
|
|
if (IMPACT_PATTERN.test(title)) eventType = 'impact';
|
|
else if (STRIKE_PATTERN.test(title)) eventType = 'airstrike';
|
|
else if (MISSILE_PATTERN.test(title)) eventType = 'missile_launch';
|
|
else if (EXPLOSION_PATTERN.test(title)) eventType = 'explosion';
|
|
else if (INTERCEPT_PATTERN.test(title)) eventType = 'intercept';
|
|
|
|
let source: GeoEvent['source'] | undefined;
|
|
if (/US|미국|America|Pentagon|CENTCOM/i.test(title)) source = 'US';
|
|
else if (/Israel|이스라엘|IAF|IDF/i.test(title)) source = 'IL';
|
|
else if (/Iran|이란|IRGC/i.test(title)) source = 'IR';
|
|
else if (/Houthi|후티|Hezbollah|헤즈볼라|PMF|proxy|대리/i.test(title)) source = 'proxy';
|
|
|
|
return {
|
|
id: `osint-${item.id}`,
|
|
timestamp: item.timestamp,
|
|
lat: item.lat!,
|
|
lng: item.lng!,
|
|
type: eventType,
|
|
source,
|
|
label: `[OSINT] ${item.title}`,
|
|
description: `출처: ${item.source} | ${item.url}`,
|
|
intensity: eventType === 'impact' ? 80 : eventType === 'airstrike' ? 70 : 50,
|
|
};
|
|
});
|
|
}, [osintFeed, dashboardTab]);
|
|
|
|
// 피격 선박 → GeoEvent 변환
|
|
const damageEvents = useMemo<GeoEvent[]>(() =>
|
|
damagedShips.map(s => ({
|
|
id: `dmg-${s.id}`,
|
|
timestamp: s.damagedAt,
|
|
lat: s.lat,
|
|
lng: s.lng,
|
|
type: 'sea_attack' as const,
|
|
source: s.flag === 'IR' ? 'IR' as const : undefined,
|
|
label: `${s.name} (${s.flag}) — ${s.cause}`,
|
|
description: s.description,
|
|
intensity: s.damage === 'sunk' ? 100 : s.damage === 'severe' ? 75 : s.damage === 'moderate' ? 50 : 25,
|
|
})),
|
|
[]);
|
|
|
|
// 기본 이벤트 + OSINT 이벤트 + 피격 선박 병합 (시간순 정렬)
|
|
// API 모드: DB에 이미 sampleEvents+damagedShips 포함 → damageEvents 중복 방지
|
|
const mergedEvents = useMemo(() => {
|
|
const extra = isApi ? [] : damageEvents;
|
|
return [...events, ...osintEvents, ...extra].sort((a, b) => a.timestamp - b.timestamp);
|
|
}, [events, osintEvents, damageEvents, isApi]);
|
|
|
|
// Aircraft stats
|
|
const aircraftByCategory = useMemo(() => {
|
|
const counts: Record<string, number> = {};
|
|
for (const ac of aircraft) {
|
|
counts[ac.category] = (counts[ac.category] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}, [aircraft]);
|
|
|
|
const militaryCount = useMemo(
|
|
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
|
|
[aircraft],
|
|
);
|
|
|
|
// Ship stats — MT classification
|
|
const shipsByCategory = useMemo(() => {
|
|
const counts: Record<string, number> = {};
|
|
for (const s of ships) {
|
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
|
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}, [ships]);
|
|
|
|
// Korean ship stats
|
|
const koreanShips = useMemo(() => ships.filter(s => s.flag === 'KR'), [ships]);
|
|
const koreanShipsByCategory = useMemo(() => {
|
|
const counts: Record<string, number> = {};
|
|
for (const s of koreanShips) {
|
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
|
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}, [koreanShips]);
|
|
|
|
return {
|
|
aircraft,
|
|
ships,
|
|
visibleAircraft,
|
|
visibleShips,
|
|
satPositions,
|
|
events,
|
|
mergedEvents,
|
|
seismicData,
|
|
pressureData,
|
|
osintFeed,
|
|
aircraftByCategory,
|
|
militaryCount,
|
|
shipsByCategory,
|
|
koreanShips,
|
|
koreanShipsByCategory,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 센서 데이터 병합: 새 데이터를 기존 배열에 추가, 중복 제거, 오래된 데이터 제거
|
|
*/
|
|
function mergeSensor<T extends { timestamp: number }>(
|
|
existing: T[],
|
|
incoming: T[],
|
|
keyFn: (item: T) => string,
|
|
maxMinutes: number,
|
|
): T[] {
|
|
const cutoff = Date.now() - maxMinutes * 60_000;
|
|
const map = new Map<string, T>();
|
|
for (const item of existing) {
|
|
if (item.timestamp >= cutoff) map.set(keyFn(item), item);
|
|
}
|
|
for (const item of incoming) {
|
|
if (item.timestamp >= cutoff) map.set(keyFn(item), item);
|
|
}
|
|
return Array.from(map.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
}
|