feat(map): 자유 시점 토글 추가 (모드별 독립 상태)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-20 11:45:28 +09:00
부모 51ba126086
커밋 7ae867fe35
7개의 변경된 파일57개의 추가작업 그리고 13개의 파일을 삭제

파일 보기

@ -411,6 +411,7 @@ export function DashboardPage() {
onMapReady={handleMapReady} onMapReady={handleMapReady}
alarmMmsiMap={alarmMmsiMap} alarmMmsiMap={alarmMmsiMap}
onClickShipPhoto={handleOpenImageModal} onClickShipPhoto={handleOpenImageModal}
freeCamera={state.freeCamera}
/> />
<GlobalTrackReplayPanel /> <GlobalTrackReplayPanel />
<WeatherPanel <WeatherPanel

파일 보기

@ -80,6 +80,7 @@ export function DashboardSidebar({
typeEnabled, setTypeEnabled, typeEnabled, setTypeEnabled,
overlays, setOverlays, overlays, setOverlays,
projection, setProjection, isProjectionToggleDisabled, projection, setProjection, isProjectionToggleDisabled,
freeCamera, toggleFreeCamera,
selectedMmsi, setSelectedMmsi, selectedMmsi, setSelectedMmsi,
fleetRelationSortMode, setFleetRelationSortMode, fleetRelationSortMode, setFleetRelationSortMode,
hoveredFleetOwnerKey, hoveredFleetMmsiSet, hoveredFleetOwnerKey, hoveredFleetMmsiSet,
@ -158,14 +159,24 @@ export function DashboardSidebar({
title="지도 표시 설정" title="지도 표시 설정"
className="md:shrink-0" className="md:shrink-0"
actions={ actions={
<ToggleButton <div className="flex gap-1">
on={projection === 'globe'} <ToggleButton
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))} on={freeCamera}
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영'} onClick={toggleFreeCamera}
className={`px-2 py-0.5 text-[9px]${isProjectionToggleDisabled ? " opacity-40 cursor-not-allowed" : ""}`} title="자유 시점 모드 (회전/틸트 허용)"
> className="px-2 py-0.5 text-[9px]"
3D >
</ToggleButton>
</ToggleButton>
<ToggleButton
on={projection === 'globe'}
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영'}
className={`px-2 py-0.5 text-[9px]${isProjectionToggleDisabled ? " opacity-40 cursor-not-allowed" : ""}`}
>
3D
</ToggleButton>
</div>
} }
> >
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} /> <MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />

파일 보기

@ -54,6 +54,15 @@ export function useDashboardState(uid: number | null) {
}); });
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null); const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
// ── 자유 시점 (모드별 독립) ──
const [freeCameraMercator, setFreeCameraMercator] = usePersistedState(uid, 'freeCameraMercator', true);
const [freeCameraGlobe, setFreeCameraGlobe] = usePersistedState(uid, 'freeCameraGlobe', true);
const freeCamera = projection === 'globe' ? freeCameraGlobe : freeCameraMercator;
const toggleFreeCamera = useCallback(() => {
if (projection === 'globe') setFreeCameraGlobe((v) => !v);
else setFreeCameraMercator((v) => !v);
}, [projection, setFreeCameraGlobe, setFreeCameraMercator]);
// ── Sort & alarm filters (persisted) ── // ── Sort & alarm filters (persisted) ──
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count'); const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>( const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
@ -132,7 +141,7 @@ export function useDashboardState(uid: number | null) {
baseMap, projection, setProjection, baseMap, projection, setProjection,
mapStyleSettings, setMapStyleSettings, mapStyleSettings, setMapStyleSettings,
overlays, setOverlays, settings, setSettings, overlays, setOverlays, settings, setSettings,
mapView, setMapView, mapView, setMapView, freeCamera, toggleFreeCamera,
fleetRelationSortMode, setFleetRelationSortMode, fleetRelationSortMode, setFleetRelationSortMode,
alarmKindEnabled, setAlarmKindEnabled, alarmKindEnabled, setAlarmKindEnabled,
fleetFocus, setFleetFocus, fleetFocus, setFleetFocus,

파일 보기

@ -83,6 +83,7 @@ export function Map3D({
onMapReady, onMapReady,
alarmMmsiMap, alarmMmsiMap,
onClickShipPhoto, onClickShipPhoto,
freeCamera = true,
}: Props) { }: Props) {
// ── Shared refs ────────────────────────────────────────────────────── // ── Shared refs ──────────────────────────────────────────────────────
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -549,6 +550,25 @@ export function Map3D({
{ projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
); );
// freeCamera 토글에 따라 회전/틸트 제어 (모드별 독립 상태)
// mapSyncEpoch: 맵 비동기 생성 후 최초 적용을 위해 의존
useEffect(() => {
const map = mapRef.current;
if (!map) return;
try {
if (freeCamera) {
map.dragRotate.enable();
map.touchPitch.enable();
} else {
map.dragRotate.disable();
map.touchPitch.disable();
if (!projectionBusyRef.current && (map.getPitch() !== 0 || map.getBearing() !== 0)) {
map.easeTo({ pitch: 0, bearing: 0, duration: 300 });
}
}
} catch { /* ignore */ }
}, [freeCamera, projection, mapSyncEpoch]);
useBaseMapToggle( useBaseMapToggle(
mapRef, mapRef,
{ baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync }, { baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync },

파일 보기

@ -81,12 +81,12 @@ export function useMapInit(
style, style,
center: iv?.center ?? [126.5, 34.2], center: iv?.center ?? [126.5, 34.2],
zoom: iv?.zoom ?? 7, zoom: iv?.zoom ?? 7,
pitch: iv?.pitch ?? 45, pitch: 0,
bearing: iv?.bearing ?? 0, bearing: 0,
maxPitch: 85, maxPitch: 85,
dragRotate: true, dragRotate: false,
pitchWithRotate: true, pitchWithRotate: true,
touchPitch: true, touchPitch: false,
scrollZoom: { around: 'center' }, scrollZoom: { around: 'center' },
}); });

파일 보기

@ -238,6 +238,7 @@ export function useProjectionToggle(
map.setRenderWorldCopies(next !== 'globe'); map.setRenderWorldCopies(next !== 'globe');
// Globe에서는 easeTo around 미지원 → scrollZoom 동작 전환 // Globe에서는 easeTo around 미지원 → scrollZoom 동작 전환
// dragRotate/touchPitch는 Map3D의 freeCamera effect에서 제어
try { try {
map.scrollZoom.disable(); map.scrollZoom.disable();
if (next === 'globe') { if (next === 'globe') {

파일 보기

@ -73,6 +73,8 @@ export interface Map3DProps {
alarmMmsiMap?: Map<number, LegacyAlarmKind>; alarmMmsiMap?: Map<number, LegacyAlarmKind>;
/** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */ /** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */
onClickShipPhoto?: (mmsi: number) => void; onClickShipPhoto?: (mmsi: number) => void;
/** 자유 시점 모드 (회전/틸트 허용) */
freeCamera?: boolean;
} }
export type DashSeg = { export type DashSeg = {