diff --git a/javascript/components/Camera.tsx b/javascript/components/Camera.tsx index 93407b9ff..01c12a5cc 100644 --- a/javascript/components/Camera.tsx +++ b/javascript/components/Camera.tsx @@ -1,18 +1,11 @@ -import React, { - memo, - RefObject, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useCallback, -} from "react"; +import { point } from "@turf/helpers"; +import React, { memo, useCallback, useImperativeHandle, useMemo } from "react"; import { NativeModules, requireNativeComponent, ViewProps } from "react-native"; import { useNativeRef } from "../hooks/useNativeRef"; import { MaplibreGLEvent } from "../types"; -import { toJSONString } from "../utils"; -import * as geoUtils from "../utils/geoUtils"; +import BaseProps from "../types/BaseProps"; +import { makeLatLngBounds } from "../utils/geoUtils"; const MapLibreGL = NativeModules.MLNModule; @@ -34,6 +27,21 @@ export type UserTrackingModeChangeCallback = ( >, ) => void; +export function nativeAnimationMode( + mode?: CameraAnimationMode, +): NativeAnimationMode { + switch (mode) { + case "flyTo": + return MapLibreGL.CameraModes.Flight; + case "moveTo": + return MapLibreGL.CameraModes.None; + case "linearTo": + return MapLibreGL.CameraModes.Linear; + default: + return MapLibreGL.CameraModes.Ease; + } +} + export interface CameraRef { setCamera: (config: CameraStop | CameraStops) => void; fitBounds: ( @@ -51,15 +59,6 @@ export interface CameraRef { animationDuration?: number, ) => void; zoomTo: (zoomLevel: number, animationDuration?: number) => void; - - _defaultCamera: RefObject; - _getMaxBounds: () => string | null; - _getNativeCameraMode: (config: CameraStop) => NativeAnimationMode; - _createStopConfig: ( - config: CameraStop, - ignoreFollowUserLocation?: boolean, - ) => NativeCameraStop | null; - _createDefaultCamera: () => NativeCameraStop | null; } export interface CameraPadding { @@ -99,11 +98,11 @@ interface CameraBoundsWithPadding type NativeAnimationMode = "flight" | "ease" | "linear" | "none" | "move"; export type CameraAnimationMode = "flyTo" | "easeTo" | "linearTo" | "moveTo"; -export interface NativeCameraStop extends Required { - mode: NativeAnimationMode; +export interface NativeCameraStop extends CameraPadding { + duration?: number; + mode?: NativeAnimationMode; pitch?: number; heading?: number; - duration: number; zoom?: number; centerCoordinate?: string; bounds?: string; @@ -133,7 +132,7 @@ export type CameraStops = { stops: CameraStop[]; }; -interface CameraProps extends Omit, CameraStop { +export interface CameraProps extends BaseProps, CameraStop { /** * If false, the camera will not send any props to the native module. Intended to be used to prevent unnecessary tile fetching and improve performance when the map is not visible. Defaults to true. */ @@ -184,19 +183,16 @@ interface CameraProps extends Omit, CameraStop { */ followHeading?: number; - /** - * Manually update the camera - helpful for when props did not update, however you still want the camera to move - */ - triggerKey?: string | number; - // Triggered when the onUserTrackingModeChange?: UserTrackingModeChangeCallback; } -interface NativeProps extends Omit { - maxBounds: string | null; - stop: NativeCameraStop | null; - defaultStop: NativeCameraStop | null; +export interface NativeCameraProps + extends Omit, + ViewProps { + maxBounds?: string; + stop?: NativeCameraStop; + defaultStop?: NativeCameraStop; } const Camera = memo( @@ -204,20 +200,228 @@ const Camera = memo( ( { allowUpdates = true, - animationMode = "easeTo", - animationDuration = 2000, - ...rest + animationMode, + animationDuration, + bounds, + centerCoordinate, + defaultSettings, + followUserLocation, + followHeading, + followPitch, + followUserMode, + followZoomLevel, + heading, + maxBounds, + maxZoomLevel, + minZoomLevel, + onUserTrackingModeChange, + padding, + pitch, + zoomLevel, }: CameraProps, ref, ) => { - const props = useMemo(() => { - return { - allowUpdates, - animationMode, + const nativeCamera = useNativeRef(); + + const buildNativeStop = useCallback( + ( + stop: CameraStop, + ignoreFollowUserLocation = false, + ): NativeCameraStop | undefined => { + if (followUserLocation && !ignoreFollowUserLocation) { + return undefined; + } + + const _nativeStop: NativeCameraStop = {}; + + if (stop.animationDuration !== undefined) { + _nativeStop.duration = stop.animationDuration; + } + if (stop.animationMode !== undefined) { + _nativeStop.mode = nativeAnimationMode(stop.animationMode); + } + if (stop.centerCoordinate) { + _nativeStop.centerCoordinate = JSON.stringify( + point(stop.centerCoordinate), + ); + } + if (stop.heading !== undefined) { + _nativeStop.heading = stop.heading; + } + if (stop.pitch !== undefined) { + _nativeStop.pitch = stop.pitch; + } + if (stop.zoomLevel !== undefined) { + _nativeStop.zoom = stop.zoomLevel; + } + + if (stop.bounds && stop.bounds.ne && stop.bounds.sw) { + const { ne, sw } = stop.bounds; + _nativeStop.bounds = JSON.stringify(makeLatLngBounds(ne, sw)); + } + + const paddingTop = + stop.padding?.paddingTop ?? stop.bounds?.paddingTop; + if (paddingTop !== undefined) { + _nativeStop.paddingTop = paddingTop; + } + + const paddingRight = + stop.padding?.paddingRight ?? stop.bounds?.paddingRight; + if (paddingRight !== undefined) { + _nativeStop.paddingRight = paddingRight; + } + + const paddingBottom = + stop.padding?.paddingBottom ?? stop.bounds?.paddingBottom; + if (paddingBottom !== undefined) { + _nativeStop.paddingBottom = paddingBottom; + } + + const paddingLeft = + stop.padding?.paddingLeft ?? stop.bounds?.paddingLeft; + if (paddingLeft !== undefined) { + _nativeStop.paddingLeft = paddingLeft; + } + + return _nativeStop; + }, + [followUserLocation], + ); + + const nativeStop = useMemo(() => { + return buildNativeStop({ animationDuration, - ...rest, - }; - }, [allowUpdates, animationMode, animationDuration, rest]); + animationMode, + bounds, + centerCoordinate, + heading, + padding, + pitch, + zoomLevel, + }); + }, [ + buildNativeStop, + animationDuration, + animationMode, + bounds, + centerCoordinate, + heading, + padding, + pitch, + zoomLevel, + ]); + + const nativeDefaultStop = useMemo((): NativeCameraStop | undefined => { + if (!defaultSettings) { + return undefined; + } + + return buildNativeStop(defaultSettings, true); + }, [buildNativeStop, defaultSettings]); + + const nativeMaxBounds = useMemo(() => { + if (!maxBounds?.ne || !maxBounds?.sw) { + return undefined; + } + return JSON.stringify(makeLatLngBounds(maxBounds.ne, maxBounds.sw)); + }, [maxBounds]); + + const setCamera = useCallback( + (config: CameraStop | CameraStops = {}): void => { + if (!allowUpdates) { + return; + } + + if ("stops" in config) { + nativeCamera.current?.setNativeProps({ + stop: { + stops: config.stops + .map((stopItem) => buildNativeStop(stopItem)) + .filter((stopItem) => !!stopItem), + }, + }); + } else { + const nativeStop = buildNativeStop(config); + + if (nativeStop) { + nativeCamera.current?.setNativeProps({ stop: nativeStop }); + } + } + }, + [allowUpdates, buildNativeStop, nativeCamera], + ); + + const fitBounds = useCallback( + ( + ne: GeoJSON.Position, + sw: GeoJSON.Position, + paddingConfig?: number | number[], + animationDuration?: number, + ): void => { + const _padding: CameraPadding = {}; + + if (Array.isArray(paddingConfig)) { + if (paddingConfig.length === 2) { + _padding.paddingTop = paddingConfig[0]; + _padding.paddingBottom = paddingConfig[0]; + _padding.paddingLeft = paddingConfig[1]; + _padding.paddingRight = paddingConfig[1]; + } else if (paddingConfig.length === 4) { + _padding.paddingTop = paddingConfig[0]; + _padding.paddingRight = paddingConfig[1]; + _padding.paddingBottom = paddingConfig[2]; + _padding.paddingLeft = paddingConfig[3]; + } + } else if (typeof paddingConfig === "number") { + _padding.paddingLeft = paddingConfig; + _padding.paddingRight = paddingConfig; + _padding.paddingTop = paddingConfig; + _padding.paddingBottom = paddingConfig; + } + + setCamera({ + bounds: { ne, sw }, + padding: _padding, + animationDuration, + animationMode: "easeTo", + }); + }, + [setCamera], + ); + + const flyTo = useCallback( + (coordinates: GeoJSON.Position, animationDuration = 2000): void => { + setCamera({ + centerCoordinate: coordinates, + animationDuration, + animationMode: "flyTo", + }); + }, + [setCamera], + ); + + const moveTo = useCallback( + (centerCoordinate: GeoJSON.Position, animationDuration = 0): void => { + setCamera({ + centerCoordinate, + animationDuration, + animationMode: "easeTo", + }); + }, + [setCamera], + ); + + const zoomTo = useCallback( + (zoomLevel: number, animationDuration = 2000): void => { + setCamera({ + zoomLevel, + animationDuration, + animationMode: "flyTo", + }); + }, + [setCamera], + ); useImperativeHandle( ref, @@ -294,330 +498,31 @@ const Camera = memo( * @param {Object} config - Camera configuration */ setCamera, - _defaultCamera, - _getMaxBounds, - _getNativeCameraMode, - _createStopConfig, - _createDefaultCamera, }), ); - const _defaultCamera = useRef(null); - - const cameraRef = useNativeRef(); - - const _createStopConfig = useCallback( - ( - config: CameraStop = {}, - ignoreFollowUserLocation = false, - ): NativeCameraStop | null => { - if (props.followUserLocation && !ignoreFollowUserLocation) { - return null; - } - - const stopConfig: NativeCameraStop = { - mode: _getNativeCameraMode(config), - pitch: config.pitch, - heading: config.heading, - duration: config.animationDuration || 0, - zoom: config.zoomLevel, - paddingTop: - config.padding?.paddingTop || config.bounds?.paddingTop || 0, - paddingRight: - config.padding?.paddingRight || config.bounds?.paddingRight || 0, - paddingBottom: - config.padding?.paddingBottom || - config.bounds?.paddingBottom || - 0, - paddingLeft: - config.padding?.paddingLeft || config.bounds?.paddingLeft || 0, - }; - - if (config.centerCoordinate) { - stopConfig.centerCoordinate = toJSONString( - geoUtils.makePoint(config.centerCoordinate), - ); - } - - if (config.bounds && config.bounds.ne && config.bounds.sw) { - const { ne, sw } = config.bounds; - stopConfig.bounds = toJSONString(geoUtils.makeLatLngBounds(ne, sw)); - } - - return stopConfig; - }, - [props.followUserLocation], - ); - - const _setCamera = useCallback( - (config: CameraStop | CameraStops = {}): void => { - if ("stops" in config) { - let nativeStops: NativeCameraStop[] = []; - - for (const stop of config.stops) { - const nativeStop = _createStopConfig(stop); - if (nativeStop) { - nativeStops = [...nativeStops, nativeStop]; - } - } - cameraRef.current?.setNativeProps({ stop: { stops: nativeStops } }); - } else { - const nativeStop = _createStopConfig(config); - - if (nativeStop) { - cameraRef.current?.setNativeProps({ stop: nativeStop }); - } - } - }, - [cameraRef.current, _createStopConfig], - ); - - const _getMaxBounds = useCallback((): string | null => { - const bounds = props.maxBounds; - if (!bounds || !bounds.ne || !bounds.sw) { - return null; - } - return toJSONString(geoUtils.makeLatLngBounds(bounds.ne, bounds.sw)); - }, [props.maxBounds]); - - useEffect(() => { - if (!props.allowUpdates) { - return; - } - - cameraRef.current?.setNativeProps({ - followUserLocation: props.followUserLocation, - }); - }, [cameraRef.current, props.followUserLocation]); - - useEffect(() => { - if (!props.maxBounds || !props.allowUpdates) { - return; - } - - cameraRef.current?.setNativeProps({ - maxBounds: _getMaxBounds(), - }); - }, [cameraRef.current, props.maxBounds, _getMaxBounds]); - - useEffect(() => { - if (!props.minZoomLevel || !props.allowUpdates) { - return; - } - - cameraRef.current?.setNativeProps({ - minZoomLevel: props.minZoomLevel, - }); - }, [cameraRef.current, props.minZoomLevel]); - - useEffect(() => { - if (!props.maxZoomLevel || !props.allowUpdates) { - return; - } - - cameraRef.current?.setNativeProps({ - maxZoomLevel: props.maxZoomLevel, - }); - }, [cameraRef.current, props.maxZoomLevel]); - - useEffect(() => { - if (!props.allowUpdates) { - return; - } - - if (!props.followUserLocation) { - return; - } - - cameraRef.current?.setNativeProps({ - followUserMode: props.followUserMode, - followPitch: props.followPitch || props.pitch, - followHeading: props.followHeading || props.heading, - followZoomLevel: props.followZoomLevel || props.zoomLevel, - }); - }, [ - cameraRef.current, - props.allowUpdates, - props.followUserLocation, - props.followUserMode, - props.followPitch, - props.pitch, - props.followHeading, - props.heading, - props.followZoomLevel, - props.zoomLevel, - ]); - - const cameraConfig: CameraStop = useMemo(() => { - return { - bounds: props.bounds, - centerCoordinate: props.centerCoordinate, - padding: props.padding, - zoomLevel: props.zoomLevel, - minZoomLevel: props.minZoomLevel, - maxZoomLevel: props.maxZoomLevel, - pitch: props.pitch, - heading: props.heading, - animationMode: props.animationMode, - animationDuration: props.animationDuration, - }; - }, [ - props.bounds, - props.centerCoordinate, - props.padding, - props.zoomLevel, - props.minZoomLevel, - props.maxZoomLevel, - props.pitch, - props.heading, - props.animationMode, - props.animationDuration, - ]); - - useEffect(() => { - if (!props.allowUpdates) { - return; - } - - _setCamera(cameraConfig); - }, [_setCamera, cameraConfig]); - - const fitBounds = ( - northEastCoordinates: GeoJSON.Position, - southWestCoordinates: GeoJSON.Position, - padding: number | number[] = 0, - animationDuration: number = 0.0, - ): void => { - const pad = { - paddingLeft: 0, - paddingRight: 0, - paddingTop: 0, - paddingBottom: 0, - }; - - if (Array.isArray(padding)) { - if (padding.length === 2) { - pad.paddingTop = padding[0]; - pad.paddingBottom = padding[0]; - pad.paddingLeft = padding[1]; - pad.paddingRight = padding[1]; - } else if (padding.length === 4) { - pad.paddingTop = padding[0]; - pad.paddingRight = padding[1]; - pad.paddingBottom = padding[2]; - pad.paddingLeft = padding[3]; - } - } else { - pad.paddingLeft = padding; - pad.paddingRight = padding; - pad.paddingTop = padding; - pad.paddingBottom = padding; - } - - setCamera({ - bounds: { - ne: northEastCoordinates, - sw: southWestCoordinates, - }, - padding: pad, - animationDuration, - animationMode: "easeTo", - }); - }; - - const flyTo = ( - coordinates: GeoJSON.Position, - animationDuration = 2000, - ): void => { - setCamera({ - centerCoordinate: coordinates, - animationDuration, - animationMode: "flyTo", - }); - }; - - const moveTo = ( - coordinates: GeoJSON.Position, - animationDuration = 0, - ): void => { - setCamera({ - centerCoordinate: coordinates, - animationDuration, - }); - }; - - const zoomTo = (zoomLevel: number, animationDuration = 2000): void => { - setCamera({ - zoomLevel, - animationDuration, - animationMode: "flyTo", - }); - }; - - const setCamera = (config: CameraStop | CameraStops = {}): void => { - _setCamera(config); - }; - - const _createDefaultCamera = (): NativeCameraStop | null => { - if (_defaultCamera.current) { - return _defaultCamera.current; - } - if (!props.defaultSettings) { - return null; - } - - _defaultCamera.current = _createStopConfig( - { - ...props.defaultSettings, - animationMode: "moveTo", - }, - true, - ); - return _defaultCamera.current; - }; - - const _getNativeCameraMode = ( - config: CameraStop, - ): NativeAnimationMode => { - switch (config.animationMode) { - case "flyTo": - return MapLibreGL.CameraModes.Flight; - case "moveTo": - return MapLibreGL.CameraModes.None; - case "linearTo": - return MapLibreGL.CameraModes.Linear; - default: - return MapLibreGL.CameraModes.Ease; - } - }; - - const nativeProps = Object.assign({}, props); - - const callbacks = { - onUserTrackingModeChange: nativeProps.onUserTrackingModeChange, - }; - return ( ); }, ), ); -const RCTMLNCamera = requireNativeComponent(NATIVE_MODULE_NAME); +const RCTMLNCamera = + requireNativeComponent(NATIVE_MODULE_NAME); export default Camera;