Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/features/map/client/MapClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -159,6 +160,7 @@ export function MapClient() {
void pagerSnap;
const [viewportBbox, setViewportBbox] = useState<ViewportBbox | null>(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 되면
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -623,7 +649,7 @@ export function MapClient() {
<PersonaDotMarkerBlob
name={persona.name}
variant="ai"
moving
moving={shouldAnimatePersonaDots}
/>
),
};
Expand All @@ -642,6 +668,7 @@ export function MapClient() {
swarmSubscribe,
swarmPositionsRef,
shouldRenderPersonaDots,
shouldAnimatePersonaDots,
inViewport,
]);

Expand Down
58 changes: 58 additions & 0 deletions src/features/map/model/marker-visual-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
});
31 changes: 31 additions & 0 deletions src/features/map/model/marker-visual-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
74 changes: 63 additions & 11 deletions src/features/map/ui/MapCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down
Loading