[v7] Map component (#1652)

This commit is contained in:
Xiaoji Chen 2021-12-30 13:51:57 -08:00 committed by Xiaoji Chen
parent 170217280e
commit 5b674be3c4
16 changed files with 1243 additions and 37 deletions

View File

@ -1,8 +1,8 @@
module.exports = {
extends: '@istanbuljs/nyc-config-typescript',
all: 'true',
sourceMap: false,
instrument: true,
extensions: ['.ts', '.tsx'],
include: ['src']
};
module.exports = {
extends: '@istanbuljs/nyc-config-typescript',
all: 'true',
sourceMap: false,
instrument: true,
extensions: ['.ts', '.tsx'],
include: ['src']
};

View File

@ -0,0 +1,4 @@
import * as React from 'react';
import type {MapboxMap} from '../utils/types';
export default React.createContext<MapboxMap>(null);

View File

@ -1,9 +1,93 @@
import * as React from 'react';
import {useState, useRef, useEffect, forwardRef, useImperativeHandle} from 'react';
export type MapProps = {
id: string;
import Mapbox from '../mapbox/mapbox';
import type {MapboxProps} from '../mapbox/mapbox';
import MapContext from './map-context';
import type {CSSProperties} from 'react';
import type {MapboxMap} from '../utils/types';
import useIsomorphicLayoutEffect from '../utils/use-isomorphic-layout-effect';
export interface MapRef {
getMap(): MapboxMap;
}
export type MapProps = MapboxProps & {
/** Map container id */
id?: string;
/** Map container CSS style */
style?: CSSProperties;
children?: any;
ref?: React.Ref<MapRef>;
};
export default function Map(props: MapProps) {
return <div id={props.id} />;
}
const defaultProps: MapProps = {
// Constraints
minZoom: 0,
maxZoom: 22,
minPitch: 0,
maxPitch: 85,
// Interaction handlers
scrollZoom: true,
boxZoom: true,
dragRotate: true,
dragPan: true,
keyboard: true,
doubleClickZoom: true,
touchZoomRotate: true,
touchPitch: true,
// Style
mapStyle: {version: 8},
styleDiffing: true,
projection: 'mercator',
renderWorldCopies: true
};
const Map = forwardRef((props: MapProps, ref) => {
const [mapInstance, setMapInstance] = useState<Mapbox>(null);
const containerRef = useRef();
useEffect(() => {
const map = new Mapbox(props);
map.initialize(containerRef.current);
setMapInstance(map);
return () => map.destroy();
}, []);
useIsomorphicLayoutEffect(() => {
if (mapInstance) {
mapInstance.setProps(props);
}
});
useImperativeHandle(
ref,
() => ({
getMap: () => mapInstance.getMap()
}),
[mapInstance]
);
const style: CSSProperties = {
position: 'relative',
width: '100%',
height: '100%',
...props.style
};
return (
<div id={props.id} ref={containerRef} style={style}>
{mapInstance && (
<MapContext.Provider value={mapInstance.getMap()}>{props.children}</MapContext.Provider>
)}
</div>
);
});
Map.defaultProps = defaultProps;
export default Map;

View File

@ -1,3 +1,6 @@
export {default} from './components/map';
export {default as Map, MapProps} from './components/map';
export {default as Map, MapProps, MapRef} from './components/map';
// Types
export * from './utils/types';

601
src/mapbox/mapbox.ts Normal file
View File

@ -0,0 +1,601 @@
import mapboxgl from '../utils/mapboxgl';
import {Transform, transformToViewState, applyViewStateToTransform} from '../utils/transform';
import {deepEqual} from '../utils/deep-equal';
import type {
ProjectionSpecification,
ViewState,
ViewStateChangeEvent,
MapboxOptions,
Style,
MapMouseEvent,
MapLayerMouseEvent,
MapLayerTouchEvent,
MapWheelEvent,
MapDataEvent,
MapboxEvent,
ErrorEvent,
MapboxGeoJSONFeature
} from '../utils/types';
export type MapboxProps = Omit<
MapboxOptions,
'center' | 'accessToken' | 'container' | 'style' | 'bounds' | 'fitBoundsOptions'
> &
ViewState & {
mapboxAccessToken?: string;
/** Camera options used when constructing the Map instance */
initialViewState?: Pick<MapboxOptions, 'bounds' | 'fitBoundsOptions'> & ViewState;
/** If provided, render into an external WebGL context */
gl?: WebGLRenderingContext;
/** Aternative way to specify camera state */
viewState?: ViewState;
/** Mapbox style */
mapStyle?: string | Style;
/** Enable diffing when the map style changes */
styleDiffing?: boolean;
/** Default layers to query on pointer events */
interactiveLayerIds?: string[];
/** The projection the map should be rendered in */
projection?: ProjectionSpecification | string;
/** CSS cursor */
cursor?: string;
// Callbacks
onMouseDown?: (e: MapLayerMouseEvent) => void;
onMouseUp?: (e: MapLayerMouseEvent) => void;
onMouseOver?: (e: MapLayerMouseEvent) => void;
onMouseMove?: (e: MapLayerMouseEvent) => void;
onClick?: (e: MapLayerMouseEvent) => void;
onDblClick?: (e: MapLayerMouseEvent) => void;
onMouseEnter?: (e: MapLayerMouseEvent) => void;
onMouseLeave?: (e: MapLayerMouseEvent) => void;
onMouseOut?: (e: MapLayerMouseEvent) => void;
onContextMenu?: (e: MapLayerMouseEvent) => void;
onWheel?: (e: MapWheelEvent) => void;
onTouchStart?: (e: MapLayerTouchEvent) => void;
onTouchEnd?: (e: MapLayerTouchEvent) => void;
onTouchMove?: (e: MapLayerTouchEvent) => void;
onTouchCancel?: (e: MapLayerTouchEvent) => void;
onMoveStart?: (e: ViewStateChangeEvent) => void;
onMove?: (e: ViewStateChangeEvent) => void;
onMoveEnd?: (e: ViewStateChangeEvent) => void;
onDragStart?: (e: ViewStateChangeEvent) => void;
onDrag?: (e: ViewStateChangeEvent) => void;
onDragEnd?: (e: ViewStateChangeEvent) => void;
onZoomStart?: (e: ViewStateChangeEvent) => void;
onZoom?: (e: ViewStateChangeEvent) => void;
onZoomEnd?: (e: ViewStateChangeEvent) => void;
onRotateStart?: (e: ViewStateChangeEvent) => void;
onRotate?: (e: ViewStateChangeEvent) => void;
onRotateEnd?: (e: ViewStateChangeEvent) => void;
onPitchStart?: (e: ViewStateChangeEvent) => void;
onPitch?: (e: ViewStateChangeEvent) => void;
onPitchEnd?: (e: ViewStateChangeEvent) => void;
onBoxZoomStart?: (e: ViewStateChangeEvent) => void;
onBoxZoomEnd?: (e: ViewStateChangeEvent) => void;
onBoxZoomCancel?: (e: ViewStateChangeEvent) => void;
onResize?: (e: MapboxEvent) => void;
onLoad?: (e: MapboxEvent) => void;
onRender?: (e: MapboxEvent) => void;
onIdle?: (e: MapboxEvent) => void;
onError?: (e: ErrorEvent) => void;
onRemove?: (e: MapboxEvent) => void;
onData?: (e: MapDataEvent) => void;
onStyleData?: (e: MapDataEvent) => void;
onSourceData?: (e: MapDataEvent) => void;
};
const pointerEvents = {
mousedown: 'onMouseDown',
mouseup: 'onMouseUp',
mouseover: 'onMouseOver',
mousemove: 'onMouseMove',
click: 'onClick',
dblclick: 'onDblClick',
mouseenter: 'onMouseEnter',
mouseleave: 'onMouseLeave',
mouseout: 'onMouseOut',
contextmenu: 'onContextMenu',
touchstart: 'onTouchStart',
touchend: 'onTouchEnd',
touchmove: 'onTouchMove',
touchcancel: 'onTouchCancel'
};
const cameraEvents = {
movestart: 'onMoveStart',
move: 'onMove',
moveend: 'onMoveEnd',
dragstart: 'onDragStart',
drag: 'onDrag',
dragend: 'onDragEnd',
zoomstart: 'onZoomStart',
zoom: 'onZoom',
zoomend: 'onZoomEnd',
rotatestart: 'onRotateStart',
rotate: 'onRotate',
rotateend: 'onRotateEnd',
pitchstart: 'onPitchStart',
pitch: 'onPitch',
pitchend: 'onPitchEnd',
boxzoomstart: 'onBoxZoomStart',
boxzoomend: 'onBoxZoomEnd',
boxzoomcancel: 'onBoxZoomCancel'
};
const otherEvents = {
wheel: 'onWheel',
resize: 'onResize',
load: 'onLoad',
render: 'onRender',
idle: 'onIdle',
remove: 'onRemove',
data: 'onData',
styledata: 'onStyleData',
sourcedata: 'onSourceData'
};
const settingNames: (keyof MapboxProps)[] = [
'minZoom',
'maxZoom',
'minPitch',
'maxPitch',
'maxBounds',
'projection',
'renderWorldCopies'
];
const handlerNames: (keyof MapboxProps)[] = [
'scrollZoom',
'boxZoom',
'dragRotate',
'dragPan',
'keyboard',
'doubleClickZoom',
'touchZoomRotate',
'touchPitch'
];
/**
* A wrapper for mapbox-gl's Map class
*/
export default class Mapbox {
// mapboxgl.Map instance. Not using type here because we are accessing
// private members and methods
private _map: any = null;
// User-supplied props
props: MapboxProps;
// Mapbox map is stateful.
// During method calls/user interactions, map.transform is mutated and
// deviate from user-supplied props.
// In order to control the map reactively, we shadow the transform
// with the one below, which reflects the view state resolved from
// both user-supplied props and the underlying state
private _renderTransform: Transform;
// Internal states
private _internalUpdate: boolean = false;
private _inRender: boolean = false;
private _hoveredFeatures: MapboxGeoJSONFeature[] = null;
private _moved: boolean = false;
private _zoomed: boolean = false;
private _pitched: boolean = false;
private _rotated: boolean = false;
private _nextProps: MapboxProps | null;
constructor(props: MapboxProps) {
this.props = props;
}
getMap() {
return this._map;
}
setProps(props: MapboxProps) {
if (this._inRender) {
this._nextProps = props;
return;
}
const oldProps = this.props;
this.props = props;
const settingsChanged = this._updateSettings(props, oldProps);
if (settingsChanged) {
this._renderTransform = this._map.transform.clone();
}
const viewStateChanged = this._updateViewState(props, true);
this._updateStyle(props, oldProps);
this._updateHandlers(props, oldProps);
// If 1) view state has changed to match props and
// 2) the props change is not triggered by map events,
// it's driven by an external state change. Redraw immediately
if (settingsChanged || (viewStateChanged && !this._map.isMoving())) {
this.redraw();
}
}
initialize(container: HTMLDivElement) {
const {props} = this;
const mapOptions = {
...props,
...props.initialViewState,
accessToken: props.mapboxAccessToken || getAccessTokenFromEnv() || null,
container,
style: props.mapStyle
};
const viewState = mapOptions.initialViewState || mapOptions.viewState || mapOptions;
Object.assign(mapOptions, {
center: [viewState.longitude || 0, viewState.latitude || 0],
zoom: viewState.zoom || 0,
pitch: viewState.pitch || 0,
bearing: viewState.bearing || 0
});
if (props.gl) {
// eslint-disable-next-line
const getContext = HTMLCanvasElement.prototype.getContext;
// Hijack canvas.getContext to return our own WebGLContext
// This will be called inside the mapboxgl.Map constructor
// @ts-expect-error
HTMLCanvasElement.prototype.getContext = () => {
// Unhijack immediately
HTMLCanvasElement.prototype.getContext = getContext;
return props.gl;
};
}
const map: any = new mapboxgl.Map(mapOptions);
if (viewState.padding) {
map.setPadding(viewState.padding);
}
this._renderTransform = map.transform.clone();
// Hack
// Insert code into map's render cycle
const renderMap = map._render;
map._render = this._render.bind(this, renderMap);
const runRenderTaskQueue = map._renderTaskQueue.run;
map._renderTaskQueue.run = (arg: number) => {
runRenderTaskQueue.call(map._renderTaskQueue, arg);
this._onBeforeRepaint();
};
// Insert code into map's event pipeline
const fireEvent = map.fire;
map.fire = this._fireEvent.bind(this, fireEvent);
// add listeners
for (const eventName in pointerEvents) {
map.on(eventName, this._onPointerEvent);
}
for (const eventName in cameraEvents) {
map.on(eventName, this._onCameraEvent);
}
for (const eventName in otherEvents) {
map.on(eventName, this._onEvent);
}
map.on('error', this._onError);
this._map = map;
}
destroy() {
this._map.remove();
}
// Force redraw the map now. Typically resize() and jumpTo() is reflected in the next
// render cycle, which is managed by Mapbox's animation loop.
// This removes the synchronization issue caused by requestAnimationFrame.
redraw() {
const map = this._map;
// map._render will throw error if style does not exist
// https://github.com/mapbox/mapbox-gl-js/blob/fb9fc316da14e99ff4368f3e4faa3888fb43c513
// /src/ui/map.js#L1834
if (map.style) {
// cancel the scheduled update
if (map._frame) {
map._frame.cancel();
map._frame = null;
}
// the order is important - render() may schedule another update
map._render();
}
}
// Adapted from map.jumpTo
/* Update camera to match props
@param {object} nextProps
@param {bool} triggerEvents - should fire camera events
@returns {bool} true if anything is changed
*/
_updateViewState(nextProps: MapboxProps, triggerEvents: boolean): boolean {
if (this._internalUpdate) {
return false;
}
const map = this._map;
const tr = this._renderTransform;
// Take a snapshot of the transform before mutation
const {zoom, pitch, bearing} = tr;
const changed = applyViewStateToTransform(tr, {
...transformToViewState(map.transform),
...nextProps
});
if (changed && triggerEvents) {
// Delay DOM control updates to the next render cycle
this._moved = true;
this._zoomed = this._zoomed || zoom !== tr.zoom;
this._rotated = this._rotated || bearing !== tr.bearing;
this._pitched = this._pitched || pitch !== tr.pitch;
}
// Avoid manipulating the real transform when interaction/animation is ongoing
// as it would interfere with Mapbox's handlers
if (!map.isMoving()) {
applyViewStateToTransform(map.transform, nextProps);
}
return changed;
}
/* Update camera constraints and projection settings to match props
@param {object} nextProps
@param {object} currProps
@returns {bool} true if anything is changed
*/
_updateSettings(nextProps: MapboxProps, currProps: MapboxProps): boolean {
const map = this._map;
let changed = false;
for (const propName of settingNames) {
if (!deepEqual(nextProps[propName], currProps[propName])) {
changed = true;
map[`set${propName[0].toUpperCase()}${propName.slice(1)}`]?.(nextProps[propName]);
}
}
return changed;
}
/* Update map style to match props
@param {object} nextProps
@param {object} currProps
@returns {bool} true if style is changed
*/
_updateStyle(nextProps: MapboxProps, currProps: MapboxProps): boolean {
if (nextProps.cursor !== currProps.cursor) {
this._map.getCanvas().style.cursor = nextProps.cursor;
}
if (nextProps.mapStyle !== currProps.mapStyle) {
const options: any = {
diff: nextProps.styleDiffing
};
if ('localIdeographFontFamily' in nextProps) {
options.localIdeographFontFamily = nextProps.localIdeographFontFamily;
}
this._map.setStyle(nextProps.mapStyle, options);
return true;
}
return false;
}
/* Update interaction handlers to match props
@param {object} nextProps
@param {object} currProps
@returns {bool} true if anything is changed
*/
_updateHandlers(nextProps: MapboxProps, currProps: MapboxProps): boolean {
const map = this._map;
let changed = false;
for (const propName of handlerNames) {
const newValue = nextProps[propName];
if (!deepEqual(newValue, currProps[propName])) {
changed = true;
if (newValue) {
map[propName].enable(newValue);
} else {
map[propName].disable();
}
}
}
return changed;
}
_onEvent = (e: MapboxEvent) => {
// @ts-ignore
const cb = this.props[otherEvents[e.type]];
if (cb) {
cb(e);
}
};
_onError = (e: ErrorEvent) => {
if (this.props.onError) {
this.props.onError(e);
} else {
console.error(e.error);
}
};
_updateHover(e: MapMouseEvent) {
const {props} = this;
const shouldTrackHoveredFeatures =
props.interactiveLayerIds && (props.onMouseMove || props.onMouseEnter || props.onMouseLeave);
if (shouldTrackHoveredFeatures) {
const eventType = e.type;
const wasHovering = this._hoveredFeatures?.length > 0;
let features;
if (eventType === 'mousemove') {
try {
features = this._map.queryRenderedFeatures(e.point, {
layers: props.interactiveLayerIds
});
} catch {
features = [];
}
} else {
features = [];
}
const isHovering = features.length > 0;
if (!isHovering && wasHovering) {
e.type = 'mouseleave';
this._onPointerEvent(e);
}
this._hoveredFeatures = features;
if (isHovering && !wasHovering) {
e.type = 'mouseenter';
this._onPointerEvent(e);
}
e.type = eventType;
} else {
this._hoveredFeatures = null;
}
}
_onPointerEvent = (e: MapLayerMouseEvent | MapLayerTouchEvent) => {
// @ts-ignore
const cb = this.props[pointerEvents[e.type]];
if (cb) {
if (this.props.interactiveLayerIds && e.type !== 'mouseover' && e.type !== 'mouseout') {
const features =
this._hoveredFeatures ||
this._map.queryRenderedFeatures(e.point, {
layers: this.props.interactiveLayerIds
});
if (!features.length) {
return;
}
e.features = features;
}
cb(e);
delete e.features;
}
};
_onCameraEvent = (e: ViewStateChangeEvent) => {
if (this._internalUpdate) {
return;
}
// @ts-ignore
const cb = this.props[cameraEvents[e.type]];
if (cb) {
cb(e);
}
};
_fireEvent(baseFire: Function, event: string | MapboxEvent, properties?: object) {
const map = this._map;
const tr = map.transform;
const eventType = typeof event === 'string' ? event : event.type;
switch (eventType) {
case 'resize':
this._renderTransform.resize(tr.width, tr.height);
break;
case 'move':
this._updateViewState(this.props, false);
break;
case 'mousemove':
case 'mouseout':
// @ts-ignore
this._updateHover(event);
break;
}
if (typeof event === 'object' && event.type in cameraEvents) {
(event as ViewStateChangeEvent).viewState = transformToViewState(tr);
}
// Replace map.transform with ours during the callbacks
map.transform = this._renderTransform;
baseFire.call(map, event, properties);
map.transform = tr;
return map;
}
_render(baseRender: Function, arg: number) {
const map = this._map;
this._inRender = true;
if (this._moved) {
this._internalUpdate = true;
map.fire('move');
if (this._zoomed) {
map.fire('zoom');
this._zoomed = false;
}
if (this._rotated) {
map.fire('rotate');
this._rotated = false;
}
if (this._pitched) {
map.fire('pitch');
this._pitched = false;
}
this._moved = false;
this._internalUpdate = false;
}
// map.transform will be swapped out in _onBeforeRender
const tr = map.transform;
baseRender.call(map, arg);
map.transform = tr;
this._inRender = false;
// We do not allow props to change during a render
// When render is done, apply any pending changes
if (this._nextProps) {
this.setProps(this._nextProps);
this._nextProps = null;
}
}
_onBeforeRepaint() {
// Make sure camera matches the current props
this._map.transform = this._renderTransform;
}
}
/**
* Access token can be provided via one of:
* mapboxAccessToken prop
* access_token query parameter
* MapboxAccessToken environment variable
* REACT_APP_MAPBOX_ACCESS_TOKEN environment variable
* @returns access token
*/
function getAccessTokenFromEnv(): string {
let accessToken = null;
/* global location, process */
if (typeof location !== 'undefined') {
const match = /access_token=([^&\/]*)/.exec(location.search);
accessToken = match && match[1];
}
// Note: This depends on bundler plugins (e.g. webpack) importing environment correctly
try {
accessToken = accessToken || process.env.MapboxAccessToken;
} catch {
// ignore
}
try {
accessToken = accessToken || process.env.REACT_APP_MAPBOX_ACCESS_TOKEN;
} catch {
// ignore
}
return accessToken;
}

60
src/utils/deep-equal.ts Normal file
View File

@ -0,0 +1,60 @@
import type {PointLike} from 'mapbox-gl';
/**
* Compare two points
* @param a
* @param b
* @returns true if the points are equal
*/
export function arePointsEqual(a?: PointLike, b?: PointLike): boolean {
const ax = Array.isArray(a) ? a[0] : a ? a.x : 0;
const ay = Array.isArray(a) ? a[1] : a ? a.y : 0;
const bx = Array.isArray(b) ? b[0] : b ? b.x : 0;
const by = Array.isArray(b) ? b[1] : b ? b.y : 0;
return ax === bx && ay === by;
}
/**
* Compare any two objects
* @param a
* @param b
* @returns true if the objects are deep equal
*/
export function deepEqual(a: any, b: any): boolean {
if (a === b) {
return true;
}
if (!a || !b) {
return false;
}
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
return true;
} else if (Array.isArray(b)) {
return false;
}
if (typeof a === 'object' && typeof b === 'object') {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) {
return false;
}
for (const key of aKeys) {
if (!b.hasOwnProperty(key)) {
return false;
}
if (!deepEqual(a[key], b[key])) {
return false;
}
}
return true;
}
return false;
}

