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:
부모
4cf0f20504
커밋
91df90b528
@ -107,7 +107,8 @@ export function DashboardPage() {
|
||||
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
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 [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
|
||||
@ -132,12 +133,14 @@ export function DashboardPage() {
|
||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(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() {
|
||||
지도 표시 설정
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
||||
onClick={() => 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
|
||||
</div>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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> = {
|
||||
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`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<HTMLDivElement | null>,
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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<maplibregl.Map | null>,
|
||||
@ -15,14 +13,13 @@ export function useProjectionToggle(
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
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<ReturnType<typeof window.setTimeout> | 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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user