From 6305fd3c2665b0942d9229bb3a05e61aeb45b815 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 09:22:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20localStorage=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4/=ED=95=84=ED=84=B0=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=98=81=EC=86=8D=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useLocalStorage / useLocalStorageSet 훅 추가 - 영속 대상: - dashboardTab, mapMode, iranLayers, koreaLayers - koreaFilters, hiddenAcCategories, hiddenShipCategories - hiddenNationalities, hiddenFishingNats - layerPanelExpanded, analysisPanelExpanded, analysisPanelOpen - Record 타입은 새 키 추가 시 defaults와 자동 머지 - AI 분석 패널 위치: top:10, right:50 (줌 버튼 간격) - AI 분석 닫힘 시 위험도 마커 off, 열림 시 on --- frontend/src/App.tsx | 29 ++++---- frontend/src/components/common/LayerPanel.tsx | 5 +- .../components/korea/AnalysisStatsPanel.tsx | 22 ++++-- frontend/src/components/korea/KoreaMap.tsx | 5 +- frontend/src/hooks/useKoreaFilters.ts | 3 +- frontend/src/hooks/useLocalStorage.ts | 68 +++++++++++++++++++ 6 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 frontend/src/hooks/useLocalStorage.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index df06edf..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); @@ -243,7 +244,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { 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,