From 81cd094c5662c2a05b78fa191ade4d7b6d218193 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 08:21:42 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(frontend):=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20import=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(vite=20build=20=EC=8B=A4=ED=8C=A8=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0)=20(#42)=20Co-authored-by:=20htlee=20=20Co-committed-by:=20htlee=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/common/CollectorMonitor.tsx | 4 ++-- frontend/src/components/common/EventLog.tsx | 4 ++-- frontend/src/components/common/EventStrip.tsx | 2 +- frontend/src/components/common/SensorChart.tsx | 2 +- frontend/src/components/common/TimelineSlider.tsx | 2 +- frontend/src/components/iran/AirportLayer.tsx | 2 +- frontend/src/components/iran/GlobeMap.tsx | 4 ++-- frontend/src/components/iran/OilFacilityLayer.tsx | 2 +- frontend/src/components/iran/ReplayMap.tsx | 8 ++++---- frontend/src/components/iran/SatelliteMap.tsx | 8 ++++---- frontend/src/components/korea/CctvLayer.tsx | 4 ++-- frontend/src/components/korea/CoastGuardLayer.tsx | 4 ++-- frontend/src/components/korea/EezLayer.tsx | 2 +- frontend/src/components/korea/InfraLayer.tsx | 2 +- frontend/src/components/korea/KoreaAirportLayer.tsx | 4 ++-- frontend/src/components/korea/KoreaMap.tsx | 10 +++++----- frontend/src/components/korea/NavWarningLayer.tsx | 4 ++-- frontend/src/components/korea/OsintMapLayer.tsx | 2 +- frontend/src/components/korea/PiracyLayer.tsx | 4 ++-- frontend/src/components/korea/SubmarineCableLayer.tsx | 4 ++-- frontend/src/components/layers/AircraftLayer.tsx | 2 +- frontend/src/components/layers/DamagedShipLayer.tsx | 4 ++-- frontend/src/components/layers/SatelliteLayer.tsx | 2 +- frontend/src/components/layers/ShipLayer.tsx | 2 +- 24 files changed, 44 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/common/CollectorMonitor.tsx b/frontend/src/components/common/CollectorMonitor.tsx index 5b86731..e1d8ee7 100644 --- a/frontend/src/components/common/CollectorMonitor.tsx +++ b/frontend/src/components/common/CollectorMonitor.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { fetchCollectorStatus } from '../services/collectorStatus'; -import type { CollectorInfo } from '../services/collectorStatus'; +import { fetchCollectorStatus } from '../../services/collectorStatus'; +import type { CollectorInfo } from '../../services/collectorStatus'; interface CollectorMonitorProps { onClose: () => void; diff --git a/frontend/src/components/common/EventLog.tsx b/frontend/src/components/common/EventLog.tsx index 01e6b70..e098280 100644 --- a/frontend/src/components/common/EventLog.tsx +++ b/frontend/src/components/common/EventLog.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import type { GeoEvent, Ship } from '../types'; -import type { OsintItem } from '../services/osint'; +import type { GeoEvent, Ship } from '../../types'; +import type { OsintItem } from '../../services/osint'; type DashboardTab = 'iran' | 'korea'; diff --git a/frontend/src/components/common/EventStrip.tsx b/frontend/src/components/common/EventStrip.tsx index 8a72c87..922a14a 100644 --- a/frontend/src/components/common/EventStrip.tsx +++ b/frontend/src/components/common/EventStrip.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { GeoEvent } from '../types'; +import type { GeoEvent } from '../../types'; interface Props { events: GeoEvent[]; diff --git a/frontend/src/components/common/SensorChart.tsx b/frontend/src/components/common/SensorChart.tsx index 288a22d..9aa24d1 100644 --- a/frontend/src/components/common/SensorChart.tsx +++ b/frontend/src/components/common/SensorChart.tsx @@ -10,7 +10,7 @@ import { ResponsiveContainer, ReferenceLine, } from 'recharts'; -import type { SensorLog } from '../types'; +import type { SensorLog } from '../../types'; interface Props { data: SensorLog[]; diff --git a/frontend/src/components/common/TimelineSlider.tsx b/frontend/src/components/common/TimelineSlider.tsx index 23e4e23..27cc837 100644 --- a/frontend/src/components/common/TimelineSlider.tsx +++ b/frontend/src/components/common/TimelineSlider.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import type { GeoEvent } from '../types'; +import type { GeoEvent } from '../../types'; interface Props { currentTime: number; diff --git a/frontend/src/components/iran/AirportLayer.tsx b/frontend/src/components/iran/AirportLayer.tsx index 1b3b1ab..bc0cc96 100644 --- a/frontend/src/components/iran/AirportLayer.tsx +++ b/frontend/src/components/iran/AirportLayer.tsx @@ -1,6 +1,6 @@ import { memo, useMemo, useState } from 'react'; import { Marker, Popup } from 'react-map-gl/maplibre'; -import type { Airport } from '../data/airports'; +import type { Airport } from '../../data/airports'; const US_BASE_ICAOS = new Set([ 'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL', diff --git a/frontend/src/components/iran/GlobeMap.tsx b/frontend/src/components/iran/GlobeMap.tsx index 109bdc5..682f9cd 100644 --- a/frontend/src/components/iran/GlobeMap.tsx +++ b/frontend/src/components/iran/GlobeMap.tsx @@ -1,8 +1,8 @@ import { useRef, useEffect } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; -import { countryLabelsGeoJSON } from '../data/countryLabels'; -import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types'; +import { countryLabelsGeoJSON } from '../../data/countryLabels'; +import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types'; interface Props { events: GeoEvent[]; diff --git a/frontend/src/components/iran/OilFacilityLayer.tsx b/frontend/src/components/iran/OilFacilityLayer.tsx index 8e95089..e1248ce 100644 --- a/frontend/src/components/iran/OilFacilityLayer.tsx +++ b/frontend/src/components/iran/OilFacilityLayer.tsx @@ -1,7 +1,7 @@ import { memo, useState } from 'react'; import { Marker, Popup } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; -import type { OilFacility, OilFacilityType } from '../types'; +import type { OilFacility, OilFacilityType } from '../../types'; interface Props { facilities: OilFacility[]; diff --git a/frontend/src/components/iran/ReplayMap.tsx b/frontend/src/components/iran/ReplayMap.tsx index 388b922..f6be33c 100644 --- a/frontend/src/components/iran/ReplayMap.tsx +++ b/frontend/src/components/iran/ReplayMap.tsx @@ -8,10 +8,10 @@ import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { OilFacilityLayer } from './OilFacilityLayer'; import { AirportLayer } from './AirportLayer'; -import { iranOilFacilities } from '../data/oilFacilities'; -import { middleEastAirports } from '../data/airports'; -import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types'; -import { countryLabelsGeoJSON } from '../data/countryLabels'; +import { iranOilFacilities } from '../../data/oilFacilities'; +import { middleEastAirports } from '../../data/airports'; +import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types'; +import { countryLabelsGeoJSON } from '../../data/countryLabels'; import 'maplibre-gl/dist/maplibre-gl.css'; export interface FlyToTarget { diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index 8ac21f6..9180ee8 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -8,10 +8,10 @@ import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { OilFacilityLayer } from './OilFacilityLayer'; import { AirportLayer } from './AirportLayer'; -import { iranOilFacilities } from '../data/oilFacilities'; -import { middleEastAirports } from '../data/airports'; -import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types'; -import { countryLabelsGeoJSON } from '../data/countryLabels'; +import { iranOilFacilities } from '../../data/oilFacilities'; +import { middleEastAirports } from '../../data/airports'; +import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types'; +import { countryLabelsGeoJSON } from '../../data/countryLabels'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; diff --git a/frontend/src/components/korea/CctvLayer.tsx b/frontend/src/components/korea/CctvLayer.tsx index 53c3a65..30604e1 100644 --- a/frontend/src/components/korea/CctvLayer.tsx +++ b/frontend/src/components/korea/CctvLayer.tsx @@ -2,8 +2,8 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { Marker, Popup } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import Hls from 'hls.js'; -import { KOREA_CCTV_CAMERAS } from '../services/cctv'; -import type { CctvCamera } from '../services/cctv'; +import { KOREA_CCTV_CAMERAS } from '../../services/cctv'; +import type { CctvCamera } from '../../services/cctv'; const REGION_COLOR: Record = { '제주': '#ff6b6b', diff --git a/frontend/src/components/korea/CoastGuardLayer.tsx b/frontend/src/components/korea/CoastGuardLayer.tsx index 87a674f..df10aef 100644 --- a/frontend/src/components/korea/CoastGuardLayer.tsx +++ b/frontend/src/components/korea/CoastGuardLayer.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Marker, Popup } from 'react-map-gl/maplibre'; -import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard'; -import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard'; +import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../../services/coastGuard'; +import type { CoastGuardFacility, CoastGuardType } from '../../services/coastGuard'; const TYPE_COLOR: Record = { hq: '#ff6b6b', diff --git a/frontend/src/components/korea/EezLayer.tsx b/frontend/src/components/korea/EezLayer.tsx index 00d2cb6..a97f2ee 100644 --- a/frontend/src/components/korea/EezLayer.tsx +++ b/frontend/src/components/korea/EezLayer.tsx @@ -1,5 +1,5 @@ import { Source, Layer } from 'react-map-gl/maplibre'; -import { KOREA_EEZ_BOUNDARY, KOREA_CHINA_PMZ, NLL_WEST_SEA, NLL_EAST_SEA } from '../services/koreaEez'; +import { KOREA_EEZ_BOUNDARY, KOREA_CHINA_PMZ, NLL_WEST_SEA, NLL_EAST_SEA } from '../../services/koreaEez'; import type { FillLayerSpecification, LineLayerSpecification } from 'maplibre-gl'; // Convert [lat, lng][] to GeoJSON [lng, lat][] ring diff --git a/frontend/src/components/korea/InfraLayer.tsx b/frontend/src/components/korea/InfraLayer.tsx index 5681159..f4795b9 100644 --- a/frontend/src/components/korea/InfraLayer.tsx +++ b/frontend/src/components/korea/InfraLayer.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { Marker, Popup } from 'react-map-gl/maplibre'; -import type { PowerFacility } from '../services/infra'; +import type { PowerFacility } from '../../services/infra'; // SVG Wind Turbine Icon function WindTurbineIcon({ color, size = 14 }: { color: string; size?: number }) { diff --git a/frontend/src/components/korea/KoreaAirportLayer.tsx b/frontend/src/components/korea/KoreaAirportLayer.tsx index a95a18d..5c95573 100644 --- a/frontend/src/components/korea/KoreaAirportLayer.tsx +++ b/frontend/src/components/korea/KoreaAirportLayer.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Marker, Popup } from 'react-map-gl/maplibre'; -import { KOREAN_AIRPORTS } from '../services/airports'; -import type { KoreanAirport } from '../services/airports'; +import { KOREAN_AIRPORTS } from '../../services/airports'; +import type { KoreanAirport } from '../../services/airports'; export function KoreaAirportLayer() { const [selected, setSelected] = useState(null); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 2443e36..3a5d0c3 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -14,11 +14,11 @@ import { NavWarningLayer } from './NavWarningLayer'; import { OsintMapLayer } from './OsintMapLayer'; import { EezLayer } from './EezLayer'; import { PiracyLayer } from './PiracyLayer'; -import { fetchKoreaInfra } from '../services/infra'; -import type { PowerFacility } from '../services/infra'; -import type { Ship, Aircraft, SatellitePosition } from '../types'; -import type { OsintItem } from '../services/osint'; -import { countryLabelsGeoJSON } from '../data/countryLabels'; +import { fetchKoreaInfra } from '../../services/infra'; +import type { PowerFacility } from '../../services/infra'; +import type { Ship, Aircraft, SatellitePosition } from '../../types'; +import type { OsintItem } from '../../services/osint'; +import { countryLabelsGeoJSON } from '../../data/countryLabels'; import 'maplibre-gl/dist/maplibre-gl.css'; export interface KoreaFiltersState { diff --git a/frontend/src/components/korea/NavWarningLayer.tsx b/frontend/src/components/korea/NavWarningLayer.tsx index f8bdf2b..4015b62 100644 --- a/frontend/src/components/korea/NavWarningLayer.tsx +++ b/frontend/src/components/korea/NavWarningLayer.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Marker, Popup } from 'react-map-gl/maplibre'; -import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning'; -import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning'; +import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../../services/navWarning'; +import type { NavWarning, NavWarningLevel, TrainingOrg } from '../../services/navWarning'; const LEVEL_COLOR: Record = { danger: '#ef4444', diff --git a/frontend/src/components/korea/OsintMapLayer.tsx b/frontend/src/components/korea/OsintMapLayer.tsx index 2fa3d6d..bf2c616 100644 --- a/frontend/src/components/korea/OsintMapLayer.tsx +++ b/frontend/src/components/korea/OsintMapLayer.tsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Marker, Popup } from 'react-map-gl/maplibre'; -import type { OsintItem } from '../services/osint'; +import type { OsintItem } from '../../services/osint'; const CAT_COLOR: Record = { maritime_accident: '#ef4444', diff --git a/frontend/src/components/korea/PiracyLayer.tsx b/frontend/src/components/korea/PiracyLayer.tsx index 89bc2e8..a575c53 100644 --- a/frontend/src/components/korea/PiracyLayer.tsx +++ b/frontend/src/components/korea/PiracyLayer.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Marker, Popup } from 'react-map-gl/maplibre'; -import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy'; -import type { PiracyZone } from '../services/piracy'; +import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../../services/piracy'; +import type { PiracyZone } from '../../services/piracy'; function SkullIcon({ color, size }: { color: string; size: number }) { return ( diff --git a/frontend/src/components/korea/SubmarineCableLayer.tsx b/frontend/src/components/korea/SubmarineCableLayer.tsx index f832d3d..5be99a8 100644 --- a/frontend/src/components/korea/SubmarineCableLayer.tsx +++ b/frontend/src/components/korea/SubmarineCableLayer.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; -import { KOREA_SUBMARINE_CABLES, KOREA_LANDING_POINTS } from '../services/submarineCable'; -import type { SubmarineCable } from '../services/submarineCable'; +import { KOREA_SUBMARINE_CABLES, KOREA_LANDING_POINTS } from '../../services/submarineCable'; +import type { SubmarineCable } from '../../services/submarineCable'; export function SubmarineCableLayer() { const [selectedCable, setSelectedCable] = useState(null); diff --git a/frontend/src/components/layers/AircraftLayer.tsx b/frontend/src/components/layers/AircraftLayer.tsx index 173157d..874852d 100644 --- a/frontend/src/components/layers/AircraftLayer.tsx +++ b/frontend/src/components/layers/AircraftLayer.tsx @@ -1,7 +1,7 @@ import { memo, useMemo, useState, useEffect } from 'react'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; -import type { Aircraft, AircraftCategory } from '../types'; +import type { Aircraft, AircraftCategory } from '../../types'; interface Props { aircraft: Aircraft[]; diff --git a/frontend/src/components/layers/DamagedShipLayer.tsx b/frontend/src/components/layers/DamagedShipLayer.tsx index faa577f..6b79ec4 100644 --- a/frontend/src/components/layers/DamagedShipLayer.tsx +++ b/frontend/src/components/layers/DamagedShipLayer.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { Marker, Popup } from 'react-map-gl/maplibre'; -import { damagedShips } from '../data/damagedShips'; -import type { DamagedShip } from '../data/damagedShips'; +import { damagedShips } from '../../data/damagedShips'; +import type { DamagedShip } from '../../data/damagedShips'; interface Props { currentTime: number; diff --git a/frontend/src/components/layers/SatelliteLayer.tsx b/frontend/src/components/layers/SatelliteLayer.tsx index 0f247b3..b5c1dde 100644 --- a/frontend/src/components/layers/SatelliteLayer.tsx +++ b/frontend/src/components/layers/SatelliteLayer.tsx @@ -1,7 +1,7 @@ import { memo, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; -import type { SatellitePosition } from '../types'; +import type { SatellitePosition } from '../../types'; interface Props { satellites: SatellitePosition[]; diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 5bd7292..addd5fe 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -1,7 +1,7 @@ import { memo, useMemo, useState, useEffect } from 'react'; import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; -import type { Ship, ShipCategory } from '../types'; +import type { Ship, ShipCategory } from '../../types'; import maplibregl from 'maplibre-gl'; interface Props { From 6c54500c70022eda4980ce1285534e374059a911 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 09:23:45 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=84=BC=EC=84=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=20=EC=8B=A4=EB=8D=B0=EC=9D=B4=ED=84=B0=20+?= =?UTF-8?q?=20=EC=84=A0=EB=B0=95=20=EB=AA=A8=EB=8B=AC=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20+=20KST/UTC=20=EB=9D=BC=EB=94=94=EC=98=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SensorChart: 백엔드 실데이터(지진/기압) + 동적 x축 시간 + 히스토리 10M/30M/1H/3H/6H - LiveControls: KST/UTC 토글 → 라디오 버튼 그룹 - ShipLayer: 모달 고정크기(300px), 드래그 가능, S&P Global 다중사진 슬라이드 - 선박 모달 CSS 통일 (태그 스타일, 2컬럼 그리드, 긴 값 단독행) - 센서 API: hours→min 파라미터 (기본 2880=48h), 인증 예외 처리 - useIranData/useKoreaData: 센서 10분 polling + 선박 60분 초기/6분 incremental merge Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 6 +- .claude/workflow-version.json | 2 +- .../main/java/gc/mda/kcg/auth/AuthFilter.java | 3 +- .../kcg/domain/sensor/SensorController.java | 10 +- frontend/src/App.css | 184 ++++++++++ frontend/src/App.tsx | 13 +- .../src/components/common/LiveControls.tsx | 49 ++- .../src/components/common/SensorChart.tsx | 181 +++++++--- frontend/src/components/layers/ShipLayer.tsx | 333 +++++++++++++----- frontend/src/hooks/useIranData.ts | 116 +++++- frontend/src/hooks/useKoreaData.ts | 47 ++- frontend/src/services/sensorApi.ts | 48 +++ frontend/src/services/ships.ts | 16 +- frontend/vite.config.ts | 3 +- 14 files changed, 814 insertions(+), 197 deletions(-) create mode 100644 frontend/src/services/sensorApi.ts diff --git a/.claude/settings.json b/.claude/settings.json index 868df2d..4224c81 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -83,5 +83,7 @@ ] } ] - } -} + }, + "deny": [], + "allow": [] +} \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 8e28c8b..b907d95 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -4,4 +4,4 @@ "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} +} \ No newline at end of file diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java index d25beb4..8e4a0c3 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java @@ -21,13 +21,14 @@ public class AuthFilter extends OncePerRequestFilter { private static final String JWT_COOKIE_NAME = "kcg_token"; private static final String AUTH_PATH_PREFIX = "/api/auth/"; + private static final String SENSOR_PATH_PREFIX = "/api/sensor/"; private final JwtProvider jwtProvider; @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - return path.startsWith(AUTH_PATH_PREFIX); + return path.startsWith(AUTH_PATH_PREFIX) || path.startsWith(SENSOR_PATH_PREFIX); } @Override diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java index 3efa827..e499fe6 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java @@ -18,11 +18,12 @@ public class SensorController { /** * 지진 이벤트 조회 (USGS 수집 데이터) + * @param min 조회 범위 (분 단위, 기본 2880 = 48시간) */ @GetMapping("/seismic") public Map getSeismic( - @RequestParam(defaultValue = "24") int hours) { - Instant since = Instant.now().minus(hours, ChronoUnit.HOURS); + @RequestParam(defaultValue = "2880") int min) { + Instant since = Instant.now().minus(min, ChronoUnit.MINUTES); List data = seismicRepo .findByEventTimeAfterOrderByEventTimeDesc(since) .stream().map(SensorDto.SeismicDto::from).toList(); @@ -31,11 +32,12 @@ public class SensorController { /** * 기압 데이터 조회 (Open-Meteo 수집 데이터) + * @param min 조회 범위 (분 단위, 기본 2880 = 48시간) */ @GetMapping("/pressure") public Map getPressure( - @RequestParam(defaultValue = "24") int hours) { - Instant since = Instant.now().minus(hours, ChronoUnit.HOURS); + @RequestParam(defaultValue = "2880") int min) { + Instant since = Instant.now().minus(min, ChronoUnit.MINUTES); List data = pressureRepo .findByReadingTimeAfterOrderByReadingTimeAsc(since) .stream().map(SensorDto.PressureDto::from).toList(); diff --git a/frontend/src/App.css b/frontend/src/App.css index 1c27ecb..af83e5e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1137,6 +1137,157 @@ opacity: 0.7; } +/* ═══ Ship popup modal ═══ */ +.vessel-photo-frame { + height: 160px; +} + +.ship-popup-body { + width: 300px; + font-family: 'Courier New', monospace; + font-size: 11px; + line-height: 1.4; +} + +.ship-popup-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 4px 4px 0 0; + margin: -10px -10px 8px -10px; + color: #fff; +} + +.ship-popup-name { + flex: 1; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ship-popup-navy-badge { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 3px; + font-size: 9px; + font-weight: 700; + color: #000; + line-height: 1; +} + +/* Type tags row */ +.ship-popup-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding-bottom: 6px; + margin-bottom: 6px; + border-bottom: 1px solid var(--kcg-border-light, rgba(100, 116, 139, 0.15)); +} + +.ship-tag { + display: inline-block; + padding: 2px 7px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + line-height: 1.4; + white-space: nowrap; +} + +.ship-tag-primary { + color: #fff; +} + +.ship-tag-secondary { + background: var(--kcg-border, rgba(100, 116, 139, 0.3)); + color: var(--kcg-text-secondary, #94a3b8); +} + +.ship-tag-dim { + background: var(--kcg-hover, rgba(100, 116, 139, 0.1)); + color: var(--kcg-dim, #64748b); +} + +/* Data grid */ +.ship-popup-grid { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 12px; +} + +.ship-popup-row { + display: flex; + justify-content: space-between; + padding: 2px 0; + border-bottom: 1px solid var(--kcg-border-light, rgba(100, 116, 139, 0.08)); + overflow: hidden; + min-height: 18px; +} + +.ship-popup-row:nth-last-child(1), +.ship-popup-row:nth-last-child(2) { + border-bottom: none; +} + +/* Full-width rows for long values (status, destination, ETA) */ +.ship-popup-full-row { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 2px 0; + border-top: 1px solid var(--kcg-border-light, rgba(100, 116, 139, 0.08)); +} + +.ship-popup-full-row .ship-popup-value { + text-align: right; + min-width: 0; +} + +.ship-popup-label { + color: var(--kcg-muted, #64748b); + font-size: 10px; + flex-shrink: 0; + margin-right: 4px; +} + +.ship-popup-value { + font-size: 10px; + color: var(--kcg-text, #e2e8f0); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Footer */ +.ship-popup-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--kcg-border-light, rgba(100, 116, 139, 0.15)); +} + +.ship-popup-timestamp { + font-size: 9px; + color: var(--kcg-dim, #64748b); +} + +.ship-popup-link { + font-size: 10px; + color: var(--kcg-accent, #3b82f6); + text-decoration: none; +} + +.ship-popup-link:hover { + text-decoration: underline; +} + /* Footer / Controls */ .app-footer { background: var(--bg-card); @@ -1890,6 +2041,39 @@ color: var(--kcg-danger); } +.tz-radio-group { + display: inline-flex; + border: 1px solid var(--kcg-border); + border-radius: 3px; + overflow: hidden; +} + +.tz-radio-btn { + padding: 1px 6px; + font-size: 10px; + font-weight: 700; + font-family: 'Courier New', monospace; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + line-height: 1.4; +} + +.tz-radio-btn + .tz-radio-btn { + border-left: 1px solid var(--kcg-border); +} + +.tz-radio-btn.active { + background: rgba(239, 68, 68, 0.15); + color: var(--kcg-danger); +} + +.tz-radio-btn:hover:not(.active) { + color: var(--text-primary); +} + /* Live mode accent on header border */ .app-live .app-header { border-bottom-color: rgba(239, 68, 68, 0.3); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7321f26..41b9586 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -119,6 +119,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }, []); const [showCollectorMonitor, setShowCollectorMonitor] = useState(false); + const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); const replay = useReplay(); const monitor = useMonitor(); @@ -132,8 +133,6 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { // Unified time values based on current mode const currentTime = appMode === 'live' ? monitor.state.currentTime : replay.state.currentTime; - const startTime = appMode === 'live' ? monitor.startTime : replay.state.startTime; - const endTime = appMode === 'live' ? monitor.endTime : replay.state.endTime; // Iran data hook const iranData = useIranData({ @@ -430,10 +429,10 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { {layers.sensorCharts && (
)} @@ -447,6 +446,8 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { aircraftCount={iranData.aircraft.length} shipCount={iranData.ships.length} satelliteCount={iranData.satPositions.length} + timeZone={timeZone} + onTimeZoneChange={setTimeZone} /> ) : ( <> @@ -550,6 +551,8 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { aircraftCount={koreaData.aircraft.length} shipCount={koreaData.ships.length} satelliteCount={koreaData.satPositions.length} + timeZone={timeZone} + onTimeZoneChange={setTimeZone} /> ) : ( <> diff --git a/frontend/src/components/common/LiveControls.tsx b/frontend/src/components/common/LiveControls.tsx index 20d73af..6adcd14 100644 --- a/frontend/src/components/common/LiveControls.tsx +++ b/frontend/src/components/common/LiveControls.tsx @@ -1,5 +1,4 @@ import { useTranslation } from 'react-i18next'; -import { useState } from 'react'; interface Props { currentTime: number; @@ -8,35 +7,35 @@ interface Props { aircraftCount: number; shipCount: number; satelliteCount: number; + timeZone: 'KST' | 'UTC'; + onTimeZoneChange: (tz: 'KST' | 'UTC') => void; } const HISTORY_PRESETS = [ + { label: '10M', minutes: 10 }, { label: '30M', minutes: 30 }, { label: '1H', minutes: 60 }, { label: '3H', minutes: 180 }, { label: '6H', minutes: 360 }, - { label: '12H', minutes: 720 }, - { label: '24H', minutes: 1440 }, ]; function formatTime(epoch: number, tz: 'KST' | 'UTC'): string { const d = new Date(epoch); - if (tz === 'UTC') { - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`; - } - // KST: 브라우저 로컬 타임존 사용 (한국 환경에서 자동 KST) 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())}:${pad(d.getSeconds())} KST`; + if (tz === 'UTC') { + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`; + } + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } export function LiveControls({ currentTime, historyMinutes, onHistoryChange, + timeZone, + onTimeZoneChange, }: Props) { const { t } = useTranslation(); - const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST'); return (
@@ -47,24 +46,18 @@ export function LiveControls({
{formatTime(currentTime, timeZone)} - +
+ {(['KST', 'UTC'] as const).map(tz => ( + + ))} +
diff --git a/frontend/src/components/common/SensorChart.tsx b/frontend/src/components/common/SensorChart.tsx index 9aa24d1..5ebe373 100644 --- a/frontend/src/components/common/SensorChart.tsx +++ b/frontend/src/components/common/SensorChart.tsx @@ -8,33 +8,122 @@ import { CartesianGrid, Tooltip, ResponsiveContainer, - ReferenceLine, } from 'recharts'; -import type { SensorLog } from '../../types'; +import type { SeismicDto, PressureDto } from '../../services/sensorApi'; interface Props { - data: SensorLog[]; + seismicData: SeismicDto[]; + pressureData: PressureDto[]; currentTime: number; - startTime: number; - endTime: number; + historyMinutes: number; } -export function SensorChart({ data, currentTime, startTime }: Props) { +const MINUTE = 60_000; +const BUCKET_COUNT = 48; + +function formatTickTime(epoch: number): string { + const d = new Date(epoch); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function buildTicks(currentTime: number, historyMinutes: number): number[] { + const interval = historyMinutes * MINUTE; + const ticks: number[] = []; + for (let i = 8; i >= 0; i--) { + ticks.push(currentTime - i * interval); + } + return ticks; +} + +function aggregateSeismic( + 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; +} + +function aggregatePressure( + 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[], + })); + 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); + } + return buckets.map(b => ({ + time: b.time, + value: b.values.length > 0 ? b.values.reduce((a, c) => a + c, 0) / b.values.length : 0, + })); +} + +function generateDemoData( + rangeStart: number, + rangeEnd: number, + baseValue: number, + variance: number, +): { time: number; value: number }[] { + const bucketSize = (rangeEnd - rangeStart) / BUCKET_COUNT; + return Array.from({ length: BUCKET_COUNT }, (_, i) => { + const t = rangeStart + (i + 0.5) * bucketSize; + const seed = Math.sin(t / 100_000) * 10000; + const noise = (seed - Math.floor(seed)) * 2 - 1; + return { time: t, value: Math.max(0, baseValue + noise * variance) }; + }); +} + +export function SensorChart({ seismicData, pressureData, currentTime, historyMinutes }: Props) { const { t } = useTranslation(); - const visibleData = useMemo( - () => data.filter(d => d.timestamp <= currentTime), - [data, currentTime], + const totalMinutes = historyMinutes * 8; + const rangeStart = currentTime - totalMinutes * MINUTE; + const rangeEnd = currentTime; + + const ticks = useMemo(() => buildTicks(currentTime, historyMinutes), [currentTime, historyMinutes]); + + const seismicChart = useMemo( + () => aggregateSeismic(seismicData, rangeStart, rangeEnd), + [seismicData, rangeStart, rangeEnd], + ); + const pressureChart = useMemo( + () => aggregatePressure(pressureData, rangeStart, rangeEnd), + [pressureData, rangeStart, rangeEnd], + ); + const noiseChart = useMemo( + () => generateDemoData(rangeStart, rangeEnd, 45, 30), + [rangeStart, rangeEnd], + ); + const radiationChart = useMemo( + () => generateDemoData(rangeStart, rangeEnd, 0.08, 0.06), + [rangeStart, rangeEnd], ); - const chartData = useMemo( - () => - visibleData.map(d => ({ - ...d, - time: formatHour(d.timestamp, startTime), - })), - [visibleData, startTime], - ); + const commonXAxis = { + dataKey: 'time' as const, + type: 'number' as const, + domain: [rangeStart, rangeEnd] as [number, number], + ticks, + tickFormatter: formatTickTime, + tick: { fontSize: 9, fill: '#888' }, + }; return (
@@ -43,13 +132,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {

{t('sensor.seismicActivity')}

- + - + - - - + [v.toFixed(1), 'Magnitude×10']} + /> +
@@ -57,12 +149,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) {

{t('sensor.airPressureHpa')}

- + - - - - + + + [v.toFixed(1), 'hPa']} + /> +
@@ -73,12 +169,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) { (DEMO) - + - + - - + [v.toFixed(1), 'dB']} + /> +
@@ -89,12 +189,16 @@ export function SensorChart({ data, currentTime, startTime }: Props) { (DEMO) - + - + - - + [v.toFixed(3), 'μSv/h']} + /> +
@@ -102,10 +206,3 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
); } - -function formatHour(timestamp: number, startTime: number): string { - const hours = (timestamp - startTime) / 3600_000; - const h = Math.floor(hours); - const m = Math.round((hours - h) * 60); - return `${h}:${m.toString().padStart(2, '0')}`; -} diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index addd5fe..feed6f9 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useState, useEffect } from 'react'; +import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react'; import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import type { Ship, ShipCategory } from '../../types'; @@ -135,27 +135,63 @@ interface VesselPhotoProps { mmsi: string; imo?: string; shipImagePath?: string | null; + shipImageCount?: number; } function toHighRes(path: string): string { return path.replace(/_1\.(jpg|jpeg|png)$/i, '_2.$1'); } -function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { +/** + * S&P Global 이미지 경로에서 N번째 이미지 URL 생성 + * 패턴: /shipimg/.../photo_1.jpg → photo_2.jpg (고화질), photo_3.jpg, ... + * shipImageCount가 N이면 _1 ~ _N 존재 (각각 _2 고화질 버전) + */ +function buildSpgUrls(basePath: string, count: number): string[] { + const urls: string[] = []; + // 항상 고화질(_2) 우선, count만큼 생성 + for (let i = 1; i <= Math.max(count, 1); i++) { + // basePath 예: /shipimg/.../1234_1.jpg + // _1을 _i로 교체 후 고화질로 변환 + const indexed = basePath.replace(/_1\.(jpg|jpeg|png)$/i, `_${i}.$1`); + urls.push(toHighRes(indexed)); + } + return urls; +} + +function VesselPhoto({ mmsi, shipImagePath, shipImageCount }: VesselPhotoProps) { const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; const hasSPGlobal = !!shipImagePath; - const defaultTab: PhotoSource = hasSPGlobal ? 'spglobal' : 'marinetraffic'; - const [activeTab, setActiveTab] = useState(defaultTab); + // 항상 S&P Global 우선 (모달 열릴 때마다 리셋) + const [activeTab, setActiveTab] = useState(hasSPGlobal ? 'spglobal' : 'marinetraffic'); + const [spgSlideIdx, setSpgSlideIdx] = useState(0); + const [spgErrors, setSpgErrors] = useState>(new Set()); - // S&P Global image error state - const [spgError, setSpgError] = useState(false); + // 모달이 다른 선박으로 변경될 때 탭/슬라이드 리셋 + useEffect(() => { + setActiveTab(hasSPGlobal ? 'spglobal' : 'marinetraffic'); + setSpgSlideIdx(0); + setSpgErrors(new Set()); + }, [mmsi, hasSPGlobal]); + + // S&P Global slide URLs + const spgUrls = useMemo( + () => shipImagePath ? buildSpgUrls(shipImagePath, shipImageCount ?? 1) : [], + [shipImagePath, shipImageCount], + ); + const validSpgCount = spgUrls.length; // MarineTraffic image state (lazy loaded) const [mtPhoto, setMtPhoto] = useState(() => { return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined; }); + useEffect(() => { + // 새 선박이면 캐시 확인 + setMtPhoto(vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined); + }, [mmsi]); + useEffect(() => { if (activeTab !== 'marinetraffic') return; if (mtPhoto !== undefined) return; @@ -170,8 +206,8 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { let currentUrl: string | null = null; if (localUrl) { currentUrl = localUrl; - } else if (activeTab === 'spglobal' && shipImagePath && !spgError) { - currentUrl = toHighRes(shipImagePath); + } else if (activeTab === 'spglobal' && spgUrls.length > 0 && !spgErrors.has(spgSlideIdx)) { + currentUrl = spgUrls[spgSlideIdx]; } else if (activeTab === 'marinetraffic' && mtPhoto) { currentUrl = mtPhoto.url; } @@ -180,15 +216,18 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { if (localUrl) { return (
- Vessel { (e.target as HTMLImageElement).style.display = 'none'; }} - /> +
+ Vessel { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
); } - const noPhoto = (!hasSPGlobal || spgError) && mtPhoto === null; + const allSpgFailed = spgUrls.length > 0 && spgUrls.every((_, i) => spgErrors.has(i)); + const noPhoto = (!hasSPGlobal || allSpgFailed) && mtPhoto === null; return (
@@ -212,40 +251,67 @@ function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { MarineTraffic
- {currentUrl ? ( - Vessel { - const el = e.target as HTMLImageElement; - if (activeTab === 'spglobal') { - setSpgError(true); - el.style.display = 'none'; - } else { - el.style.display = 'none'; - } - }} - /> - ) : noPhoto ? ( -
- No photo available -
- ) : ( - activeTab === 'marinetraffic' && mtPhoto === undefined - ?
Loading...
- :
- No photo available + + {/* 고정 높이 사진 영역 */} +
+ {currentUrl ? ( + Vessel { + if (activeTab === 'spglobal') { + setSpgErrors(prev => new Set(prev).add(spgSlideIdx)); + } + }} + /> + ) : noPhoto ? ( +
+ No photo available +
+ ) : activeTab === 'marinetraffic' && mtPhoto === undefined ? ( +
+ Loading... +
+ ) : ( +
+ No photo available +
+ )} + + {/* S&P Global 슬라이드 네비게이션 */} + {activeTab === 'spglobal' && validSpgCount > 1 && ( + <> + + +
+ {spgUrls.map((_, i) => ( + + ))}
- )} + + )} +
); } -function formatCoord(lat: number, lng: number): string { - const latDir = lat >= 0 ? 'N' : 'S'; - const lngDir = lng >= 0 ? 'E' : 'W'; - return `${Math.abs(lat).toFixed(3)}${latDir}, ${Math.abs(lng).toFixed(3)}${lngDir}`; -} - // Create triangle SDF image for MapLibre symbol layer const TRIANGLE_SIZE = 64; @@ -422,63 +488,168 @@ const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClo const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined; const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : ''; + // Draggable popup + const popupRef = useRef(null); + const dragging = useRef(false); + const dragOffset = useRef({ x: 0, y: 0 }); + + const onMouseDown = useCallback((e: React.MouseEvent) => { + // Only drag from header area + const target = e.target as HTMLElement; + if (!target.closest('.ship-popup-header')) return; + e.preventDefault(); + dragging.current = true; + const popupEl = popupRef.current?.closest('.maplibregl-popup') as HTMLElement | null; + if (!popupEl) return; + const rect = popupEl.getBoundingClientRect(); + dragOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + popupEl.style.transition = 'none'; + }, []); + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + const popupEl = popupRef.current?.closest('.maplibregl-popup') as HTMLElement | null; + if (!popupEl) return; + // Switch to fixed positioning for free drag + popupEl.style.transform = 'none'; + popupEl.style.position = 'fixed'; + popupEl.style.left = `${e.clientX - dragOffset.current.x}px`; + popupEl.style.top = `${e.clientY - dragOffset.current.y}px`; + }; + const onMouseUp = () => { dragging.current = false; }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }, []); + return ( -
+
+ {/* Header — draggable handle */}
- {flagEmoji && {flagEmoji}} - {ship.name} + {flagEmoji && {flagEmoji}} + {ship.name} {navyLabel && ( - {navyLabel} + + {navyLabel} + )}
- -
- {t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })} - + + {/* Photo */} + + + {/* Type tags */} +
+ + {t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })} + + {t(`categoryLabel.${ship.category}`)} {ship.typeDesc && ( - {ship.typeDesc} + {ship.typeDesc} )}
-
-
-
{t('popup.mmsi')} : {ship.mmsi}
- {ship.callSign &&
{t('popup.callSign')} : {ship.callSign}
} - {ship.imo &&
{t('popup.imo')} : {ship.imo}
} - {ship.status &&
{t('popup.status')} : {ship.status}
} - {ship.length &&
{t('popup.length')} : {ship.length}m
} - {ship.width &&
{t('popup.width')} : {ship.width}m
} - {ship.draught &&
{t('popup.draught')} : {ship.draught}m
} + + {/* Data grid — paired rows */} +
+ {/* Identity */} +
+ MMSI + {ship.mmsi}
-
-
{t('popup.heading')} : {ship.heading.toFixed(1)}°
-
{t('popup.course')} : {ship.course.toFixed(1)}°
-
{t('popup.speed')} : {ship.speed.toFixed(1)} kn
-
{t('popup.lat')} : {formatCoord(ship.lat, 0).split(',')[0]}
-
{t('popup.lon')} : {formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}
- {ship.destination &&
{t('popup.destination')} : {ship.destination}
} - {ship.eta &&
{t('popup.eta')} : {new Date(ship.eta).toLocaleString()}
} +
+ IMO + {ship.imo || '-'} +
+ + {ship.callSign && ( + <> +
+ {t('popup.callSign')} + {ship.callSign} +
+
+ + )} + + {/* Position — paired */} +
+ Lat + {ship.lat.toFixed(4)} +
+
+ Lon + {ship.lng.toFixed(4)} +
+ + {/* Navigation — paired */} +
+ HDG + {ship.heading.toFixed(1)}° +
+
+ COG + {ship.course.toFixed(1)}° +
+ +
+ SOG + {ship.speed.toFixed(1)} kn +
+
+ Draught + {ship.draught ? `${ship.draught.toFixed(2)}m` : '-'} +
+ + {/* Dimensions — paired */} +
+ Length + {ship.length ? `${ship.length}m` : '-'} +
+
+ Width + {ship.width ? `${ship.width}m` : '-'}
-
- {t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()} -
-
+ + {/* Long-value fields — full width below grid */} + {ship.status && ( +
+ Status + {ship.status} +
+ )} + {ship.destination && ( +
+ Dest + {ship.destination} +
+ )} + {ship.eta && ( +
+ ETA + {new Date(ship.eta).toLocaleString()} +
+ )} + + {/* Footer */} +
+ + {t('popup.lastUpdate')}: {new Date(ship.lastSeen).toLocaleString()} + + target="_blank" rel="noopener noreferrer" className="ship-popup-link"> MarineTraffic →
diff --git a/frontend/src/hooks/useIranData.ts b/frontend/src/hooks/useIranData.ts index 47cbb88..cd38e8d 100644 --- a/frontend/src/hooks/useIranData.ts +++ b/frontend/src/hooks/useIranData.ts @@ -1,14 +1,16 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; -import { fetchEvents, fetchSensorData } from '../services/api'; +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { fetchEvents } from '../services/api'; import { fetchAircraftFromBackend } 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 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, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types'; +import type { GeoEvent, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types'; interface UseIranDataArgs { appMode: AppMode; @@ -28,7 +30,8 @@ interface UseIranDataResult { satPositions: SatellitePosition[]; events: GeoEvent[]; mergedEvents: GeoEvent[]; - sensorData: SensorLog[]; + seismicData: SeismicDto[]; + pressureData: PressureDto[]; osintFeed: OsintItem[]; aircraftByCategory: Record; militaryCount: number; @@ -37,6 +40,10 @@ interface UseIranDataResult { koreanShipsByCategory: Record; } +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, @@ -47,7 +54,8 @@ export function useIranData({ dashboardTab, }: UseIranDataArgs): UseIranDataResult { const [events, setEvents] = useState([]); - const [sensorData, setSensorData] = useState([]); + const [seismicData, setSeismicData] = useState([]); + const [pressureData, setPressureData] = useState([]); const [baseAircraft, setBaseAircraft] = useState([]); const [baseShips, setBaseShips] = useState([]); const [satellites, setSatellites] = useState([]); @@ -55,14 +63,46 @@ export function useIranData({ const [osintFeed, setOsintFeed] = useState([]); const satTimeRef = useRef(0); + const sensorInitRef = useRef(false); + const shipMapRef = useRef>(new Map()); // Load initial data useEffect(() => { fetchEvents().then(setEvents).catch(() => {}); - fetchSensorData().then(setSensorData).catch(() => {}); fetchSatelliteTLE().then(setSatellites).catch(() => {}); }, [refreshKey]); + // 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) useEffect(() => { const load = async () => { @@ -78,22 +118,45 @@ export function useIranData({ return () => clearInterval(interval); }, [appMode, refreshKey]); - // Fetch Iran ship data (signal-batch + sample military, 5-min cycle) + // 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(() => { - const load = async () => { + let initialDone = false; + const loadInitial = async () => { try { - const data = await fetchShips(); + 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 data - } + } catch { /* keep previous */ } }; - load(); - const interval = setInterval(load, 300_000); + + 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]); + }, [appMode, refreshKey, mergeShips]); // Fetch OSINT feed — live mode (both tabs) + replay mode (iran tab) useEffect(() => { @@ -245,7 +308,8 @@ export function useIranData({ satPositions, events, mergedEvents, - sensorData, + seismicData, + pressureData, osintFeed, aircraftByCategory, militaryCount, @@ -254,3 +318,23 @@ export function useIranData({ koreanShipsByCategory, }; } + +/** + * 센서 데이터 병합: 새 데이터를 기존 배열에 추가, 중복 제거, 오래된 데이터 제거 + */ +function mergeSensor( + existing: T[], + incoming: T[], + keyFn: (item: T) => string, + maxMinutes: number, +): T[] { + const cutoff = Date.now() - maxMinutes * 60_000; + const map = new Map(); + 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); +} diff --git a/frontend/src/hooks/useKoreaData.ts b/frontend/src/hooks/useKoreaData.ts index 65487ca..f7952af 100644 --- a/frontend/src/hooks/useKoreaData.ts +++ b/frontend/src/hooks/useKoreaData.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { fetchAircraftFromBackend } from '../services/aircraftApi'; import { fetchSatelliteTLEKorea, propagateAll } from '../services/celestrak'; import { fetchShipsKorea } from '../services/ships'; @@ -30,6 +30,9 @@ interface UseKoreaDataResult { militaryCount: number; } +const SHIP_POLL_INTERVAL = 300_000; // 5 min +const SHIP_STALE_MS = 3_600_000; // 60 min + export function useKoreaData({ currentTime, isLive, @@ -44,6 +47,7 @@ export function useKoreaData({ const [osintFeed, setOsintFeed] = useState([]); const satTimeKoreaRef = useRef(0); + const shipMapRef = useRef>(new Map()); // Fetch Korea satellite TLE data useEffect(() => { @@ -61,18 +65,45 @@ export function useKoreaData({ return () => clearInterval(interval); }, [refreshKey]); - // Fetch Korea region ship data (signal-batch, 4-min cycle) + // Ship merge with stale cleanup + const mergeShips = useCallback((newShips: Ship[]) => { + const map = shipMapRef.current; + for (const s of newShips) { + map.set(s.mmsi, s); + } + const cutoff = Date.now() - SHIP_STALE_MS; + for (const [mmsi, ship] of map) { + if (ship.lastSeen < cutoff) map.delete(mmsi); + } + setBaseShipsKorea(Array.from(map.values())); + }, []); + + // Fetch Korea region ship data: initial 60min, then 5min polling with 6min window useEffect(() => { - const load = async () => { + let initialDone = false; + const loadInitial = async () => { try { - const data = await fetchShipsKorea(); - if (data.length > 0) setBaseShipsKorea(data); + const data = await fetchShipsKorea(60); // 초기: 60분 데이터 + if (data.length > 0) { + shipMapRef.current = new Map(data.map(s => [s.mmsi, s])); + setBaseShipsKorea(data); + initialDone = true; + } } catch { /* keep previous */ } }; - load(); - const interval = setInterval(load, 240_000); + + const loadIncremental = async () => { + if (!initialDone) return; + try { + const data = await fetchShipsKorea(6); // polling: 6분 데이터 + if (data.length > 0) mergeShips(data); + } catch { /* keep previous */ } + }; + + loadInitial(); + const interval = setInterval(loadIncremental, SHIP_POLL_INTERVAL); return () => clearInterval(interval); - }, [refreshKey]); + }, [refreshKey, mergeShips]); // Fetch OSINT feed for Korea tab useEffect(() => { diff --git a/frontend/src/services/sensorApi.ts b/frontend/src/services/sensorApi.ts new file mode 100644 index 0000000..4f2cbdf --- /dev/null +++ b/frontend/src/services/sensorApi.ts @@ -0,0 +1,48 @@ +const API_BASE = '/api/kcg/sensor'; + +export interface SeismicDto { + usgsId: string; + magnitude: number; + depth: number | null; + lat: number; + lng: number; + place: string; + timestamp: number; // epoch ms +} + +export interface PressureDto { + station: string; + lat: number; + lng: number; + pressureHpa: number; + timestamp: number; // epoch ms +} + +interface SensorResponse { + count: number; + data: T[]; +} + +/** + * 지진 이벤트 조회 + * @param min 조회 범위 (분, 기본 2880=48h) + */ +export async function fetchSeismic(min?: number): Promise { + const params = min != null ? `?min=${min}` : ''; + const res = await fetch(`${API_BASE}/seismic${params}`); + if (!res.ok) throw new Error(`seismic API ${res.status}`); + const body: SensorResponse = await res.json(); + return body.data; +} + +/** + * 기압 데이터 조회 + * @param min 조회 범위 (분, 기본 2880=48h) + */ +export async function fetchPressure(min?: number): Promise { + const params = min != null ? `?min=${min}` : ''; + const res = await fetch(`${API_BASE}/pressure${params}`); + if (!res.ok) throw new Error(`pressure API ${res.status}`); + const body: SensorResponse = await res.json(); + return body.data; +} diff --git a/frontend/src/services/ships.ts b/frontend/src/services/ships.ts index 2aa9242..1a14997 100644 --- a/frontend/src/services/ships.ts +++ b/frontend/src/services/ships.ts @@ -330,9 +330,9 @@ export async function fetchTankers(): Promise { // ═══ Main fetch function ═══ // Tries signal-batch Iran region first, merges with sample military ships -export async function fetchShips(): Promise { +export async function fetchShips(minutes = 10): Promise { const sampleShips = getSampleShips(); - const real = await fetchShipsFromSignalBatchIran(); + const real = await fetchShipsFromSignalBatchIran(minutes); if (real.length > 0) { console.log(`signal-batch: ${real.length} vessels in Iran/Hormuz region`); @@ -715,10 +715,10 @@ function parseSignalBatchVessel(d: RecentPositionDetailDto): Ship { }; } -async function fetchShipsFromSignalBatchIran(): Promise { +async function fetchShipsFromSignalBatchIran(minutes = 10): Promise { try { const body: RecentPositionDetailRequest = { - minutes: 10, + minutes, coordinates: [ [IRAN_BOUNDS.minLng, IRAN_BOUNDS.minLat], [IRAN_BOUNDS.maxLng, IRAN_BOUNDS.minLat], @@ -745,10 +745,10 @@ async function fetchShipsFromSignalBatchIran(): Promise { } } -async function fetchShipsFromSignalBatch(): Promise { +async function fetchShipsFromSignalBatch(minutes = 5): Promise { try { const body: RecentPositionDetailRequest = { - minutes: 5, + minutes, coordinates: [ [KR_BOUNDS.minLng, KR_BOUNDS.minLat], [KR_BOUNDS.maxLng, KR_BOUNDS.minLat], @@ -775,9 +775,9 @@ async function fetchShipsFromSignalBatch(): Promise { } } -export async function fetchShipsKorea(): Promise { +export async function fetchShipsKorea(minutes = 5): Promise { const sample = getSampleShipsKorea(); - const real = await fetchShipsFromSignalBatch(); + const real = await fetchShipsFromSignalBatch(minutes); if (real.length > 0) { console.log(`signal-batch: ${real.length} vessels in Korea region`); const sampleMMSIs = new Set(sample.map(s => s.mmsi)); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a1bceee..0f574e2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -90,9 +90,10 @@ export default defineConfig(({ mode }): UserConfig => ({ }, }, '/api/kcg': { - target: 'http://localhost:8080', + target: 'https://kcg.gc-si.dev', changeOrigin: true, rewrite: (path) => path.replace(/^\/api\/kcg/, '/api'), + secure: true, }, '/signal-batch': { target: 'https://wing.gc-si.dev', From a8f6bfe1db36ef2ddd6da558f8f4778a691543b5 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 09:28:53 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 1bf517a..96d4f84 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,26 @@ ## [Unreleased] +### 추가 +- 센서 API 서비스(sensorApi.ts): 백엔드 지진/기압 실데이터 연동 +- 선박 모달 S&P Global 다중 사진 슬라이드 (좌우 화살표 + 인디케이터) +- 선박 모달 드래그 이동 (헤더 영역 grab) +- LiveControls KST/UTC 라디오 버튼 그룹 + +### 변경 +- SensorChart: 더미 → 실데이터(지진/기압), x축 동적 시간 표시 +- 히스토리 프리셋: 30M/1H/3H/6H/12H/24H → 10M/30M/1H/3H/6H (8칸 구조) +- 센서 API 파라미터: hours → min (기본 2880=48h) +- 센서 데이터 polling: 초기 48h 전체 → 10분마다 incremental merge +- 선박 데이터 polling: 초기 60분 → 5분마다 6분 윈도우 merge + 60분 stale 제거 +- 선박 모달 고정 크기(300px) + 사진 영역 고정(160px, object-contain) +- 선박 모달 데이터 레이아웃: 2컬럼 그리드 + 연관 정보 쌍 배치 + 긴 값 단독행 +- 선박 모달 CSS 통일 (태그 패딩/배경, 컬럼 간격 12px) + +### 수정 +- 센서 API(/api/sensor/*) 인증 예외 처리 (공개 데이터) +- 선박 모달 열 때마다 S&P Global 우선 탭 리셋 (MarineTraffic 포커스 유지 버그) + ## [2026-03-18.2] ### 추가 From 1e8c0659e550c91f454a864a258288e29c55acbd Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 09:34:51 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20S&P=20Global=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?URL=20=EB=AA=A9=EB=A1=9D=20API=20=EC=97=B0=EB=8F=99=20+=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20DEMO=20=ED=91=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShipLayer: IMO 기반 /signal-batch/api/v1/shipimg/{imo} API로 실제 이미지 목록 조회 - 각 이미지 path + _2.jpg(원본) 사용 (기존 잘못된 _1→_2→_3 번호 패턴 제거) - IMO별 이미지 목록 캐시(spgImageCache) 적용 - LoginPage: KCG 로고 우측 하단에 DEMO 문구 오버레이 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/auth/LoginPage.tsx | 17 ++++- frontend/src/components/layers/ShipLayer.tsx | 65 ++++++++++++-------- 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx index 9742506..0703a47 100644 --- a/frontend/src/components/auth/LoginPage.tsx +++ b/frontend/src/components/auth/LoginPage.tsx @@ -104,7 +104,22 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => { > {/* Title */}
- KCG +
+ KCG + + DEMO + +

(); + +async function fetchSpgImages(imo: string): Promise { + if (spgImageCache.has(imo)) return spgImageCache.get(imo) || []; + try { + const res = await fetch(`/signal-batch/api/v1/shipimg/${imo}`); + if (!res.ok) throw new Error(`${res.status}`); + const data: SpgImageInfo[] = await res.json(); + spgImageCache.set(imo, data); + return data; + } catch { + spgImageCache.set(imo, null); + return []; + } +} + +function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) { const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; const hasSPGlobal = !!shipImagePath; - // 항상 S&P Global 우선 (모달 열릴 때마다 리셋) const [activeTab, setActiveTab] = useState(hasSPGlobal ? 'spglobal' : 'marinetraffic'); const [spgSlideIdx, setSpgSlideIdx] = useState(0); const [spgErrors, setSpgErrors] = useState>(new Set()); + const [spgImages, setSpgImages] = useState([]); - // 모달이 다른 선박으로 변경될 때 탭/슬라이드 리셋 + // 모달이 다른 선박으로 변경될 때 리셋 + 이미지 목록 조회 useEffect(() => { setActiveTab(hasSPGlobal ? 'spglobal' : 'marinetraffic'); setSpgSlideIdx(0); setSpgErrors(new Set()); - }, [mmsi, hasSPGlobal]); + setSpgImages([]); - // S&P Global slide URLs + if (imo && hasSPGlobal) { + fetchSpgImages(imo).then(setSpgImages); + } else if (shipImagePath) { + // IMO 없으면 shipImagePath 단일 이미지 사용 + setSpgImages([{ picId: 0, path: shipImagePath.replace(/_[12]\.\w+$/, ''), copyright: '', date: '' }]); + } + }, [mmsi, imo, hasSPGlobal, shipImagePath]); + + // S&P Global slide URLs: 각 이미지의 path + _2.jpg (원본) const spgUrls = useMemo( - () => shipImagePath ? buildSpgUrls(shipImagePath, shipImageCount ?? 1) : [], - [shipImagePath, shipImageCount], + () => spgImages.map(img => `${img.path}_2.jpg`), + [spgImages], ); const validSpgCount = spgUrls.length; @@ -188,7 +204,6 @@ function VesselPhoto({ mmsi, shipImagePath, shipImageCount }: VesselPhotoProps) }); useEffect(() => { - // 새 선박이면 캐시 확인 setMtPhoto(vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined); }, [mmsi]); From 0deb55b44a9f0b2aa18f19d205830b582913104a Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 09:35:15 +0900 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 96d4f84..1362318 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -23,6 +23,10 @@ ### 수정 - 센서 API(/api/sensor/*) 인증 예외 처리 (공개 데이터) - 선박 모달 열 때마다 S&P Global 우선 탭 리셋 (MarineTraffic 포커스 유지 버그) +- S&P Global 사진 URL: IMO 기반 이미지 목록 API 연동 (잘못된 번호 패턴 제거) + +### 기타 +- 로그인 화면 KCG 로고에 DEMO 문구 오버레이 ## [2026-03-18.2] From 23511e1f2217beab4c0e1bfc91f75d8ef5d475f8 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 09:36:51 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 1362318..85ca74b 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-18.3] + ### 추가 - 센서 API 서비스(sensorApi.ts): 백엔드 지진/기압 실데이터 연동 - 선박 모달 S&P Global 다중 사진 슬라이드 (좌우 화살표 + 인디케이터)