feat: 사용자 설정 영속화 + 지도 뷰 저장
usePersistedState hook으로 대시보드 상태를 localStorage에 자동 저장. 지도 뷰(중심/줌/방위)도 60초 주기 + 언마운트 시 저장하여 새로고침 복원. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
bd3b3f9a8c
커밋
16ebf3abca
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { usePersistedState } from "../../shared/hooks";
|
||||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||||
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
|
||||||
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
|
||||||
@ -18,6 +19,7 @@ import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
|
|||||||
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
|
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
|
||||||
import { MapLegend } from "../../widgets/legend/MapLegend";
|
import { MapLegend } from "../../widgets/legend/MapLegend";
|
||||||
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
|
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 { RelationsPanel } from "../../widgets/relations/RelationsPanel";
|
||||||
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
|
||||||
import { Topbar } from "../../widgets/topbar/Topbar";
|
import { Topbar } from "../../widgets/topbar/Topbar";
|
||||||
@ -96,49 +98,39 @@ export function DashboardPage() {
|
|||||||
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
||||||
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
||||||
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
||||||
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
|
const uid = null;
|
||||||
PT: true,
|
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
|
||||||
"PT-S": true,
|
uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true },
|
||||||
GN: true,
|
);
|
||||||
OT: true,
|
const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true);
|
||||||
PS: true,
|
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false);
|
||||||
FC: true,
|
|
||||||
});
|
|
||||||
const [showTargets, setShowTargets] = useState(true);
|
|
||||||
const [showOthers, setShowOthers] = useState(false);
|
|
||||||
|
|
||||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
|
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
|
||||||
const [projection, setProjection] = useState<MapProjectionId>("mercator");
|
const [projection, setProjection] = usePersistedState<MapProjectionId>(uid, 'projection', "mercator");
|
||||||
const [mapStyleSettings, setMapStyleSettings] = useState<MapStyleSettings>(DEFAULT_MAP_STYLE_SETTINGS);
|
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
|
||||||
|
|
||||||
const [overlays, setOverlays] = useState<MapToggleState>({
|
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
|
||||||
pairLines: true,
|
pairLines: true, pairRange: true, fcLines: true, zones: true,
|
||||||
pairRange: true,
|
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
|
||||||
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>>(() => {
|
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
|
||||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as 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 [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
|
||||||
|
|
||||||
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [settings, setSettings] = useState<Map3DSettings>({
|
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
||||||
showShips: true,
|
showShips: true, showDensity: false, showSeamark: false,
|
||||||
showDensity: false,
|
|
||||||
showSeamark: false,
|
|
||||||
});
|
});
|
||||||
|
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||||
|
|
||||||
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
||||||
|
|
||||||
@ -722,6 +714,8 @@ export function DashboardPage() {
|
|||||||
onHoverCable={setHoveredCableId}
|
onHoverCable={setHoveredCableId}
|
||||||
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
|
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
|
||||||
mapStyleSettings={mapStyleSettings}
|
mapStyleSettings={mapStyleSettings}
|
||||||
|
initialView={mapView}
|
||||||
|
onViewStateChange={setMapView}
|
||||||
/>
|
/>
|
||||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||||
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
<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,
|
onHoverCable,
|
||||||
onClickCable,
|
onClickCable,
|
||||||
mapStyleSettings,
|
mapStyleSettings,
|
||||||
|
initialView,
|
||||||
|
onViewStateChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
void onHoverFleet;
|
void onHoverFleet;
|
||||||
void onClearFleetHover;
|
void onClearFleetHover;
|
||||||
@ -437,7 +439,7 @@ export function Map3D({
|
|||||||
const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit(
|
const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit(
|
||||||
containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef,
|
containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef,
|
||||||
baseMapRef, projectionRef,
|
baseMapRef, projectionRef,
|
||||||
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch },
|
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange },
|
||||||
);
|
);
|
||||||
|
|
||||||
const reorderGlobeFeatureLayers = useProjectionToggle(
|
const reorderGlobeFeatureLayers = useProjectionToggle(
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, t
|
|||||||
import maplibregl, { type StyleSpecification } from 'maplibre-gl';
|
import maplibregl, { type StyleSpecification } from 'maplibre-gl';
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
||||||
import type { BaseMapId, MapProjectionId } from '../types';
|
import type { BaseMapId, MapProjectionId, MapViewState } from '../types';
|
||||||
import { DECK_VIEW_ID } from '../constants';
|
import { DECK_VIEW_ID } from '../constants';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
import { ensureSeamarkOverlay } from '../layers/seamark';
|
import { ensureSeamarkOverlay } from '../layers/seamark';
|
||||||
@ -23,10 +23,14 @@ export function useMapInit(
|
|||||||
showSeamark: boolean;
|
showSeamark: boolean;
|
||||||
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
||||||
setMapSyncEpoch: Dispatch<SetStateAction<number>>;
|
setMapSyncEpoch: Dispatch<SetStateAction<number>>;
|
||||||
|
initialView?: MapViewState | null;
|
||||||
|
onViewStateChange?: (view: MapViewState) => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts;
|
const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts;
|
||||||
const showSeamarkRef = useRef(showSeamark);
|
const showSeamarkRef = useRef(showSeamark);
|
||||||
|
const onViewStateChangeRef = useRef(opts.onViewStateChange);
|
||||||
|
useEffect(() => { onViewStateChangeRef.current = opts.onViewStateChange; }, [opts.onViewStateChange]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showSeamarkRef.current = showSeamark;
|
showSeamarkRef.current = showSeamark;
|
||||||
}, [showSeamark]);
|
}, [showSeamark]);
|
||||||
@ -65,6 +69,7 @@ export function useMapInit(
|
|||||||
|
|
||||||
let map: maplibregl.Map | null = null;
|
let map: maplibregl.Map | null = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let viewSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -77,13 +82,14 @@ export function useMapInit(
|
|||||||
}
|
}
|
||||||
if (cancelled || !containerRef.current) return;
|
if (cancelled || !containerRef.current) return;
|
||||||
|
|
||||||
|
const iv = opts.initialView;
|
||||||
map = new maplibregl.Map({
|
map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style,
|
style,
|
||||||
center: [126.5, 34.2],
|
center: iv?.center ?? [126.5, 34.2],
|
||||||
zoom: 7,
|
zoom: iv?.zoom ?? 7,
|
||||||
pitch: 45,
|
pitch: iv?.pitch ?? 45,
|
||||||
bearing: 0,
|
bearing: iv?.bearing ?? 0,
|
||||||
maxPitch: 85,
|
maxPitch: 85,
|
||||||
dragRotate: true,
|
dragRotate: true,
|
||||||
pitchWithRotate: true,
|
pitchWithRotate: true,
|
||||||
@ -147,6 +153,14 @@ export function useMapInit(
|
|||||||
map.on('load', emitBbox);
|
map.on('load', emitBbox);
|
||||||
map.on('moveend', 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', () => {
|
map.once('load', () => {
|
||||||
if (showSeamarkRef.current) {
|
if (showSeamarkRef.current) {
|
||||||
try {
|
try {
|
||||||
@ -167,6 +181,14 @@ export function useMapInit(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
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 {
|
try {
|
||||||
globeDeckLayerRef.current?.requestFinalize();
|
globeDeckLayerRef.current?.requestFinalize();
|
||||||
|
|||||||
@ -15,6 +15,13 @@ export type Map3DSettings = {
|
|||||||
export type BaseMapId = 'enhanced' | 'legacy';
|
export type BaseMapId = 'enhanced' | 'legacy';
|
||||||
export type MapProjectionId = 'mercator' | 'globe';
|
export type MapProjectionId = 'mercator' | 'globe';
|
||||||
|
|
||||||
|
export interface MapViewState {
|
||||||
|
center: [number, number]; // [lon, lat]
|
||||||
|
zoom: number;
|
||||||
|
bearing: number;
|
||||||
|
pitch: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Map3DProps {
|
export interface Map3DProps {
|
||||||
targets: AisTarget[];
|
targets: AisTarget[];
|
||||||
zones: ZonesGeoJson | null;
|
zones: ZonesGeoJson | null;
|
||||||
@ -52,6 +59,8 @@ export interface Map3DProps {
|
|||||||
onHoverCable?: (cableId: string | null) => void;
|
onHoverCable?: (cableId: string | null) => void;
|
||||||
onClickCable?: (cableId: string | null) => void;
|
onClickCable?: (cableId: string | null) => void;
|
||||||
mapStyleSettings?: MapStyleSettings;
|
mapStyleSettings?: MapStyleSettings;
|
||||||
|
initialView?: MapViewState | null;
|
||||||
|
onViewStateChange?: (view: MapViewState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashSeg = {
|
export type DashSeg = {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user