import { useCallback, useEffect } from 'react'; /** * Hides cartographic captions that overlap. * * @description * At low zoom, permanent captions pile on top of each other and the map becomes * unreadable — every label unreadable rather than a few missing. The first * caption in DOM order wins each conflict and the rest are hidden, which is the * standard cartographic trade: fewer labels, all legible. * * Hidden captions are made `visibility: hidden` rather than unmounted, so the * decision reverses instantly when the user zooms back in, and nothing about * the accessibility tree changes (captions are `aria-hidden` regardless — the * marker's accessible name already carries the text). */ export function useCaptionCollision( container: HTMLElement | null, enabled: boolean, dependencies: unknown[], /** * The map instance. Panning and zooming move every caption without changing * the container's size, so a ResizeObserver alone never re-runs — which left * labels overlapping the moment the user touched the map. */ map?: google.maps.Map | null ) { const resolve = useCallback(() => { if (!container) return; const captions = Array.from(container.querySelectorAll('[data-map-caption]')); if (captions.length === 0) return; // Reset first: a caption hidden at the previous zoom may be free now. captions.forEach(caption => { caption.style.visibility = ''; }); if (!enabled) return; const placed: DOMRect[] = []; captions.forEach(caption => { const rect = caption.getBoundingClientRect(); // A zero-sized rect means the element is not laid out yet; leave it be // rather than treating it as occupying the top-left corner. if (rect.width === 0 && rect.height === 0) return; const collides = placed.some( other => rect.left < other.right && rect.right > other.left && rect.top < other.bottom && rect.bottom > other.top ); if (collides) caption.style.visibility = 'hidden'; else placed.push(rect); }); }, [container, enabled]); useEffect(() => { if (!container) return; // Run after layout has settled, so the rects are real. const frame = requestAnimationFrame(resolve); const cleanups: Array<() => void> = [() => cancelAnimationFrame(frame)]; if (typeof ResizeObserver !== 'undefined') { const observer = new ResizeObserver(() => resolve()); observer.observe(container); cleanups.push(() => observer.disconnect()); } // `idle` fires once the camera comes to rest after a pan or zoom, which is // exactly when the captions have finished moving. if (map && typeof map.addListener === 'function') { const listener = map.addListener('idle', resolve); cleanups.push(() => listener.remove()); } return () => cleanups.forEach(cleanup => cleanup()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [container, resolve, map, ...dependencies]); return resolve; }