View File

@ -1,2 +1,3 @@
import mapboxgl from 'mapbox-gl';
export default mapboxgl;
// @ts-ignore
import mapboxgl from 'mapbox-gl';
export default mapboxgl;

76
src/utils/transform.ts Normal file
View File

@ -0,0 +1,76 @@
import mapboxgl from './mapboxgl';
import type {PaddingOptions, ViewState} from './types';
/**
* Stub for mapbox's Transform class
* https://github.com/mapbox/mapbox-gl-js/blob/main/src/geo/transform.js
*/
export type Transform = {
center: {lng: number; lat: number};
zoom: number;
bearing: number;
pitch: number;
padding: PaddingOptions;
clone: () => Transform;
resize: (width: number, height: number) => void;
isPaddingEqual: (value: PaddingOptions) => boolean;
};
/**
* Capture a transform's current state
* @param transform
* @returns descriptor of the view state
*/
export function transformToViewState(tr: Transform): ViewState {
return {
longitude: tr.center.lng,
latitude: tr.center.lat,
zoom: tr.zoom,
pitch: tr.pitch,
bearing: tr.bearing,
padding: tr.padding
};
}
/**
* Mutate a transform to match the given view state
* @param transform
* @param viewState
* @returns true if the transform has changed
*/
export function applyViewStateToTransform(
tr: Transform,
vs: ViewState | {viewState: ViewState}
): boolean {
// @ts-ignore
const v: ViewState = vs.viewState || vs;
let changed = false;
if ('longitude' in v && 'latitude' in v) {
const center = tr.center;
tr.center = new mapboxgl.LngLat(v.longitude, v.latitude);
changed = changed || center !== tr.center;
}
if ('zoom' in v) {
const zoom = tr.zoom;
tr.zoom = v.zoom;
changed = changed || zoom !== tr.zoom;
}
if ('bearing' in v) {
const bearing = tr.bearing;
tr.bearing = v.bearing;
changed = changed || bearing !== tr.bearing;
}
if ('pitch' in v) {
const pitch = tr.pitch;
tr.pitch = v.pitch;
changed = changed || pitch !== tr.pitch;
}
if (v.padding && !tr.isPaddingEqual(v.padding)) {
changed = true;
tr.padding = v.padding;
}
return changed;
}

