From 6d4ac4d3fef28aa8548bda3e32f4ee32de240ce7 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 07:52:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=9D=B4=EB=9E=80=20?= =?UTF-8?q?=EB=A6=AC=ED=94=8C=EB=A0=88=EC=9D=B4=20=EC=8B=A4=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=84=ED=99=98=20+=20=ED=94=BC?= =?UTF-8?q?=EA=B2=A9=EC=84=A0=EB=B0=95=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 서비스 함수 --- frontend/src/App.css | 4 ++ frontend/src/components/common/EventLog.tsx | 2 + frontend/src/components/common/EventStrip.tsx | 1 + .../src/components/common/ReplayControls.tsx | 24 +++++++ .../src/components/common/TimelineSlider.tsx | 1 + frontend/src/components/iran/GlobeMap.tsx | 1 + .../src/components/iran/IranDashboard.tsx | 7 +- frontend/src/components/iran/ReplayMap.tsx | 1 + frontend/src/components/iran/SatelliteMap.tsx | 1 + frontend/src/hooks/useIranData.ts | 67 ++++++++++++++----- frontend/src/services/aircraftApi.ts | 14 ++++ frontend/src/services/api.ts | 28 ++++++-- frontend/src/services/osint.ts | 26 +++++++ frontend/src/types.ts | 2 +- 14 files changed, 157 insertions(+), 22 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 6ac030e..8ec542e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/components/common/EventLog.tsx b/frontend/src/components/common/EventLog.tsx index b1ae5b2..0caf308 100644 --- a/frontend/src/components/common/EventLog.tsx +++ b/frontend/src/components/common/EventLog.tsx @@ -260,6 +260,7 @@ const TYPE_LABELS: Record = { alert: 'ALERT', impact: 'IMPACT', osint: 'OSINT', + sea_attack: 'SEA ATK', }; const TYPE_COLORS: Record = { @@ -270,6 +271,7 @@ const TYPE_COLORS: Record = { alert: 'var(--kcg-event-alert)', impact: 'var(--kcg-event-impact)', osint: 'var(--kcg-event-osint)', + sea_attack: '#0ea5e9', }; // MarineTraffic-style ship type classification diff --git a/frontend/src/components/common/EventStrip.tsx b/frontend/src/components/common/EventStrip.tsx index 922a14a..602c9d0 100644 --- a/frontend/src/components/common/EventStrip.tsx +++ b/frontend/src/components/common/EventStrip.tsx @@ -20,6 +20,7 @@ const TYPE_COLORS: Record = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; const TYPE_KEYS: Record = { diff --git a/frontend/src/components/common/ReplayControls.tsx b/frontend/src/components/common/ReplayControls.tsx index d90b05e..51b3dc9 100644 --- a/frontend/src/components/common/ReplayControls.tsx +++ b/frontend/src/components/common/ReplayControls.tsx @@ -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({ ))} + {/* Data source toggle */} + {dataSource && onDataSourceChange && ( +
+ + +
+ )} + {/* Spacer */}
diff --git a/frontend/src/components/common/TimelineSlider.tsx b/frontend/src/components/common/TimelineSlider.tsx index 27cc837..6b5bbd9 100644 --- a/frontend/src/components/common/TimelineSlider.tsx +++ b/frontend/src/components/common/TimelineSlider.tsx @@ -21,6 +21,7 @@ const TYPE_COLORS: Record = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; const TYPE_I18N_KEYS: Record = { diff --git a/frontend/src/components/iran/GlobeMap.tsx b/frontend/src/components/iran/GlobeMap.tsx index 682f9cd..011b8e0 100644 --- a/frontend/src/components/iran/GlobeMap.tsx +++ b/frontend/src/components/iran/GlobeMap.tsx @@ -21,6 +21,7 @@ const EVENT_COLORS: Record = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; // Navy flag-based colors for military vessels diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx index c581596..421bfe3 100644 --- a/frontend/src/components/iran/IranDashboard.tsx +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -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(null); const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); const [focusShipMmsi, setFocusShipMmsi] = useState(null); + const [dataSource, setDataSource] = useLocalStorage('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} /> = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; const SOURCE_COLORS: Record = { diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index 1aacc2c..e1d6512 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -74,6 +74,7 @@ const EVENT_COLORS: Record = { alert: '#a855f7', impact: '#ff0000', osint: '#06b6d4', + sea_attack: '#0ea5e9', }; const SOURCE_COLORS: Record = { diff --git a/frontend/src/hooks/useIranData.ts b/frontend/src/hooks/useIranData.ts index cd38e8d..334d8f8 100644 --- a/frontend/src/hooks/useIranData.ts +++ b/frontend/src/hooks/useIranData.ts @@ -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; 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([]); const [seismicData, setSeismicData] = useState([]); const [pressureData, setPressureData] = useState([]); @@ -66,11 +73,15 @@ export function useIranData({ const sensorInitRef = useRef(false); const shipMapRef = useRef>(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(() => + 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(() => { diff --git a/frontend/src/services/aircraftApi.ts b/frontend/src/services/aircraftApi.ts index 951978d..3aacce1 100644 --- a/frontend/src/services/aircraftApi.ts +++ b/frontend/src/services/aircraftApi.ts @@ -19,3 +19,17 @@ export async function fetchAircraftFromBackend(region: 'iran' | 'korea'): Promis return []; } } + +/** 시점 범위 조회 (리플레이용) */ +export async function fetchAircraftByRange(region: string, from: string, to: string): Promise { + 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 []; + } +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7b175d1..9ad7701 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -9,13 +9,33 @@ const defaultConfig: ApiConfig = { let cachedSensorData: SensorLog[] | null = null; -export async function fetchEvents(_config?: Partial): Promise { - // In production, replace with actual API call: - // const res = await fetch(config.eventsEndpoint); - // return res.json(); +export async function fetchEvents(_config?: Partial): Promise { + // 더미 모드: sampleEvents 반환 return Promise.resolve(sampleEvents); } +/** Backend DB에서 이벤트 범위 조회 (API 모드 리플레이용) */ +export async function fetchEventsByRange(from: string, to: string): Promise { + 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) => ({ + 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): Promise { // In production, replace with actual API call: // const res = await fetch(config.sensorEndpoint); diff --git a/frontend/src/services/osint.ts b/frontend/src/services/osint.ts index 1304c45..32520b9 100644 --- a/frontend/src/services/osint.ts +++ b/frontend/src/services/osint.ts @@ -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 { + 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) => ({ + 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 []; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5be101f..685fecc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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;