import upperfirst from 'lodash.upperfirst'; import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { useContainerContext } from './contexts/container'; import { EventTargetContext } from './contexts/event-target'; import { NaverMapContext } from './contexts/naver-map'; import { HandleEvents } from './helpers/event'; import { usePrevious } from './hooks/use-previous'; import { useNavermaps } from './use-navermaps'; type MapPaddingOptions = { top?: number; right?: number; bottom?: number; left?: number; }; type MapOptions = { background?: string; baseTileOpacity?: number; /** * @type naver.maps.Bounds | naver.maps.BoundsLiteral | null */ bounds?: naver.maps.Bounds | naver.maps.BoundsLiteral | null; /** * @type naver.maps.Coord | naver.maps.CoordLiteral */ center?: naver.maps.Coord | naver.maps.CoordLiteral; disableDoubleClickZoom?: boolean; disableDoubleTapZoom?: boolean; disableKineticPan?: boolean; disableTwoFingerTapZoom?: boolean; draggable?: boolean; keyboardShortcuts?: boolean; logoControl?: boolean; logoControlOptions?: naver.maps.LogoControlOptions; mapDataControl?: boolean; mapDataControlOptions?: naver.maps.MapDataControlOptions; mapTypeControl?: boolean; mapTypeControlOptions?: naver.maps.MapTypeControlOptions; mapTypeId?: string; mapTypes?: naver.maps.MapTypeRegistry; maxBounds?: naver.maps.Bounds | naver.maps.BoundsLiteral | null; maxZoom?: number; minZoom?: number; padding?: MapPaddingOptions; pinchZoom?: boolean; resizeOrigin?: naver.maps.Position; scaleControl?: boolean; scaleControlOptions?: naver.maps.ScaleControlOptions; scrollWheel?: boolean; size?: naver.maps.Size | naver.maps.SizeLiteral; overlayZoomEffect?: string | null; tileSpare?: number; tileTransition?: boolean; zoom?: number; zoomControl?: boolean; zoomControlOptions?: naver.maps.ZoomControlOptions; zoomOrigin?: naver.maps.Coord | naver.maps.CoordLiteral | null; blankTileImage?: string | null; // special. centerPoint?: naver.maps.Point | naver.maps.PointLiteral; }; type Uncontrolled = { /** * Uncontrolled prop of mapTypeId */ defaultMapTypeId?: MapOptions['mapTypeId']; /** * Uncontrolled prop of size * @type naver.maps.Coord | naver.maps.CoordLiteral */ defaultSize?: MapOptions['size']; /** * Uncontrolled prop of bounds * @type naver.maps.Bounds | naver.maps.BoundsLiteral | null */ defaultBounds?: MapOptions['bounds']; /** * Uncontrolled prop of center * @type naver.maps.Coord | naver.maps.CoordLiteral */ defaultCenter?: MapOptions['center']; /** * Uncontrolled prop of zoom */ defaultZoom?: MapOptions['zoom']; /** * Uncontrolled prop of centerPoint * @type naver.maps.Point | naver.maps.PointLiteral */ defaultCenterPoint?: MapOptions['centerPoint']; }; type MapEventCallbacks = { onMapTypeIdChanged?: (value: string) => void; onMapTypeChanged?: (value: naver.maps.MapType) => void; onSizeChanged?: (value: naver.maps.Size) => void; onBoundsChanged?: (value: naver.maps.Bounds) => void; onCenterChanged?: (value: naver.maps.Coord) => void; onCenterPointChanged?: (value: naver.maps.Point) => void; onZoomChanged?: (value: number) => void; }; const basicMapOptionKeys: Array = [ 'background', 'baseTileOpacity', // 'bounds', // 'center', 'disableDoubleClickZoom', 'disableDoubleTapZoom', 'disableKineticPan', 'disableTwoFingerTapZoom', 'draggable', 'keyboardShortcuts', 'logoControl', 'logoControlOptions', 'mapDataControl', 'mapDataControlOptions', 'mapTypeControl', 'mapTypeControlOptions', // 'mapTypeId', 'mapTypes', 'maxBounds', 'maxZoom', 'minZoom', 'padding', 'pinchZoom', 'resizeOrigin', 'scaleControl', 'scaleControlOptions', 'scrollWheel', // 'size', 'overlayZoomEffect', 'tileSpare', 'tileTransition', // 'zoom', 'zoomControl', 'zoomControlOptions', 'zoomOrigin', 'blankTileImage', ]; const kvoKeys = [ 'mapTypeId', 'size', 'bounds', 'center', 'zoom', 'centerPoint', ] as const; const kvoEvents = [ ...kvoKeys.map(key => `${key}_changed`), 'mapType_changed', // special. https://navermaps.github.io/maps.js.ncp/docs/naver.maps.Map.html#event:mapType_changed__anchor ]; const uiEvents = [ 'mousedown', 'mouseup', 'click', 'dblclick', 'rightclick', 'mouseover', 'mouseout', 'mousemove', 'dragstart', 'drag', 'dragend', 'touchstart', 'touchmove', 'touchend', 'pinchstart', 'pinch', 'pinchend', 'tap', 'longtap', 'twofingertap', 'doubletap', ] as const; const mapOnlyEvents = [ 'addLayer', 'idle', 'init', 'keydown', 'keyup', 'panning', 'projection_changed', 'removeLayer', 'resize', 'tilesloaded', 'zooming', ] as const; const events = [...uiEvents, ...kvoEvents, ...mapOnlyEvents]; // type FunctionTypeChildren = (nmap: naver.maps.Map) => React.ReactNode; const defaultOptionKeyMap = { mapTypeId: 'defaultMapTypeId', size: 'defaultSize', bounds: 'defaultBounds', center: 'defaultCenter', zoom: 'defaultZoom', centerPoint: 'defaultCenterPoint', } as const; export type Props = Uncontrolled & { /** * Map 관련 components */ children?: ReactNode; } & MapOptions & MapEventCallbacks; export const NaverMap = forwardRef(function NaverMap(props, ref) { const navermaps = useNavermaps(); const { element: mapDiv } = useContainerContext(); const [nmap, setNmap] = useState(); const nmapRef = useRef(null); // https://github.com/facebook/react/issues/20090 useLayoutEffect(() => { if (!mapDiv) { throw new Error('react-naver-maps: MapDiv is not found. Did you correctly wrap with `MapDiv`?'); } const basicMapOptions = Object.fromEntries(basicMapOptionKeys.map(key => [key, props[key]])); const kvos = kvoKeys.reduce((acc, key) => { // default kvo if (props[defaultOptionKeyMap[key]]) { return { ...acc, [key]: props[defaultOptionKeyMap[key]], }; } // kvo if (props[key]) { return { ...acc, [key]: props[key], }; } return acc; }, {}); const _nmap = new navermaps.Map(mapDiv, { ...basicMapOptions, ...kvos } as naver.maps.MapOptions); setNmap(_nmap); // for ref hack nmapRef.current = _nmap; return () => { _nmap.destroy(); }; }, []); const uncontrolledOmittedProps = (Object.keys(props) as Array).reduce((acc, key) => { // kvo key가 defaultKvo key와 함께 있을 경우 무시한다. if (key in defaultOptionKeyMap && props[defaultOptionKeyMap[key as keyof typeof defaultOptionKeyMap]]) { return acc; } return { ...acc, [key]: props[key], }; }, {}) as Props; // nmap 이 layoutEffect에서 생성되므로 항상 Map이 존재한다. useImperativeHandle(ref, () => nmapRef.current); return ( <>{nmap && } ); }); function NaverMapCore({ nmap, children, ...mapProps }: Props & { nmap: naver.maps.Map }) { const basicMapOptions = Object.fromEntries(basicMapOptionKeys.map(key => [key, mapProps[key]])); const { mapTypeId, size, bounds, center, centerPoint, zoom, } = mapProps; const prevKVOs = usePrevious({ mapTypeId, size, bounds, center, centerPoint, zoom, }, [ mapTypeId, size, bounds, center, centerPoint, zoom, ]); function getDirtyKVOs(keys: Array): Pick { return keys.reduce((acc, key) => { /* eslint-disable */ const currentValue = (nmap[`get${upperfirst(key)}` as keyof naver.maps.Map] as Function)(); const propValue = mapProps[key]; if (!propValue || prevKVOs && prevKVOs[key] === propValue) { return acc; } const isEqual = typeof currentValue.equals === 'function' ? currentValue.equals(propValue) : currentValue === propValue; if (isEqual) { return acc; } return { ...acc, [key]: propValue, }; }, {} as Pick); } useLayoutEffect(() => { nmap.setOptions(basicMapOptions); }, [Object.values(basicMapOptions)]); useLayoutEffect(() => { const updated = getDirtyKVOs(['size']).size; if (updated) { nmap.setSize(updated); } }, [size]); useLayoutEffect(() => { const updated = getDirtyKVOs(['mapTypeId']).mapTypeId; if (updated) { nmap.setMapTypeId(updated); } }, [mapTypeId]); useLayoutEffect(() => { const dirties = getDirtyKVOs(['bounds', 'center', 'centerPoint', 'zoom']); if (dirties.bounds) { // TODO nmap.fitBounds(dirties.bounds); // Ignore rest kvos return; } if (dirties.center && dirties.zoom) { nmap.morph(dirties.center, dirties.zoom); // Ignore rest kvos return; } if (dirties.centerPoint) { nmap.setCenterPoint(dirties.centerPoint); } if (dirties.center) { // TODO nmap.panTo(dirties.center, {}); } if (dirties.zoom) { nmap.setZoom(dirties.zoom); } }, [bounds, center, centerPoint, zoom]); return ( <> {children} ); }