import hash from 'object-hash'; import React, { type ForwardedRef, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, } from 'react'; import { type ColorValue, type NativeSyntheticEvent, Platform, processColor, type ViewProps, } from 'react-native'; import type { Double } from 'react-native/Libraries/Types/CodegenTypes'; import { cameraChangeReasonFromNumber, cameraEasingToNumber, convertJsImagePropToNativeProp, createCameraInstance, } from '../internal/Util'; import { Const } from '../internal/util/Const'; import { useStableCallback } from '../internal/util/useStableCallback'; import { Commands, type NativeClusterProp, type NativeClustersProp, type NativeLocationOverlayProp, default as NativeNaverMapView, } from '../spec/RNCNaverMapViewNativeComponent'; import type { Camera } from '../types/Camera'; import type { CameraAnimationEasing } from '../types/CameraAnimationEasing'; import type { CameraChangeReason } from '../types/CameraChangeReason'; import type { CameraMoveBaseParams } from '../types/CameraMoveBaseParams'; import type { ClusterMarkerProp } from '../types/ClusterMarkerProp'; import type { Coord } from '../types/Coord'; import type { LocationTrackingMode } from '../types/LocationTrackingMode'; import type { LogoAlign } from '../types/LogoAlign'; import type { MapImageProp } from '../types/MapImageProp'; import type { MapType } from '../types/MapType'; import type { Rect } from '../types/Rect'; import type { Region } from '../types/Region'; export interface NaverMapViewProps extends ViewProps { /** * mapType 속성을 지정하면 지도의 유형을 변경할 수 있습니다. * 지도의 유형을 변경하면 가장 바닥에 나타나는 배경 지도의 스타일이 변경됩니다. * * @see {@link MapType} * @group Map Look & Feel * @default Basic */ mapType?: MapType; /** * 바닥 지도 위에 부가적인 정보를 나타내는 레이어 그룹을 노출할 수 있습니다. * * 레이어 그룹은 지도 유형과 달리 동시에 두 개 이상을 활성화할 수 있습니다. * 단, 지도 유형에 따라 표현 가능한 레이어 그룹이 정해져 있습니다. * 지도 유형이 특정 레이어 그룹을 지원하지 않으면 활성화하더라도 해당하는 요소가 노출되지 않습니다. * * default값은 BUILDING만 활성화되어있는 상태입니다. * * @see [references](https://navermaps.github.io/android-map-sdk/guide-ko/2-3.html) * @group Map Look & Feel * * @default ['BUILDING'] */ layerGroups?: { /** 건물 그룹입니다. 활성화할 경우 건물 형상, 주소 심벌 등 건물과 관련된 요소가 노출됩니다. 기본적으로 활성화됩니다. */ BUILDING: boolean; /** 실시간 교통정보 그룹입니다. 활성화할 경우 실시간 교통정보가 노출됩니다. */ TRAFFIC: boolean; /** 대중교통 그룹입니다. 활성화할 경우 철도, 지하철 노선, 버스정류장 등 대중교통과 관련된 요소가 노출됩니다. */ TRANSIT: boolean; /** 자전거 그룹입니다. 활성화할 경우 자전거 도로, 자전거 주차대 등 자전거와 관련된 요소가 노출됩니다. */ BICYCLE: boolean; /** 등산로 그룹입니다. 활성화할 경우 등산로, 등고선 등 등산과 관련된 요소가 노출됩니다. */ MOUNTAIN: boolean; /** 지적편집도 그룹입니다. 활성화할 경우 지적편집도가 노출됩니다. */ CADASTRAL: boolean; }; /** * 카메라의 위치를 조절합니다. `region`이 존재해도 `camera`가 설정되면 동작하지 않습니다. * * [Reference](https://navermaps.github.io/ios-map-sdk/guide-ko/3-1.html) * * @see {@link Camera} * @see {@link initialCamera} * @group Camera */ camera?: Camera; /** * 맵이 생성된 후 첫 카메라 설정입니다. * * `camera`를 사용하지 않을 때만 사용해야합니다. * * 컴포넌트가 mount되고 변경해도 동작하지 않습니다. * * @see {@link Camera} * @see {@link camera} * @group Camera */ initialCamera?: Camera; /** * 해당 영역이 완전히 보이는 좌표와 최대 줌 레벨로 카메라가 이동합니다. * * `camera`가 존재한다면 동작하지 않습니다. * * `latitude`, `longtidue`는 지도의 south-west(위도, 경도가 낮은 부분)를 의미합니다. * * `latitudeDelta`, `longitudeDelta`는 각 위도 경도로 얼마만큼의 범위를 가질 것인지를 의미합니다. * 항상 양수입니다. * * 예를 들어, `latitudeDelta`가 0.1이라면 위도가 지도의 보이는 곳의 끝과 끝에서 0.1이 차이난다는 것을 의미합니다. * 하지만 `longitudeDelta`가 특정 값보다 커서 최대 줌 레벨이 더 작아진다면 0.1보다 더 차이나게 될 수도 있고 반대도 같습니다. * * @see {@link Region} * @see {@link initialRegion} * @group Camera */ region?: Region; /** * 맵이 생성된 후 첫 위치 설정입니다. * * `region`를 사용하지 않을 때만 사용해야합니다. * * 컴포넌트가 mount되고 변경해도 동작하지 않습니다. * * @see {@link Region} * @see {@link region} * @group Camera */ initialRegion?: Region; /** * `camera`, `region`이 변경될 때 카메라 이동의 애니메이션 지속시간, milliseconds * 0일 때는 애니메이션이 작동하지 않습니다. * * @group Animation * @see {@link animationEasing} * @default 0 */ animationDuration?: number; /** * * `camera`, `region`이 변경될 때 카메라 이동의 애니메이션 Easing * * @group Animation * @see {@link animationDuration} * @default EaseOut */ animationEasing?: CameraAnimationEasing; /** * indoorMapEnabled 속성을 사용하면 실내지도를 활성화할 수 있습니다. * 실내지도가 활성화되면 줌 레벨이 일정 수준 이상이고 실내지도가 있는 영역에 지도의 중심이 위치할 경우 자동으로 해당 영역에 대한 실내지도가 나타납니다. * 단, 지도 유형이 실내지도를 지원하지 않으면 실내지도를 활성화하더라도 아무런 변화가 일어나지 않습니다. * Basic, Terrain 지도 유형만이 실내지도를 지원합니다. * * @default false */ isIndoorEnabled?: boolean; /** * nightModeEnabled 속성을 사용하면 야간 모드를 활성화할 수 있습니다. * 야간 모드가 활성화되면 지도의 전반적인 스타일이 어두운 톤으로 변경됩니다. * 단, 지도 유형이 야간 모드를 지원하지 않을 경우 야간 모드를 활성화하더라도 아무런 변화가 일어나지 않습니다. * Navi 지도 유형만이 야간 모드를 지원합니다. * * @default false */ isNightModeEnabled?: boolean; /** * 이 속성을 사용하면 라이트 모드를 활성화할 수 있습니다. * 라이트 모드가 활성화되면 지도의 로딩이 빨라지고 메모리 소모가 줄어듭니다. * 그러나 다음과 같은 제약이 생깁니다. * * - 지도의 전반적인 화질이 하락합니다. * - Navi 지도 유형을 사용할 수 없습니다. * - 레이어 그룹을 활성화할 수 없습니다. * - 실내지도, 야간 모드를 사용할 수 없습니다. * - 디스플레이 옵션을 변경할 수 없습니다. * - 카메라가 회전하거나 기울어지면 지도 심벌도 함께 회전하거나 기울어집니다. * - 줌 레벨이 커지거나 작아지면 지도 심벌도 일정 정도 함께 커지거나 작아집니다. * - 지도 심벌의 클릭 이벤트를 처리할 수 없습니다. * - 마커와 지도 심벌 간 겹침을 처리할 수 없습니다. * * 따라서 라이트 모드는 꼭 필요한 상황에만 제한적으로 사용하는 것이 좋습니다. * * @default false */ isLiteModeEnabled?: boolean; /** * lightness 속성을 사용하면 지도의 밝기를 지정할 수 있습니다. * 지도의 밝기를 지정하더라도 오버레이의 밝기는 변경되지 않으므로 오버레이를 강조하고자 할 때 사용할 수 있습니다. * 값은 -1~1의 비율로 지정할 수 있으며, -1에 가까울수록 어두워지고 1에 가까울수록 밝아집니다. * * @default 0 */ lightness?: number; /** * 지도가 기울어지면 건물이 입체적으로 표시됩니다. * buildingHeight 속성을 사용하면 입체적으로 표현되는 건물의 높이를 지정할 수 있습니다. * 값은 0~1의 비율로 지정할 수 있으며, 0으로 지정하면 지도가 기울어지더라도 건물이 입체적으로 표시되지 않습니다. * * @default 1 */ buildingHeight?: number; /** * symbolScale 속성을 사용하면 심벌의 크기를 변경할 수 있습니다. * 0~2의 비율로 지정할 수 있으며, 값이 클수록 심벌이 커집니다. * * @default 1 */ symbolScale?: number; /** * 지도를 기울이면 가까이 있는 심벌은 크게, 멀리 있는 심벌은 작게 그려집니다. * symbolPerspectiveRatio 속성을 사용하면 심벌의 원근 효과를 조절할 수 있습니다. * 0~1의 비율로 지정할 수 있으며, 값이 작을수록 원근감이 줄어들어 0이 되면 원근 효과가 완전히 사라집니다. * * @default 1 */ symbolPerspectiveRatio?: number; /** * 콘텐츠 패딩을 지정할 수 있습니다. * 다음 그림과 같이 UI 요소가 지도의 일부를 덮을 경우, 카메라는 지도 뷰의 중심에 위치하므로 실제로 보이는 지도의 중심과 카메라의 위치가 불일치하게 됩니다. * * Docs Image */ mapPadding?: Partial; /** * 나침반: 카메라의 회전 및 틸트 상태를 표현합니다. * 탭하면 카메라의 헤딩과 틸트가 0으로 초기화됩니다. * 헤딩과 틸트가 0이 되면 자동으로 사라집니다. */ isShowCompass?: boolean; /** * 축척 바: 지도의 축척을 표현합니다. 지도를 조작하는 기능은 없습니다. */ isShowScaleBar?: boolean; /** * 줌 버튼: 탭하면 지도의 줌 레벨을 1씩 증가 또는 감소합니다. */ isShowZoomControls?: boolean; /** * 실내지도 층 피커: 노출 중인 실내지도 구역의 층 정보를 표현합니다 * 층을 선택하면 해당 층의 실내지도가 노출됩니다. * 실내지도가 보이는 상황에만 나타납니다. */ isShowIndoorLevelPicker?: boolean; /** * 현위치 버튼: 위치 추적 모드를 표현합니다. 탭하면 모드가 변경됩니다. */ isShowLocationButton?: boolean; /** * 카메라의 최소 줌 레벨입니다. */ minZoom?: number; /** * 카메라의 최대 줌 레벨입니다. */ maxZoom?: number; /** * extent 속성을 지정하면 카메라의 대상 지점을 영역 내로 제한할 수 있습니다. * 카메라가 제한 영역을 벗어나도록 API를 호출하더라도 대상 지점이 영역 내로 조정됩니다. * * 카메라 영역을 제한할 때 최소 줌 레벨도 함께 제한하는 것이 좋습니다. * 그렇지 않으면 지도가 축소되었을 때 제한 영역이 너무 작게 나타날 수 있습니다. */ extent?: Region; /** * 한반도로 `extent`를 제한합니다. `extent`가 존재한다면 동작하지 않습니다. */ isExtentBoundedInKorea?: boolean; /** * 네이버 로고의 위치입니다. * * @see {@link LogoAlign} * @group Logo */ logoAlign?: LogoAlign; /** * 네이버 로고의 마진입니다. * * @see {@link Rect} * @group Logo */ logoMargin?: Partial; /** * 탭 활성화 여부를 지정할 수 있습니다. * 로고 탭을 비활성화한 앱은 반드시 앱 내에 네이버 지도 SDK의 * 법적 공지(-showLegalNotice) 및 오픈소스 라이선스(-showOpenSourceLicense)뷰를 보여주는 메뉴를 만들어야 합니다. */ // isLogoInteractionEnabled?: boolean; /** * 스크롤: 한 개 또는 두 개 이상의 손가락으로 지도를 드래그하면 카메라가 손가락을 따라 이동합니다. * * @group Gesture */ isScrollGesturesEnabled?: boolean; /** * 줌: 지도를 더블 탭하면 줌 레벨이 한 단계 확대됩니다. 두 손가락 탭하면 한 단계 축소됩니다. * 핀치와 스트레치 또는 한 손가락 줌 제스처로도 지도의 줌 레벨을 변경할 수 있습니다. * * @group Gesture */ isZoomGesturesEnabled?: boolean; /** * 틸트: 두 개의 손가락으로 지도를 위아래로 드래그하면 기울임 각도가 바뀝니다. * * @group Gesture */ isTiltGesturesEnabled?: boolean; /** * 회전: 두 개의 손가락으로 지도를 돌리면 베어링 각도가 바뀝니다. * * @group Gesture */ isRotateGesturesEnabled?: boolean; /** * 스톱: 카메라 애니메이션이 진행 중일 때 지도를 탭하면 애니메이션이 취소되고 카메라가 현재 위치에 멈춥니다. * * @group Gesture */ isStopGesturesEnabled?: boolean; /** * 안드로이드에서 SurfaceView대신 TextureView를 사용합니다. * * 컴포넌트가 mount되고 변경해도 동작하지 않습니다. */ isUseTextureViewAndroid?: boolean; /** * 지도의 언어를 지정합니다. * * @example `ko`, `ja`, `en` * @default system locale */ locale?: string; /** * 한 화면에 대량의 마커가 노출되면 성능이 저하될 뿐만 아니라 여러 마커가 겹쳐 나타나므로 시인성이 떨어집니다. * 마커의 겹침 처리 기능을 사용하면 시인성을 일부 향상시킬 수 있으나 겹침 처리로 인해 가려진 마커의 정보를 알 수 없으며, 성능도 여전히 저하됩니다. * 마커 클러스터링 기능을 이용하면 카메라의 줌 레벨에 따라 근접한 마커를 클러스터링해 성능과 시인성을 모두 향상시킬 수 있습니다. */ clusters?: { /** * 클러스터 마커의 넓이 */ width?: number; /** * 클러스터 마커의 높이 */ height?: number; markers: ClusterMarkerProp[]; screenDistance?: number; /** * 클러스터링할 최소 줌 레벨. * * 카메라의 줌 레벨이 최소 줌 레벨보다 낮다면 두 데이터가 화면상 기준 거리보다 가깝더라도 더 이상 클러스터링되지 않습니다. * 예를 들어, 클러스터링할 최소 줌 레벨이 4라면, 카메라의 줌 레벨을 3레벨 이하로 축소하더라도 4레벨의 클러스터가 더 이상 클러스터링되지 않고 그대로 유지됩니다. * * @default 0 */ minZoom?: number; /** * 클러스터링할 최대 줌 레벨. * * 카메라의 줌 레벨이 최대 줌 레벨보다 높다면 두 데이터가 화면상 기준 거리보다 가깝더라도 더 이상 클러스터링되지 않습니다. * 예를 들어, 클러스터링할 최대 줌 레벨이 16이라면, 카메라의 줌 레벨을 17레벨 이상으로 확대하면 모든 데이터가 클러스터링되지 않고 낱개로 나타납니다. 따라서 클러스터링할 최대 줌 레벨이 지도의 최대 줌 레벨보다 크거나 같다면 카메라를 최대 줌 레벨로 확대하더라도 일부 데이터는 여전히 클러스터링된 채 더 이상 펼쳐지지 않을 수 있습니다. * * @default 21 */ maxZoom?: number; /** * 카메라 확대/축소시 클러스터가 펼쳐지는/합쳐지는 애니메이션을 적용할지 여부. * * @default true */ animate?: boolean; }[]; /** * 지도의 최대 초당 프레임 수(FPS, frames per second)를 지정합니다. * * 지도 객체가 생길때의 초기값만 동작하고 동적으로 바꿀 수 없습니다. * * 안드로이드에서만 동작합니다. * * 기본값은 제한을 두지 않음을 의미하는 0입니다. * * @default 0 */ fpsLimit?: number; /** * Custom style ID from Naver Style Editor * * 네이버 스타일 에디터에서 생성한 커스텀 스타일 ID를 지정하면 해당 스타일이 지도에 적용됩니다. * * @see https://style-editor.map.naver.com */ customStyleId?: string; /** * Called when custom style is successfully loaded * * 커스텀 스타일이 성공적으로 로드되었을 때 호출되는 콜백입니다. * * @event */ onCustomStyleLoaded?: () => void; /** * Called when custom style loading fails * * 커스텀 스타일 로딩이 실패했을 때 호출되는 콜백입니다. * * @event */ onCustomStyleLoadFailed?: (params: { message: string }) => void; /** * 위치 오버레이 설정입니다. * * 위치 오버레이는 사용자의 현재 위치를 나타내는 특수한 오버레이입니다. * 지도당 하나만 존재하며, 직접 생성할 수 없고 지도에서 제공하는 오버레이를 사용해야 합니다. * * @see {@link MapImageProp} * @group Location */ locationOverlay?: { /** * 위치 오버레이의 가시성 여부 * @default false */ isVisible?: boolean; /** * 위치 오버레이의 좌표 */ position?: Coord; /** * 위치 오버레이의 방향 (각도) * @default 0 */ bearing?: Double; /** * 메인 아이콘 이미지 */ image?: MapImageProp; /** * 메인 아이콘의 너비 * @default SIZE_AUTO */ imageWidth?: Double; /** * 메인 아이콘의 높이 * @default SIZE_AUTO */ imageHeight?: Double; /** * 메인 아이콘의 앵커 포인트 (0~1 사이의 값) * @default { x: 0.5, y: 0.5 } */ anchor?: Readonly<{ x: Double; y: Double }>; /** * 서브 아이콘 이미지 (메인 아이콘 뒤에 표시) */ subImage?: MapImageProp; /** * 서브 아이콘의 너비 * @default SIZE_AUTO */ subImageWidth?: Double; /** * 서브 아이콘의 높이 * @default SIZE_AUTO */ subImageHeight?: Double; /** * 서브 아이콘의 앵커 포인트 (0~1 사이의 값) * @default { x: 0.5, y: 0.5 } */ subAnchor?: Readonly<{ x: Double; y: Double }>; /** * 강조 원의 반경 (픽셀 단위) * @default 0 */ circleRadius?: Double; /** * 강조 원의 색상 */ circleColor?: ColorValue; /** * 강조 원 테두리의 너비 * @default 0 */ circleOutlineWidth?: Double; /** * 강조 원 테두리의 색상 */ circleOutlineColor?: ColorValue; }; /** * 지도 객체가 초기화가 완료된 뒤에 호출됩니다. * * @group Events */ onInitialized?: () => void; /** * 지도 유형, 디스플레이 옵션 등 지도와 관련된 옵션이 변경되면 이벤트가 발생합니다. * 지도의 옵션이 변경되면 콜백 메서드가 호출됩니다. * * 이 이벤트는 지도의 옵션에 따라 UI나 다른 속성을 변경하고자 할 때 유용합니다. * 예를 들어 지도가 야간 모드로 변경되면 마커의 색상도 어두운 색으로 변경해야 자연스러운데, * 이런 경우에 옵션 변경 이벤트를 사용할 수 있습니다. * * @event */ onOptionChanged?: (params: { locationTrackingMode: LocationTrackingMode; }) => void; /** * 어떤 이유에 의해서건 카메라가 움직이면 카메라 변경 이벤트가 발생합니다. * * reason은 이벤트를 발생시킨 카메라 이동의 원인입니다. * reason을 이용해 카메라 이동의 원인을 지정할 수 있으며, 이벤트 리스너 내에서 이 값을 이용해 어떤 원인에 의해 발생한 이벤트인지 판단할 수 있습니다. * * latitude, longitude, zoom, tilt, bearing은 카메라와 관련된 속성이며, 카메라 변경 이벤트에 대한 Region을 이벤트로 받으려면 region인자를 사용할 수 있습니다. * * @see {@link CameraChangeReason} * @see {@link Camera} * @event */ onCameraChanged?: ( params: Camera & { reason: CameraChangeReason; region: Region; } ) => void; /** * 카메라의 움직임이 끝나 대기 상태가 되면 카메라 대기 이벤트가 발생합니다. * * 카메라는 다음과 같은 시점에 대기 상태가 된 것으로 간주되어 이벤트가 발생합니다. * * - 카메라가 애니메이션 없이 움직일 때. 단, 사용자가 제스처로 지도를 움직이는 경우 제스처가 완전히 끝날 때까지는 연속적인 이동으로 간주되어 이벤트가 발생하지 않습니다. * - 카메라 애니메이션이 완료될 때. * - NaverMap.cancelTransitions()가 호출되어 카메라 애니메이션이 명시적으로 취소될 때. * @see {@link Camera} * @see {@link Region} * @event */ onCameraIdle?: (params: Camera & { region: Region }) => void; /** * 맵을 클릭했을 때 발생하는 이벤트입니다. * * @see {@link Coord} * @event */ onTapMap?: (params: Coord & { x: number; y: number }) => void; /** * 클러스터 Leaf 마커를 클릭했을 때 발생하는 이벤트입니다. * * @event */ onTapClusterLeaf?: (params: { markerIdentifier: string }) => void; } export interface NaverMapViewRef { /** * 카메라를 애니메이션과 함께 이동시킵니다. */ animateCameraTo: ( params: Coord & CameraMoveBaseParams & { zoom?: number } ) => void; /** * 카메라를 특정 위치만큼 델타값으로 애니메이션과 함께 이동시킵니다. */ animateCameraBy: ( params: { x: number; y: number; } & CameraMoveBaseParams ) => void; /** * 카메라를 특정 Region으로 애니메이션과 함께 이동시킵니다. */ animateRegionTo: (params: Region & CameraMoveBaseParams) => void; /** * 카메라를 두 좌표가 모두 보이는 최대 줌 레벨로 애니메이션과 함께 이동시킵니다. * * 카메라의 중심은 두 좌표의 중심이며 `pivot`으로 조절할 수 있습니다. * `pivot`은 기본 0.5(중앙)이며 0 ~ 1 값으로 설정할 수 있습니다. */ animateCameraWithTwoCoords: ( params: { coord1: Coord; coord2: Coord; } & CameraMoveBaseParams ) => void; /** * 카메라의 애니메이션을 취소합니다. */ cancelAnimation: () => void; /** * 위치 추적 모드를 변경합니다. * * {@link LocationTrackingMode} */ setLocationTrackingMode: (mode: LocationTrackingMode) => void; /** * 지도에서 특정 부분을 위도 경도값으로 반환합니다. * * `screenX`, `screenY`는 DP, PT 단위입니다. * * `isValid`가 `false`이면 항상 `latitude`, `longitude`는 0입니다. */ screenToCoordinate: (params: { screenX: number; screenY: number; }) => Promise<{ isValid: boolean; latitude: number; longitude: number; }>; /** * 지도에서 특정 위도 부분을 화면에서의 특정 위치로 반환합니다. * * `screenX`, `screenY`는 DP, PT 단위입니다. * * `isValid`가 `false`이면 항상 `screenX`, `screenY`는 0입니다. */ coordinateToScreen: (params: { latitude: number; longitude: number; }) => Promise<{ isValid: boolean; screenX: number; screenY: number; }>; } function clamp(v: number, min: number, max: number): number { return Math.min(max, Math.max(min, v)); } const southKoreaExtent: Region = { latitude: 31.43, longitude: 122.37, latitudeDelta: 44.35 - 31.43, longitudeDelta: 132 - 122.37, }; const nullRegion: Region = { latitude: Const.NULL_NUMBER, longitude: Const.NULL_NUMBER, latitudeDelta: Const.NULL_NUMBER, longitudeDelta: Const.NULL_NUMBER, }; const nullCamera: Camera = { latitude: Const.NULL_NUMBER, longitude: Const.NULL_NUMBER, zoom: Const.NULL_NUMBER, tilt: Const.NULL_NUMBER, bearing: Const.NULL_NUMBER, }; export const NaverMapView = forwardRef( ( { camera, initialCamera, region, initialRegion, animationDuration = 0, animationEasing = Const.DEFAULT_EASING, mapType = 'Basic', layerGroups = { BUILDING: true, BICYCLE: false, CADASTRAL: false, MOUNTAIN: false, TRAFFIC: false, TRANSIT: false, }, isIndoorEnabled, isNightModeEnabled, isLiteModeEnabled, lightness = 0, buildingHeight = 1, symbolScale = 1, symbolPerspectiveRatio = 1, mapPadding, isShowCompass, isShowIndoorLevelPicker, isShowLocationButton, isShowScaleBar, isShowZoomControls, minZoom, maxZoom, extent, isExtentBoundedInKorea, logoAlign, logoMargin, onCameraChanged: onCameraChangedProp, onCameraIdle: onCameraIdleProp, onTapMap: onTapMapProp, onInitialized, onOptionChanged: onOptionChangedProp, customStyleId, onCustomStyleLoaded: onCustomStyleLoadedProp, onCustomStyleLoadFailed: onCustomStyleLoadFailedProp, isScrollGesturesEnabled, isZoomGesturesEnabled, isTiltGesturesEnabled, isRotateGesturesEnabled, isStopGesturesEnabled, isUseTextureViewAndroid, locale, clusters, fpsLimit = 0, locationOverlay, onTapClusterLeaf, ...rest }: NaverMapViewProps, ref: ForwardedRef ) => { const innerRef = useRef(null); const isLeafTapCallbackExist: boolean = !!onTapClusterLeaf; const _clusters = useMemo(() => { if (!clusters || clusters.length === 0) { return { key: '', clusters: [], isLeafTapCallbackExist }; } let propKey = ''; const ret: NativeClusterProp[] = []; for (const { animate = true, markers, // eslint-disable-next-line @typescript-eslint/no-shadow minZoom = Const.MIN_ZOOM, // eslint-disable-next-line @typescript-eslint/no-shadow maxZoom = Const.MAX_ZOOM, screenDistance = Const.DEFAULT_SCREEN_DISTANCE, width, height, } of clusters) { const key = hash([ animate, maxZoom, minZoom, screenDistance, markers, width, height, ]); ret.push({ key, animate, markers: markers.map((m) => ({ ...m, image: convertJsImagePropToNativeProp( m.image ?? { symbol: 'green' } ), })), maxZoom, minZoom, screenDistance, width, height, }); propKey += `${key}---`; } return { key: hash(propKey), clusters: ret, isLeafTapCallbackExist, }; }, [clusters, isLeafTapCallbackExist]); const _locationOverlay: NativeLocationOverlayProp | undefined = useMemo(() => { if (!locationOverlay) return Platform.OS === 'ios' ? { circleOutlineWidth: Const.NULL_NUMBER } : undefined; return { isVisible: locationOverlay.isVisible, position: locationOverlay.position, bearing: locationOverlay.bearing, image: locationOverlay.image ? convertJsImagePropToNativeProp(locationOverlay.image) : undefined, imageWidth: locationOverlay.imageWidth, imageHeight: locationOverlay.imageHeight, anchor: locationOverlay.anchor, subImage: locationOverlay.subImage ? convertJsImagePropToNativeProp(locationOverlay.subImage) : undefined, subImageWidth: locationOverlay.subImageWidth, subImageHeight: locationOverlay.subImageHeight, subAnchor: locationOverlay.subAnchor, circleRadius: locationOverlay.circleRadius, circleColor: locationOverlay.circleColor ? (processColor(locationOverlay.circleColor) as number) : undefined, circleOutlineWidth: locationOverlay.circleOutlineWidth, circleOutlineColor: locationOverlay.circleOutlineColor ? (processColor(locationOverlay.circleOutlineColor) as number) : undefined, } satisfies NativeLocationOverlayProp; }, [locationOverlay]); const onCameraChanged = useStableCallback( ({ nativeEvent: { bearing, latitude, longitude, reason, tilt, zoom, regionLatitude, regionLatitudeDelta, regionLongitude, regionLongitudeDelta, }, }: NativeSyntheticEvent< Camera & { reason: number; regionLatitude: Double; regionLongitude: Double; regionLatitudeDelta: Double; regionLongitudeDelta: Double; } >) => { onCameraChangedProp?.({ zoom, tilt, reason: cameraChangeReasonFromNumber(reason), latitude, longitude, bearing, region: { latitude: regionLatitude, longitude: regionLongitude, latitudeDelta: regionLatitudeDelta, longitudeDelta: regionLongitudeDelta, }, }); } ); const onCameraIdle = useStableCallback( ({ nativeEvent: { bearing, latitude, longitude, tilt, zoom, regionLatitude, regionLatitudeDelta, regionLongitude, regionLongitudeDelta, }, }: NativeSyntheticEvent< Camera & { regionLatitude: Double; regionLongitude: Double; regionLatitudeDelta: Double; regionLongitudeDelta: Double; } >) => { onCameraIdleProp?.({ zoom, tilt, latitude, longitude, bearing, region: { latitude: regionLatitude, longitude: regionLongitude, latitudeDelta: regionLatitudeDelta, longitudeDelta: regionLongitudeDelta, }, }); } ); const onTapMap = useStableCallback( ({ nativeEvent: { longitude, latitude, x, y }, }: NativeSyntheticEvent) => { onTapMapProp?.({ longitude, latitude, x, y, }); } ); const screenToCoordinatePromise = useRef<{ resolve: (params: { isValid: boolean; latitude: number; longitude: number; }) => void; reject: (e: any) => void; }>(undefined); const coordinateToScreenPromise = useRef<{ resolve: (params: { isValid: boolean; screenX: number; screenY: number; }) => void; reject: (e: any) => void; }>(undefined); useEffect(() => { return () => { screenToCoordinatePromise.current?.resolve({ isValid: false, latitude: 0, longitude: 0, }); screenToCoordinatePromise.current = undefined; coordinateToScreenPromise.current?.resolve({ isValid: false, screenX: 0, screenY: 0, }); coordinateToScreenPromise.current = undefined; }; }, []); const onScreenToCoordinate = useStableCallback( ({ nativeEvent, }: NativeSyntheticEvent<{ isValid: boolean; latitude: number; longitude: number; }>) => { screenToCoordinatePromise.current?.resolve(nativeEvent); screenToCoordinatePromise.current = undefined; } ); const onCoordinateToScreen = useStableCallback( ({ nativeEvent, }: NativeSyntheticEvent<{ isValid: boolean; screenX: number; screenY: number; }>) => { coordinateToScreenPromise.current?.resolve(nativeEvent); coordinateToScreenPromise.current = undefined; } ); const onCustomStyleLoaded = useStableCallback(() => { onCustomStyleLoadedProp?.(); }); const onCustomStyleLoadFailed = useStableCallback( ({ nativeEvent: { message }, }: NativeSyntheticEvent<{ message: string }>) => { onCustomStyleLoadFailedProp?.({ message }); } ); useImperativeHandle( ref, () => ({ animateCameraTo: ({ duration, easing, latitude, longitude, pivot, zoom = Const.NULL_NUMBER, }) => { if (innerRef.current) { Commands.animateCameraTo( innerRef.current, latitude, longitude, duration ?? Const.DEFAULT_ANIM_DURATION, cameraEasingToNumber(easing ?? Const.DEFAULT_EASING), pivot?.x ?? 0.5, pivot?.y ?? 0.5, zoom ); } }, animateCameraBy: ({ duration, easing, x, y, pivot }) => { if (innerRef.current) { Commands.animateCameraBy( innerRef.current, x, y, duration ?? Const.DEFAULT_ANIM_DURATION, cameraEasingToNumber(easing ?? Const.DEFAULT_EASING), pivot?.x ?? 0.5, pivot?.y ?? 0.5 ); } }, animateRegionTo: ({ easing, longitude, latitude, duration, latitudeDelta, longitudeDelta, pivot, }) => { if (innerRef.current) { Commands.animateRegionTo( innerRef.current, latitude, longitude, latitudeDelta, longitudeDelta, duration ?? Const.DEFAULT_ANIM_DURATION, cameraEasingToNumber(easing ?? Const.DEFAULT_EASING), pivot?.x ?? 0.5, pivot?.y ?? 0.5 ); } }, animateCameraWithTwoCoords: ({ duration, easing, coord1, coord2, pivot, }) => { if (innerRef.current) { const latitude = Math.min(coord1.latitude, coord2.latitude); const longitude = Math.min(coord1.longitude, coord2.longitude); const latitudeDelta = Math.abs(coord1.latitude - coord2.latitude); const longitudeDelta = Math.abs( coord1.longitude - coord2.longitude ); Commands.animateRegionTo( innerRef.current, latitude, longitude, latitudeDelta, longitudeDelta, duration ?? Const.DEFAULT_ANIM_DURATION, cameraEasingToNumber(easing ?? Const.DEFAULT_EASING), pivot?.x ?? 0.5, pivot?.y ?? 0.5 ); } }, cancelAnimation: () => { if (innerRef.current) { Commands.cancelAnimation(innerRef.current); } }, setLocationTrackingMode: (mode: LocationTrackingMode) => { if (innerRef.current) { Commands.setLocationTrackingMode(innerRef.current, mode); } }, screenToCoordinate: ({ screenX, screenY }) => { screenToCoordinatePromise.current?.resolve({ isValid: false, latitude: 0, longitude: 0, }); screenToCoordinatePromise.current = undefined; if (innerRef.current) { const newPromise = new Promise((resolve, reject) => { screenToCoordinatePromise.current = { resolve, reject }; }); Commands.screenToCoordinate(innerRef.current, screenX, screenY); return newPromise; } else { return new Promise((_, reject) => reject(new Error('ref not set yet')) ); } }, coordinateToScreen: ({ latitude, longitude }) => { coordinateToScreenPromise.current?.resolve({ isValid: false, screenX: 0, screenY: 0, }); coordinateToScreenPromise.current = undefined; if (innerRef.current) { const newPromise = new Promise((resolve, reject) => { coordinateToScreenPromise.current = { resolve, reject }; }); Commands.coordinateToScreen(innerRef.current, latitude, longitude); return newPromise; } else { return new Promise((_, reject) => reject(new Error('ref not set yet')) ); } }, }), [] ); return ( onOptionChangedProp({ locationTrackingMode: locationTrackingMode as LocationTrackingMode, }) : undefined } mapPadding={mapPadding} isShowCompass={isShowCompass} isShowIndoorLevelPicker={isShowIndoorLevelPicker} isShowLocationButton={isShowLocationButton} isShowScaleBar={isShowScaleBar} isShowZoomControls={isShowZoomControls} minZoom={minZoom} maxZoom={maxZoom} extent={ extent ? extent : isExtentBoundedInKorea ? southKoreaExtent : undefined } logoAlign={logoAlign} logoMargin={logoMargin} isScrollGesturesEnabled={isScrollGesturesEnabled} isZoomGesturesEnabled={isZoomGesturesEnabled} isTiltGesturesEnabled={isTiltGesturesEnabled} isRotateGesturesEnabled={isRotateGesturesEnabled} isStopGesturesEnabled={isStopGesturesEnabled} isUseTextureViewAndroid={isUseTextureViewAndroid} locale={locale} clusters={_clusters} onScreenToCoordinate={onScreenToCoordinate} onCoordinateToScreen={onCoordinateToScreen} fpsLimit={fpsLimit} customStyleId={customStyleId} onCustomStyleLoaded={ onCustomStyleLoadedProp ? onCustomStyleLoaded : undefined } onCustomStyleLoadFailed={ onCustomStyleLoadFailedProp ? onCustomStyleLoadFailed : undefined } locationOverlay={_locationOverlay} onTapClusterLeaf={ onTapClusterLeaf ? ({ nativeEvent: { markerIdentifier } }) => onTapClusterLeaf({ markerIdentifier }) : undefined } {...rest} /> ); } );