51
src/utils/types.ts Normal file
View File

@ -0,0 +1,51 @@
import type {PaddingOptions, MapboxEvent} from 'mapbox-gl';
/** Defines the projection that the map should be rendered in */
export type ProjectionSpecification = {
name:
| 'albers'
| 'equalEarth'
| 'equirectangular'
| 'lambertConformalConic'
| 'mercator'
| 'naturalEarth'
| 'winkelTripel';
center?: [number, number];
parallels?: [number, number];
};
/** Describes the camera's state */
export type ViewState = {
/** Longitude at map center */
longitude?: number;
/** Latitude at map center */
latitude?: number;
/** Map zoom level */
zoom?: number;
/** Map rotation bearing in degrees counter-clockwise from north */
bearing?: number;
/** Map angle in degrees at which the camera is looking at the ground */
pitch?: number;
/** Dimensions in pixels applied on each side of the viewport for shifting the vanishing point. */
padding?: PaddingOptions;
};
export type ViewStateChangeEvent = MapboxEvent & {
viewState: ViewState;
};
// re-export mapbox types
export type {
MapboxOptions,
Style,
PaddingOptions,
MapMouseEvent,
MapLayerMouseEvent,
MapLayerTouchEvent,
MapWheelEvent,
MapDataEvent,
MapboxEvent,
ErrorEvent,
MapboxGeoJSONFeature,
Map as MapboxMap
} from 'mapbox-gl';

