diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 4812d0b..fed34a5 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -92,37 +92,22 @@ jobs: chmod 600 ~/.ssh/id_deploy ssh-keyscan -T 5 $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null || true - SSH_CMD="ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 root@$DEPLOY_HOST" + SSH_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15" - # SSH 연결 테스트 (최대 3회, kex_exchange 거부 대응) - for attempt in 1 2 3; do - echo "SSH connectivity test $attempt/3..." - if $SSH_CMD echo "SSH OK"; then - break - fi - if [ "$attempt" -eq 3 ]; then - echo "ERROR: SSH connection failed after 3 attempts" - exit 1 - fi - echo "SSH failed, retrying in 10s..." - sleep 10 - done - - $SSH_CMD bash -s << 'RESTART' + # 재시작 스크립트를 SCP로 업로드 후 SSH로 실행 (각각 재시도) + cat > /tmp/restart-kcg.sh << 'SCRIPT' + #!/bin/bash DEPLOY_DIR=/devdata/services/kcg/backend SYSTEMD_DIR=/etc/systemd/system - # systemd 서비스 파일 갱신 if [ -f "$DEPLOY_DIR/kcg-backend.service" ] && ! diff -q "$DEPLOY_DIR/kcg-backend.service" "$SYSTEMD_DIR/kcg-backend.service" >/dev/null 2>&1; then cp "$DEPLOY_DIR/kcg-backend.service" "$SYSTEMD_DIR/kcg-backend.service" systemctl daemon-reload fi - # 백엔드 재시작 echo "--- Restarting kcg-backend ---" systemctl restart kcg-backend - # 기동 확인 (최대 60초) for i in $(seq 1 60); do HTTP=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/api/aircraft 2>/dev/null || echo "000") if [ "$HTTP" = "200" ] || [ "$HTTP" = "401" ] || [ "$HTTP" = "403" ]; then @@ -134,7 +119,29 @@ jobs: echo "WARNING: Startup timeout" journalctl -u kcg-backend --no-pager -n 10 exit 1 - RESTART + SCRIPT + + # SCP 업로드 (최대 3회 재시도) + for attempt in 1 2 3; do + echo "SCP upload attempt $attempt/3..." + if scp $SSH_OPTS /tmp/restart-kcg.sh root@$DEPLOY_HOST:/tmp/restart-kcg.sh; then + break + fi + [ "$attempt" -eq 3 ] && { echo "ERROR: SCP failed after 3 attempts"; exit 1; } + sleep 10 + done + + # SSH 실행 (최대 3회 재시도) + for attempt in 1 2 3; do + echo "SSH execute attempt $attempt/3..." + if ssh $SSH_OPTS root@$DEPLOY_HOST "bash /tmp/restart-kcg.sh && rm -f /tmp/restart-kcg.sh"; then + exit 0 + fi + SSH_EXIT=$? + [ "$attempt" -eq 3 ] && { echo "ERROR: SSH failed after 3 attempts (exit $SSH_EXIT)"; exit 1; } + echo "SSH failed (exit $SSH_EXIT), retrying in 10s..." + sleep 10 + done - name: Cleanup if: always() diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 113c10c..573b436 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,21 @@ ## [Unreleased] +### 추가 +- 지진 포인트 클릭 → 지도 flyTo + SeismicMarker 진도별 펄스 원형 영향범위 표시 +- SatelliteMap flyTo 지원 + +### 변경 +- 히스토리 프리셋: 10M/30M/1H/3H/6H → 1H/2H/3H/6H (최소 1시간) +- 기압 그래프: 해수면 기압 보정(SLP), 원본 포인트 기반 렌더링 +- 그래프 데이터 범위: 표시 범위보다 1칸 확장 (y축 시작점 연결) +- Tooltip: KST 시간 포맷, 상단 고정, 전체 스타일 통일 +- OilFacilityLayer: planned ring SVG 내부 이동 (아이콘 중심 정렬) +- 밝은 테마: 지도 라벨 text-shadow CSS 변수 분리 + +### 수정 +- deploy.yml: SSH SCP+실행 각 3회 재시도 (kex_exchange 거부 대응) + ## [2026-03-18.4] ### 추가 diff --git a/frontend/src/App.css b/frontend/src/App.css index 6780df2..bc45c80 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1132,8 +1132,10 @@ border-top: 1px solid var(--kcg-border); flex-shrink: 0; height: 110px; - overflow: hidden; + overflow: visible; box-shadow: var(--kcg-panel-shadow); + position: relative; + z-index: 5; } .sensor-chart h3 { @@ -1856,7 +1858,7 @@ font-weight: 600; font-size: 11px; font-family: 'Courier New', monospace; - text-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6); + text-shadow: var(--kcg-map-label-shadow); background: var(--kcg-glass); border: 1px solid var(--kcg-border); border-radius: 3px; @@ -1874,7 +1876,7 @@ font-weight: 700; font-size: 10px; font-family: 'Courier New', monospace; - text-shadow: 0 0 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.7); + text-shadow: var(--kcg-map-impact-shadow); background: rgba(40, 0, 0, 0.85); border: 1px solid var(--kcg-event-impact); border-radius: 3px; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index afb421e..e114e0e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -122,6 +122,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); const [hoveredShipMmsi, setHoveredShipMmsi] = useState(null); const [focusShipMmsi, setFocusShipMmsi] = useState(null); + const [seismicMarker, setSeismicMarker] = useState<{ lat: number; lng: number; magnitude: number; place: string } | null>(null); const replay = useReplay(); const monitor = useMonitor(); @@ -371,6 +372,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { hoveredShipMmsi={hoveredShipMmsi} focusShipMmsi={focusShipMmsi} onFocusShipClear={() => setFocusShipMmsi(null)} + seismicMarker={seismicMarker} /> ) : mapMode === 'globe' ? ( setFocusShipMmsi(null)} + flyToTarget={flyToTarget} + onFlyToDone={() => setFlyToTarget(null)} + seismicMarker={seismicMarker} /> )}
@@ -445,6 +450,10 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { pressureData={iranData.pressureData} currentTime={currentTime} historyMinutes={monitor.state.historyMinutes} + onSeismicClick={(lat, lng, magnitude, place) => { + setFlyToTarget({ lat, lng, zoom: 8 }); + setSeismicMarker({ lat, lng, magnitude, place }); + }} /> )} diff --git a/frontend/src/components/common/LiveControls.tsx b/frontend/src/components/common/LiveControls.tsx index 6adcd14..6d9d0d6 100644 --- a/frontend/src/components/common/LiveControls.tsx +++ b/frontend/src/components/common/LiveControls.tsx @@ -12,9 +12,8 @@ interface Props { } const HISTORY_PRESETS = [ - { label: '10M', minutes: 10 }, - { label: '30M', minutes: 30 }, { label: '1H', minutes: 60 }, + { label: '2H', minutes: 120 }, { label: '3H', minutes: 180 }, { label: '6H', minutes: 360 }, ]; diff --git a/frontend/src/components/common/SensorChart.tsx b/frontend/src/components/common/SensorChart.tsx index 734b8e4..a3019e2 100644 --- a/frontend/src/components/common/SensorChart.tsx +++ b/frontend/src/components/common/SensorChart.tsx @@ -13,6 +13,7 @@ import { ZAxis, Cell, } from 'recharts'; +import type { TooltipProps } from 'recharts'; import type { SeismicDto, PressureDto } from '../../services/sensorApi'; interface Props { @@ -20,6 +21,7 @@ interface Props { pressureData: PressureDto[]; currentTime: number; historyMinutes: number; + onSeismicClick?: (lat: number, lng: number, magnitude: number, place: string) => void; } const MINUTE = 60_000; @@ -47,32 +49,46 @@ function prepareSeismicPoints( data: SeismicDto[], rangeStart: number, rangeEnd: number, -): { time: number; magnitude: number; place: string }[] { +): { time: number; magnitude: number; place: string; lat: number; lng: number }[] { return data .filter(ev => ev.timestamp >= rangeStart && ev.timestamp <= rangeEnd) - .map(ev => ({ time: ev.timestamp, magnitude: ev.magnitude, place: ev.place })); + .map(ev => ({ time: ev.timestamp, magnitude: ev.magnitude, place: ev.place, lat: ev.lat, lng: ev.lng })); } /** - * 기압 데이터: 관측소별 시간순 포인트 (버킷 없이 원본 사용) - * connectNulls 대신, 데이터가 있는 포인트만 연결 + * 기압 데이터: 원본 시간 포인트를 관측소별 컬럼으로 통합 + * 동일 timestamp의 관측소 데이터를 하나의 row로 합침 + * 버킷 없이 원본 그대로 사용하여 히스토리 범위와 무관하게 일관된 그래프 표시 */ -function preparePressureByStation( +function preparePressureMerged( data: PressureDto[], rangeStart: number, rangeEnd: number, -): Record { - const byStation: Record = {}; - for (const r of data) { - if (r.timestamp < rangeStart || r.timestamp > rangeEnd) continue; - if (!byStation[r.station]) byStation[r.station] = []; - byStation[r.station].push({ time: r.timestamp, value: r.pressureHpa }); +): { time: number; [station: string]: number | null }[] { + // 범위 내 데이터만 필터 + const filtered = data.filter(r => r.timestamp >= rangeStart && r.timestamp <= rangeEnd); + const stations = new Set(); + for (const r of filtered) stations.add(r.station); + + // timestamp별 그룹핑 (해수면 보정 적용) + const timeMap = new Map>(); + for (const r of filtered) { + if (!timeMap.has(r.timestamp)) timeMap.set(r.timestamp, {}); + const alt = STATION_ALTITUDE[r.station] ?? 0; + timeMap.get(r.timestamp)![r.station] = toSeaLevelPressure(r.pressureHpa, alt); } - // 시간순 정렬 - for (const arr of Object.values(byStation)) { - arr.sort((a, b) => a.time - b.time); - } - return byStation; + + // 시간순 정렬하여 row 배열 생성 + const stationList = Array.from(stations); + const times = Array.from(timeMap.keys()).sort((a, b) => a - b); + return times.map(t => { + const vals = timeMap.get(t)!; + const row: Record = { time: t }; + for (const s of stationList) { + row[s] = vals[s] ?? null; + } + return row as { time: number; [station: string]: number | null }; + }); } function generateDemoData( @@ -98,7 +114,51 @@ const STATION_COLORS: Record = { 'tabriz': '#a855f7', }; +// 관측소 고도(m) — 해수면 기압 보정용 +const STATION_ALTITUDE: Record = { + 'tehran': 1190, + 'isfahan': 1590, + 'bandar-abbas': 10, + 'shiraz': 1486, + 'tabriz': 1352, +}; + +// 기압 해수면 보정 (barometric formula 근사) +// SLP ≈ station_pressure × (1 + 0.0065 × altitude / (temperature + 0.0065 × altitude + 273.15))^5.257 +// 간단 근사: SLP ≈ station_pressure + altitude / 8.3 (1 hPa per 8.3m at low altitude) +function toSeaLevelPressure(stationHpa: number, altitudeM: number): number { + return stationHpa + altitudeM / 8.3; +} + const MAGNITUDE_COLORS = ['#fbbf24', '#f97316', '#ef4444', '#dc2626', '#991b1b']; +const TOOLTIP_STYLE = { + contentStyle: { background: '#1a1a2e', border: '1px solid #333', fontSize: 10, color: '#e0e0e0', padding: '4px 8px' }, + itemStyle: { color: '#e0e0e0' }, + labelStyle: { color: '#aaa' }, + wrapperStyle: { zIndex: 10 }, + position: { y: -10 } as { y: number }, +}; + +function formatTooltipTime(epoch: number): string { + const d = new Date(epoch); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())} KST`; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function SeismicTooltip({ active, payload }: TooltipProps) { + if (!active || !payload?.length) return null; + const point = payload[0]?.payload as { time: number; magnitude: number; place: string; lat: number; lng: number } | undefined; + if (!point) return null; + return ( +
+
{formatTooltipTime(point.time)}
+
M{point.magnitude.toFixed(1)} {point.place}
+
{point.lat.toFixed(3)}N, {point.lng.toFixed(3)}E — click to focus
+
+ ); +} + function getMagnitudeColor(mag: number): string { if (mag < 2.5) return MAGNITUDE_COLORS[0]; if (mag < 3.5) return MAGNITUDE_COLORS[1]; @@ -107,32 +167,41 @@ function getMagnitudeColor(mag: number): string { return MAGNITUDE_COLORS[4]; } -export function SensorChart({ seismicData, pressureData, currentTime, historyMinutes }: Props) { +export function SensorChart({ seismicData, pressureData, currentTime, historyMinutes, onSeismicClick }: Props) { const { t } = useTranslation(); const totalMinutes = historyMinutes * 8; const rangeStart = currentTime - totalMinutes * MINUTE; const rangeEnd = currentTime; + // 데이터 수집 범위: 표시 범위보다 충분히 과거 (선이 y축에서 시작되도록) + // 기압 데이터는 1시간 간격이므로 최소 60분 + 1칸 여유 + const dataStart = rangeStart - Math.max(historyMinutes, 60) * MINUTE; const ticks = useMemo(() => buildTicks(currentTime, historyMinutes), [currentTime, historyMinutes]); const seismicPoints = useMemo( - () => prepareSeismicPoints(seismicData, rangeStart, rangeEnd), - [seismicData, rangeStart, rangeEnd], + () => prepareSeismicPoints(seismicData, dataStart, rangeEnd), + [seismicData, dataStart, rangeEnd], ); - const pressureByStation = useMemo( - () => preparePressureByStation(pressureData, rangeStart, rangeEnd), - [pressureData, rangeStart, rangeEnd], + const pressureMerged = useMemo( + () => preparePressureMerged(pressureData, dataStart, rangeEnd), + [pressureData, dataStart, rangeEnd], ); + const pressureStations = useMemo(() => { + const stations = new Set(); + for (const r of pressureData) stations.add(r.station); + return Array.from(stations); + }, [pressureData]); + const noiseChart = useMemo( - () => generateDemoData(rangeStart, rangeEnd, 45, 30), - [rangeStart, rangeEnd], + () => generateDemoData(dataStart, rangeEnd, 45, 30), + [dataStart, rangeEnd], ); const radiationChart = useMemo( - () => generateDemoData(rangeStart, rangeEnd, 0.08, 0.06), - [rangeStart, rangeEnd], + () => generateDemoData(dataStart, rangeEnd, 0.08, 0.06), + [dataStart, rangeEnd], ); const commonXAxis = { @@ -169,15 +238,15 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin - { - if (name === 'M') return [`M${v.toFixed(1)}`, 'Magnitude']; - return [v, name]; + } {...TOOLTIP_STYLE} /> + { + if (entry && onSeismicClick) onSeismicClick(entry.lat, entry.lng, entry.magnitude, entry.place); }} - /> - + > {seismicPoints.map((p, i) => ( ))} @@ -186,24 +255,23 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
- {/* 기압: 관측소별 개별 라인 */} + {/* 기압: 관측소별 개별 라인 (해수면 보정) */}
-

{t('sensor.airPressureHpa')}

+

{t('sensor.airPressureHpa')} (SLP)

- + [`${v.toFixed(1)} hPa`, name]} + {...TOOLTIP_STYLE} + labelFormatter={formatTooltipTime} + formatter={(v: number, name: string) => [v != null ? `${v.toFixed(1)} hPa` : '-', name]} /> - {Object.entries(pressureByStation).map(([station, points]) => ( + {pressureStations.map(station => ( [v.toFixed(1), 'dB']} /> @@ -247,8 +315,8 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin [v.toFixed(3), 'μSv/h']} /> diff --git a/frontend/src/components/iran/OilFacilityLayer.tsx b/frontend/src/components/iran/OilFacilityLayer.tsx index e1248ce..00c52c5 100644 --- a/frontend/src/components/iran/OilFacilityLayer.tsx +++ b/frontend/src/components/iran/OilFacilityLayer.tsx @@ -28,13 +28,23 @@ function getTooltipLabel(f: OilFacility): string { return ''; } -function getIconSize(f: OilFacility): number { - if (f.type === 'desalination') { const m = f.capacityMgd ?? 0; return m >= 200 ? 14 : m >= 80 ? 12 : 10; } - if (f.type === 'terminal') return (f.capacityBpd ?? 0) >= 1_000_000 ? 20 : 16; - if (f.type === 'oilfield') { const b = f.reservesBbl ?? 0; return b >= 20 ? 20 : b >= 10 ? 18 : 14; } - if (f.type === 'gasfield') { const t = f.reservesTcf ?? 0; return t >= 100 ? 20 : t >= 50 ? 18 : 14; } - if (f.type === 'refinery') { const b = f.capacityBpd ?? 0; return b >= 300_000 ? 20 : b >= 100_000 ? 18 : 14; } - return 16; + +// Planned strike targeting ring (SVG 내부 — 위치 정확도) +function PlannedOverlay() { + return ( + <> + + + + + {/* Crosshair lines */} + + + + + + ); } // Shared damage overlay (X mark + circle) @@ -49,7 +59,7 @@ function DamageOverlay() { } // SVG icon renderers (JSX versions) -function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { +function RefineryIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { const sc = damaged ? '#ff0000' : color; return ( @@ -74,11 +84,12 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d {damaged && } + {planned && } ); } -function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { +function OilFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { const sc = damaged ? '#ff0000' : color; return ( @@ -97,11 +108,12 @@ function OilFieldIcon({ size, color, damaged }: { size: number; color: string; d {damaged && } + {planned && } ); } -function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { +function GasFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { return ( @@ -118,11 +130,12 @@ function GasFieldIcon({ size, color, damaged }: { size: number; color: string; d {damaged && } + {planned && } ); } -function TerminalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { +function TerminalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { return ( @@ -135,11 +148,12 @@ function TerminalIcon({ size, color, damaged }: { size: number; color: string; d {damaged && } + {planned && } ); } -function PetrochemIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { +function PetrochemIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { return ( @@ -149,11 +163,12 @@ function PetrochemIcon({ size, color, damaged }: { size: number; color: string; {damaged && } + {planned && } ); } -function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { +function DesalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: boolean }) { const sc = damaged ? '#ff0000' : color; return ( @@ -175,20 +190,24 @@ function DesalIcon({ size, color, damaged }: { size: number; color: string; dama {damaged && } + {planned && } ); } -function FacilityIconSvg({ facility, damaged }: { facility: OilFacility; damaged: boolean }) { +// 모든 아이콘을 36x36 고정 크기로 렌더링 (anchor="center" 정렬용) +const ICON_RENDER_SIZE = 36; + +function FacilityIconSvg({ facility, damaged, planned }: { facility: OilFacility; damaged: boolean; planned: boolean }) { const color = TYPE_COLORS[facility.type]; - const size = getIconSize(facility); + const props = { size: ICON_RENDER_SIZE, color, damaged, planned }; switch (facility.type) { - case 'refinery': return ; - case 'oilfield': return ; - case 'gasfield': return ; - case 'terminal': return ; - case 'petrochemical': return ; - case 'desalination': return ; + case 'refinery': return ; + case 'oilfield': return ; + case 'gasfield': return ; + case 'terminal': return ; + case 'petrochemical': return ; + case 'desalination': return ; } } @@ -212,28 +231,19 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr return ( <> - -
- {/* Planned strike targeting ring */} - {isPlanned && ( -
- {/* Crosshair lines */} -
-
-
-
-
- )} -
{ e.stopPropagation(); setShowPopup(true); }}> - -
+ +
{ e.stopPropagation(); setShowPopup(true); }} + > + + {/* Label */}
diff --git a/frontend/src/components/iran/ReplayMap.tsx b/frontend/src/components/iran/ReplayMap.tsx index eee712f..3bf4a99 100644 --- a/frontend/src/components/iran/ReplayMap.tsx +++ b/frontend/src/components/iran/ReplayMap.tsx @@ -6,6 +6,7 @@ import { AircraftLayer } from '../layers/AircraftLayer'; import { SatelliteLayer } from '../layers/SatelliteLayer'; import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; +import { SeismicMarker } from '../layers/SeismicMarker'; import { OilFacilityLayer } from './OilFacilityLayer'; import { AirportLayer } from './AirportLayer'; import { iranOilFacilities } from '../../data/oilFacilities'; @@ -34,6 +35,7 @@ interface Props { hoveredShipMmsi?: string | null; focusShipMmsi?: string | null; onFocusShipClear?: () => void; + seismicMarker?: { lat: number; lng: number; magnitude: number; place: string } | null; } // MarineTraffic-style: dark ocean + satellite land + nautical overlay @@ -108,7 +110,7 @@ const EVENT_RADIUS: Record = { osint: 8, }; -export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear }: Props) { +export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, seismicMarker }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [selectedEventId, setSelectedEventId] = useState(null); @@ -428,6 +430,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la {layers.satellites && } {layers.ships && } {layers.ships && } + {seismicMarker && } {layers.airports && } {layers.oilFacilities && } diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index c69248f..67d854f 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useRef } from 'react'; +import { useMemo, useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; @@ -6,6 +6,7 @@ import { AircraftLayer } from '../layers/AircraftLayer'; import { SatelliteLayer } from '../layers/SatelliteLayer'; import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; +import { SeismicMarker } from '../layers/SeismicMarker'; import { OilFacilityLayer } from './OilFacilityLayer'; import { AirportLayer } from './AirportLayer'; import { iranOilFacilities } from '../../data/oilFacilities'; @@ -25,6 +26,9 @@ interface Props { hoveredShipMmsi?: string | null; focusShipMmsi?: string | null; onFocusShipClear?: () => void; + flyToTarget?: { lat: number; lng: number; zoom?: number } | null; + onFlyToDone?: () => void; + seismicMarker?: { lat: number; lng: number; magnitude: number; place: string } | null; } // ESRI World Imagery + ESRI boundaries overlay @@ -89,11 +93,22 @@ const EVENT_RADIUS: Record = { osint: 8, }; -export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear }: Props) { +export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, flyToTarget, onFlyToDone, seismicMarker }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [selectedEventId, setSelectedEventId] = useState(null); + useEffect(() => { + if (flyToTarget && mapRef.current) { + mapRef.current.flyTo({ + center: [flyToTarget.lng, flyToTarget.lat], + zoom: flyToTarget.zoom ?? 8, + duration: 1200, + }); + onFlyToDone?.(); + } + }, [flyToTarget, onFlyToDone]); + const visibleEvents = useMemo(() => { if (!layers.events) return []; return events.filter(e => e.timestamp <= currentTime); @@ -253,6 +268,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, {layers.satellites && } {layers.ships && } + {seismicMarker && } {layers.oilFacilities && } {layers.airports && } diff --git a/frontend/src/components/layers/SeismicMarker.css b/frontend/src/components/layers/SeismicMarker.css new file mode 100644 index 0000000..d3e3dcd --- /dev/null +++ b/frontend/src/components/layers/SeismicMarker.css @@ -0,0 +1,81 @@ +.seismic-marker-container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.seismic-pulse-ring { + position: absolute; + border-radius: 50%; + border: 2px solid; + opacity: 0; + animation: seismic-pulse 2.5s ease-out infinite; +} + +.seismic-pulse-ring-inner { + animation-delay: 0.8s; +} + +.seismic-fill { + position: absolute; + border-radius: 50%; + animation: seismic-breathe 2s ease-in-out infinite; +} + +.seismic-center-dot { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + z-index: 1; + box-shadow: 0 0 6px currentColor; +} + +.seismic-label { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 4px; + white-space: nowrap; + text-align: center; + font-family: 'Courier New', monospace; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); + pointer-events: none; +} + +.seismic-magnitude { + display: block; + font-size: 13px; + font-weight: 800; +} + +.seismic-place { + display: block; + font-size: 9px; + opacity: 0.8; +} + +@keyframes seismic-pulse { + 0% { + transform: scale(0.3); + opacity: 0.8; + } + 100% { + transform: scale(1.2); + opacity: 0; + } +} + +@keyframes seismic-breathe { + 0%, 100% { + opacity: 0.5; + transform: scale(0.9); + } + 50% { + opacity: 0.8; + transform: scale(1); + } +} diff --git a/frontend/src/components/layers/SeismicMarker.tsx b/frontend/src/components/layers/SeismicMarker.tsx new file mode 100644 index 0000000..27545bb --- /dev/null +++ b/frontend/src/components/layers/SeismicMarker.tsx @@ -0,0 +1,82 @@ +import { Marker } from 'react-map-gl/maplibre'; +import './SeismicMarker.css'; + +interface Props { + lat: number; + lng: number; + magnitude: number; + place: string; +} + +/** + * 진도 기반 영향 반경 (km → px 근사, zoom 8 기준) + * M2: ~5km, M3: ~15km, M4: ~40km, M5: ~100km, M6: ~200km + * zoom 8에서 약 1km ≈ 0.6px + */ +function getRadiusPx(magnitude: number): number { + const radiusKm = Math.pow(10, 0.5 * magnitude - 0.5); + return Math.max(20, Math.min(radiusKm * 0.6, 200)); +} + +function getMagnitudeColor(magnitude: number): string { + if (magnitude < 3) return 'rgba(251, 191, 36, 0.4)'; // yellow + if (magnitude < 4) return 'rgba(249, 115, 22, 0.4)'; // orange + if (magnitude < 5) return 'rgba(239, 68, 68, 0.4)'; // red + if (magnitude < 6) return 'rgba(220, 38, 38, 0.5)'; // dark red + return 'rgba(153, 27, 27, 0.6)'; // maroon +} + +function getStrokeColor(magnitude: number): string { + if (magnitude < 3) return '#fbbf24'; + if (magnitude < 4) return '#f97316'; + if (magnitude < 5) return '#ef4444'; + if (magnitude < 6) return '#dc2626'; + return '#991b1b'; +} + +export function SeismicMarker({ lat, lng, magnitude, place }: Props) { + const size = getRadiusPx(magnitude) * 2; + const color = getMagnitudeColor(magnitude); + const stroke = getStrokeColor(magnitude); + + return ( + +
+ {/* Outer pulse ring */} +
+ {/* Inner pulse ring */} +
+ {/* Fill circle */} +
+ {/* Center dot */} +
+ {/* Label */} +
+ M{magnitude.toFixed(1)} + {place} +
+
+ + ); +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 1aa821d..0253a92 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -80,6 +80,10 @@ /* 패널 그림자 */ --kcg-panel-shadow: none; + + /* 지도 라벨 그림자 — dark에서 검정 기반 */ + --kcg-map-label-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6); + --kcg-map-impact-shadow: 0 0 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.7); } /* ── Light Theme ── */ @@ -116,6 +120,10 @@ /* 패널 그림자 — light에서 영역 구분 강화 (outline 제거 — 폰트 가독성) */ --kcg-panel-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + + /* 지도 라벨 그림자 — light에서 흰 배경 기반 */ + --kcg-map-label-shadow: 0 0 3px rgba(255,255,255,0.9), 0 0 6px rgba(255,255,255,0.6); + --kcg-map-impact-shadow: 0 0 4px rgba(255,255,255,0.9), 0 0 8px rgba(255,255,255,0.7); } /* ── Tailwind @theme mapping ── */