diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 85da83c..311f02d 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -107,7 +107,8 @@ export function DashboardPage() { // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 // eslint-disable-next-line @typescript-eslint/no-unused-vars const [baseMap, _setBaseMap] = useState("enhanced"); - const [projection, setProjection] = usePersistedState(uid, 'projection', "mercator"); + // 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환 + const [projection, setProjection] = useState('mercator'); const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { @@ -132,12 +133,14 @@ export function DashboardPage() { const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); const [isProjectionLoading, setIsProjectionLoading] = useState(false); - const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(true); + // 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화 + const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false); const handleProjectionLoadingChange = useCallback((loading: boolean) => { setIsProjectionLoading(loading); - if (loading) setIsGlobeShipsReady(false); }, []); - const showMapLoader = isProjectionLoading || (projection === "globe" && !isGlobeShipsReady); + const showMapLoader = isProjectionLoading; + // globe 레이어 미준비 또는 전환 중일 때 토글 비활성화 + const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading; const [clock, setClock] = useState(() => fmtDateTimeFull(new Date())); useEffect(() => { @@ -354,10 +357,10 @@ export function DashboardPage() { 지도 표시 설정
setProjection((p) => (p === "globe" ? "mercator" : "globe"))} - title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소" - style={{ fontSize: 9, padding: "2px 8px" }} + className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`} + onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === "globe" ? "mercator" : "globe"))} + title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"} + style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }} > 3D
diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 721b7e6..ae66351 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -437,7 +437,7 @@ export function Map3D({ }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); // ── Hook orchestration ─────────────────────────────────────────────── - const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit( + const { ensureMercatorOverlay, pulseMapSync } = useMapInit( containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, baseMapRef, projectionRef, { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange }, @@ -445,7 +445,7 @@ export function Map3D({ const reorderGlobeFeatureLayers = useProjectionToggle( mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef, - { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, + { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, ); useBaseMapToggle( diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts index dad2779..2803246 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -304,10 +304,13 @@ export function useGlobeOverlays( }), }; + // fill 폴리곤은 globe 테셀레이션으로 vertex 수가 급격히 증가하므로 + // 스텝 수를 줄이고 (24), 반경을 500nm으로 상한 설정 + const MAX_FILL_RADIUS_M = 500 * 1852; const fcFill: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: (fleetCircles || []).map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + const ring = circleRingLngLat(c.center, Math.min(c.radiusNm * 1852, MAX_FILL_RADIUS_M), 24); return { type: 'Feature', id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index 32b8a1d..0b6a08e 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -272,44 +272,49 @@ export function useGlobeShips( const symbolId = 'ships-globe'; const labelId = 'ships-globe-label'; - const remove = () => { + // 레이어를 제거하지 않고 visibility만 'none'으로 설정 + const hide = () => { for (const id of [labelId, symbolId, outlineId, haloId]) { try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } + if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); + } catch { /* ignore */ } } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ''; - reorderGlobeFeatureLayers(); - kickRepaint(map); }; - // 이미지 보장: useMapInit에서 미리 로드됨 → 대부분 즉시 반환 - // 미리 로드되지 않았다면 fallback canvas 아이콘 사용 const ensureImage = () => { ensureFallbackShipImage(map, imgId); ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; - // useMapInit에서 pre-load가 아직 완료되지 않은 경우 fallback으로 진행 kickRepaint(map); }; const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== 'globe' || !settings.showShips) { - remove(); + if (!settings.showShips) { + hide(); onGlobeShipsReady?.(false); return; } + // 빠른 visibility 토글 — projectionBusy 중에도 실행 + // 이미 생성된 레이어의 표시 상태만 즉시 전환하여 projection 전환 체감 속도 개선 + const visibility = projection === 'globe' ? 'visible' : 'none'; + const labelVisibility = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; + if (map.getLayer(symbolId)) { + for (const id of [haloId, outlineId, symbolId]) { + try { map.setLayoutProperty(id, 'visibility', visibility); } catch { /* ignore */ } + } + try { map.setLayoutProperty(labelId, 'visibility', labelVisibility); } catch { /* ignore */ } + if (projection === 'globe') kickRepaint(map); + } + + // 데이터 업데이트는 projectionBusy 중에는 차단 + if (projectionBusyRef.current) { + // 레이어가 이미 존재하면 ready 상태 유지 + if (map.getLayer(symbolId)) onGlobeShipsReady?.(true); + return; + } + if (!map.isStyleLoaded()) return; + if (globeShipsEpochRef.current !== mapSyncEpoch) { globeShipsEpochRef.current = mapSyncEpoch; } @@ -332,7 +337,6 @@ export function useGlobeShips( return; } - const visibility = settings.showShips ? 'visible' : 'none'; const before = undefined; if (!map.getLayer(haloId)) { @@ -558,7 +562,6 @@ export function useGlobeShips( } } - const labelVisibility = overlays.shipLabels ? 'visible' : 'none'; const labelFilter = [ 'all', ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], @@ -618,9 +621,12 @@ export function useGlobeShips( } } - reorderGlobeFeatureLayers(); - kickRepaint(map); + // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용) onGlobeShipsReady?.(true); + if (projection === 'globe') { + reorderGlobeFeatureLayers(); + } + kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); @@ -650,22 +656,12 @@ export function useGlobeShips( const outlineId = 'ships-globe-hover-outline'; const symbolId = 'ships-globe-hover'; - const remove = () => { + const hideHover = () => { for (const id of [symbolId, outlineId, haloId]) { try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } + if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); + } catch { /* ignore */ } } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ''; - reorderGlobeFeatureLayers(); - kickRepaint(map); }; const ensure = () => { @@ -673,7 +669,7 @@ export function useGlobeShips( if (!map.isStyleLoaded()) return; if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { - remove(); + hideHover(); return; } @@ -688,7 +684,7 @@ export function useGlobeShips( const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); if (hovered.length === 0) { - remove(); + hideHover(); return; } const hoverSignature = hovered diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 372eadb..a9a325e 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -7,7 +7,6 @@ import { DECK_VIEW_ID, ANCHORED_SHIP_ICON_ID } from '../constants'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { ensureSeamarkOverlay } from '../layers/seamark'; import { resolveMapStyle } from '../layers/bathymetry'; -import { clearGlobeNativeLayers } from '../lib/layerHelpers'; export function useMapInit( containerRef: MutableRefObject, @@ -50,12 +49,6 @@ export function useMapInit( } }, []); - const clearGlobeNativeLayersCb = useCallback(() => { - const map = mapRef.current; - if (!map) return; - clearGlobeNativeLayers(map); - }, []); - const pulseMapSync = useCallback(() => { setMapSyncEpoch((prev) => prev + 1); requestAnimationFrame(() => { @@ -140,17 +133,13 @@ export function useMapInit( mapRef.current = map; - if (projectionRef.current === 'mercator') { - const overlay = ensureMercatorOverlay(); - if (!overlay) return; - overlayRef.current = overlay; - } else { - globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ - id: 'deck-globe', - viewId: DECK_VIEW_ID, - deckProps: { layers: [] }, - }); - } + // 양쪽 overlay를 모두 초기화 — projection 전환 시 재생성 비용 제거 + ensureMercatorOverlay(); + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: 'deck-globe', + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); function applyProjection() { if (!map) return; @@ -166,8 +155,9 @@ export function useMapInit( onMapStyleReady(map, () => { applyProjection(); + // deck-globe를 항상 추가 (projection과 무관) const deckLayer = globeDeckLayerRef.current; - if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) { + if (deckLayer && !map!.getLayer(deckLayer.id)) { try { map!.addLayer(deckLayer); } catch { @@ -191,10 +181,10 @@ export function useMapInit( map.on('load', emitBbox); map.on('moveend', emitBbox); - // 60초 인터벌로 뷰 상태 저장 + // 60초 인터벌로 뷰 상태 저장 (mercator일 때만) viewSaveTimer = setInterval(() => { const cb = onViewStateChangeRef.current; - if (!cb || !map) return; + if (!cb || !map || projectionRef.current !== 'mercator') return; const c = map.getCenter(); cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); }, 60_000); @@ -223,9 +213,9 @@ export function useMapInit( controller.abort(); if (viewSaveTimer) clearInterval(viewSaveTimer); - // 최종 뷰 상태 저장 + // 최종 뷰 상태 저장 (mercator일 때만 — globe 위치는 영속화하지 않음) const cb = onViewStateChangeRef.current; - if (cb && map) { + if (cb && map && projectionRef.current === 'mercator') { const c = map.getCenter(); cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); } @@ -254,5 +244,5 @@ export function useMapInit( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync }; + return { ensureMercatorOverlay, pulseMapSync }; } diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index 2b92733..a4f0cd3 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -3,9 +3,7 @@ import type maplibregl from 'maplibre-gl'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; import type { MapProjectionId } from '../types'; -import { DECK_VIEW_ID } from '../constants'; import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore'; -import { removeLayerIfExists } from '../lib/layerHelpers'; export function useProjectionToggle( mapRef: MutableRefObject, @@ -15,14 +13,13 @@ export function useProjectionToggle( projectionBusyRef: MutableRefObject, opts: { projection: MapProjectionId; - clearGlobeNativeLayers: () => void; ensureMercatorOverlay: () => MapboxOverlay | null; onProjectionLoadingChange?: (loading: boolean) => void; pulseMapSync: () => void; setMapSyncEpoch: (updater: (prev: number) => number) => void; }, ): () => void { - const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts; + const { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts; const projectionBusyTokenRef = useRef(0); const projectionBusyTimerRef = useRef | null>(null); @@ -71,7 +68,7 @@ export function useProjectionToggle( if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; console.debug('Projection loading fallback timeout reached.'); endProjectionLoading(); - }, 4000); + }, 2000); }, [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], ); @@ -176,45 +173,14 @@ export function useProjectionToggle( if (isTransition) setProjectionLoading(true); - const disposeMercatorOverlays = () => { - const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => { - if (!target) return; - try { - target.setProps({ layers: [] } as never); - } catch { - // ignore - } - try { - map.removeControl(target as never); - } catch { - // ignore - } - try { - target.finalize(); - } catch { - // ignore - } - if (toNull === 'base') { - overlayRef.current = null; - } else { - overlayInteractionRef.current = null; - } - }; - - disposeOne(overlayRef.current, 'base'); - disposeOne(overlayInteractionRef.current, 'interaction'); + // 파괴하지 않고 레이어만 비움 — 양쪽 파이프라인 항상 유지 + const quietMercatorOverlays = () => { + try { overlayRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ } + try { overlayInteractionRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ } }; - const disposeGlobeDeckLayer = () => { - const current = globeDeckLayerRef.current; - if (!current) return; - removeLayerIfExists(map, current.id); - try { - current.requestFinalize(); - } catch { - // ignore - } - globeDeckLayerRef.current = null; + const quietGlobeDeckLayer = () => { + try { globeDeckLayerRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ } }; const syncProjectionAndDeck = () => { @@ -236,11 +202,9 @@ export function useProjectionToggle( const shouldSwitchProjection = currentProjection !== next; if (projection === 'globe') { - disposeMercatorOverlays(); - clearGlobeNativeLayers(); + quietMercatorOverlays(); } else { - disposeGlobeDeckLayer(); - clearGlobeNativeLayers(); + quietGlobeDeckLayer(); } try { @@ -248,6 +212,17 @@ export function useProjectionToggle( map.setProjection({ type: next }); } map.setRenderWorldCopies(next !== 'globe'); + + // Globe에서는 easeTo around 미지원 → scrollZoom 동작 전환 + try { + map.scrollZoom.disable(); + if (next === 'globe') { + map.scrollZoom.enable(); + } else { + map.scrollZoom.enable({ around: 'center' }); + } + } catch { /* ignore */ } + if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) { retries += 1; window.requestAnimationFrame(() => syncProjectionAndDeck()); @@ -263,17 +238,9 @@ export function useProjectionToggle( console.warn('Projection switch failed:', e); } + // 양쪽 overlay가 항상 존재하므로 재생성 불필요 + // deck-globe가 map에서 빠져있을 경우에만 재추가 if (projection === 'globe') { - disposeGlobeDeckLayer(); - - if (!globeDeckLayerRef.current) { - globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ - id: 'deck-globe', - viewId: DECK_VIEW_ID, - deckProps: { layers: [] }, - }); - } - const layer = globeDeckLayerRef.current; const layerId = layer?.id; if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { @@ -282,14 +249,8 @@ export function useProjectionToggle( } catch { // ignore } - if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } } } else { - disposeGlobeDeckLayer(); ensureMercatorOverlay(); } @@ -324,7 +285,7 @@ export function useProjectionToggle( if (settleCleanup) settleCleanup(); if (isTransition) setProjectionLoading(false); }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]); + }, [projection, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]); return reorderGlobeFeatureLayers; } diff --git a/apps/web/src/widgets/map3d/lib/geometry.ts b/apps/web/src/widgets/map3d/lib/geometry.ts index 6e8f5eb..c1c26a6 100644 --- a/apps/web/src/widgets/map3d/lib/geometry.ts +++ b/apps/web/src/widgets/map3d/lib/geometry.ts @@ -38,20 +38,19 @@ export function destinationPointLngLat( return [outLon, outLat]; } -export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] { - const [lon0, lat0] = center; - const latRad = lat0 * DEG2RAD; - const cosLat = Math.max(1e-6, Math.cos(latRad)); - const r = Math.max(0, radiusMeters); +export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 36): [number, number][] { + // 반경이 지구 둘레의 1/4 (≈10,000km)을 넘으면 클램핑 + const r = clampNumber(radiusMeters, 0, EARTH_RADIUS_M * Math.PI * 0.5); const ring: [number, number][] = []; for (let i = 0; i <= steps; i++) { const a = (i / steps) * Math.PI * 2; - const dy = r * Math.sin(a); - const dx = r * Math.cos(a); - const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD; - const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD; - ring.push([lon0 + dLon, lat0 + dLat]); + const pt = destinationPointLngLat(center, a * RAD2DEG, r); + ring.push(pt); + } + // 고리 닫기 보정 + if (ring.length > 1) { + ring[ring.length - 1] = ring[0]; } return ring; }