View File

@ -0,0 +1,7 @@
// From https://github.com/streamich/react-use/blob/master/src/useIsomorphicLayoutEffect.ts
// useLayoutEffect but does not trigger warning in server-side rendering
import {useEffect, useLayoutEffect} from 'react';
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export default useIsomorphicLayoutEffect;

View File

@ -1,5 +1,5 @@
const register = require('@babel/register').default;
register({ extensions: ['.ts', '.tsx', '.js'] });
require('./src');
const register = require('@babel/register').default;
register({extensions: ['.ts', '.tsx', '.js']});
require('./src');

View File

@ -1 +1,4 @@
import './components/map.spec';
import './utils/deep-equal.spec';
import './utils/transform.spec';

View File

@ -0,0 +1,95 @@
import test from 'tape-promise/tape';
import {deepEqual, arePointsEqual} from 'react-map-gl/utils/deep-equal';
test('deepEqual', t => {
const testCases = [
{
a: null,
b: null,
result: true
},
{
a: undefined,
b: 0,
result: false
},
{
a: [1, 2, 3],
b: [1, 2, 3],
result: true
},
{
a: [1, 2],
b: [1, 2, 3],
result: false
},
{
a: [1, 2],
b: {0: 1, 1: 2},
result: false
},
{
a: {x: 0, y: 0, offset: [1, -1]},
b: {x: 0, y: 0, offset: [1, -1]},
result: true
},
{
a: {x: 0, y: 0},
b: {x: 0, y: 0, offset: [1, -1]},
result: false
},
{
a: {x: 0, y: 0, z: 0},
b: {x: 0, y: 0, offset: [1, -1]},
result: false
}
];
for (const {a, b, result} of testCases) {
t.is(deepEqual(a, b), result, `${JSON.stringify(a)} vs ${JSON.stringify(b)}`);
if (a !== b) {
t.is(deepEqual(b, a), result, `${JSON.stringify(b)} vs ${JSON.stringify(a)}`);
}
}
t.end();
});
test('arePointsEqual', t => {
const testCases = [
{
a: undefined,
b: undefined,
result: true
},
{
a: undefined,
b: [0, 0],
result: true
},
{
a: undefined,
b: [0, 1],
result: false
},
{
a: undefined,
b: [1, 0],
result: false
},
{
a: {x: 1, y: 1},
b: [1, 1],
result: true
}
];
for (const {a, b, result} of testCases) {
t.is(arePointsEqual(a, b), result, `${JSON.stringify(a)}, ${JSON.stringify(b)}`);
if (a !== b) {
t.is(arePointsEqual(b, a), result, `${JSON.stringify(b)}, ${JSON.stringify(a)}`);
}
}
t.end();
});

