perf(map): Globe/Mercator 양방향 동시 렌더링

- overlay 파괴/재생성 대신 layers 비움으로 전환
- globe ship 레이어 visibility 즉시 토글 (projectionBusy 우회)
- fleet circles fill vertex 초과 수정 (steps 72→36/24)
- globe scrollZoom easing 경고 수정
- projection 비영속화 (항상 mercator 시작)
- globe 레이어 준비 전까지 3D 토글 비활성화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-16 13:08:54 +09:00
부모 4cf0f20504
커밋 91df90b528
7개의 변경된 파일100개의 추가작업 그리고 148개의 파일을 삭제

파일 보기

@ -107,7 +107,8 @@ export function DashboardPage() {
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
// 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] = usePersistedState<MapProjectionId>(uid, 'projection', "mercator"); // 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환
const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', { const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
@ -132,12 +133,14 @@ export function DashboardPage() {
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null); const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
const [isProjectionLoading, setIsProjectionLoading] = useState(false); const [isProjectionLoading, setIsProjectionLoading] = useState(false);
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(true); // 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
const handleProjectionLoadingChange = useCallback((loading: boolean) => { const handleProjectionLoadingChange = useCallback((loading: boolean) => {
setIsProjectionLoading(loading); 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())); const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
useEffect(() => { useEffect(() => {
@ -354,10 +357,10 @@ export function DashboardPage() {
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<div <div
className={`tog-btn ${projection === "globe" ? "on" : ""}`} className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`}
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))} onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소" title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"}
style={{ fontSize: 9, padding: "2px 8px" }} style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }}
> >
3D 3D
</div> </div>

파일 보기

@ -437,7 +437,7 @@ export function Map3D({
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]);
// ── Hook orchestration ─────────────────────────────────────────────── // ── Hook orchestration ───────────────────────────────────────────────
const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit( const { ensureMercatorOverlay, pulseMapSync } = useMapInit(
containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef,
baseMapRef, projectionRef, baseMapRef, projectionRef,
{ baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange }, { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch, initialView, onViewStateChange },
@ -445,7 +445,7 @@ export function Map3D({
const reorderGlobeFeatureLayers = useProjectionToggle( const reorderGlobeFeatureLayers = useProjectionToggle(
mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef,
{ projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch },
); );
useBaseMapToggle( useBaseMapToggle(

파일 보기

@ -304,10 +304,13 @@ export function useGlobeOverlays(
}), }),
}; };
// fill 폴리곤은 globe 테셀레이션으로 vertex 수가 급격히 증가하므로
// 스텝 수를 줄이고 (24), 반경을 500nm으로 상한 설정
const MAX_FILL_RADIUS_M = 500 * 1852;
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = { const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
type: 'FeatureCollection', type: 'FeatureCollection',
features: (fleetCircles || []).map((c) => { 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 { return {
type: 'Feature', type: 'Feature',
id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`,

파일 보기

@ -272,44 +272,49 @@ export function useGlobeShips(
const symbolId = 'ships-globe'; const symbolId = 'ships-globe';
const labelId = 'ships-globe-label'; const labelId = 'ships-globe-label';
const remove = () => { // 레이어를 제거하지 않고 visibility만 'none'으로 설정
const hide = () => {
for (const id of [labelId, symbolId, outlineId, haloId]) { for (const id of [labelId, symbolId, outlineId, haloId]) {
try { try {
if (map.getLayer(id)) map.removeLayer(id); if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none');
} catch { } catch { /* ignore */ }
// ignore
}
} }
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
globeHoverShipSignatureRef.current = '';
reorderGlobeFeatureLayers();
kickRepaint(map);
}; };
// 이미지 보장: useMapInit에서 미리 로드됨 → 대부분 즉시 반환
// 미리 로드되지 않았다면 fallback canvas 아이콘 사용
const ensureImage = () => { const ensureImage = () => {
ensureFallbackShipImage(map, imgId); ensureFallbackShipImage(map, imgId);
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
// useMapInit에서 pre-load가 아직 완료되지 않은 경우 fallback으로 진행
kickRepaint(map); kickRepaint(map);
}; };
const ensure = () => { const ensure = () => {
if (projectionBusyRef.current) return; if (!settings.showShips) {
if (!map.isStyleLoaded()) return; hide();
if (projection !== 'globe' || !settings.showShips) {
remove();
onGlobeShipsReady?.(false); onGlobeShipsReady?.(false);
return; 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) { if (globeShipsEpochRef.current !== mapSyncEpoch) {
globeShipsEpochRef.current = mapSyncEpoch; globeShipsEpochRef.current = mapSyncEpoch;
} }
@ -332,7 +337,6 @@ export function useGlobeShips(
return; return;
} }
const visibility = settings.showShips ? 'visible' : 'none';
const before = undefined; const before = undefined;
if (!map.getLayer(haloId)) { if (!map.getLayer(haloId)) {
@ -558,7 +562,6 @@ export function useGlobeShips(
} }
} }
const labelVisibility = overlays.shipLabels ? 'visible' : 'none';
const labelFilter = [ const labelFilter = [
'all', 'all',
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
@ -618,9 +621,12 @@ export function useGlobeShips(
} }
} }
reorderGlobeFeatureLayers(); // 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
kickRepaint(map);
onGlobeShipsReady?.(true); onGlobeShipsReady?.(true);
if (projection === 'globe') {
reorderGlobeFeatureLayers();
}
kickRepaint(map);
}; };
const stop = onMapStyleReady(map, ensure); const stop = onMapStyleReady(map, ensure);
@ -650,22 +656,12 @@ export function useGlobeShips(
const outlineId = 'ships-globe-hover-outline'; const outlineId = 'ships-globe-hover-outline';
const symbolId = 'ships-globe-hover'; const symbolId = 'ships-globe-hover';
const remove = () => { const hideHover = () => {
for (const id of [symbolId, outlineId, haloId]) { for (const id of [symbolId, outlineId, haloId]) {
try { try {
if (map.getLayer(id)) map.removeLayer(id); if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none');
} catch { } catch { /* ignore */ }
// ignore
}
} }
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
globeHoverShipSignatureRef.current = '';
reorderGlobeFeatureLayers();
kickRepaint(map);
}; };
const ensure = () => { const ensure = () => {
@ -673,7 +669,7 @@ export function useGlobeShips(
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
remove(); hideHover();
return; return;
} }
@ -688,7 +684,7 @@ export function useGlobeShips(
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
if (hovered.length === 0) { if (hovered.length === 0) {
remove(); hideHover();
return; return;
} }
const hoverSignature = hovered const hoverSignature = hovered

파일 보기

@ -7,7 +7,6 @@ import { DECK_VIEW_ID, ANCHORED_SHIP_ICON_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';
import { resolveMapStyle } from '../layers/bathymetry'; import { resolveMapStyle } from '../layers/bathymetry';
import { clearGlobeNativeLayers } from '../lib/layerHelpers';
export function useMapInit( export function useMapInit(
containerRef: MutableRefObject<HTMLDivElement | null>, containerRef: MutableRefObject<HTMLDivElement | null>,
@ -50,12 +49,6 @@ export function useMapInit(
} }
}, []); }, []);
const clearGlobeNativeLayersCb = useCallback(() => {
const map = mapRef.current;
if (!map) return;
clearGlobeNativeLayers(map);
}, []);
const pulseMapSync = useCallback(() => { const pulseMapSync = useCallback(() => {
setMapSyncEpoch((prev) => prev + 1); setMapSyncEpoch((prev) => prev + 1);
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -140,17 +133,13 @@ export function useMapInit(
mapRef.current = map; mapRef.current = map;
if (projectionRef.current === 'mercator') { // 양쪽 overlay를 모두 초기화 — projection 전환 시 재생성 비용 제거
const overlay = ensureMercatorOverlay(); ensureMercatorOverlay();
if (!overlay) return; globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
overlayRef.current = overlay; id: 'deck-globe',
} else { viewId: DECK_VIEW_ID,
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ deckProps: { layers: [] },
id: 'deck-globe', });
viewId: DECK_VIEW_ID,
deckProps: { layers: [] },
});
}
function applyProjection() { function applyProjection() {
if (!map) return; if (!map) return;
@ -166,8 +155,9 @@ export function useMapInit(
onMapStyleReady(map, () => { onMapStyleReady(map, () => {
applyProjection(); applyProjection();
// deck-globe를 항상 추가 (projection과 무관)
const deckLayer = globeDeckLayerRef.current; const deckLayer = globeDeckLayerRef.current;
if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) { if (deckLayer && !map!.getLayer(deckLayer.id)) {
try { try {
map!.addLayer(deckLayer); map!.addLayer(deckLayer);
} catch { } catch {
@ -191,10 +181,10 @@ export function useMapInit(
map.on('load', emitBbox); map.on('load', emitBbox);
map.on('moveend', emitBbox); map.on('moveend', emitBbox);
// 60초 인터벌로 뷰 상태 저장 // 60초 인터벌로 뷰 상태 저장 (mercator일 때만)
viewSaveTimer = setInterval(() => { viewSaveTimer = setInterval(() => {
const cb = onViewStateChangeRef.current; const cb = onViewStateChangeRef.current;
if (!cb || !map) return; if (!cb || !map || projectionRef.current !== 'mercator') return;
const c = map.getCenter(); const c = map.getCenter();
cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() });
}, 60_000); }, 60_000);
@ -223,9 +213,9 @@ export function useMapInit(
controller.abort(); controller.abort();
if (viewSaveTimer) clearInterval(viewSaveTimer); if (viewSaveTimer) clearInterval(viewSaveTimer);
// 최종 뷰 상태 저장 // 최종 뷰 상태 저장 (mercator일 때만 — globe 위치는 영속화하지 않음)
const cb = onViewStateChangeRef.current; const cb = onViewStateChangeRef.current;
if (cb && map) { if (cb && map && projectionRef.current === 'mercator') {
const c = map.getCenter(); const c = map.getCenter();
cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync }; return { ensureMercatorOverlay, pulseMapSync };
} }

파일 보기

@ -3,9 +3,7 @@ import type maplibregl 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 { MapProjectionId } from '../types'; import type { MapProjectionId } from '../types';
import { DECK_VIEW_ID } from '../constants';
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore'; import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
import { removeLayerIfExists } from '../lib/layerHelpers';
export function useProjectionToggle( export function useProjectionToggle(
mapRef: MutableRefObject<maplibregl.Map | null>, mapRef: MutableRefObject<maplibregl.Map | null>,
@ -15,14 +13,13 @@ export function useProjectionToggle(
projectionBusyRef: MutableRefObject<boolean>, projectionBusyRef: MutableRefObject<boolean>,
opts: { opts: {
projection: MapProjectionId; projection: MapProjectionId;
clearGlobeNativeLayers: () => void;
ensureMercatorOverlay: () => MapboxOverlay | null; ensureMercatorOverlay: () => MapboxOverlay | null;
onProjectionLoadingChange?: (loading: boolean) => void; onProjectionLoadingChange?: (loading: boolean) => void;
pulseMapSync: () => void; pulseMapSync: () => void;
setMapSyncEpoch: (updater: (prev: number) => number) => void; setMapSyncEpoch: (updater: (prev: number) => number) => void;
}, },
): () => void { ): () => void {
const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts; const { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
const projectionBusyTokenRef = useRef(0); const projectionBusyTokenRef = useRef(0);
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null); const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
@ -71,7 +68,7 @@ export function useProjectionToggle(
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
console.debug('Projection loading fallback timeout reached.'); console.debug('Projection loading fallback timeout reached.');
endProjectionLoading(); endProjectionLoading();
}, 4000); }, 2000);
}, },
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
); );
@ -176,45 +173,14 @@ export function useProjectionToggle(
if (isTransition) setProjectionLoading(true); if (isTransition) setProjectionLoading(true);
const disposeMercatorOverlays = () => { // 파괴하지 않고 레이어만 비움 — 양쪽 파이프라인 항상 유지
const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => { const quietMercatorOverlays = () => {
if (!target) return; try { overlayRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
try { try { overlayInteractionRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
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 disposeGlobeDeckLayer = () => { const quietGlobeDeckLayer = () => {
const current = globeDeckLayerRef.current; try { globeDeckLayerRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
if (!current) return;
removeLayerIfExists(map, current.id);
try {
current.requestFinalize();
} catch {
// ignore
}
globeDeckLayerRef.current = null;
}; };
const syncProjectionAndDeck = () => { const syncProjectionAndDeck = () => {
@ -236,11 +202,9 @@ export function useProjectionToggle(
const shouldSwitchProjection = currentProjection !== next; const shouldSwitchProjection = currentProjection !== next;
if (projection === 'globe') { if (projection === 'globe') {
disposeMercatorOverlays(); quietMercatorOverlays();
clearGlobeNativeLayers();
} else { } else {
disposeGlobeDeckLayer(); quietGlobeDeckLayer();
clearGlobeNativeLayers();
} }
try { try {
@ -248,6 +212,17 @@ export function useProjectionToggle(
map.setProjection({ type: next }); map.setProjection({ type: next });
} }
map.setRenderWorldCopies(next !== 'globe'); 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) { if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) {
retries += 1; retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck()); window.requestAnimationFrame(() => syncProjectionAndDeck());
@ -263,17 +238,9 @@ export function useProjectionToggle(
console.warn('Projection switch failed:', e); console.warn('Projection switch failed:', e);
} }
// 양쪽 overlay가 항상 존재하므로 재생성 불필요
// deck-globe가 map에서 빠져있을 경우에만 재추가
if (projection === 'globe') { 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 layer = globeDeckLayerRef.current;
const layerId = layer?.id; const layerId = layer?.id;
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
@ -282,14 +249,8 @@ export function useProjectionToggle(
} catch { } catch {
// ignore // ignore
} }
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
retries += 1;
window.requestAnimationFrame(() => syncProjectionAndDeck());
return;
}
} }
} else { } else {
disposeGlobeDeckLayer();
ensureMercatorOverlay(); ensureMercatorOverlay();
} }
@ -324,7 +285,7 @@ export function useProjectionToggle(
if (settleCleanup) settleCleanup(); if (settleCleanup) settleCleanup();
if (isTransition) setProjectionLoading(false); if (isTransition) setProjectionLoading(false);
}; };
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]); }, [projection, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
return reorderGlobeFeatureLayers; return reorderGlobeFeatureLayers;
} }

파일 보기

@ -38,20 +38,19 @@ export function destinationPointLngLat(
return [outLon, outLat]; return [outLon, outLat];
} }
export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] { export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 36): [number, number][] {
const [lon0, lat0] = center; // 반경이 지구 둘레의 1/4 (≈10,000km)을 넘으면 클램핑
const latRad = lat0 * DEG2RAD; const r = clampNumber(radiusMeters, 0, EARTH_RADIUS_M * Math.PI * 0.5);
const cosLat = Math.max(1e-6, Math.cos(latRad));
const r = Math.max(0, radiusMeters);
const ring: [number, number][] = []; const ring: [number, number][] = [];
for (let i = 0; i <= steps; i++) { for (let i = 0; i <= steps; i++) {
const a = (i / steps) * Math.PI * 2; const a = (i / steps) * Math.PI * 2;
const dy = r * Math.sin(a); const pt = destinationPointLngLat(center, a * RAD2DEG, r);
const dx = r * Math.cos(a); ring.push(pt);
const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD; }
const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD; // 고리 닫기 보정
ring.push([lon0 + dLon, lat0 + dLat]); if (ring.length > 1) {
ring[ring.length - 1] = ring[0];
} }
return ring; return ring;
} }