Merge branch 'feature/persist-user-state' into develop

This commit is contained in:
htlee 2026-02-16 12:18:46 +09:00
커밋 8dc216ae1c
6개의 변경된 파일166개의 추가작업 그리고 35개의 파일을 삭제

파일 보기

@ -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<number[]>([]);
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
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<Record<VesselTypeCode, boolean>>(
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<BaseMapId>("enhanced");
const [projection, setProjection] = useState<MapProjectionId>("mercator");
const [mapStyleSettings, setMapStyleSettings] = useState<MapStyleSettings>(DEFAULT_MAP_STYLE_SETTINGS);
const [projection, setProjection] = usePersistedState<MapProjectionId>(uid, 'projection', "mercator");
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = useState<MapToggleState>({
pairLines: true,
pairRange: true,
fcLines: true,
zones: true,
fleetCircles: true,
predictVectors: true,
shipLabels: true,
subcables: false,
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairRange: true, fcLines: true, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
});
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', "count");
const [alarmKindEnabled, setAlarmKindEnabled] = useState<Record<LegacyAlarmKind, boolean>>(() => {
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>;
});
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
uid, 'alarmKindEnabled',
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
);
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
const [settings, setSettings] = useState<Map3DSettings>({
showShips: true,
showDensity: false,
showSeamark: false,
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false,
});
const [mapView, setMapView] = usePersistedState<MapViewState | null>(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}
/>
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
<DepthLegend depthStops={mapStyleSettings.depthStops} />

파일 보기

@ -0,0 +1 @@
export { usePersistedState } from './usePersistedState';

파일 보기

@ -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<T>(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<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// quota exceeded or unavailable — silent
}
}
function resolveDefault<T>(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<T>(
userId: number | null,
name: string,
defaultValue: T | (() => T),
debounceMs = 300,
): [T, Dispatch<SetStateAction<T>>] {
const resolved = resolveDefault(defaultValue);
const [state, setState] = useState<T>(() => {
if (userId == null) return resolved;
return readStorage(buildKey(userId, name), resolved);
});
const timerRef = useRef<ReturnType<typeof setTimeout> | 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];
}

파일 보기

@ -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(

파일 보기

@ -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<SetStateAction<number>>;
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<typeof setInterval> | 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();

파일 보기

@ -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 = {