128
test/src/utils/transform.js Normal file
View File

@ -0,0 +1,128 @@
function wrap(n, min, max) {
const d = max - min;
const w = ((((n - min) % d) + d) % d) + min;
return w === min ? max : w;
}
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
/**
* A dummy class that simulates mapbox's EdgeInsets
*/
class EdgeInsets {
constructor(top = 0, bottom = 0, left = 0, right = 0) {
this.top = top;
this.bottom = bottom;
this.left = left;
this.right = right;
}
set(target) {
if (target.top !== null) this.top = target.top;
if (target.bottom !== null) this.bottom = target.bottom;
if (target.left !== null) this.left = target.left;
if (target.right !== null) this.right = target.right;
return this;
}
equals(other) {
return (
this.top === other.top &&
this.bottom === other.bottom &&
this.left === other.left &&
this.right === other.right
);
}
toJSON() {
return {
top: this.top,
bottom: this.bottom,
left: this.left,
right: this.right
};
}
}
/**
* A dummy class that simulates mapbox's Transform
*/
export default class Transform {
constructor() {
this.minZoom = 0;
this.maxZoom = 22;
this.minPitch = 0;
this.maxPitch = 60;
this.minLat = -85.051129;
this.maxLat = 85.051129;
this.minLng = -180;
this.maxLng = 180;
this.width = 1;
this.height = 1;
this._center = {lng: 0, lat: 0};
this._zoom = 0;
this.angle = 0;
this._pitch = 0;
this._edgeInsets = new EdgeInsets();
}
get bearing() {
return wrap(this.rotation, -180, 180);
}
set bearing(bearing) {
this.rotation = bearing;
}
get rotation() {
return (-this.angle / Math.PI) * 180;
}
set rotation(rotation) {
const b = (-rotation * Math.PI) / 180;
if (this.angle === b) return;
this.angle = b;
}
get pitch() {
return (this._pitch / Math.PI) * 180;
}
set pitch(pitch) {
const p = (clamp(pitch, this.minPitch, this.maxPitch) / 180) * Math.PI;
if (this._pitch === p) return;
this._pitch = p;
}
get zoom() {
return this._zoom;
}
set zoom(zoom) {
const z = Math.min(Math.max(zoom, this.minZoom), this.maxZoom);
if (this._zoom === z) return;
this._zoom = z;
}
get center() {
return this._center;
}
set center(center) {
if (center.lat === this._center.lat && center.lng === this._center.lng) return;
this._center = center;
}
get padding() {
return this._edgeInsets.toJSON();
}
set padding(padding) {
if (this._edgeInsets.equals(padding)) return;
// Update edge-insets inplace
this._edgeInsets.set(padding);
}
isPaddingEqual(padding) {
return this._edgeInsets.equals(padding);
}
}

