From cdc4cb57b1b96a048fabf55ca7a8808090241bca Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 09:31:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A4=91=EA=B5=AD=EC=96=B4=EC=84=A0?= =?UTF-8?q?=EA=B0=90=EC=8B=9C=20=ED=83=AD=20=EA=B0=95=ED=99=94=20+=20local?= =?UTF-8?q?Storage=20=EC=83=81=ED=83=9C=20=EC=98=81=EC=86=8D=ED=99=94=20(#?= =?UTF-8?q?152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/VesselAnalysisService.java | 2 +- frontend/src/App.tsx | 30 +-- frontend/src/components/common/LayerPanel.tsx | 5 +- .../components/korea/AnalysisStatsPanel.tsx | 22 +- .../components/korea/FleetClusterLayer.tsx | 198 ++++++++++++------ frontend/src/components/korea/KoreaMap.tsx | 13 +- frontend/src/hooks/useKoreaFilters.ts | 14 +- frontend/src/hooks/useLocalStorage.ts | 68 ++++++ 8 files changed, 260 insertions(+), 92 deletions(-) create mode 100644 frontend/src/hooks/useLocalStorage.ts diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java index 775065e..0dfb546 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -34,7 +34,7 @@ public class VesselAnalysisService { } } - Instant since = Instant.now().minus(1, ChronoUnit.HOURS); + Instant since = Instant.now().minus(2, ChronoUnit.HOURS); // mmsi별 최신 analyzed_at 1건만 유지 Map latest = new LinkedHashMap<>(); for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d94524a..bdd8948 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { useLocalStorage, useLocalStorageSet } from './hooks/useLocalStorage'; import { ReplayMap } from './components/iran/ReplayMap'; import type { FlyToTarget } from './components/iran/ReplayMap'; import { GlobeMap } from './components/iran/GlobeMap'; @@ -68,9 +69,9 @@ interface AuthenticatedAppProps { function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [appMode, setAppMode] = useState('live'); - const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); - const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); - const [layers, setLayers] = useState({ + const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite'); + const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran'); + const [layers, setLayers] = useLocalStorage('iranLayers', { events: true, aircraft: true, satellites: true, @@ -94,7 +95,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }); // Korea tab layer visibility (lifted from KoreaMap) - const [koreaLayers, setKoreaLayers] = useState>({ + const [koreaLayers, setKoreaLayers] = useLocalStorage>('koreaLayers', { ships: true, aircraft: true, satellites: true, @@ -134,11 +135,11 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const toggleKoreaLayer = useCallback((key: string) => { setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] })); - }, []); + }, [setKoreaLayers]); // Category filter state (shared across tabs) - const [hiddenAcCategories, setHiddenAcCategories] = useState>(new Set()); - const [hiddenShipCategories, setHiddenShipCategories] = useState>(new Set()); + const [hiddenAcCategories, setHiddenAcCategories] = useLocalStorageSet('hiddenAcCategories', new Set()); + const [hiddenShipCategories, setHiddenShipCategories] = useLocalStorageSet('hiddenShipCategories', new Set()); const toggleAcCategory = useCallback((cat: string) => { setHiddenAcCategories(prev => { @@ -146,7 +147,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } return next; }); - }, []); + }, [setHiddenAcCategories]); const toggleShipCategory = useCallback((cat: string) => { setHiddenShipCategories(prev => { @@ -154,27 +155,27 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } return next; }); - }, []); + }, [setHiddenShipCategories]); // Nationality filter state (Korea tab) - const [hiddenNationalities, setHiddenNationalities] = useState>(new Set()); + const [hiddenNationalities, setHiddenNationalities] = useLocalStorageSet('hiddenNationalities', new Set()); const toggleNationality = useCallback((nat: string) => { setHiddenNationalities(prev => { const next = new Set(prev); if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } return next; }); - }, []); + }, [setHiddenNationalities]); // Fishing vessel nationality filter state - const [hiddenFishingNats, setHiddenFishingNats] = useState>(new Set()); + const [hiddenFishingNats, setHiddenFishingNats] = useLocalStorageSet('hiddenFishingNats', new Set()); const toggleFishingNat = useCallback((nat: string) => { setHiddenFishingNats(prev => { const next = new Set(prev); if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } return next; }); - }, []); + }, [setHiddenFishingNats]); const [flyToTarget, setFlyToTarget] = useState(null); @@ -238,11 +239,12 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { koreaData.visibleShips, currentTime, vesselAnalysis.analysisMap, + koreaLayers.cnFishing, ); const toggleLayer = useCallback((key: keyof LayerVisibility) => { setLayers(prev => ({ ...prev, [key]: !prev[key] })); - }, []); + }, [setLayers]); // Handle event card click from timeline: fly to location on map const handleEventFlyTo = useCallback((event: GeoEvent) => { diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index b41844e..0f4c3bf 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useLocalStorageSet } from '../../hooks/useLocalStorage'; // Aircraft category colors (matches AircraftLayer military fixed colors) const AC_CAT_COLORS: Record = { @@ -172,7 +173,7 @@ export function LayerPanel({ onFishingNatToggle, }: LayerPanelProps) { const { t } = useTranslation(['common', 'ships']); - const [expanded, setExpanded] = useState>(new Set(['ships'])); + const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships'])); const [legendOpen, setLegendOpen] = useState>(new Set()); const toggleExpand = useCallback((key: string) => { @@ -181,7 +182,7 @@ export function LayerPanel({ if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); - }, []); + }, [setExpanded]); const toggleLegend = useCallback((key: string) => { setLegendOpen(prev => { diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index dd8b27f..836d8bc 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -1,6 +1,7 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types'; import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; import { fetchVesselTrack } from '../../services/vesselTrack'; interface Props { @@ -12,6 +13,7 @@ interface Props { allShips?: Ship[]; onShipSelect?: (mmsi: string) => void; onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; + onExpandedChange?: (expanded: boolean) => void; } interface VesselListItem { @@ -71,8 +73,16 @@ const LEGEND_LINES = [ '스푸핑: 순간이동+SOG급변+BD09 종합', ]; -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad }: Props) { - const [expanded, setExpanded] = useState(true); +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad, onExpandedChange }: Props) { + const [expanded, setExpanded] = useLocalStorage('analysisPanelExpanded', false); + const toggleExpanded = () => { + const next = !expanded; + setExpanded(next); + onExpandedChange?.(next); + }; + // 마운트 시 저장된 상태를 부모에 동기화 + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { onExpandedChange?.(expanded); }, []); const [selectedLevel, setSelectedLevel] = useState(null); const [selectedMmsi, setSelectedMmsi] = useState(null); const [showLegend, setShowLegend] = useState(false); @@ -124,8 +134,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, const panelStyle: React.CSSProperties = { position: 'absolute', - top: 60, - right: 10, + top: 10, + right: 50, zIndex: 10, minWidth: 200, maxWidth: 280, @@ -231,7 +241,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, - - - {expanded && ( -
- {fleetList.length === 0 ? ( -
- 선단 데이터 없음 -
- ) : ( - fleetList.map(({ id, mmsiList }) => { +
+ {/* ── 선단 현황 섹션 ── */} +
toggleSection('fleet')}> + + 선단 현황 ({fleetList.length}개) + + +
+ {sectionExpanded.fleet && ( +
+ {fleetList.length === 0 ? ( +
+ 선단 데이터 없음 +
+ ) : ( + fleetList.map(({ id, mmsiList }) => { const company = companies.get(id); const companyName = company?.nameCn ?? `선단 #${id}`; const color = clusterColor(id); @@ -838,26 +863,76 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, }) )} - {/* 비허가 어구 그룹 섹션 */} - {gearGroupList.length > 0 && ( - <> -
-
- 비허가 어구 그룹 ({gearGroupList.length}개) +
+ )} + + {/* ── 조업구역내 어구 그룹 섹션 ── */} + {inZoneGearGroups.length > 0 && ( + <> +
toggleSection('inZone')}> + + 조업구역내 어구 ({inZoneGearGroups.length}개) + + +
+ {sectionExpanded.inZone && ( +
+ {inZoneGearGroups.map(({ name, parent, gears, zone }) => { + const isOpen = expandedGearGroup === name; + const accentColor = '#dc2626'; + return ( +
+
{ (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(220,38,38,0.06)'; }} + onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }} + > + setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'} + + setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zone}`}>{name} + {zone} + ({gears.length}) + +
+ {isOpen && ( +
+ {parent &&
모선: {parent.name || parent.mmsi}
} +
어구 목록:
+ {gears.map(g => ( +
+ {g.name || g.mmsi} + +
+ ))} +
+ )} +
+ ); + })}
- {gearGroupList.map(({ name, parent, gears }) => { + )} + + )} + + {/* ── 비허가 어구 그룹 섹션 ── */} + {outZoneGearGroups.length > 0 && ( + <> +
toggleSection('outZone')}> + + 비허가 어구 ({outZoneGearGroups.length}개) + + +
+ {sectionExpanded.outZone && ( +
+ {outZoneGearGroups.map(({ name, parent, gears }) => { const isOpen = expandedGearGroup === name; return ( -
+
); })} - - )} -
- )} +
+ )} + + )} +
); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 2c40b73..e608001 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -34,6 +34,7 @@ import type { Ship, Aircraft, SatellitePosition } from '../../types'; import type { OsintItem } from '../../services/osint'; import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis'; import { countryLabelsGeoJSON } from '../../data/countryLabels'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; import 'maplibre-gl/dist/maplibre-gl.css'; export interface KoreaFiltersState { @@ -142,6 +143,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const [selectedFleetData, setSelectedFleetData] = useState(null); const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM); const [staticPickInfo, setStaticPickInfo] = useState(null); + const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false); useEffect(() => { fetchKoreaInfra().then(setInfra).catch(() => {}); @@ -607,14 +609,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {layers.cables && } {layers.cctv && } {/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */} - {koreaFilters.illegalFishing && } + {(koreaFilters.illegalFishing || layers.cnFishing) && } {layers.cnFishing && } {/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */} - {layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && ( + {layers.cnFishing && ( {/* 정적 마커 클릭 Popup — 통합 리치 디자인 */} {staticPickInfo && (() => { @@ -928,6 +930,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF allShips={allShips ?? ships} onShipSelect={handleAnalysisShipSelect} onTrackLoad={handleTrackLoad} + onExpandedChange={setAnalysisPanelOpen} /> )} diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index f1dae97..253599d 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -1,4 +1,5 @@ import { useState, useMemo, useRef } from 'react'; +import { useLocalStorage } from './useLocalStorage'; import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable'; import { getMarineTrafficCategory } from '../utils/marineTraffic'; import { classifyFishingZone } from '../utils/fishingAnalysis'; @@ -43,8 +44,9 @@ export function useKoreaFilters( visibleShips: Ship[], currentTime: number, analysisMap?: Map, + cnFishingOn = false, ): UseKoreaFiltersResult { - const [filters, setFilters] = useState({ + const [filters, setFilters] = useLocalStorage('koreaFilters', { illegalFishing: false, illegalTransship: false, darkVessel: false, @@ -69,7 +71,8 @@ export function useKoreaFilters( filters.darkVessel || filters.cableWatch || filters.dokdoWatch || - filters.ferryWatch; + filters.ferryWatch || + cnFishingOn; // 불법환적 의심 선박 탐지 const transshipSuspects = useMemo(() => { @@ -326,9 +329,14 @@ export function useKoreaFilters( if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true; if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true; if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true; + if (cnFishingOn) { + const isCnFishing = s.flag === 'CN' && getMarineTrafficCategory(s.typecode, s.category) === 'fishing'; + const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || ''); + if (isCnFishing || isGearPattern) return true; + } return false; }); - }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap]); + }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingOn]); return { filters, diff --git a/frontend/src/hooks/useLocalStorage.ts b/frontend/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..a2bdbed --- /dev/null +++ b/frontend/src/hooks/useLocalStorage.ts @@ -0,0 +1,68 @@ +import { useState, useCallback } from 'react'; + +const PREFIX = 'kcg.'; + +/** + * localStorage 연동 useState — JSON 직렬화/역직렬화 자동 처리. + * 새 키가 추가된 Record 타입은 defaults와 자동 머지. + */ +export function useLocalStorage(key: string, defaults: T): [T, (v: T | ((prev: T) => T)) => void] { + const storageKey = PREFIX + key; + + const [value, setValueRaw] = useState(() => { + try { + const raw = localStorage.getItem(storageKey); + if (raw === null) return defaults; + const parsed = JSON.parse(raw) as T; + // Record 타입이면 defaults에 있는 키가 저장값에 없을 때 머지 + if (defaults !== null && typeof defaults === 'object' && !Array.isArray(defaults)) { + return { ...defaults, ...parsed }; + } + return parsed; + } catch { + return defaults; + } + }); + + const setValue = useCallback((updater: T | ((prev: T) => T)) => { + setValueRaw(prev => { + const next = typeof updater === 'function' ? (updater as (prev: T) => T)(prev) : updater; + try { + localStorage.setItem(storageKey, JSON.stringify(next)); + } catch { /* quota exceeded — 무시 */ } + return next; + }); + }, [storageKey]); + + return [value, setValue]; +} + +/** + * Set용 localStorage 연동 — 내부적으로 Array로 직렬화. + */ +export function useLocalStorageSet(key: string, defaults: Set): [Set, (v: Set | ((prev: Set) => Set)) => void] { + const storageKey = PREFIX + key; + + const [value, setValueRaw] = useState>(() => { + try { + const raw = localStorage.getItem(storageKey); + if (raw === null) return defaults; + const arr = JSON.parse(raw); + return Array.isArray(arr) ? new Set(arr) : defaults; + } catch { + return defaults; + } + }); + + const setValue = useCallback((updater: Set | ((prev: Set) => Set)) => { + setValueRaw(prev => { + const next = typeof updater === 'function' ? updater(prev) : updater; + try { + localStorage.setItem(storageKey, JSON.stringify(Array.from(next))); + } catch { /* quota exceeded */ } + return next; + }); + }, [storageKey]); + + return [value, setValue]; +}