From 16ebf3abca93f0836ea25efa049f397c63058b99 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 12:18:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=98=81=EC=86=8D=ED=99=94=20+=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EB=B7=B0=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usePersistedState hook으로 대시보드 상태를 localStorage에 자동 저장. 지도 뷰(중심/줌/방위)도 60초 주기 + 언마운트 시 저장하여 새로고침 복원. Co-Authored-By: Claude Opus 4.6 --- .../web/src/pages/dashboard/DashboardPage.tsx | 52 ++++----- apps/web/src/shared/hooks/index.ts | 1 + .../web/src/shared/hooks/usePersistedState.ts | 103 ++++++++++++++++++ apps/web/src/widgets/map3d/Map3D.tsx | 4 +- .../web/src/widgets/map3d/hooks/useMapInit.ts | 32 +++++- apps/web/src/widgets/map3d/types.ts | 9 ++ 6 files changed, 166 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/shared/hooks/index.ts create mode 100644 apps/web/src/shared/hooks/usePersistedState.ts diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index dc4e1dc..e85426f 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { usePersistedState } from "../../shared/hooks"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles"; import type { MapToggleState } from "../../features/mapToggles/MapToggles"; @@ -18,6 +19,7 @@ import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList"; import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel"; import { MapLegend } from "../../widgets/legend/MapLegend"; import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D"; +import type { MapViewState } from "../../widgets/map3d/types"; import { RelationsPanel } from "../../widgets/relations/RelationsPanel"; import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel"; import { Topbar } from "../../widgets/topbar/Topbar"; @@ -96,49 +98,39 @@ export function DashboardPage() { const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState([]); const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); - const [typeEnabled, setTypeEnabled] = useState>({ - PT: true, - "PT-S": true, - GN: true, - OT: true, - PS: true, - FC: true, - }); - const [showTargets, setShowTargets] = useState(true); - const [showOthers, setShowOthers] = useState(false); + const uid = null; + const [typeEnabled, setTypeEnabled] = usePersistedState>( + uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true }, + ); + const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true); + const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false); // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 // eslint-disable-next-line @typescript-eslint/no-unused-vars const [baseMap, _setBaseMap] = useState("enhanced"); - const [projection, setProjection] = useState("mercator"); - const [mapStyleSettings, setMapStyleSettings] = useState(DEFAULT_MAP_STYLE_SETTINGS); + const [projection, setProjection] = usePersistedState(uid, 'projection', "mercator"); + const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); - const [overlays, setOverlays] = useState({ - pairLines: true, - pairRange: true, - fcLines: true, - zones: true, - fleetCircles: true, - predictVectors: true, - shipLabels: true, - subcables: false, + const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { + pairLines: true, pairRange: true, fcLines: true, zones: true, + fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, }); - const [fleetRelationSortMode, setFleetRelationSortMode] = useState("count"); + const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState(uid, 'sortMode', "count"); - const [alarmKindEnabled, setAlarmKindEnabled] = useState>(() => { - return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record; - }); + const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState>( + uid, 'alarmKindEnabled', + () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record, + ); const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); const [hoveredCableId, setHoveredCableId] = useState(null); const [selectedCableId, setSelectedCableId] = useState(null); - const [settings, setSettings] = useState({ - showShips: true, - showDensity: false, - showSeamark: false, + const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { + showShips: true, showDensity: false, showSeamark: false, }); + const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); const [isProjectionLoading, setIsProjectionLoading] = useState(false); @@ -722,6 +714,8 @@ export function DashboardPage() { onHoverCable={setHoveredCableId} onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} mapStyleSettings={mapStyleSettings} + initialView={mapView} + onViewStateChange={setMapView} /> diff --git a/apps/web/src/shared/hooks/index.ts b/apps/web/src/shared/hooks/index.ts new file mode 100644 index 0000000..2f9fca0 --- /dev/null +++ b/apps/web/src/shared/hooks/index.ts @@ -0,0 +1 @@ +export { usePersistedState } from './usePersistedState'; diff --git a/apps/web/src/shared/hooks/usePersistedState.ts b/apps/web/src/shared/hooks/usePersistedState.ts new file mode 100644 index 0000000..d250bad --- /dev/null +++ b/apps/web/src/shared/hooks/usePersistedState.ts @@ -0,0 +1,103 @@ +import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'; + +const PREFIX = 'wing'; + +function buildKey(userId: number, name: string): string { + return `${PREFIX}:${userId}:${name}`; +} + +function readStorage(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + if (raw === null) return fallback; + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function writeStorage(key: string, value: T): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // quota exceeded or unavailable — silent + } +} + +function resolveDefault(d: T | (() => T)): T { + return typeof d === 'function' ? (d as () => T)() : d; +} + +/** + * useState와 동일한 API, localStorage 자동 동기화. + * + * @param userId null이면 일반 useState처럼 동작 (비영속) + * @param name 설정 이름 (e.g. 'typeEnabled') + * @param defaultValue 초기값 또는 lazy initializer + * @param debounceMs localStorage 쓰기 디바운스 (기본 300ms) + */ +export function usePersistedState( + userId: number | null, + name: string, + defaultValue: T | (() => T), + debounceMs = 300, +): [T, Dispatch>] { + const resolved = resolveDefault(defaultValue); + + const [state, setState] = useState(() => { + if (userId == null) return resolved; + return readStorage(buildKey(userId, name), resolved); + }); + + const timerRef = useRef | null>(null); + const stateRef = useRef(state); + const userIdRef = useRef(userId); + const nameRef = useRef(name); + + stateRef.current = state; + userIdRef.current = userId; + nameRef.current = name; + + // userId 변경 시 해당 사용자의 저장값 재로드 + useEffect(() => { + if (userId == null) return; + const stored = readStorage(buildKey(userId, name), resolved); + setState(stored); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]); + + // debounced write + useEffect(() => { + if (userId == null) return; + const key = buildKey(userId, name); + + if (timerRef.current != null) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + writeStorage(key, state); + timerRef.current = null; + }, debounceMs); + + return () => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [state, userId, name, debounceMs]); + + // unmount 시 pending write flush + useEffect(() => { + return () => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (userIdRef.current != null) { + writeStorage(buildKey(userIdRef.current, nameRef.current), stateRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return [state, setState]; +} diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index c3a534f..ec2abef 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -66,6 +66,8 @@ export function Map3D({ onHoverCable, onClickCable, mapStyleSettings, + initialView, + onViewStateChange, }: Props) { void onHoverFleet; void onClearFleetHover; @@ -437,7 +439,7 @@ export function Map3D({ const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit( containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, baseMapRef, projectionRef, - { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch }, + { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange }, ); const reorderGlobeFeatureLayers = useProjectionToggle( diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 26fb2de..243ef55 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, t import maplibregl, { type StyleSpecification } from 'maplibre-gl'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; -import type { BaseMapId, MapProjectionId } from '../types'; +import type { BaseMapId, MapProjectionId, MapViewState } from '../types'; import { DECK_VIEW_ID } from '../constants'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { ensureSeamarkOverlay } from '../layers/seamark'; @@ -23,10 +23,14 @@ export function useMapInit( showSeamark: boolean; onViewBboxChange?: (bbox: [number, number, number, number]) => void; setMapSyncEpoch: Dispatch>; + initialView?: MapViewState | null; + onViewStateChange?: (view: MapViewState) => void; }, ) { const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts; const showSeamarkRef = useRef(showSeamark); + const onViewStateChangeRef = useRef(opts.onViewStateChange); + useEffect(() => { onViewStateChangeRef.current = opts.onViewStateChange; }, [opts.onViewStateChange]); useEffect(() => { showSeamarkRef.current = showSeamark; }, [showSeamark]); @@ -65,6 +69,7 @@ export function useMapInit( let map: maplibregl.Map | null = null; let cancelled = false; + let viewSaveTimer: ReturnType | null = null; const controller = new AbortController(); (async () => { @@ -77,13 +82,14 @@ export function useMapInit( } if (cancelled || !containerRef.current) return; + const iv = opts.initialView; map = new maplibregl.Map({ container: containerRef.current, style, - center: [126.5, 34.2], - zoom: 7, - pitch: 45, - bearing: 0, + center: iv?.center ?? [126.5, 34.2], + zoom: iv?.zoom ?? 7, + pitch: iv?.pitch ?? 45, + bearing: iv?.bearing ?? 0, maxPitch: 85, dragRotate: true, pitchWithRotate: true, @@ -147,6 +153,14 @@ export function useMapInit( map.on('load', emitBbox); map.on('moveend', emitBbox); + // 60초 인터벌로 뷰 상태 저장 + viewSaveTimer = setInterval(() => { + const cb = onViewStateChangeRef.current; + if (!cb || !map) return; + const c = map.getCenter(); + cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); + }, 60_000); + map.once('load', () => { if (showSeamarkRef.current) { try { @@ -167,6 +181,14 @@ export function useMapInit( return () => { cancelled = true; controller.abort(); + if (viewSaveTimer) clearInterval(viewSaveTimer); + + // 최종 뷰 상태 저장 + const cb = onViewStateChangeRef.current; + if (cb && map) { + const c = map.getCenter(); + cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); + } try { globeDeckLayerRef.current?.requestFinalize(); diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 16d1d1f..4429e2f 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -15,6 +15,13 @@ export type Map3DSettings = { export type BaseMapId = 'enhanced' | 'legacy'; export type MapProjectionId = 'mercator' | 'globe'; +export interface MapViewState { + center: [number, number]; // [lon, lat] + zoom: number; + bearing: number; + pitch: number; +} + export interface Map3DProps { targets: AisTarget[]; zones: ZonesGeoJson | null; @@ -52,6 +59,8 @@ export interface Map3DProps { onHoverCable?: (cableId: string | null) => void; onClickCable?: (cableId: string | null) => void; mapStyleSettings?: MapStyleSettings; + initialView?: MapViewState | null; + onViewStateChange?: (view: MapViewState) => void; } export type DashSeg = {