View File

@ -0,0 +1,94 @@
import test from 'tape-promise/tape';
import {transformToViewState, applyViewStateToTransform} from 'react-map-gl/utils/transform';
import Transform from './transform';
test('applyViewStateToTransform', t => {
const tr = new Transform();
let changed = applyViewStateToTransform(tr, {});
t.notOk(changed, 'empty view state');
changed = applyViewStateToTransform(tr, {longitude: -10, latitude: 5});
t.ok(changed, 'center changed');
t.deepEqual(
transformToViewState(tr),
{
longitude: -10,
latitude: 5,
zoom: 0,
pitch: 0,
bearing: 0,
padding: {left: 0, right: 0, top: 0, bottom: 0}
},
'view state is correct'
);
changed = applyViewStateToTransform(tr, {zoom: -1});
t.notOk(changed, 'zoom is clamped');
changed = applyViewStateToTransform(tr, {zoom: 10});
t.ok(changed, 'zoom changed');
t.deepEqual(
transformToViewState(tr),
{
longitude: -10,
latitude: 5,
zoom: 10,
pitch: 0,
bearing: 0,
padding: {left: 0, right: 0, top: 0, bottom: 0}
},
'view state is correct'
);
changed = applyViewStateToTransform(tr, {pitch: 30});
t.ok(changed, 'pitch changed');
t.deepEqual(
transformToViewState(tr),
{
longitude: -10,
latitude: 5,
zoom: 10,
pitch: 30,
bearing: 0,
padding: {left: 0, right: 0, top: 0, bottom: 0}
},
'view state is correct'
);
changed = applyViewStateToTransform(tr, {bearing: 270});
t.ok(changed, 'bearing changed');
t.deepEqual(
transformToViewState(tr),
{
longitude: -10,
latitude: 5,
zoom: 10,
pitch: 30,
bearing: -90,
padding: {left: 0, right: 0, top: 0, bottom: 0}
},
'view state is correct'
);
changed = applyViewStateToTransform(tr, {padding: {left: 10, right: 10, top: 10, bottom: 10}});
t.ok(changed, 'padding changed');
t.deepEqual(
transformToViewState(tr),
{
longitude: -10,
latitude: 5,
zoom: 10,
pitch: 30,
bearing: -90,
padding: {left: 10, right: 10, top: 10, bottom: 10}
},
'view state is correct'
);
changed = applyViewStateToTransform(tr, {viewState: {pitch: 30}});
t.notOk(changed, 'nothing changed');
t.end();
});

View File

@ -1,25 +1,24 @@
const webpack = require('webpack');
const getWebpackConfig = require('ocular-dev-tools/config/webpack.config');
const BABEL_CONFIG = {
presets: ['@babel/env', '@babel/react'],
plugins: ['version-inline', '@babel/proposal-class-properties']
};
const {getWebpackConfig} = require('ocular-dev-tools');
module.exports = env => {
const config = getWebpackConfig(env);
config.module.rules.push({
// This is required to handle inline worker!
test: /\.js$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: BABEL_CONFIG
config.resolve = {...config.resolve, extensions: ['.ts', '.tsx', '.js', '.json']};
config.module.rules = [
...config.module.rules.filter(r => r.loader !== 'babel-loader'),
{
// Compile source using babel
test: /(\.js|\.ts|\.tsx)$/,
loader: 'babel-loader',
exclude: [/node_modules/],
options: {
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
plugins: ['@babel/plugin-proposal-class-properties']
}
]
});
}
];
config.plugins = (config.plugins || []).concat([
new webpack.DefinePlugin({