feat(frontend): 이란 리플레이 실데이터 전환 + 피격선박 이벤트 통합
- 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 서비스 함수
This commit is contained in:
부모
9e1b3730ff
커밋
6d4ac4d3fe
@ -1431,6 +1431,10 @@
|
||||
gap: 3px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.data-source-toggle {
|
||||
border-left: 1px solid rgba(255,255,255,0.15);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.speed-btn {
|
||||
padding: 3px 8px;
|
||||
|
||||
@ -260,6 +260,7 @@ const TYPE_LABELS: Record<GeoEvent['type'], string> = {
|
||||
alert: 'ALERT',
|
||||
impact: 'IMPACT',
|
||||
osint: 'OSINT',
|
||||
sea_attack: 'SEA ATK',
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
||||
@ -270,6 +271,7 @@ const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
||||
alert: 'var(--kcg-event-alert)',
|
||||
impact: 'var(--kcg-event-impact)',
|
||||
osint: 'var(--kcg-event-osint)',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
// MarineTraffic-style ship type classification
|
||||
|
||||
@ -20,6 +20,7 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
const TYPE_KEYS: Record<string, string> = {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type DataSource = 'dummy' | 'api';
|
||||
|
||||
interface Props {
|
||||
isPlaying: boolean;
|
||||
speed: number;
|
||||
@ -11,6 +13,8 @@ interface Props {
|
||||
onReset: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onRangeChange: (start: number, end: number) => void;
|
||||
dataSource?: DataSource;
|
||||
onDataSourceChange?: (ds: DataSource) => void;
|
||||
}
|
||||
|
||||
const SPEEDS = [1, 2, 4, 8, 16];
|
||||
@ -51,6 +55,8 @@ export function ReplayControls({
|
||||
onReset,
|
||||
onSpeedChange,
|
||||
onRangeChange,
|
||||
dataSource,
|
||||
onDataSourceChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
@ -110,6 +116,24 @@ export function ReplayControls({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Data source toggle */}
|
||||
{dataSource && onDataSourceChange && (
|
||||
<div className="speed-controls data-source-toggle">
|
||||
<button
|
||||
className={`speed-btn ${dataSource === 'dummy' ? 'active' : ''}`}
|
||||
onClick={() => onDataSourceChange('dummy')}
|
||||
>
|
||||
더미
|
||||
</button>
|
||||
<button
|
||||
className={`speed-btn ${dataSource === 'api' ? 'active' : ''}`}
|
||||
onClick={() => onDataSourceChange('api')}
|
||||
>
|
||||
API
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
const TYPE_I18N_KEYS: Record<string, string> = {
|
||||
|
||||
@ -21,6 +21,7 @@ const EVENT_COLORS: Record<string, string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
// Navy flag-based colors for military vessels
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
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';
|
||||
@ -12,7 +13,7 @@ import { SensorChart } from '../common/SensorChart';
|
||||
import { EventLog } from '../common/EventLog';
|
||||
import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel';
|
||||
import { LiveControls } from '../common/LiveControls';
|
||||
import { ReplayControls } from '../common/ReplayControls';
|
||||
import { ReplayControls, type DataSource } from '../common/ReplayControls';
|
||||
import { TimelineSlider } from '../common/TimelineSlider';
|
||||
import { useIranData } from '../../hooks/useIranData';
|
||||
import { useSharedFilters } from '../../hooks/useSharedFilters';
|
||||
@ -95,6 +96,7 @@ const IranDashboard = ({
|
||||
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();
|
||||
@ -107,6 +109,7 @@ const IranDashboard = ({
|
||||
hiddenShipCategories,
|
||||
refreshKey,
|
||||
dashboardTab: 'iran',
|
||||
dataSource,
|
||||
});
|
||||
|
||||
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
||||
@ -331,6 +334,8 @@ const IranDashboard = ({
|
||||
onReset={replay.reset}
|
||||
onSpeedChange={replay.setSpeed}
|
||||
onRangeChange={replay.setRange}
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={setDataSource}
|
||||
/>
|
||||
<TimelineSlider
|
||||
currentTime={replay.state.currentTime}
|
||||
|
||||
@ -91,6 +91,7 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
|
||||
@ -74,6 +74,7 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { fetchEvents } from '../services/api';
|
||||
import { fetchAircraftFromBackend } from '../services/aircraftApi';
|
||||
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 } from '../services/osint';
|
||||
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;
|
||||
@ -20,6 +23,7 @@ interface UseIranDataArgs {
|
||||
hiddenShipCategories: Set<string>;
|
||||
refreshKey: number;
|
||||
dashboardTab: 'iran' | 'korea';
|
||||
dataSource?: DataSource;
|
||||
}
|
||||
|
||||
interface UseIranDataResult {
|
||||
@ -52,7 +56,10 @@ export function useIranData({
|
||||
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[]>([]);
|
||||
@ -66,11 +73,15 @@ export function useIranData({
|
||||
const sensorInitRef = useRef(false);
|
||||
const shipMapRef = useRef<Map<string, Ship>>(new Map());
|
||||
|
||||
// Load initial data
|
||||
// Load initial data (events + satellites)
|
||||
useEffect(() => {
|
||||
fetchEvents().then(setEvents).catch(() => {});
|
||||
if (isApi) {
|
||||
fetchEventsByRange(IRAN_T0, new Date().toISOString()).then(setEvents).catch(() => {});
|
||||
} else {
|
||||
fetchEvents().then(setEvents).catch(() => {});
|
||||
}
|
||||
fetchSatelliteTLE().then(setSatellites).catch(() => {});
|
||||
}, [refreshKey]);
|
||||
}, [refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Sensor data: initial full 48h load + 10min polling (incremental merge)
|
||||
useEffect(() => {
|
||||
@ -103,20 +114,23 @@ export function useIranData({
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshKey]);
|
||||
|
||||
// Fetch base aircraft data (LIVE: backend, REPLAY: sample)
|
||||
// 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, 60_000);
|
||||
const interval = setInterval(load, appMode === 'live' ? 60_000 : 300_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [appMode, refreshKey]);
|
||||
}, [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[]) => {
|
||||
@ -164,15 +178,20 @@ export function useIranData({
|
||||
if (!shouldFetch) { setOsintFeed([]); return; }
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchOsintFeed('iran');
|
||||
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, 120_000);
|
||||
const interval = setInterval(load, isApi ? 300_000 : 120_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLive, dashboardTab, refreshKey]);
|
||||
}, [isLive, dashboardTab, refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Propagate satellite positions — throttle to every 2s of real time
|
||||
useEffect(() => {
|
||||
@ -259,11 +278,27 @@ export function useIranData({
|
||||
});
|
||||
}, [osintFeed, dashboardTab]);
|
||||
|
||||
// 기본 이벤트 + OSINT 이벤트 병합 (시간순 정렬)
|
||||
// 피격 선박 → 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(() => {
|
||||
if (osintEvents.length === 0) return events;
|
||||
return [...events, ...osintEvents].sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [events, osintEvents]);
|
||||
const extra = isApi ? [] : damageEvents;
|
||||
return [...events, ...osintEvents, ...extra].sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [events, osintEvents, damageEvents, isApi]);
|
||||
|
||||
// Aircraft stats
|
||||
const aircraftByCategory = useMemo(() => {
|
||||
|
||||
@ -19,3 +19,17 @@ export async function fetchAircraftFromBackend(region: 'iran' | 'korea'): Promis
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 시점 범위 조회 (리플레이용) */
|
||||
export async function fetchAircraftByRange(region: string, from: string, to: string): Promise<Aircraft[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/kcg/aircraft?region=${region}&from=${from}&to=${to}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.items ?? data.aircraft ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,12 +10,32 @@ const defaultConfig: ApiConfig = {
|
||||
let cachedSensorData: SensorLog[] | null = null;
|
||||
|
||||
export async function fetchEvents(_config?: Partial<ApiConfig>): Promise<GeoEvent[]> {
|
||||
// In production, replace with actual API call:
|
||||
// const res = await fetch(config.eventsEndpoint);
|
||||
// return res.json();
|
||||
// 더미 모드: sampleEvents 반환
|
||||
return Promise.resolve(sampleEvents);
|
||||
}
|
||||
|
||||
/** Backend DB에서 이벤트 범위 조회 (API 모드 리플레이용) */
|
||||
export async function fetchEventsByRange(from: string, to: string): Promise<GeoEvent[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/kcg/events?from=${from}&to=${to}`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.items ?? []).map((d: Record<string, unknown>) => ({
|
||||
id: String(d.id ?? ''),
|
||||
timestamp: Number(d.timestamp ?? 0),
|
||||
lat: Number(d.lat ?? 0),
|
||||
lng: Number(d.lng ?? 0),
|
||||
type: (String(d.type ?? 'alert')) as GeoEvent['type'],
|
||||
source: d.source ? String(d.source) as GeoEvent['source'] : undefined,
|
||||
label: String(d.label ?? ''),
|
||||
description: d.description ? String(d.description) : undefined,
|
||||
intensity: d.intensity != null ? Number(d.intensity) : undefined,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSensorData(_config?: Partial<ApiConfig>): Promise<SensorLog[]> {
|
||||
// In production, replace with actual API call:
|
||||
// const res = await fetch(config.sensorEndpoint);
|
||||
|
||||
@ -945,3 +945,29 @@ export async function fetchOsintFeed(focus: 'iran' | 'korea' = 'iran'): Promise<
|
||||
|
||||
return unique.slice(0, 50); // cap at 50 items
|
||||
}
|
||||
|
||||
/** 시점 범위 조회 (리플레이용) — Backend DB에서 날짜 범위로 OSINT 조회 */
|
||||
export async function fetchOsintByRange(region: string, from: string, to: string): Promise<OsintItem[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/kcg/osint?region=${region}&from=${from}&to=${to}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const items = data.items ?? [];
|
||||
return items.map((d: Record<string, unknown>) => ({
|
||||
id: String(d.id ?? ''),
|
||||
timestamp: Number(d.timestamp ?? 0),
|
||||
title: String(d.title ?? ''),
|
||||
source: String(d.source ?? ''),
|
||||
url: String(d.url ?? ''),
|
||||
category: (d.category as OsintItem['category']) ?? 'general',
|
||||
language: (d.language as OsintItem['language']) ?? 'other',
|
||||
imageUrl: d.imageUrl ? String(d.imageUrl) : undefined,
|
||||
lat: d.lat != null ? Number(d.lat) : undefined,
|
||||
lng: d.lng != null ? Number(d.lng) : undefined,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ export interface GeoEvent {
|
||||
timestamp: number; // unix ms
|
||||
lat: number;
|
||||
lng: number;
|
||||
type: 'airstrike' | 'explosion' | 'missile_launch' | 'intercept' | 'alert' | 'impact' | 'osint';
|
||||
type: 'airstrike' | 'explosion' | 'missile_launch' | 'intercept' | 'alert' | 'impact' | 'osint' | 'sea_attack';
|
||||
source?: 'US' | 'IL' | 'IR' | 'proxy'; // 공격 주체: 미국, 이스라엘, 이란, 대리세력
|
||||
label: string;
|
||||
description?: string;
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user