From b366f38774f8245cd4969f84c60211408df3c816 Mon Sep 17 00:00:00 2001 From: seoJing Date: Tue, 2 Jun 2026 00:59:53 +0900 Subject: [PATCH] perf(map): reduce mobile overlay animation work --- src/features/map/client/MapClient.tsx | 29 +++++++- .../map/model/marker-visual-mode.test.ts | 58 +++++++++++++++ src/features/map/model/marker-visual-mode.ts | 31 ++++++++ src/features/map/ui/MapCanvas.tsx | 74 ++++++++++++++++--- 4 files changed, 180 insertions(+), 12 deletions(-) diff --git a/src/features/map/client/MapClient.tsx b/src/features/map/client/MapClient.tsx index 22f6f1c..bef40ec 100644 --- a/src/features/map/client/MapClient.tsx +++ b/src/features/map/client/MapClient.tsx @@ -43,6 +43,7 @@ import type { TickerEvent } from '@/features/map/model/ticker-adapter'; import { createSwarmTickerAdapter } from '@/features/map/model/swarm-ticker-adapter'; import { decideMarkerVisualMode, + shouldAnimatePersonaDots as shouldAnimatePersonaDotsForBudget, shouldRenderPersonaDots as shouldRenderPersonaDotsForBudget, } from '@/features/map/model/marker-visual-mode'; import { useTheme } from '@/shared/model/use-theme'; @@ -159,6 +160,7 @@ export function MapClient() { void pagerSnap; const [viewportBbox, setViewportBbox] = useState(null); const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM); + const [isCoarsePointer, setIsCoarsePointer] = useState(false); const isMapMarkerDeckOpen = selectedClusterId !== null; const isFeedPagerHidden = isMapMarkerDeckOpen || isPagerHiddenByMarkerDeck; // next-themes 의 resolvedTheme 은 초기 렌더에서 undefined — 이 상태로 MapV3Canvas 가 mount 되면 @@ -187,6 +189,23 @@ export function MapClient() { setTutorialOpen(true); }, []); + useEffect(() => { + if (typeof window === 'undefined') return; + const media = window.matchMedia('(pointer: coarse)'); + const updateCoarsePointer = () => setIsCoarsePointer(media.matches); + updateCoarsePointer(); + if (typeof media.addEventListener === 'function') { + media.addEventListener('change', updateCoarsePointer); + return () => { + media.removeEventListener('change', updateCoarsePointer); + }; + } + media.addListener?.(updateCoarsePointer); + return () => { + media.removeListener?.(updateCoarsePointer); + }; + }, []); + useEffect(() => { if (!isMapMarkerDeckOpen) return; setIsPagerHiddenByMarkerDeck(true); @@ -535,6 +554,13 @@ export function MapClient() { const shouldRenderPersonaDots = shouldRenderPersonaDotsForBudget({ showPersonas, }); + const shouldAnimatePersonaDots = shouldAnimatePersonaDotsForBudget({ + showPersonas, + mapZoom, + viewportMarkerCount, + viewportReady: viewportBbox !== null, + isCoarsePointer, + }); const handleClusterSelect = useCallback( (clusterId: string) => { @@ -623,7 +649,7 @@ export function MapClient() { ), }; @@ -642,6 +668,7 @@ export function MapClient() { swarmSubscribe, swarmPositionsRef, shouldRenderPersonaDots, + shouldAnimatePersonaDots, inViewport, ]); diff --git a/src/features/map/model/marker-visual-mode.test.ts b/src/features/map/model/marker-visual-mode.test.ts index c877150..057030c 100644 --- a/src/features/map/model/marker-visual-mode.test.ts +++ b/src/features/map/model/marker-visual-mode.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + COARSE_POINTER_PERSONA_PULSE_COUNT_THRESHOLD, decideMarkerVisualMode, SIMPLE_MARKER_COUNT_THRESHOLD, SIMPLE_MARKER_ZOOM_THRESHOLD, + shouldAnimatePersonaDots, shouldRenderPersonaDots, } from './marker-visual-mode'; @@ -74,3 +76,59 @@ describe('shouldRenderPersonaDots', () => { expect(shouldRenderPersonaDots({ showPersonas: false })).toBe(false); }); }); + +describe('shouldAnimatePersonaDots', () => { + it('keeps persona pulse enabled at high zoom with a sparse viewport', () => { + expect( + shouldAnimatePersonaDots({ + showPersonas: true, + mapZoom: SIMPLE_MARKER_ZOOM_THRESHOLD + 1, + viewportMarkerCount: SIMPLE_MARKER_COUNT_THRESHOLD - 1, + }), + ).toBe(true); + }); + + it('disables persona pulse when map zoom already simplifies markers', () => { + expect( + shouldAnimatePersonaDots({ + showPersonas: true, + mapZoom: SIMPLE_MARKER_ZOOM_THRESHOLD, + viewportMarkerCount: 1, + }), + ).toBe(false); + }); + + it('disables persona pulse in dense viewports while keeping dots visible', () => { + expect( + shouldAnimatePersonaDots({ + showPersonas: true, + mapZoom: SIMPLE_MARKER_ZOOM_THRESHOLD + 1, + viewportMarkerCount: SIMPLE_MARKER_COUNT_THRESHOLD, + }), + ).toBe(false); + }); + + it('uses a lower pulse budget on coarse pointer devices', () => { + expect( + shouldAnimatePersonaDots({ + showPersonas: true, + mapZoom: SIMPLE_MARKER_ZOOM_THRESHOLD + 1, + viewportMarkerCount: + COARSE_POINTER_PERSONA_PULSE_COUNT_THRESHOLD, + isCoarsePointer: true, + }), + ).toBe(false); + }); + + it('does not disable pulse before viewport readiness is known', () => { + expect( + shouldAnimatePersonaDots({ + showPersonas: true, + mapZoom: SIMPLE_MARKER_ZOOM_THRESHOLD + 1, + viewportMarkerCount: SIMPLE_MARKER_COUNT_THRESHOLD + 50, + viewportReady: false, + isCoarsePointer: true, + }), + ).toBe(true); + }); +}); diff --git a/src/features/map/model/marker-visual-mode.ts b/src/features/map/model/marker-visual-mode.ts index 908decd..9e230a8 100644 --- a/src/features/map/model/marker-visual-mode.ts +++ b/src/features/map/model/marker-visual-mode.ts @@ -2,6 +2,9 @@ export type MarkerVisualMode = 'full' | 'simple'; export const SIMPLE_MARKER_ZOOM_THRESHOLD = 14; export const SIMPLE_MARKER_COUNT_THRESHOLD = 28; +export const COARSE_POINTER_PERSONA_PULSE_COUNT_THRESHOLD = 12; + +const PERSONA_DOT_PULSE_ZOOM_THRESHOLD = SIMPLE_MARKER_ZOOM_THRESHOLD; type MarkerVisualModeInput = { mapZoom: number; @@ -32,3 +35,31 @@ export function shouldRenderPersonaDots({ }: PersonaDotVisibilityInput): boolean { return showPersonas; } + +type PersonaDotPulseInput = { + showPersonas: boolean; + mapZoom: number; + viewportMarkerCount: number; + viewportReady?: boolean; + isCoarsePointer?: boolean; +}; + +export function shouldAnimatePersonaDots({ + showPersonas, + mapZoom, + viewportMarkerCount, + viewportReady = true, + isCoarsePointer = false, +}: PersonaDotPulseInput): boolean { + if (!showPersonas) return false; + if (!viewportReady) return true; + if (mapZoom <= PERSONA_DOT_PULSE_ZOOM_THRESHOLD) return false; + if (viewportMarkerCount >= SIMPLE_MARKER_COUNT_THRESHOLD) return false; + if ( + isCoarsePointer && + viewportMarkerCount >= COARSE_POINTER_PERSONA_PULSE_COUNT_THRESHOLD + ) { + return false; + } + return true; +} diff --git a/src/features/map/ui/MapCanvas.tsx b/src/features/map/ui/MapCanvas.tsx index 51b411b..a08eaf7 100644 --- a/src/features/map/ui/MapCanvas.tsx +++ b/src/features/map/ui/MapCanvas.tsx @@ -8,6 +8,66 @@ const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_KEY ?? ''; const DEFAULT_CENTER = { lat: 37.2636, lng: 127.0286 }; const DEFAULT_ZOOM = 15; +type OverlayRedrawRegistry = { + callbacks: Set<() => void>; + listeners: naver.maps.MapEventListener[]; + rafId: number | null; +}; + +const overlayRedrawRegistries = new WeakMap< + naver.maps.Map, + OverlayRedrawRegistry +>(); + +function scheduleOverlayRedraw(registry: OverlayRedrawRegistry) { + if (registry.rafId !== null) return; + registry.rafId = window.requestAnimationFrame(() => { + registry.rafId = null; + for (const callback of registry.callbacks) { + try { + callback(); + } catch { + // 한 overlay redraw 실패가 같은 batch 의 나머지 overlay 위치 동기화를 막지 않게 격리. + } + } + }); +} + +function registerMapOverlayRedraw(map: naver.maps.Map, redraw: () => void) { + let registry = overlayRedrawRegistries.get(map); + if (!registry) { + const nextRegistry: OverlayRedrawRegistry = { + callbacks: new Set(), + listeners: [], + rafId: null, + }; + const schedule = () => scheduleOverlayRedraw(nextRegistry); + nextRegistry.listeners = [ + naver.maps.Event.addListener(map, 'idle', schedule), + naver.maps.Event.addListener(map, 'zoom_changed', schedule), + naver.maps.Event.addListener(map, 'bounds_changed', schedule), + ]; + overlayRedrawRegistries.set(map, nextRegistry); + registry = nextRegistry; + } + + registry.callbacks.add(redraw); + return () => { + const current = overlayRedrawRegistries.get(map); + if (!current) return; + current.callbacks.delete(redraw); + if (current.callbacks.size > 0) return; + + for (const listener of current.listeners) { + naver.maps.Event.removeListener(listener); + } + if (current.rafId !== null) { + window.cancelAnimationFrame(current.rafId); + } + overlayRedrawRegistries.delete(map); + }; +} + export type MapOverlayItem = { key: string; position: { lat: number; lng: number }; @@ -346,23 +406,15 @@ export function NaverOverlay({ // 줌 애니메이션 중·후 모두 redraw 를 강제해 overlay 가 stale pixel 에 남지 않도록. // GL 렌더링에선 zoom_changed 가 한 번만 발화될 수 있어 bounds_changed + idle 을 함께 구독. - // 과거엔 200ms setInterval 로 폴링했으나, 오버레이 N개 × 초당 5회 draw 가 모바일 발열의 - // 주범이라 map 이벤트 구독으로 전환. positionSubscribe 가 있는 overlay 는 subscribe 자체가 - // 동기화 역할이므로 스킵. + // map 이벤트 listener 는 overlay 마다 붙이지 않고 map 단위 registry 에 모아 rAF 로 batch redraw 한다. + // positionSubscribe 가 있는 overlay 는 subscribe 자체가 동기화 역할이므로 스킵. useEffect(() => { if (!map || positionSubscribe) return; const redraw = () => { const ov = overlayRef.current; if (ov && ov.getMap()) ov.draw(); }; - const listeners = [ - naver.maps.Event.addListener(map, 'idle', redraw), - naver.maps.Event.addListener(map, 'zoom_changed', redraw), - naver.maps.Event.addListener(map, 'bounds_changed', redraw), - ]; - return () => { - for (const l of listeners) naver.maps.Event.removeListener(l); - }; + return registerMapOverlayRedraw(map, redraw); }, [map, positionSubscribe]); return createPortal(children, container);