@goongmaps/goong-map-react-native
Version:
A Goong GL react native module for creating custom maps
571 lines (486 loc) • 15.1 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import {NativeModules, requireNativeComponent} from 'react-native';
import {toJSONString, viewPropTypes, existenceChange} from '../utils';
import * as geoUtils from '../utils/geoUtils';
const GoongSDK = NativeModules.MGLModule;
export const NATIVE_MODULE_NAME = 'RCTMGLCamera';
const SettingsPropTypes = {
/**
* Center coordinate on map [lng, lat]
*/
centerCoordinate: PropTypes.arrayOf(PropTypes.number),
/**
* Heading on map
*/
heading: PropTypes.number,
/**
* Pitch on map
*/
pitch: PropTypes.number,
bounds: PropTypes.shape({
/**
* northEastCoordinates - North east coordinate of bound
*/
ne: PropTypes.arrayOf(PropTypes.number).isRequired,
/**
* southWestCoordinates - North east coordinate of bound
*/
sw: PropTypes.arrayOf(PropTypes.number).isRequired,
/**
* Left camera padding for bounds
*/
paddingLeft: PropTypes.number,
/**
* Right camera padding for bounds
*/
paddingRight: PropTypes.number,
/**
* Top camera padding for bounds
*/
paddingTop: PropTypes.number,
/**
* Bottom camera padding for bounds
*/
paddingBottom: PropTypes.number,
}),
/**
* Zoom level of the map
*/
zoomLevel: PropTypes.number,
};
class Camera extends React.Component {
static propTypes = {
...viewPropTypes,
animationDuration: PropTypes.number,
animationMode: PropTypes.oneOf(['flyTo', 'easeTo', 'moveTo']),
// default - view settings
defaultSettings: PropTypes.shape(SettingsPropTypes),
// normal - view settings
...SettingsPropTypes,
minZoomLevel: PropTypes.number,
maxZoomLevel: PropTypes.number,
/**
* Restrict map panning so that the center is within these bounds
*/
maxBounds: PropTypes.shape({
/**
* northEastCoordinates - North east coordinate of bound
*/
ne: PropTypes.arrayOf(PropTypes.number).isRequired,
/**
* southWestCoordinates - South west coordinate of bound
*/
sw: PropTypes.arrayOf(PropTypes.number).isRequired,
}),
/**
* Should the map orientation follow the user's.
*/
followUserLocation: PropTypes.bool,
/**
* The mode used to track the user location on the map. One of; "normal", "compass", "course". Each mode string is also available as a member on the `GoongSDK.UserTrackingModes` object. `Follow` (normal), `FollowWithHeading` (compass), `FollowWithCourse` (course). NOTE: `followUserLocation` must be set to `true` for any of the modes to take effect. [Example](../example/src/examples/SetUserTrackingModes.js)
*/
followUserMode: PropTypes.oneOf(['normal', 'compass', 'course']),
followZoomLevel: PropTypes.number,
followPitch: PropTypes.number,
followHeading: PropTypes.number,
// manual update
triggerKey: PropTypes.any,
// position
alignment: PropTypes.arrayOf(PropTypes.number),
// Triggered when the
onUserTrackingModeChange: PropTypes.func,
};
static defaultProps = {
animationMode: 'easeTo',
animationDuration: 2000,
isUserInteraction: false,
};
static Mode = {
Flight: 'flyTo',
Move: 'moveTo',
Ease: 'easeTo',
};
UNSAFE_componentWillReceiveProps(nextProps) {
this._handleCameraChange(this.props, nextProps);
}
shouldComponentUpdate() {
return false;
}
_handleCameraChange(currentCamera, nextCamera) {
const hasCameraChanged = this._hasCameraChanged(currentCamera, nextCamera);
if (!hasCameraChanged) {
return;
}
if (currentCamera.followUserLocation && !nextCamera.followUserLocation) {
this.refs.camera.setNativeProps({followUserLocation: false});
return;
}
if (!currentCamera.followUserLocation && nextCamera.followUserLocation) {
this.refs.camera.setNativeProps({followUserLocation: true});
}
if (nextCamera.followUserLocation) {
this.refs.camera.setNativeProps({
followUserMode: nextCamera.followUserMode,
followPitch: nextCamera.followPitch || nextCamera.pitch,
followHeading: nextCamera.followHeading || nextCamera.heading,
followZoomLevel: nextCamera.followZoomLevel || nextCamera.zoomLevel,
});
return;
}
const cameraConfig = {
animationMode: nextCamera.animationMode,
animationDuration: nextCamera.animationDuration,
zoomLevel: nextCamera.zoomLevel,
pitch: nextCamera.pitch,
heading: nextCamera.heading,
};
if (
nextCamera.bounds &&
this._hasBoundsChanged(currentCamera, nextCamera)
) {
cameraConfig.bounds = nextCamera.bounds;
} else {
cameraConfig.centerCoordinate = nextCamera.centerCoordinate;
}
this._setCamera(cameraConfig);
}
_hasCameraChanged(currentCamera, nextCamera) {
const c = currentCamera;
const n = nextCamera;
const hasDefaultPropsChanged =
c.heading !== n.heading ||
this._hasCenterCoordinateChanged(c, n) ||
this._hasBoundsChanged(c, n) ||
c.pitch !== n.pitch ||
c.zoomLevel !== n.zoomLevel ||
c.triggerKey !== n.triggerKey;
const hasFollowPropsChanged =
c.followUserLocation !== n.followUserLocation ||
c.followUserMode !== n.followUserMode ||
c.followZoomLevel !== n.followZoomLevel ||
c.followHeading !== n.followHeading ||
c.followPitch !== n.followPitch;
const hasAnimationPropsChanged =
c.animationMode !== n.animationMode ||
c.animationDuration !== n.animationDuration;
return (
hasDefaultPropsChanged ||
hasFollowPropsChanged ||
hasAnimationPropsChanged
);
}
_hasCenterCoordinateChanged(currentCamera, nextCamera) {
const cC = currentCamera.centerCoordinate;
const nC = nextCamera.centerCoordinate;
if (existenceChange(cC, nC)) {
return true;
}
if (!cC && !nC) {
return false;
}
const isLngDiff =
currentCamera.centerCoordinate[0] !== nextCamera.centerCoordinate[0];
const isLatDiff =
currentCamera.centerCoordinate[1] !== nextCamera.centerCoordinate[1];
return isLngDiff || isLatDiff;
}
_hasBoundsChanged(currentCamera, nextCamera) {
const cB = currentCamera.bounds;
const nB = nextCamera.bounds;
if (!cB && !nB) {
return false;
}
if (existenceChange(cB, nB)) {
return true;
}
return (
cB.ne[0] !== nB.ne[0] ||
cB.ne[1] !== nB.ne[1] ||
cB.sw[0] !== nB.sw[0] ||
cB.sw[1] !== nB.sw[1] ||
cB.paddingTop !== nB.paddingTop ||
cB.paddingLeft !== nB.paddingLeft ||
cB.paddingRight !== nB.paddingRight ||
cB.paddingBottom !== nB.paddingBottom
);
}
/**
* Map camera transitions to fit provided bounds
*
* @example
* this.camera.fitBounds([lng, lat], [lng, lat])
* this.camera.fitBounds([lng, lat], [lng, lat], 20, 1000) // padding for all sides
* this.camera.fitBounds([lng, lat], [lng, lat], [verticalPadding, horizontalPadding], 1000)
* this.camera.fitBounds([lng, lat], [lng, lat], [top, right, bottom, left], 1000)
*
* @param {Array<Number>} northEastCoordinates - North east coordinate of bound
* @param {Array<Number>} southWestCoordinates - South west coordinate of bound
* @param {Number=} padding - Camera padding for bound
* @param {Number=} animationDuration - Duration of camera animation
* @return {void}
*/
fitBounds(
northEastCoordinates,
southWestCoordinates,
padding = 0,
animationDuration = 0.0,
) {
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;
}
return this.setCamera({
bounds: {
ne: northEastCoordinates,
sw: southWestCoordinates,
...pad,
},
animationDuration,
animationMode: Camera.Mode.Ease,
});
}
/**
* Map camera will fly to new coordinate
*
* @example
* this.camera.flyTo([lng, lat])
* this.camera.flyTo([lng, lat], 12000)
*
* @param {Array<Number>} coordinates - Coordinates that map camera will jump too
* @param {Number=} animationDuration - Duration of camera animation
* @return {void}
*/
flyTo(coordinates, animationDuration = 2000) {
return this.setCamera({
centerCoordinate: coordinates,
animationDuration,
animationMode: Camera.Mode.Flight,
});
}
/**
* Map camera will move to new coordinate at the same zoom level
*
* @example
* this.camera.moveTo([lng, lat], 200) // eases camera to new location based on duration
* this.camera.moveTo([lng, lat]) // snaps camera to new location without any easing
*
* @param {Array<Number>} coordinates - Coordinates that map camera will move too
* @param {Number=} animationDuration - Duration of camera animation
* @return {void}
*/
moveTo(coordinates, animationDuration = 0) {
return this._setCamera({
centerCoordinate: coordinates,
animationDuration,
});
}
/**
* Map camera will zoom to specified level
*
* @example
* this.camera.zoomTo(16)
* this.camera.zoomTo(16, 100)
*
* @param {Number} zoomLevel - Zoom level that the map camera will animate too
* @param {Number=} animationDuration - Duration of camera animation
* @return {void}
*/
zoomTo(zoomLevel, animationDuration = 2000) {
return this._setCamera({
zoomLevel,
animationDuration,
animationMode: Camera.Mode.Flight,
});
}
/**
* Map camera will perform updates based on provided config. Advanced use only!
*
* @example
* this.camera.setCamera({
* centerCoordinate: [lng, lat],
* zoomLevel: 16,
* animationDuration: 2000,
* })
*
* this.camera.setCamera({
* stops: [
* { pitch: 45, animationDuration: 200 },
* { heading: 180, animationDuration: 300 },
* ]
* })
*
* @param {Object} config - Camera configuration
*/
setCamera(config = {}) {
this._setCamera(config);
}
_setCamera(config = {}) {
let cameraConfig = {};
if (config.stops) {
cameraConfig.stops = [];
for (const stop of config.stops) {
cameraConfig.stops.push(this._createStopConfig(stop));
}
} else {
cameraConfig = this._createStopConfig(config);
}
this.refs.camera.setNativeProps({stop: cameraConfig});
}
_createDefaultCamera() {
if (this.defaultCamera) {
return this.defaultCamera;
}
if (!this.props.defaultSettings) {
return null;
}
this.defaultCamera = this._createStopConfig(
{
...this.props.defaultSettings,
animationMode: Camera.Mode.Move,
},
true,
);
return this.defaultCamera;
}
_createStopConfig(config = {}, ignoreFollowUserLocation = false) {
if (this.props.followUserLocation && !ignoreFollowUserLocation) {
return null;
}
const stopConfig = {
mode: this._getNativeCameraMode(config),
pitch: config.pitch,
heading: config.heading,
duration: config.animationDuration || 0,
zoom: config.zoomLevel,
};
if (config.centerCoordinate) {
stopConfig.centerCoordinate = toJSONString(
geoUtils.makePoint(config.centerCoordinate),
);
}
if (config.bounds && config.bounds.ne && config.bounds.sw) {
const {
ne,
sw,
paddingLeft,
paddingRight,
paddingTop,
paddingBottom,
} = config.bounds;
stopConfig.bounds = toJSONString(geoUtils.makeLatLngBounds(ne, sw));
stopConfig.boundsPaddingTop = paddingTop || 0;
stopConfig.boundsPaddingRight = paddingRight || 0;
stopConfig.boundsPaddingBottom = paddingBottom || 0;
stopConfig.boundsPaddingLeft = paddingLeft || 0;
}
return stopConfig;
}
_getNativeCameraMode(config) {
switch (config.animationMode) {
case Camera.Mode.Flight:
return GoongSDK.CameraModes.Flight;
case Camera.Mode.Move:
return GoongSDK.CameraModes.None;
default:
return GoongSDK.CameraModes.Ease;
}
}
_getAlignment(coordinate, zoomLevel) {
const region = geoUtils.getOrCalculateVisibleRegion(
coordinate,
zoomLevel,
this.props._mapWidth,
this.props._mapHeight,
this.props._region,
);
const topLeftCorner = [region.sw[0], region.ne[1]];
const topRightCorner = [region.ne[0], region.ne[1]];
const bottomLeftCorner = [region.sw[0], region.sw[1]];
const verticalLineString = geoUtils.makeLineString([
topLeftCorner,
bottomLeftCorner,
]);
const horizontalLineString = geoUtils.makeLineString([
topLeftCorner,
topRightCorner,
]);
const distVertical = geoUtils.calculateDistance(
topLeftCorner,
bottomLeftCorner,
);
const distHorizontal = geoUtils.calculateDistance(
topLeftCorner,
topRightCorner,
);
const verticalPoint = geoUtils.pointAlongLine(
verticalLineString,
distVertical * this.props.alignment[0],
);
const horizontalPoint = geoUtils.pointAlongLine(
horizontalLineString,
distHorizontal * this.props.alignment[1],
);
return [verticalPoint[0], horizontalPoint[1]];
}
_getMaxBounds() {
const bounds = this.props.maxBounds;
if (!bounds || !bounds.ne || !bounds.sw) {
return null;
}
return toJSONString(geoUtils.makeLatLngBounds(bounds.ne, bounds.sw));
}
render() {
const props = Object.assign({}, this.props);
const callbacks = {
onUserTrackingModeChange: props.onUserTrackingModeChange,
};
return (
<RCTMGLCamera
ref="camera"
followUserLocation={this.props.followUserLocation}
followUserMode={this.props.followUserMode}
followUserPitch={this.props.followUserPitch}
followHeading={this.props.followHeading}
followZoomLevel={this.props.followZoomLevel}
stop={this._createStopConfig(props)}
maxZoomLevel={this.props.maxZoomLevel}
minZoomLevel={this.props.minZoomLevel}
maxBounds={this._getMaxBounds()}
defaultStop={this._createDefaultCamera()}
{...callbacks}
/>
);
}
}
const RCTMGLCamera = requireNativeComponent(NATIVE_MODULE_NAME, Camera, {
nativeOnly: {
stop: true,
},
});
Camera.UserTrackingModes = {
Follow: 'normal',
FollowWithHeading: 'compass',
FollowWithCourse: 'course',
};
export default Camera;