feat: localStorage 기반 레이어/필터 상태 영속화

- useLocalStorage / useLocalStorageSet 훅 추가
- 영속 대상:
  - dashboardTab, mapMode, iranLayers, koreaLayers
  - koreaFilters, hiddenAcCategories, hiddenShipCategories
  - hiddenNationalities, hiddenFishingNats
  - layerPanelExpanded, analysisPanelExpanded, analysisPanelOpen
- Record 타입은 새 키 추가 시 defaults와 자동 머지
- AI 분석 패널 위치: top:10, right:50 (줌 버튼 간격)
- AI 분석 닫힘 시 위험도 마커 off, 열림 시 on
This commit is contained in:
htlee 2026-03-23 09:22:23 +09:00
부모 98f3b6a59c
커밋 6305fd3c26
6개의 변경된 파일108개의 추가작업 그리고 24개의 파일을 삭제

파일 보기

@ -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<AppMode>('live');
const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite');
const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran');
const [layers, setLayers] = useState<LayerVisibility>({
const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite');
const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran');
const [layers, setLayers] = useLocalStorage<LayerVisibility>('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<Record<string, boolean>>({
const [koreaLayers, setKoreaLayers] = useLocalStorage<Record<string, boolean>>('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<Set<string>>(new Set());
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(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<Set<string>>(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<Set<string>>(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<FlyToTarget | null>(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) => {

파일 보기

@ -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<string, string> = {
@ -172,7 +173,7 @@ export function LayerPanel({
onFishingNatToggle,
}: LayerPanelProps) {
const { t } = useTranslation(['common', 'ships']);
const [expanded, setExpanded] = useState<Set<string>>(new Set(['ships']));
const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships']));
const [legendOpen, setLegendOpen] = useState<Set<string>>(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 => {

파일 보기

@ -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<RiskLevel | null>(null);
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(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,
</button>
<button
style={toggleButtonStyle}
onClick={() => setExpanded(prev => !prev)}
onClick={toggleExpanded}
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
>
{expanded ? '▲' : '▼'}

파일 보기

@ -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<SelectedFleetData | null>(null);
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
@ -638,7 +640,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...analysisDeckLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean)} />
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
{staticPickInfo && (() => {
@ -928,6 +930,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
allShips={allShips ?? ships}
onShipSelect={handleAnalysisShipSelect}
onTrackLoad={handleTrackLoad}
onExpandedChange={setAnalysisPanelOpen}
/>
)}
</Map>

파일 보기

@ -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';
@ -45,7 +46,7 @@ export function useKoreaFilters(
analysisMap?: Map<string, VesselAnalysisDto>,
cnFishingOn = false,
): UseKoreaFiltersResult {
const [filters, setFilters] = useState<KoreaFilters>({
const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', {
illegalFishing: false,
illegalTransship: false,
darkVessel: false,

파일 보기

@ -0,0 +1,68 @@
import { useState, useCallback } from 'react';
const PREFIX = 'kcg.';
/**
* localStorage useState JSON / .
* Record defaults와 .
*/
export function useLocalStorage<T>(key: string, defaults: T): [T, (v: T | ((prev: T) => T)) => void] {
const storageKey = PREFIX + key;
const [value, setValueRaw] = useState<T>(() => {
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<string> localStorage Array로 .
*/
export function useLocalStorageSet(key: string, defaults: Set<string>): [Set<string>, (v: Set<string> | ((prev: Set<string>) => Set<string>)) => void] {
const storageKey = PREFIX + key;
const [value, setValueRaw] = useState<Set<string>>(() => {
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<string> | ((prev: Set<string>) => Set<string>)) => {
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];
}