Merge branch 'feature/persist-user-state' into develop
This commit is contained in:
커밋
8dc216ae1c
@ -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} />
|
||||
|
||||
1
apps/web/src/shared/hooks/index.ts
Normal file
1
apps/web/src/shared/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { usePersistedState } from './usePersistedState';
|
||||
103
apps/web/src/shared/hooks/usePersistedState.ts
Normal file
103
apps/web/src/shared/hooks/usePersistedState.ts
Normal file
@ -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 = {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user