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
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;
}