diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 85ca74b..5b1dc49 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,14 @@ ## [Unreleased] +### 추가 +- 한국 선박 현황 헤더 ON/OFF 토글 → 지도 강조 링+라벨 표시 (기본 ON) +- 우측 패널 한국 선박 목록: hover 시 지도 강조 링, 클릭 시 선박 모달 호출 + +### 변경 +- 지진파 그래프: LineChart → ScatterChart (진도별 색상/크기, 이벤트 점 표시) +- 기압 그래프: 버킷 평균 → 관측소별 개별 라인 (데이터 없는 구간 0 제거) + ## [2026-03-18.3] ### 추가 diff --git a/frontend/src/App.css b/frontend/src/App.css index af83e5e..6780df2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -747,6 +747,27 @@ border-bottom: 1px solid var(--kcg-hover); } +.korean-highlight-toggle { + margin-left: auto; + padding: 1px 8px; + font-size: 9px; + font-weight: 700; + font-family: 'Courier New', monospace; + border-radius: 3px; + border: 1px solid var(--kcg-border); + background: transparent; + color: var(--kcg-muted); + cursor: pointer; + transition: all 0.15s; + line-height: 1.6; +} + +.korean-highlight-toggle.active { + border-color: #00e5ff; + background: rgba(0, 229, 255, 0.15); + color: #00e5ff; +} + .area-ship-icon { font-size: 14px; } @@ -920,6 +941,16 @@ .iran-mil-item:hover { background: rgba(255,255,255,0.03); } + +.iran-mil-item-interactive { + cursor: pointer; + transition: background 0.15s; + border-radius: 3px; +} + +.iran-mil-item-interactive:hover { + background: rgba(0, 229, 255, 0.1); +} .iran-mil-flag { font-size: 11px; flex-shrink: 0; } .iran-mil-name { flex: 1; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 41b9586..afb421e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,7 +58,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { aircraft: true, satellites: true, ships: true, - koreanShips: false, + koreanShips: true, airports: true, sensorCharts: true, oilFacilities: true, @@ -120,6 +120,8 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); + const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); + const [focusShipMmsi, setFocusShipMmsi] = useState(null); const replay = useReplay(); const monitor = useMonitor(); @@ -366,6 +368,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { layers={layers} flyToTarget={flyToTarget} onFlyToDone={() => setFlyToTarget(null)} + hoveredShipMmsi={hoveredShipMmsi} + focusShipMmsi={focusShipMmsi} + onFocusShipClear={() => setFocusShipMmsi(null)} /> ) : mapMode === 'globe' ? ( setFocusShipMmsi(null)} /> )}
@@ -422,6 +430,10 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { dashboardTab={dashboardTab} onTabChange={setDashboardTab} ships={iranData.ships} + highlightKoreanShips={layers.koreanShips} + onToggleHighlightKorean={() => setLayers(prev => ({ ...prev, koreanShips: !prev.koreanShips }))} + onShipHover={setHoveredShipMmsi} + onShipClick={setFocusShipMmsi} /> diff --git a/frontend/src/components/common/EventLog.tsx b/frontend/src/components/common/EventLog.tsx index e098280..50fe4d6 100644 --- a/frontend/src/components/common/EventLog.tsx +++ b/frontend/src/components/common/EventLog.tsx @@ -17,6 +17,10 @@ interface Props { dashboardTab?: DashboardTab; onTabChange?: (tab: DashboardTab) => void; ships?: Ship[]; + highlightKoreanShips?: boolean; + onToggleHighlightKorean?: () => void; + onShipHover?: (mmsi: string | null) => void; + onShipClick?: (mmsi: string) => void; } // ═══ 속보 / 트럼프 발언 + 유가·에너지 뉴스 ═══ @@ -354,7 +358,7 @@ function useTimeAgo() { }; } -export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS }: Props) { +export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS, highlightKoreanShips = false, onToggleHighlightKorean, onShipHover, onShipClick }: Props) { const { t } = useTranslation(['common', 'events', 'ships']); const timeAgo = useTimeAgo(); @@ -443,7 +447,13 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, const mtColor = MT_CATEGORY_COLORS[cat] || '#888'; const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') }); return ( -
+
onShipHover?.(s.mmsi)} + onMouseLeave={() => onShipHover?.(null)} + onClick={() => onShipClick?.(s.mmsi)} + > {'\u{1F1F0}\u{1F1F7}'} {s.name} @@ -588,6 +598,16 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, {'\u{1F1F0}\u{1F1F7}'} {t('ships:shipStatus.koreanTitle')} {koreanShips.length}{t('common:units.vessels')} + {onToggleHighlightKorean && dashboardTab === 'iran' && ( + + )}
{koreanShips.length > 0 && (() => { const groups: Record = {}; diff --git a/frontend/src/components/common/SensorChart.tsx b/frontend/src/components/common/SensorChart.tsx index 5ebe373..734b8e4 100644 --- a/frontend/src/components/common/SensorChart.tsx +++ b/frontend/src/components/common/SensorChart.tsx @@ -8,6 +8,10 @@ import { CartesianGrid, Tooltip, ResponsiveContainer, + ScatterChart, + Scatter, + ZAxis, + Cell, } from 'recharts'; import type { SeismicDto, PressureDto } from '../../services/sensorApi'; @@ -36,43 +40,39 @@ function buildTicks(currentTime: number, historyMinutes: number): number[] { return ticks; } -function aggregateSeismic( +/** + * 지진 데이터: 버킷이 아닌 실제 이벤트 포인트로 변환 + */ +function prepareSeismicPoints( data: SeismicDto[], rangeStart: number, rangeEnd: number, -): { time: number; value: number }[] { - const bucketSize = (rangeEnd - rangeStart) / BUCKET_COUNT; - const buckets = Array.from({ length: BUCKET_COUNT }, (_, i) => ({ - time: rangeStart + (i + 0.5) * bucketSize, - value: 0, - })); - for (const ev of data) { - if (ev.timestamp < rangeStart || ev.timestamp > rangeEnd) continue; - const idx = Math.min(Math.floor((ev.timestamp - rangeStart) / bucketSize), BUCKET_COUNT - 1); - buckets[idx].value = Math.max(buckets[idx].value, ev.magnitude * 10); - } - return buckets; +): { time: number; magnitude: number; place: string }[] { + return data + .filter(ev => ev.timestamp >= rangeStart && ev.timestamp <= rangeEnd) + .map(ev => ({ time: ev.timestamp, magnitude: ev.magnitude, place: ev.place })); } -function aggregatePressure( +/** + * 기압 데이터: 관측소별 시간순 포인트 (버킷 없이 원본 사용) + * connectNulls 대신, 데이터가 있는 포인트만 연결 + */ +function preparePressureByStation( data: PressureDto[], rangeStart: number, rangeEnd: number, -): { time: number; value: number }[] { - const bucketSize = (rangeEnd - rangeStart) / BUCKET_COUNT; - const buckets = Array.from({ length: BUCKET_COUNT }, (_, i) => ({ - time: rangeStart + (i + 0.5) * bucketSize, - values: [] as number[], - })); +): Record { + const byStation: Record = {}; for (const r of data) { if (r.timestamp < rangeStart || r.timestamp > rangeEnd) continue; - const idx = Math.min(Math.floor((r.timestamp - rangeStart) / bucketSize), BUCKET_COUNT - 1); - buckets[idx].values.push(r.pressureHpa); + if (!byStation[r.station]) byStation[r.station] = []; + byStation[r.station].push({ time: r.timestamp, value: r.pressureHpa }); } - return buckets.map(b => ({ - time: b.time, - value: b.values.length > 0 ? b.values.reduce((a, c) => a + c, 0) / b.values.length : 0, - })); + // 시간순 정렬 + for (const arr of Object.values(byStation)) { + arr.sort((a, b) => a.time - b.time); + } + return byStation; } function generateDemoData( @@ -90,6 +90,23 @@ function generateDemoData( }); } +const STATION_COLORS: Record = { + 'tehran': '#ef4444', + 'isfahan': '#f97316', + 'bandar-abbas': '#3b82f6', + 'shiraz': '#22c55e', + 'tabriz': '#a855f7', +}; + +const MAGNITUDE_COLORS = ['#fbbf24', '#f97316', '#ef4444', '#dc2626', '#991b1b']; +function getMagnitudeColor(mag: number): string { + if (mag < 2.5) return MAGNITUDE_COLORS[0]; + if (mag < 3.5) return MAGNITUDE_COLORS[1]; + if (mag < 4.5) return MAGNITUDE_COLORS[2]; + if (mag < 5.5) return MAGNITUDE_COLORS[3]; + return MAGNITUDE_COLORS[4]; +} + export function SensorChart({ seismicData, pressureData, currentTime, historyMinutes }: Props) { const { t } = useTranslation(); @@ -99,14 +116,16 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin const ticks = useMemo(() => buildTicks(currentTime, historyMinutes), [currentTime, historyMinutes]); - const seismicChart = useMemo( - () => aggregateSeismic(seismicData, rangeStart, rangeEnd), + const seismicPoints = useMemo( + () => prepareSeismicPoints(seismicData, rangeStart, rangeEnd), [seismicData, rangeStart, rangeEnd], ); - const pressureChart = useMemo( - () => aggregatePressure(pressureData, rangeStart, rangeEnd), + + const pressureByStation = useMemo( + () => preparePressureByStation(pressureData, rangeStart, rangeEnd), [pressureData, rangeStart, rangeEnd], ); + const noiseChart = useMemo( () => generateDemoData(rangeStart, rangeEnd, 45, 30), [rangeStart, rangeEnd], @@ -117,7 +136,6 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin ); const commonXAxis = { - dataKey: 'time' as const, type: 'number' as const, domain: [rangeStart, rangeEnd] as [number, number], ticks, @@ -125,40 +143,75 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin tick: { fontSize: 9, fill: '#888' }, }; + // 지진 y축: 데이터가 있으면 0~max+1, 없으면 0~6 + const maxMag = seismicPoints.length > 0 + ? Math.max(...seismicPoints.map(p => p.magnitude)) + : 0; + const seismicYMax = Math.max(maxMag + 1, 6); + return (

{t('sensor.title')}

+ {/* 지진파: Scatter (이벤트 점) */}
-

{t('sensor.seismicActivity')}

+

+ {t('sensor.seismicActivity')} + {seismicPoints.length > 0 && ( + + ({seismicPoints.length} events) + + )} +

- + - - + + + [v.toFixed(1), 'Magnitude×10']} + formatter={(v: number, name: string) => { + if (name === 'M') return [`M${v.toFixed(1)}`, 'Magnitude']; + return [v, name]; + }} /> - - + + {seismicPoints.map((p, i) => ( + + ))} + +
+ {/* 기압: 관측소별 개별 라인 */}

{t('sensor.airPressureHpa')}

- + - - + + [v.toFixed(1), 'hPa']} + formatter={(v: number, name: string) => [`${v.toFixed(1)} hPa`, name]} /> - + {Object.entries(pressureByStation).map(([station, points]) => ( + + ))}
@@ -171,7 +224,7 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin - + - + void; initialCenter?: { lng: number; lat: number }; initialZoom?: number; + hoveredShipMmsi?: string | null; + focusShipMmsi?: string | null; + onFocusShipClear?: () => void; } // MarineTraffic-style: dark ocean + satellite land + nautical overlay @@ -105,7 +108,7 @@ const EVENT_RADIUS: Record = { osint: 8, }; -export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) { +export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [selectedEventId, setSelectedEventId] = useState(null); @@ -423,7 +426,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la {layers.aircraft && } {layers.satellites && } - {layers.ships && } + {layers.ships && } {layers.ships && } {layers.airports && } {layers.oilFacilities && } diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index 9180ee8..c69248f 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -22,6 +22,9 @@ interface Props { satellites: SatellitePosition[]; ships: Ship[]; layers: LayerVisibility; + hoveredShipMmsi?: string | null; + focusShipMmsi?: string | null; + onFocusShipClear?: () => void; } // ESRI World Imagery + ESRI boundaries overlay @@ -86,7 +89,7 @@ const EVENT_RADIUS: Record = { osint: 8, }; -export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) { +export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [selectedEventId, setSelectedEventId] = useState(null); @@ -248,7 +251,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, {/* Overlay layers */} {layers.aircraft && } {layers.satellites && } - {layers.ships && } + {layers.ships && } {layers.oilFacilities && } {layers.airports && } diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 4f0dc51..f82a686 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -8,6 +8,9 @@ interface Props { ships: Ship[]; militaryOnly: boolean; koreanOnly?: boolean; + hoveredMmsi?: string | null; + focusMmsi?: string | null; + onFocusClear?: () => void; } // ── MarineTraffic-style vessel type colors (CSS variable references) ── @@ -351,17 +354,25 @@ function ensureTriangleImage(map: maplibregl.Map) { } // ── Main layer (WebGL symbol rendering — triangles) ── -export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) { +export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear }: Props) { const { current: map } = useMap(); const [selectedMmsi, setSelectedMmsi] = useState(null); const [imageReady, setImageReady] = useState(false); + const highlightKorean = !!koreanOnly; + + // focusMmsi로 외부에서 모달 열기 + useEffect(() => { + if (focusMmsi) { + setSelectedMmsi(focusMmsi); + onFocusClear?.(); + } + }, [focusMmsi, onFocusClear]); const filtered = useMemo(() => { let result = ships; - if (koreanOnly) result = result.filter(s => s.flag === 'KR'); if (militaryOnly) result = result.filter(s => isMilitary(s.category)); return result; - }, [ships, militaryOnly, koreanOnly]); + }, [ships, militaryOnly]); // Add triangle image to map useEffect(() => { @@ -382,11 +393,13 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) { type: 'Feature' as const, properties: { mmsi: ship.mmsi, + name: ship.name, color: getShipHex(ship), size: SIZE_MAP[ship.category], isMil: isMilitary(ship.category) ? 1 : 0, isKorean: ship.flag === 'KR' ? 1 : 0, isCheonghae: ship.mmsi === '440001981' ? 1 : 0, + isHovered: ship.mmsi === hoveredMmsi ? 1 : 0, heading: ship.heading, }, geometry: { @@ -395,7 +408,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) { }, })); return { type: 'FeatureCollection' as const, features }; - }, [filtered]); + }, [filtered, hoveredMmsi]); // Register click and cursor handlers useEffect(() => { @@ -435,19 +448,53 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) { return ( <> - {/* Korean ship outer ring (circle behind triangle) */} + {/* Hovered ship highlight ring */} + + {/* Korean ship outer ring — enlarged when highlighted */} + {/* Korean ship label (only when highlighted) */} + {highlightKorean && ( + + )} {/* Main ship triangles */}