mirror of
https://github.com/visgl/react-map-gl.git
synced 2025-12-08 20:16:02 +00:00
[v8] react-mapbox module (#2467)
This commit is contained in:
parent
8885f5f4f4
commit
2785a32263
@ -32,7 +32,7 @@
|
||||
"dependencies": {
|
||||
},
|
||||
"devDependencies": {
|
||||
"mapbox-gl": "3.9.0"
|
||||
"mapbox-gl": "^3.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mapbox-gl": ">=3.5.0",
|
||||
|
||||
27
modules/react-mapbox/src/components/attribution-control.ts
Normal file
27
modules/react-mapbox/src/components/attribution-control.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import {useEffect, memo} from 'react';
|
||||
import {applyReactStyle} from '../utils/apply-react-style';
|
||||
import {useControl} from './use-control';
|
||||
|
||||
import type {ControlPosition, AttributionControlOptions} from '../types/lib';
|
||||
|
||||
export type AttributionControlProps = AttributionControlOptions & {
|
||||
/** Placement of the control relative to the map. */
|
||||
position?: ControlPosition;
|
||||
/** CSS style override, applied to the control's container */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function _AttributionControl(props: AttributionControlProps) {
|
||||
const ctrl = useControl(({mapLib}) => new mapLib.AttributionControl(props), {
|
||||
position: props.position
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyReactStyle(ctrl._container, props.style);
|
||||
}, [props.style]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const AttributionControl = memo(_AttributionControl);
|
||||
35
modules/react-mapbox/src/components/fullscreen-control.ts
Normal file
35
modules/react-mapbox/src/components/fullscreen-control.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/* global document */
|
||||
import * as React from 'react';
|
||||
import {useEffect, memo} from 'react';
|
||||
import {applyReactStyle} from '../utils/apply-react-style';
|
||||
import {useControl} from './use-control';
|
||||
|
||||
import type {ControlPosition, FullscreenControlOptions} from '../types/lib';
|
||||
|
||||
export type FullscreenControlProps = Omit<FullscreenControlOptions, 'container'> & {
|
||||
/** Id of the DOM element which should be made full screen. By default, the map container
|
||||
* element will be made full screen. */
|
||||
containerId?: string;
|
||||
/** Placement of the control relative to the map. */
|
||||
position?: ControlPosition;
|
||||
/** CSS style override, applied to the control's container */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function _FullscreenControl(props: FullscreenControlProps) {
|
||||
const ctrl = useControl(
|
||||
({mapLib}) =>
|
||||
new mapLib.FullscreenControl({
|
||||
container: props.containerId && document.getElementById(props.containerId)
|
||||
}),
|
||||
{position: props.position}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
applyReactStyle(ctrl._controlContainer, props.style);
|
||||
}, [props.style]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const FullscreenControl = memo(_FullscreenControl);
|
||||
81
modules/react-mapbox/src/components/geolocate-control.ts
Normal file
81
modules/react-mapbox/src/components/geolocate-control.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import * as React from 'react';
|
||||
import {useImperativeHandle, useRef, useEffect, forwardRef, memo} from 'react';
|
||||
import {applyReactStyle} from '../utils/apply-react-style';
|
||||
import {useControl} from './use-control';
|
||||
|
||||
import type {
|
||||
ControlPosition,
|
||||
GeolocateControlInstance,
|
||||
GeolocateControlOptions
|
||||
} from '../types/lib';
|
||||
import type {GeolocateEvent, GeolocateResultEvent, GeolocateErrorEvent} from '../types/events';
|
||||
|
||||
export type GeolocateControlProps = GeolocateControlOptions & {
|
||||
/** Placement of the control relative to the map. */
|
||||
position?: ControlPosition;
|
||||
/** CSS style override, applied to the control's container */
|
||||
style?: React.CSSProperties;
|
||||
|
||||
/** Called on each Geolocation API position update that returned as success. */
|
||||
onGeolocate?: (e: GeolocateResultEvent) => void;
|
||||
/** Called on each Geolocation API position update that returned as an error. */
|
||||
onError?: (e: GeolocateErrorEvent) => void;
|
||||
/** Called on each Geolocation API position update that returned as success but user position
|
||||
* is out of map `maxBounds`. */
|
||||
onOutOfMaxBounds?: (e: GeolocateResultEvent) => void;
|
||||
/** Called when the GeolocateControl changes to the active lock state. */
|
||||
onTrackUserLocationStart?: (e: GeolocateEvent) => void;
|
||||
/** Called when the GeolocateControl changes to the background state. */
|
||||
onTrackUserLocationEnd?: (e: GeolocateEvent) => void;
|
||||
};
|
||||
|
||||
function _GeolocateControl(props: GeolocateControlProps, ref: React.Ref<GeolocateControlInstance>) {
|
||||
const thisRef = useRef({props});
|
||||
|
||||
const ctrl = useControl(
|
||||
({mapLib}) => {
|
||||
const gc = new mapLib.GeolocateControl(props);
|
||||
|
||||
// Hack: fix GeolocateControl reuse
|
||||
// When using React strict mode, the component is mounted twice.
|
||||
// GeolocateControl's UI creation is asynchronous. Removing and adding it back causes the UI to be initialized twice.
|
||||
const setupUI = gc._setupUI.bind(gc);
|
||||
gc._setupUI = args => {
|
||||
if (!gc._container.hasChildNodes()) {
|
||||
setupUI(args);
|
||||
}
|
||||
};
|
||||
|
||||
gc.on('geolocate', e => {
|
||||
thisRef.current.props.onGeolocate?.(e as GeolocateResultEvent);
|
||||
});
|
||||
gc.on('error', e => {
|
||||
thisRef.current.props.onError?.(e as GeolocateErrorEvent);
|
||||
});
|
||||
gc.on('outofmaxbounds', e => {
|
||||
thisRef.current.props.onOutOfMaxBounds?.(e as GeolocateResultEvent);
|
||||
});
|
||||
gc.on('trackuserlocationstart', e => {
|
||||
thisRef.current.props.onTrackUserLocationStart?.(e as GeolocateEvent);
|
||||
});
|
||||
gc.on('trackuserlocationend', e => {
|
||||
thisRef.current.props.onTrackUserLocationEnd?.(e as GeolocateEvent);
|
||||
});
|
||||
|
||||
return gc;
|
||||
},
|
||||
{position: props.position}
|
||||
);
|
||||
|
||||
thisRef.current.props = props;
|
||||
|
||||
useImperativeHandle(ref, () => ctrl, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyReactStyle(ctrl._container, props.style);
|
||||
}, [props.style]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const GeolocateControl = memo(forwardRef(_GeolocateControl));
|
||||
125
modules/react-mapbox/src/components/layer.ts
Normal file
125
modules/react-mapbox/src/components/layer.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import {useContext, useEffect, useMemo, useState, useRef} from 'react';
|
||||
import {MapContext} from './map';
|
||||
import assert from '../utils/assert';
|
||||
import {deepEqual} from '../utils/deep-equal';
|
||||
|
||||
import type {MapInstance, CustomLayerInterface} from '../types/lib';
|
||||
import type {AnyLayer} from '../types/style-spec';
|
||||
|
||||
// Omiting property from a union type, see
|
||||
// https://github.com/microsoft/TypeScript/issues/39556#issuecomment-656925230
|
||||
type OptionalId<T> = T extends {id: string} ? Omit<T, 'id'> & {id?: string} : T;
|
||||
type OptionalSource<T> = T extends {source: string} ? Omit<T, 'source'> & {source?: string} : T;
|
||||
|
||||
export type LayerProps = (OptionalSource<OptionalId<AnyLayer>> | CustomLayerInterface) & {
|
||||
/** If set, the layer will be inserted before the specified layer */
|
||||
beforeId?: string;
|
||||
};
|
||||
|
||||
/* eslint-disable complexity, max-statements */
|
||||
function updateLayer(map: MapInstance, id: string, props: LayerProps, prevProps: LayerProps) {
|
||||
assert(props.id === prevProps.id, 'layer id changed');
|
||||
assert(props.type === prevProps.type, 'layer type changed');
|
||||
|
||||
if (props.type === 'custom' || prevProps.type === 'custom') {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore filter does not exist in some Layer types
|
||||
const {layout = {}, paint = {}, filter, minzoom, maxzoom, beforeId} = props;
|
||||
|
||||
if (beforeId !== prevProps.beforeId) {
|
||||
map.moveLayer(id, beforeId);
|
||||
}
|
||||
if (layout !== prevProps.layout) {
|
||||
const prevLayout = prevProps.layout || {};
|
||||
for (const key in layout) {
|
||||
if (!deepEqual(layout[key], prevLayout[key])) {
|
||||
map.setLayoutProperty(id, key as any, layout[key]);
|
||||
}
|
||||
}
|
||||
for (const key in prevLayout) {
|
||||
if (!layout.hasOwnProperty(key)) {
|
||||
map.setLayoutProperty(id, key as any, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (paint !== prevProps.paint) {
|
||||
const prevPaint = prevProps.paint || {};
|
||||
for (const key in paint) {
|
||||
if (!deepEqual(paint[key], prevPaint[key])) {
|
||||
map.setPaintProperty(id, key as any, paint[key]);
|
||||
}
|
||||
}
|
||||
for (const key in prevPaint) {
|
||||
if (!paint.hasOwnProperty(key)) {
|
||||
map.setPaintProperty(id, key as any, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore filter does not exist in some Layer types
|
||||
if (!deepEqual(filter, prevProps.filter)) {
|
||||
map.setFilter(id, filter);
|
||||
}
|
||||
if (minzoom !== prevProps.minzoom || maxzoom !== prevProps.maxzoom) {
|
||||
map.setLayerZoomRange(id, minzoom, maxzoom);
|
||||
}
|
||||
}
|
||||
|
||||
function createLayer(map: MapInstance, id: string, props: LayerProps) {
|
||||
// @ts-ignore
|
||||
if (map.style && map.style._loaded && (!('source' in props) || map.getSource(props.source))) {
|
||||
const options: LayerProps = {...props, id};
|
||||
delete options.beforeId;
|
||||
|
||||
// @ts-ignore
|
||||
map.addLayer(options, props.beforeId);
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-enable complexity, max-statements */
|
||||
|
||||
let layerCounter = 0;
|
||||
|
||||
export function Layer(props: LayerProps) {
|
||||
const map = useContext(MapContext).map.getMap();
|
||||
const propsRef = useRef(props);
|
||||
const [, setStyleLoaded] = useState(0);
|
||||
|
||||
const id = useMemo(() => props.id || `jsx-layer-${layerCounter++}`, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (map) {
|
||||
const forceUpdate = () => setStyleLoaded(version => version + 1);
|
||||
map.on('styledata', forceUpdate);
|
||||
forceUpdate();
|
||||
|
||||
return () => {
|
||||
map.off('styledata', forceUpdate);
|
||||
// @ts-ignore
|
||||
if (map.style && map.style._loaded && map.getLayer(id)) {
|
||||
map.removeLayer(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [map]);
|
||||
|
||||
// @ts-ignore
|
||||
const layer = map && map.style && map.getLayer(id);
|
||||
if (layer) {
|
||||
try {
|
||||
updateLayer(map, id, props, propsRef.current);
|
||||
} catch (error) {
|
||||
console.warn(error); // eslint-disable-line
|
||||
}
|
||||
} else {
|
||||
createLayer(map, id, props);
|
||||
}
|
||||
|
||||
// Store last rendered props
|
||||
propsRef.current = props;
|
||||
|
||||
return null;
|
||||
}
|
||||
142
modules/react-mapbox/src/components/map.tsx
Normal file
142
modules/react-mapbox/src/components/map.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import * as React from 'react';
|
||||
import {useState, useRef, useEffect, useContext, useMemo, useImperativeHandle} from 'react';
|
||||
|
||||
import {MountedMapsContext} from './use-map';
|
||||
import Mapbox, {MapboxProps} from '../mapbox/mapbox';
|
||||
import createRef, {MapRef} from '../mapbox/create-ref';
|
||||
|
||||
import type {CSSProperties} from 'react';
|
||||
import useIsomorphicLayoutEffect from '../utils/use-isomorphic-layout-effect';
|
||||
import setGlobals, {GlobalSettings} from '../utils/set-globals';
|
||||
import type {MapLib, MapOptions} from '../types/lib';
|
||||
|
||||
export type MapContextValue = {
|
||||
mapLib: MapLib;
|
||||
map: MapRef;
|
||||
};
|
||||
|
||||
export const MapContext = React.createContext<MapContextValue>(null);
|
||||
|
||||
type MapInitOptions = Omit<
|
||||
MapOptions,
|
||||
'style' | 'container' | 'bounds' | 'fitBoundsOptions' | 'center'
|
||||
>;
|
||||
|
||||
export type MapProps = MapInitOptions &
|
||||
MapboxProps &
|
||||
GlobalSettings & {
|
||||
mapLib?: MapLib | Promise<MapLib>;
|
||||
reuseMaps?: boolean;
|
||||
/** Map container id */
|
||||
id?: string;
|
||||
/** Map container CSS style */
|
||||
style?: CSSProperties;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
function _Map(props: MapProps, ref: React.Ref<MapRef>) {
|
||||
const mountedMapsContext = useContext(MountedMapsContext);
|
||||
const [mapInstance, setMapInstance] = useState<Mapbox>(null);
|
||||
const containerRef = useRef();
|
||||
|
||||
const {current: contextValue} = useRef<MapContextValue>({mapLib: null, map: null});
|
||||
|
||||
useEffect(() => {
|
||||
const mapLib = props.mapLib;
|
||||
let isMounted = true;
|
||||
let mapbox: Mapbox;
|
||||
|
||||
Promise.resolve(mapLib || import('mapbox-gl'))
|
||||
.then((module: MapLib | {default: MapLib}) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
if (!module) {
|
||||
throw new Error('Invalid mapLib');
|
||||
}
|
||||
const mapboxgl = 'Map' in module ? module : module.default;
|
||||
if (!mapboxgl.Map) {
|
||||
throw new Error('Invalid mapLib');
|
||||
}
|
||||
|
||||
// workerUrl & workerClass may change the result of supported()
|
||||
// https://github.com/visgl/react-map-gl/discussions/2027
|
||||
setGlobals(mapboxgl, props);
|
||||
if (!mapboxgl.supported || mapboxgl.supported(props)) {
|
||||
if (props.reuseMaps) {
|
||||
mapbox = Mapbox.reuse(props, containerRef.current);
|
||||
}
|
||||
if (!mapbox) {
|
||||
mapbox = new Mapbox(mapboxgl.Map, props, containerRef.current);
|
||||
}
|
||||
contextValue.map = createRef(mapbox);
|
||||
contextValue.mapLib = mapboxgl;
|
||||
|
||||
setMapInstance(mapbox);
|
||||
mountedMapsContext?.onMapMount(contextValue.map, props.id);
|
||||
} else {
|
||||
throw new Error('Map is not supported by this browser');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
const {onError} = props;
|
||||
if (onError) {
|
||||
onError({
|
||||
type: 'error',
|
||||
target: null,
|
||||
error
|
||||
});
|
||||
} else {
|
||||
console.error(error); // eslint-disable-line
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (mapbox) {
|
||||
mountedMapsContext?.onMapUnmount(props.id);
|
||||
if (props.reuseMaps) {
|
||||
mapbox.recycle();
|
||||
} else {
|
||||
mapbox.destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (mapInstance) {
|
||||
mapInstance.setProps(props);
|
||||
}
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => contextValue.map, [mapInstance]);
|
||||
|
||||
const style: CSSProperties = useMemo(
|
||||
() => ({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
...props.style
|
||||
}),
|
||||
[props.style]
|
||||
);
|
||||
|
||||
const CHILD_CONTAINER_STYLE = {
|
||||
height: '100%'
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={props.id} ref={containerRef} style={style}>
|
||||
{mapInstance && (
|
||||
<MapContext.Provider value={contextValue}>
|
||||
<div mapboxgl-children="" style={CHILD_CONTAINER_STYLE}>
|
||||
{props.children}
|
||||
</div>
|
||||
</MapContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Map = React.forwardRef(_Map);
|
||||
129
modules/react-mapbox/src/components/marker.ts
Normal file
129
modules/react-mapbox/src/components/marker.ts
Normal file
@ -0,0 +1,129 @@
|
||||
/* global document */
|
||||
import * as React from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {useImperativeHandle, useEffect, useMemo, useRef, useContext, forwardRef, memo} from 'react';
|
||||
import {applyReactStyle} from '../utils/apply-react-style';
|
||||
|
||||
import type {PopupInstance, MarkerInstance, MarkerOptions} from '../types/lib';
|
||||
import type {MarkerEvent, MarkerDragEvent} from '../types/events';
|
||||
|
||||
import {MapContext} from './map';
|
||||
import {arePointsEqual} from '../utils/deep-equal';
|
||||
|
||||
export type MarkerProps = MarkerOptions & {
|
||||
/** Longitude of the anchor location */
|
||||
longitude: number;
|
||||
/** Latitude of the anchor location */
|
||||
latitude: number;
|
||||
|
||||
popup?: PopupInstance;
|
||||
|
||||
/** CSS style override, applied to the control's container */
|
||||
style?: React.CSSProperties;
|
||||
onClick?: (e: MarkerEvent<MouseEvent>) => void;
|
||||
onDragStart?: (e: MarkerDragEvent) => void;
|
||||
onDrag?: (e: MarkerDragEvent) => void;
|
||||
onDragEnd?: (e: MarkerDragEvent) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/* eslint-disable complexity,max-statements */
|
||||
export const Marker = memo(
|
||||
forwardRef((props: MarkerProps, ref: React.Ref<MarkerInstance>) => {
|
||||
const {map, mapLib} = useContext(MapContext);
|
||||
const thisRef = useRef({props});
|
||||
thisRef.current.props = props;
|
||||
|
||||
const marker: MarkerInstance = useMemo(() => {
|
||||
let hasChildren = false;
|
||||
React.Children.forEach(props.children, el => {
|
||||
if (el) {
|
||||
hasChildren = true;
|
||||
}
|
||||
});
|
||||
const options = {
|
||||
...props,
|
||||
element: hasChildren ? document.createElement('div') : null
|
||||
};
|
||||
|
||||
const mk = new mapLib.Marker(options);
|
||||
mk.setLngLat([props.longitude, props.latitude]);
|
||||
|
||||
mk.getElement().addEventListener('click', (e: MouseEvent) => {
|
||||
thisRef.current.props.onClick?.({
|
||||
type: 'click',
|
||||
target: mk,
|
||||
originalEvent: e
|
||||
});
|
||||
});
|
||||
|
||||
mk.on('dragstart', e => {
|
||||
const evt = e as MarkerDragEvent;
|
||||
evt.lngLat = marker.getLngLat();
|
||||
thisRef.current.props.onDragStart?.(evt);
|
||||
});
|
||||
mk.on('drag', e => {
|
||||
const evt = e as MarkerDragEvent;
|
||||
evt.lngLat = marker.getLngLat();
|
||||
thisRef.current.props.onDrag?.(evt);
|
||||
});
|
||||
mk.on('dragend', e => {
|
||||
const evt = e as MarkerDragEvent;
|
||||
evt.lngLat = marker.getLngLat();
|
||||
thisRef.current.props.onDragEnd?.(evt);
|
||||
});
|
||||
|
||||
return mk;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
marker.addTo(map.getMap());
|
||||
|
||||
return () => {
|
||||
marker.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
longitude,
|
||||
latitude,
|
||||
offset,
|
||||
style,
|
||||
draggable = false,
|
||||
popup = null,
|
||||
rotation = 0,
|
||||
rotationAlignment = 'auto',
|
||||
pitchAlignment = 'auto'
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
applyReactStyle(marker.getElement(), style);
|
||||
}, [style]);
|
||||
|
||||
useImperativeHandle(ref, () => marker, []);
|
||||
|
||||
if (marker.getLngLat().lng !== longitude || marker.getLngLat().lat !== latitude) {
|
||||
marker.setLngLat([longitude, latitude]);
|
||||
}
|
||||
if (offset && !arePointsEqual(marker.getOffset(), offset)) {
|
||||
marker.setOffset(offset);
|
||||
}
|
||||
if (marker.isDraggable() !== draggable) {
|
||||
marker.setDraggable(draggable);
|
||||
}
|
||||
if (marker.getRotation() !== rotation) {
|
||||
marker.setRotation(rotation);
|
||||
}
|
||||
if (marker.getRotationAlignment() !== rotationAlignment) {
|
||||
marker.setRotationAlignment(rotationAlignment);
|
||||
}
|
||||
if (marker.getPitchAlignment() !== pitchAlignment) {
|
||||
marker.setPitchAlignment(pitchAlignment);
|
||||
}
|
||||
if (marker.getPopup() !== popup) {
|
||||
marker.setPopup(popup);
|
||||
}
|
||||
|
||||
return createPortal(props.children, marker.getElement());
|
||||
})
|
||||
);
|
||||
27
modules/react-mapbox/src/components/navigation-control.ts
Normal file
27
modules/react-mapbox/src/components/navigation-control.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import {useEffect, memo} from 'react';
|
||||
import {applyReactStyle} from '../utils/apply-react-style';
|
||||
import {useControl} from './use-control';
|
||||
|
||||
import type {ControlPosition, NavigationControlOptions} from '../types/lib';
|
||||
|
||||
export type NavigationControlProps = NavigationControlOptions & {
|
||||
/** Placement of the control relative to the map. */
|
||||
position?: ControlPosition;
|
||||
/** CSS style override, applied to the control's container */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function _NavigationControl(props: NavigationControlProps) {
|
||||
const ctrl = useControl(({mapLib}) => new mapLib.NavigationControl(props), {
|
||||
position: props.position
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyReactStyle(ctrl._container, props.style);
|
||||
}, [props.style]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const NavigationControl = memo(_NavigationControl);
|
||||
108
modules/react-mapbox/src/components/popup.ts
Normal file
108
modules/react-mapbox/src/components/popup.ts
Normal file
@ -0,0 +1,108 @@
|
||||
/* global document */
|
||||
import * as React from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {useImperativeHandle, useEffect, useMemo, useRef, useContext, forwardRef, memo} from 'react';
|
||||
import {applyReactStyle} from '../utils/apply-react-style';
|
||||
|
||||
import type {PopupInstance, PopupOptions} from '../types/lib';
|
||||
import type {PopupEvent} from '../types/events';
|
||||
|
||||
import {MapContext} from './map';
|
||||
import {deepEqual} from '../utils/deep-equal';
|
||||
|
||||
export type PopupProps = PopupOptions & {
|
||||
/** Longitude of the anchor location */
|
||||
longitude: number;
|
||||
/** Latitude of the anchor location */
|
||||
latitude: number;
|
||||
|
||||
/** CSS style override, applied to the control's container */
|
||||
style?: React.CSSProperties;
|
||||
|
||||
onOpen?: (e: PopupEvent) => void;
|
||||
onClose?: (e: PopupEvent) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// Adapted from https://github.com/mapbox/mapbox-gl-js/blob/v1.13.0/src/ui/popup.js
|
||||
function getClassList(className: string) {
|
||||
return new Set(className ? className.trim().split(/\s+/) : []);
|
||||
}
|
||||
|
||||
/* eslint-disable complexity,max-statements */
|
||||
export const Popup = memo(
|
||||
forwardRef((props: PopupProps, ref: React.Ref<PopupInstance>) => {
|
||||
const {map, mapLib} = useContext(MapContext);
|
||||
const container = useMemo(() => {
|
||||
return document.createElement('div');
|
||||
}, []);
|
||||
const thisRef = useRef({props});
|
||||
thisRef.current.props = props;
|
||||
|
||||
const popup: PopupInstance = useMemo(() => {
|
||||
const options = {...props};
|
||||
const pp = new mapLib.Popup(options);
|
||||
pp.setLngLat([props.longitude, props.latitude]);
|
||||
pp.once('open', e => {
|
||||
thisRef.current.props.onOpen?.(e as PopupEvent);
|
||||
});
|
||||
return pp;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onClose = e => {
|
||||
thisRef.current.props.onClose?.(e as PopupEvent);
|
||||
};
|
||||
popup.on('close', onClose);
|
||||
popup.setDOMContent(container).addTo(map.getMap());
|
||||
|
||||
return () => {
|
||||
// https://github.com/visgl/react-map-gl/issues/1825
|
||||
// onClose should not be fired if the popup is removed by unmounting
|
||||
// When using React strict mode, the component is mounted twice.
|
||||
// Firing the onClose callback here would be a false signal to remove the component.
|
||||
popup.off('close', onClose);
|
||||
if (popup.isOpen()) {
|
||||
popup.remove();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyReactStyle(popup.getElement(), props.style);
|
||||
}, [props.style]);
|
||||
|
||||
useImperativeHandle(ref, () => popup, []);
|
||||
|
||||
if (popup.isOpen()) {
|
||||
if (popup.getLngLat().lng !== props.longitude || popup.getLngLat().lat !== props.latitude) {
|
||||
popup.setLngLat([props.longitude, props.latitude]);
|
||||
}
|
||||
if (props.offset && !deepEqual(popup.options.offset, props.offset)) {
|
||||
popup.setOffset(props.offset);
|
||||
}
|
||||
if (popup.options.anchor !== props.anchor || popup.options.maxWidth !== props.maxWidth) {
|
||||
popup.options.anchor = props.anchor;
|
||||
popup.setMaxWidth(props.maxWidth);
|
||||
}
|
||||
if (popup.options.className !== props.className) {
|
||||
const prevClassList = getClassList(popup.options.className);
|
||||
const nextClassList = getClassList(props.className);
|
||||
|
||||
for (const c of prevClassList) {
|
||||
if (!nextClassList.has(c)) {
|
||||
popup.removeClassName(c);
|
||||
}
|
||||
}
|
||||
for (const c of nextClassList) {
|
||||
if (!prevClassList.has(c)) {
|
||||
popup.addClassName(c);
|
||||
}
|
||||
}
|
||||
popup.options.className = props.className;
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(props.children, container);
|
||||
})
|
||||
);
|
||||
44
modules/react-mapbox/src/components/scale-control.ts
Normal file
44
modules/react-mapbox/src/components/scale-control.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import {useEffect, useRef, memo} from 'react';
|
||||
import {applyReactStyle} from '../utils/apply-react-style';
|
||||
import {useControl} from './use-control';
|
||||
|
||||
import type {ControlPosition, ScaleControlOptions} from '../types/lib';
|
||||
|
||||
export type ScaleControlProps = ScaleControlOptions & {
|
||||
// These props will be further constraint by OptionsT
|
||||
unit?: string;
|
||||
maxWidth?: number;
|
||||
|
||||
/** Placement of the control relative to the map. */
|
||||
position?: ControlPosition;
|
||||
/** CSS style override, applied to the control's container */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function _ScaleControl(props: ScaleControlProps) {
|
||||
const ctrl = useControl(({mapLib}) => new mapLib.ScaleControl(props), {
|
||||
position: props.position
|
||||
});
|
||||
const propsRef = useRef<ScaleControlProps>(props);
|
||||
|
||||
const prevProps = propsRef.current;
|
||||
propsRef.current = props;
|
||||
|
||||
const {style} = props;
|
||||
|
||||
if (props.maxWidth !== undefined && props.maxWidth !== prevProps.maxWidth) {
|
||||
ctrl.options.maxWidth = props.maxWidth;
|
||||
}
|
||||
if (props.unit !== undefined && props.unit !== prevProps.unit) {
|
||||
ctrl.setUnit(props.unit);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
applyReactStyle(ctrl._container, style);
|
||||
}, [style]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ScaleControl = memo(_ScaleControl);
|
||||
134
modules/react-mapbox/src/components/source.ts
Normal file
134
modules/react-mapbox/src/components/source.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import * as React from 'react';
|
||||
import {useContext, useEffect, useMemo, useState, useRef, cloneElement} from 'react';
|
||||
import {MapContext} from './map';
|
||||
import assert from '../utils/assert';
|
||||
import {deepEqual} from '../utils/deep-equal';
|
||||
|
||||
import type {
|
||||
GeoJSONSourceImplementation,
|
||||
ImageSourceImplemtation,
|
||||
AnySourceImplementation
|
||||
} from '../types/internal';
|
||||
import type {AnySource, ImageSourceRaw, VectorSourceRaw} from '../types/style-spec';
|
||||
import type {MapInstance} from '../types/lib';
|
||||
|
||||
export type SourceProps = AnySource & {
|
||||
id?: string;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
let sourceCounter = 0;
|
||||
|
||||
function createSource(map: MapInstance, id: string, props: SourceProps) {
|
||||
// @ts-ignore
|
||||
if (map.style && map.style._loaded) {
|
||||
const options = {...props};
|
||||
delete options.id;
|
||||
delete options.children;
|
||||
// @ts-ignore
|
||||
map.addSource(id, options);
|
||||
return map.getSource(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* eslint-disable complexity */
|
||||
function updateSource(source: AnySourceImplementation, props: SourceProps, prevProps: SourceProps) {
|
||||
assert(props.id === prevProps.id, 'source id changed');
|
||||
assert(props.type === prevProps.type, 'source type changed');
|
||||
|
||||
let changedKey = '';
|
||||
let changedKeyCount = 0;
|
||||
|
||||
for (const key in props) {
|
||||
if (key !== 'children' && key !== 'id' && !deepEqual(prevProps[key], props[key])) {
|
||||
changedKey = key;
|
||||
changedKeyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changedKeyCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = props.type;
|
||||
|
||||
if (type === 'geojson') {
|
||||
(source as GeoJSONSourceImplementation).setData(props.data);
|
||||
} else if (type === 'image') {
|
||||
(source as ImageSourceImplemtation).updateImage({
|
||||
url: props.url,
|
||||
coordinates: props.coordinates
|
||||
});
|
||||
} else if ('setCoordinates' in source && changedKeyCount === 1 && changedKey === 'coordinates') {
|
||||
source.setCoordinates((props as unknown as ImageSourceRaw).coordinates);
|
||||
} else if ('setUrl' in source && changedKey === 'url') {
|
||||
source.setUrl((props as VectorSourceRaw).url);
|
||||
} else if ('setTiles' in source && changedKey === 'tiles') {
|
||||
source.setTiles((props as VectorSourceRaw).tiles);
|
||||
} else {
|
||||
// eslint-disable-next-line
|
||||
console.warn(`Unable to update <Source> prop: ${changedKey}`);
|
||||
}
|
||||
}
|
||||
/* eslint-enable complexity */
|
||||
|
||||
export function Source(props: SourceProps) {
|
||||
const map = useContext(MapContext).map.getMap();
|
||||
const propsRef = useRef(props);
|
||||
const [, setStyleLoaded] = useState(0);
|
||||
|
||||
const id = useMemo(() => props.id || `jsx-source-${sourceCounter++}`, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (map) {
|
||||
/* global setTimeout */
|
||||
const forceUpdate = () => setTimeout(() => setStyleLoaded(version => version + 1), 0);
|
||||
map.on('styledata', forceUpdate);
|
||||
forceUpdate();
|
||||
|
||||
return () => {
|
||||
map.off('styledata', forceUpdate);
|
||||
// @ts-ignore
|
||||
if (map.style && map.style._loaded && map.getSource(id)) {
|
||||
// Parent effects are destroyed before child ones, see
|
||||
// https://github.com/facebook/react/issues/16728
|
||||
// Source can only be removed after all child layers are removed
|
||||
const allLayers = map.getStyle()?.layers;
|
||||
if (allLayers) {
|
||||
for (const layer of allLayers) {
|
||||
// @ts-ignore (2339) source does not exist on all layer types
|
||||
if (layer.source === id) {
|
||||
map.removeLayer(layer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
map.removeSource(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [map]);
|
||||
|
||||
// @ts-ignore
|
||||
let source = map && map.style && map.getSource(id);
|
||||
if (source) {
|
||||
updateSource(source, props, propsRef.current);
|
||||
} else {
|
||||
source = createSource(map, id, props);
|
||||
}
|
||||
propsRef.current = props;
|
||||
|
||||
return (
|
||||
(source &&
|
||||
React.Children.map(
|
||||
props.children,
|
||||
child =>
|
||||
child &&
|
||||
cloneElement(child, {
|
||||
source: id
|
||||
})
|
||||
)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
62
modules/react-mapbox/src/components/use-control.ts
Normal file
62
modules/react-mapbox/src/components/use-control.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import {useContext, useMemo, useEffect} from 'react';
|
||||
import type {IControl, ControlPosition} from '../types/lib';
|
||||
import {MapContext} from './map';
|
||||
import type {MapContextValue} from './map';
|
||||
|
||||
type ControlOptions = {
|
||||
position?: ControlPosition;
|
||||
};
|
||||
|
||||
export function useControl<T extends IControl>(
|
||||
onCreate: (context: MapContextValue) => T,
|
||||
opts?: ControlOptions
|
||||
): T;
|
||||
|
||||
export function useControl<T extends IControl>(
|
||||
onCreate: (context: MapContextValue) => T,
|
||||
onRemove: (context: MapContextValue) => void,
|
||||
opts?: ControlOptions
|
||||
): T;
|
||||
|
||||
export function useControl<T extends IControl>(
|
||||
onCreate: (context: MapContextValue) => T,
|
||||
onAdd: (context: MapContextValue) => void,
|
||||
onRemove: (context: MapContextValue) => void,
|
||||
opts?: ControlOptions
|
||||
): T;
|
||||
|
||||
export function useControl<T extends IControl>(
|
||||
onCreate: (context: MapContextValue) => T,
|
||||
arg1?: ((context: MapContextValue) => void) | ControlOptions,
|
||||
arg2?: ((context: MapContextValue) => void) | ControlOptions,
|
||||
arg3?: ControlOptions
|
||||
): T {
|
||||
const context = useContext(MapContext);
|
||||
const ctrl = useMemo(() => onCreate(context), []);
|
||||
|
||||
useEffect(() => {
|
||||
const opts = (arg3 || arg2 || arg1) as ControlOptions;
|
||||
const onAdd = typeof arg1 === 'function' && typeof arg2 === 'function' ? arg1 : null;
|
||||
const onRemove = typeof arg2 === 'function' ? arg2 : typeof arg1 === 'function' ? arg1 : null;
|
||||
|
||||
const {map} = context;
|
||||
if (!map.hasControl(ctrl)) {
|
||||
map.addControl(ctrl, opts?.position);
|
||||
if (onAdd) {
|
||||
onAdd(context);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (onRemove) {
|
||||
onRemove(context);
|
||||
}
|
||||
// Map might have been removed (parent effects are destroyed before child ones)
|
||||
if (map.hasControl(ctrl)) {
|
||||
map.removeControl(ctrl);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ctrl;
|
||||
}
|
||||
68
modules/react-mapbox/src/components/use-map.tsx
Normal file
68
modules/react-mapbox/src/components/use-map.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import {useState, useCallback, useMemo, useContext} from 'react';
|
||||
|
||||
import {MapRef} from '../mapbox/create-ref';
|
||||
import {MapContext} from './map';
|
||||
|
||||
type MountedMapsContextValue = {
|
||||
maps: {[id: string]: MapRef};
|
||||
onMapMount: (map: MapRef, id: string) => void;
|
||||
onMapUnmount: (id: string) => void;
|
||||
};
|
||||
|
||||
export const MountedMapsContext = React.createContext<MountedMapsContextValue>(null);
|
||||
|
||||
export const MapProvider: React.FC<{children?: React.ReactNode}> = props => {
|
||||
const [maps, setMaps] = useState<{[id: string]: MapRef}>({});
|
||||
|
||||
const onMapMount = useCallback((map: MapRef, id: string = 'default') => {
|
||||
setMaps(currMaps => {
|
||||
if (id === 'current') {
|
||||
throw new Error("'current' cannot be used as map id");
|
||||
}
|
||||
if (currMaps[id]) {
|
||||
throw new Error(`Multiple maps with the same id: ${id}`);
|
||||
}
|
||||
return {...currMaps, [id]: map};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onMapUnmount = useCallback((id: string = 'default') => {
|
||||
setMaps(currMaps => {
|
||||
if (currMaps[id]) {
|
||||
const nextMaps = {...currMaps};
|
||||
delete nextMaps[id];
|
||||
return nextMaps;
|
||||
}
|
||||
return currMaps;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MountedMapsContext.Provider
|
||||
value={{
|
||||
maps,
|
||||
onMapMount,
|
||||
onMapUnmount
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</MountedMapsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MapCollection = {
|
||||
[id: string]: MapRef | undefined;
|
||||
current?: MapRef;
|
||||
};
|
||||
|
||||
export function useMap(): MapCollection {
|
||||
const maps = useContext(MountedMapsContext)?.maps;
|
||||
const currentMap = useContext(MapContext);
|
||||
|
||||
const mapsWithCurrent = useMemo(() => {
|
||||
return {...maps, current: currentMap?.map};
|
||||
}, [maps, currentMap]);
|
||||
|
||||
return mapsWithCurrent as MapCollection;
|
||||
}
|
||||
@ -1 +1,33 @@
|
||||
export const version = 'placeholder';
|
||||
import {Map} from './components/map';
|
||||
export {Map};
|
||||
export default Map;
|
||||
|
||||
export {Marker} from './components/marker';
|
||||
export {Popup} from './components/popup';
|
||||
export {AttributionControl} from './components/attribution-control';
|
||||
export {FullscreenControl} from './components/fullscreen-control';
|
||||
export {GeolocateControl} from './components/geolocate-control';
|
||||
export {NavigationControl} from './components/navigation-control';
|
||||
export {ScaleControl} from './components/scale-control';
|
||||
export {Source} from './components/source';
|
||||
export {Layer} from './components/layer';
|
||||
export {useControl} from './components/use-control';
|
||||
export {MapProvider, useMap} from './components/use-map';
|
||||
|
||||
export type {MapProps} from './components/map';
|
||||
export type {MapRef} from './mapbox/create-ref';
|
||||
export type {MarkerProps} from './components/marker';
|
||||
export type {PopupProps} from './components/popup';
|
||||
export type {AttributionControlProps} from './components/attribution-control';
|
||||
export type {FullscreenControlProps} from './components/fullscreen-control';
|
||||
export type {GeolocateControlProps} from './components/geolocate-control';
|
||||
export type {NavigationControlProps} from './components/navigation-control';
|
||||
export type {ScaleControlProps} from './components/scale-control';
|
||||
export type {SourceProps} from './components/source';
|
||||
export type {LayerProps} from './components/layer';
|
||||
|
||||
// Types
|
||||
export * from './types/common';
|
||||
export * from './types/events';
|
||||
export * from './types/lib';
|
||||
export * from './types/style-spec';
|
||||
|
||||
109
modules/react-mapbox/src/mapbox/create-ref.ts
Normal file
109
modules/react-mapbox/src/mapbox/create-ref.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import type {MapInstance} from '../types/lib';
|
||||
import {LngLatLike, PointLike} from '../types/common';
|
||||
|
||||
import type Mapbox from './mapbox';
|
||||
|
||||
/** These methods may break the react binding if called directly */
|
||||
const skipMethods = [
|
||||
'setMaxBounds',
|
||||
'setMinZoom',
|
||||
'setMaxZoom',
|
||||
'setMinPitch',
|
||||
'setMaxPitch',
|
||||
'setRenderWorldCopies',
|
||||
'setProjection',
|
||||
'setStyle',
|
||||
'addSource',
|
||||
'removeSource',
|
||||
'addLayer',
|
||||
'removeLayer',
|
||||
'setLayerZoomRange',
|
||||
'setFilter',
|
||||
'setPaintProperty',
|
||||
'setLayoutProperty',
|
||||
'setLight',
|
||||
'setTerrain',
|
||||
'setFog',
|
||||
'remove'
|
||||
] as const;
|
||||
|
||||
export type MapRef = {
|
||||
getMap(): MapInstance;
|
||||
} & Omit<MapInstance, (typeof skipMethods)[number]>;
|
||||
|
||||
export default function createRef(mapInstance: Mapbox): MapRef | null {
|
||||
if (!mapInstance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const map = mapInstance.map;
|
||||
const ref: any = {
|
||||
getMap: () => map,
|
||||
|
||||
// Overwrite getters to use our shadow transform
|
||||
getCenter: () => mapInstance.transform.center,
|
||||
getZoom: () => mapInstance.transform.zoom,
|
||||
getBearing: () => mapInstance.transform.bearing,
|
||||
getPitch: () => mapInstance.transform.pitch,
|
||||
getPadding: () => mapInstance.transform.padding,
|
||||
getBounds: () => mapInstance.transform.getBounds(),
|
||||
project: (lnglat: LngLatLike) => {
|
||||
const tr = map.transform;
|
||||
map.transform = mapInstance.transform;
|
||||
const result = map.project(lnglat);
|
||||
map.transform = tr;
|
||||
return result;
|
||||
},
|
||||
unproject: (point: PointLike) => {
|
||||
const tr = map.transform;
|
||||
map.transform = mapInstance.transform;
|
||||
const result = map.unproject(point);
|
||||
map.transform = tr;
|
||||
return result;
|
||||
},
|
||||
// options diverge between mapbox and maplibre
|
||||
queryTerrainElevation: (lnglat: LngLatLike, options?: any) => {
|
||||
const tr = map.transform;
|
||||
map.transform = mapInstance.transform;
|
||||
const result = map.queryTerrainElevation(lnglat, options);
|
||||
map.transform = tr;
|
||||
return result;
|
||||
},
|
||||
queryRenderedFeatures: (geometry?: any, options?: any) => {
|
||||
const tr = map.transform;
|
||||
map.transform = mapInstance.transform;
|
||||
const result = map.queryRenderedFeatures(geometry, options);
|
||||
map.transform = tr;
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of getMethodNames(map)) {
|
||||
// @ts-expect-error
|
||||
if (!(key in ref) && !skipMethods.includes(key)) {
|
||||
ref[key] = map[key].bind(map);
|
||||
}
|
||||
}
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
function getMethodNames(obj: Object) {
|
||||
const result = new Set<string>();
|
||||
|
||||
let proto = obj;
|
||||
while (proto) {
|
||||
for (const key of Object.getOwnPropertyNames(proto)) {
|
||||
if (
|
||||
key[0] !== '_' &&
|
||||
typeof obj[key] === 'function' &&
|
||||
key !== 'fire' &&
|
||||
key !== 'setEventedParent'
|
||||
) {
|
||||
result.add(key);
|
||||
}
|
||||
}
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
}
|
||||
return Array.from(result);
|
||||
}
|
||||
731
modules/react-mapbox/src/mapbox/mapbox.ts
Normal file
731
modules/react-mapbox/src/mapbox/mapbox.ts
Normal file
@ -0,0 +1,731 @@
|
||||
import {
|
||||
transformToViewState,
|
||||
applyViewStateToTransform,
|
||||
cloneTransform,
|
||||
syncProjection
|
||||
} from '../utils/transform';
|
||||
import {normalizeStyle} from '../utils/style-utils';
|
||||
import {deepEqual} from '../utils/deep-equal';
|
||||
|
||||
import type {
|
||||
ViewState,
|
||||
Point,
|
||||
PointLike,
|
||||
PaddingOptions,
|
||||
ImmutableLike,
|
||||
LngLatBoundsLike,
|
||||
MapGeoJSONFeature
|
||||
} from '../types/common';
|
||||
import type {MapStyle, Light, Terrain, Fog, Projection} from '../types/style-spec';
|
||||
import type {MapInstance} from '../types/lib';
|
||||
import type {Transform} from '../types/internal';
|
||||
import type {
|
||||
MapCallbacks,
|
||||
ViewStateChangeEvent,
|
||||
MapEvent,
|
||||
ErrorEvent,
|
||||
MapMouseEvent
|
||||
} from '../types/events';
|
||||
|
||||
export type MapboxProps = Partial<ViewState> &
|
||||
MapCallbacks & {
|
||||
// Init options
|
||||
mapboxAccessToken?: string;
|
||||
|
||||
/** Camera options used when constructing the Map instance */
|
||||
initialViewState?: Partial<ViewState> & {
|
||||
/** The initial bounds of the map. If bounds is specified, it overrides longitude, latitude and zoom options. */
|
||||
bounds?: LngLatBoundsLike;
|
||||
/** A fitBounds options object to use only when setting the bounds option. */
|
||||
fitBoundsOptions?: {
|
||||
offset?: PointLike;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
padding?: number | PaddingOptions;
|
||||
};
|
||||
};
|
||||
|
||||
/** If provided, render into an external WebGL context */
|
||||
gl?: WebGLRenderingContext;
|
||||
|
||||
/** For external controller to override the camera state */
|
||||
viewState?: ViewState & {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// Styling
|
||||
|
||||
/** Mapbox style */
|
||||
mapStyle?: string | MapStyle | ImmutableLike<MapStyle>;
|
||||
/** Enable diffing when the map style changes
|
||||
* @default true
|
||||
*/
|
||||
styleDiffing?: boolean;
|
||||
/** The projection property of the style. Must conform to the Projection Style Specification.
|
||||
* @default 'mercator'
|
||||
*/
|
||||
projection?: Projection;
|
||||
/** The fog property of the style. Must conform to the Fog Style Specification .
|
||||
* If `undefined` is provided, removes the fog from the map. */
|
||||
fog?: Fog;
|
||||
/** Light properties of the map. */
|
||||
light?: Light;
|
||||
/** Terrain property of the style. Must conform to the Terrain Style Specification .
|
||||
* If `undefined` is provided, removes terrain from the map. */
|
||||
terrain?: Terrain;
|
||||
|
||||
/** Default layers to query on pointer events */
|
||||
interactiveLayerIds?: string[];
|
||||
/** CSS cursor */
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_STYLE = {version: 8, sources: {}, layers: []} as MapStyle;
|
||||
|
||||
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'
|
||||
};
|
||||
const otherEvents = {
|
||||
wheel: 'onWheel',
|
||||
boxzoomstart: 'onBoxZoomStart',
|
||||
boxzoomend: 'onBoxZoomEnd',
|
||||
boxzoomcancel: 'onBoxZoomCancel',
|
||||
resize: 'onResize',
|
||||
load: 'onLoad',
|
||||
render: 'onRender',
|
||||
idle: 'onIdle',
|
||||
remove: 'onRemove',
|
||||
data: 'onData',
|
||||
styledata: 'onStyleData',
|
||||
sourcedata: 'onSourceData',
|
||||
error: 'onError'
|
||||
};
|
||||
const settingNames = [
|
||||
'minZoom',
|
||||
'maxZoom',
|
||||
'minPitch',
|
||||
'maxPitch',
|
||||
'maxBounds',
|
||||
'projection',
|
||||
'renderWorldCopies'
|
||||
];
|
||||
const handlerNames = [
|
||||
'scrollZoom',
|
||||
'boxZoom',
|
||||
'dragRotate',
|
||||
'dragPan',
|
||||
'keyboard',
|
||||
'doubleClickZoom',
|
||||
'touchZoomRotate',
|
||||
'touchPitch'
|
||||
];
|
||||
|
||||
/**
|
||||
* A wrapper for mapbox-gl's Map class
|
||||
*/
|
||||
export default class Mapbox {
|
||||
private _MapClass: {new (options: any): MapInstance};
|
||||
// mapboxgl.Map instance
|
||||
private _map: MapInstance = 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: MapGeoJSONFeature[] = null;
|
||||
private _deferredEvents: {
|
||||
move: boolean;
|
||||
zoom: boolean;
|
||||
pitch: boolean;
|
||||
rotate: boolean;
|
||||
} = {
|
||||
move: false,
|
||||
zoom: false,
|
||||
pitch: false,
|
||||
rotate: false
|
||||
};
|
||||
|
||||
static savedMaps: Mapbox[] = [];
|
||||
|
||||
constructor(
|
||||
MapClass: {new (options: any): MapInstance},
|
||||
props: MapboxProps,
|
||||
container: HTMLDivElement
|
||||
) {
|
||||
this._MapClass = MapClass;
|
||||
this.props = props;
|
||||
this._initialize(container);
|
||||
}
|
||||
|
||||
get map(): MapInstance {
|
||||
return this._map;
|
||||
}
|
||||
|
||||
get transform(): Transform {
|
||||
return this._renderTransform;
|
||||
}
|
||||
|
||||
setProps(props: MapboxProps) {
|
||||
const oldProps = this.props;
|
||||
this.props = props;
|
||||
|
||||
const settingsChanged = this._updateSettings(props, oldProps);
|
||||
if (settingsChanged) {
|
||||
this._createShadowTransform(this._map);
|
||||
}
|
||||
const sizeChanged = this._updateSize(props);
|
||||
const viewStateChanged = this._updateViewState(props, true);
|
||||
this._updateStyle(props, oldProps);
|
||||
this._updateStyleComponents(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 || sizeChanged || (viewStateChanged && !this._map.isMoving())) {
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
static reuse(props: MapboxProps, container: HTMLDivElement): Mapbox {
|
||||
const that = Mapbox.savedMaps.pop();
|
||||
if (!that) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const map = that.map;
|
||||
// When reusing the saved map, we need to reparent the map(canvas) and other child nodes
|
||||
// intoto the new container from the props.
|
||||
// Step 1: reparenting child nodes from old container to new container
|
||||
const oldContainer = map.getContainer();
|
||||
container.className = oldContainer.className;
|
||||
while (oldContainer.childNodes.length > 0) {
|
||||
container.appendChild(oldContainer.childNodes[0]);
|
||||
}
|
||||
// Step 2: replace the internal container with new container from the react component
|
||||
// @ts-ignore
|
||||
map._container = container;
|
||||
|
||||
// Step 4: apply new props
|
||||
that.setProps({...props, styleDiffing: false});
|
||||
map.resize();
|
||||
const {initialViewState} = props;
|
||||
if (initialViewState) {
|
||||
if (initialViewState.bounds) {
|
||||
map.fitBounds(initialViewState.bounds, {...initialViewState.fitBoundsOptions, duration: 0});
|
||||
} else {
|
||||
that._updateViewState(initialViewState, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate load event
|
||||
if (map.isStyleLoaded()) {
|
||||
map.fire('load');
|
||||
} else {
|
||||
map.once('styledata', () => map.fire('load'));
|
||||
}
|
||||
|
||||
// Force reload
|
||||
// @ts-ignore
|
||||
map._update();
|
||||
return that;
|
||||
}
|
||||
|
||||
/* eslint-disable complexity,max-statements */
|
||||
_initialize(container: HTMLDivElement) {
|
||||
const {props} = this;
|
||||
const {mapStyle = DEFAULT_STYLE} = props;
|
||||
const mapOptions = {
|
||||
...props,
|
||||
...props.initialViewState,
|
||||
accessToken: props.mapboxAccessToken || getAccessTokenFromEnv() || null,
|
||||
container,
|
||||
style: normalizeStyle(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 = new this._MapClass(mapOptions);
|
||||
// Props that are not part of constructor options
|
||||
if (viewState.padding) {
|
||||
map.setPadding(viewState.padding);
|
||||
}
|
||||
if (props.cursor) {
|
||||
map.getCanvas().style.cursor = props.cursor;
|
||||
}
|
||||
this._createShadowTransform(map);
|
||||
|
||||
// Hack
|
||||
// Insert code into map's render cycle
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const renderMap = map._render;
|
||||
map._render = (arg: number) => {
|
||||
this._inRender = true;
|
||||
renderMap.call(map, arg);
|
||||
this._inRender = false;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const runRenderTaskQueue = map._renderTaskQueue.run;
|
||||
map._renderTaskQueue.run = (arg: number) => {
|
||||
runRenderTaskQueue.call(map._renderTaskQueue, arg);
|
||||
this._onBeforeRepaint();
|
||||
};
|
||||
map.on('render', () => this._onAfterRepaint());
|
||||
// Insert code into map's event pipeline
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const fireEvent = map.fire;
|
||||
map.fire = this._fireEvent.bind(this, fireEvent);
|
||||
|
||||
// add listeners
|
||||
map.on('resize', () => {
|
||||
this._renderTransform.resize(map.transform.width, map.transform.height);
|
||||
});
|
||||
map.on('styledata', () => {
|
||||
this._updateStyleComponents(this.props, {});
|
||||
// Projection can be set in stylesheet
|
||||
syncProjection(map.transform, this._renderTransform);
|
||||
});
|
||||
map.on('sourcedata', () => this._updateStyleComponents(this.props, {}));
|
||||
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);
|
||||
}
|
||||
this._map = map;
|
||||
}
|
||||
/* eslint-enable complexity,max-statements */
|
||||
|
||||
recycle() {
|
||||
// Clean up unnecessary elements before storing for reuse.
|
||||
const container = this.map.getContainer();
|
||||
const children = container.querySelector('[mapboxgl-children]');
|
||||
children?.remove();
|
||||
|
||||
Mapbox.savedMaps.push(this);
|
||||
}
|
||||
|
||||
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 as any;
|
||||
// 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 (!this._inRender && 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();
|
||||
}
|
||||
}
|
||||
|
||||
_createShadowTransform(map: any) {
|
||||
const renderTransform = cloneTransform(map.transform);
|
||||
map.painter.transform = renderTransform;
|
||||
|
||||
this._renderTransform = renderTransform;
|
||||
}
|
||||
|
||||
/* Trigger map resize if size is controlled
|
||||
@param {object} nextProps
|
||||
@returns {bool} true if size has changed
|
||||
*/
|
||||
_updateSize(nextProps: MapboxProps): boolean {
|
||||
// Check if size is controlled
|
||||
const {viewState} = nextProps;
|
||||
if (viewState) {
|
||||
const map = this._map;
|
||||
if (viewState.width !== map.transform.width || viewState.height !== map.transform.height) {
|
||||
map.resize();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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 isMoving = map.isMoving();
|
||||
|
||||
if (isMoving) {
|
||||
// All movement of the camera is done relative to the sea level
|
||||
tr.cameraElevationReference = 'sea';
|
||||
}
|
||||
const changed = applyViewStateToTransform(tr, {
|
||||
...transformToViewState(map.transform),
|
||||
...nextProps
|
||||
});
|
||||
if (isMoving) {
|
||||
// Reset camera reference
|
||||
tr.cameraElevationReference = 'ground';
|
||||
}
|
||||
|
||||
if (changed && triggerEvents) {
|
||||
const deferredEvents = this._deferredEvents;
|
||||
// Delay DOM control updates to the next render cycle
|
||||
deferredEvents.move = true;
|
||||
deferredEvents.zoom ||= zoom !== tr.zoom;
|
||||
deferredEvents.rotate ||= bearing !== tr.bearing;
|
||||
deferredEvents.pitch ||= pitch !== tr.pitch;
|
||||
}
|
||||
|
||||
// Avoid manipulating the real transform when interaction/animation is ongoing
|
||||
// as it would interfere with Mapbox's handlers
|
||||
if (!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 (propName in nextProps && !deepEqual(nextProps[propName], currProps[propName])) {
|
||||
changed = true;
|
||||
const setter = map[`set${propName[0].toUpperCase()}${propName.slice(1)}`];
|
||||
setter?.call(map, 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 {mapStyle = DEFAULT_STYLE, styleDiffing = true} = nextProps;
|
||||
const options: any = {
|
||||
diff: styleDiffing
|
||||
};
|
||||
if ('localIdeographFontFamily' in nextProps) {
|
||||
// @ts-ignore Mapbox specific prop
|
||||
options.localIdeographFontFamily = nextProps.localIdeographFontFamily;
|
||||
}
|
||||
this._map.setStyle(normalizeStyle(mapStyle), options);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Update fog, light and terrain to match props
|
||||
@param {object} nextProps
|
||||
@param {object} currProps
|
||||
@returns {bool} true if anything is changed
|
||||
*/
|
||||
_updateStyleComponents(nextProps: MapboxProps, currProps: MapboxProps): boolean {
|
||||
const map = this._map;
|
||||
let changed = false;
|
||||
if (map.isStyleLoaded()) {
|
||||
if ('light' in nextProps && map.setLight && !deepEqual(nextProps.light, currProps.light)) {
|
||||
changed = true;
|
||||
map.setLight(nextProps.light);
|
||||
}
|
||||
if ('fog' in nextProps && map.setFog && !deepEqual(nextProps.fog, currProps.fog)) {
|
||||
changed = true;
|
||||
map.setFog(nextProps.fog);
|
||||
}
|
||||
if (
|
||||
'terrain' in nextProps &&
|
||||
map.setTerrain &&
|
||||
!deepEqual(nextProps.terrain, currProps.terrain)
|
||||
) {
|
||||
if (!nextProps.terrain || map.getSource(nextProps.terrain.source)) {
|
||||
changed = true;
|
||||
map.setTerrain(nextProps.terrain);
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/* 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] ?? true;
|
||||
const oldValue = currProps[propName] ?? true;
|
||||
if (!deepEqual(newValue, oldValue)) {
|
||||
changed = true;
|
||||
if (newValue) {
|
||||
map[propName].enable(newValue);
|
||||
} else {
|
||||
map[propName].disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
_onEvent = (e: MapEvent) => {
|
||||
// @ts-ignore
|
||||
const cb = this.props[otherEvents[e.type]];
|
||||
if (cb) {
|
||||
cb(e);
|
||||
} else if (e.type === 'error') {
|
||||
console.error((e as ErrorEvent).error); // eslint-disable-line
|
||||
}
|
||||
};
|
||||
|
||||
private _queryRenderedFeatures(point: Point) {
|
||||
const map = this._map;
|
||||
const tr = map.transform;
|
||||
const {interactiveLayerIds = []} = this.props;
|
||||
try {
|
||||
map.transform = this._renderTransform;
|
||||
return map.queryRenderedFeatures(point, {
|
||||
layers: interactiveLayerIds.filter(map.getLayer.bind(map))
|
||||
});
|
||||
} catch {
|
||||
// May fail if style is not loaded
|
||||
return [];
|
||||
} finally {
|
||||
map.transform = tr;
|
||||
}
|
||||
}
|
||||
|
||||
_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;
|
||||
const features = this._queryRenderedFeatures(e.point);
|
||||
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: MapMouseEvent) => {
|
||||
if (e.type === 'mousemove' || e.type === 'mouseout') {
|
||||
this._updateHover(e);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const cb = this.props[pointerEvents[e.type]];
|
||||
if (cb) {
|
||||
if (this.props.interactiveLayerIds && e.type !== 'mouseover' && e.type !== 'mouseout') {
|
||||
e.features = this._hoveredFeatures || this._queryRenderedFeatures(e.point);
|
||||
}
|
||||
cb(e);
|
||||
delete e.features;
|
||||
}
|
||||
};
|
||||
|
||||
_onCameraEvent = (e: ViewStateChangeEvent) => {
|
||||
if (!this._internalUpdate) {
|
||||
// @ts-ignore
|
||||
const cb = this.props[cameraEvents[e.type]];
|
||||
if (cb) {
|
||||
cb(e);
|
||||
}
|
||||
}
|
||||
if (e.type in this._deferredEvents) {
|
||||
this._deferredEvents[e.type] = false;
|
||||
}
|
||||
};
|
||||
|
||||
_fireEvent(baseFire: Function, event: string | MapEvent, properties?: object) {
|
||||
const map = this._map;
|
||||
const tr = map.transform;
|
||||
|
||||
const eventType = typeof event === 'string' ? event : event.type;
|
||||
if (eventType === 'move') {
|
||||
this._updateViewState(this.props, false);
|
||||
}
|
||||
if (eventType in cameraEvents) {
|
||||
if (typeof event === 'object') {
|
||||
(event as unknown as ViewStateChangeEvent).viewState = transformToViewState(tr);
|
||||
}
|
||||
if (this._map.isMoving()) {
|
||||
// Replace map.transform with ours during the callbacks
|
||||
map.transform = this._renderTransform;
|
||||
baseFire.call(map, event, properties);
|
||||
map.transform = tr;
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
baseFire.call(map, event, properties);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// All camera manipulations are complete, ready to repaint
|
||||
_onBeforeRepaint() {
|
||||
const map = this._map;
|
||||
|
||||
// If there are camera changes driven by props, invoke camera events so that DOM controls are synced
|
||||
this._internalUpdate = true;
|
||||
for (const eventType in this._deferredEvents) {
|
||||
if (this._deferredEvents[eventType]) {
|
||||
map.fire(eventType);
|
||||
}
|
||||
}
|
||||
this._internalUpdate = false;
|
||||
|
||||
const tr = this._map.transform;
|
||||
// Make sure camera matches the current props
|
||||
map.transform = this._renderTransform;
|
||||
|
||||
this._onAfterRepaint = () => {
|
||||
// Mapbox transitions between non-mercator projection and mercator during render time
|
||||
// Copy it back to the other
|
||||
syncProjection(this._renderTransform, tr);
|
||||
// Restores camera state before render/load events are fired
|
||||
map.transform = tr;
|
||||
};
|
||||
}
|
||||
|
||||
_onAfterRepaint: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// eslint-disable-next-line no-process-env
|
||||
accessToken = accessToken || process.env.MapboxAccessToken;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-process-env
|
||||
accessToken = accessToken || process.env.REACT_APP_MAPBOX_ACCESS_TOKEN;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
34
modules/react-mapbox/src/types/common.ts
Normal file
34
modules/react-mapbox/src/types/common.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type {PaddingOptions} from 'mapbox-gl';
|
||||
|
||||
export type {
|
||||
Point,
|
||||
PointLike,
|
||||
LngLat,
|
||||
LngLatLike,
|
||||
LngLatBounds,
|
||||
LngLatBoundsLike,
|
||||
PaddingOptions,
|
||||
GeoJSONFeature as MapGeoJSONFeature
|
||||
} from 'mapbox-gl';
|
||||
|
||||
/* Public */
|
||||
|
||||
/** 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 interface ImmutableLike<T> {
|
||||
toJS: () => T;
|
||||
}
|
||||
125
modules/react-mapbox/src/types/events.ts
Normal file
125
modules/react-mapbox/src/types/events.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import type {ViewState, LngLat} from './common';
|
||||
import {
|
||||
Marker,
|
||||
Popup,
|
||||
GeolocateControl,
|
||||
MapEvent,
|
||||
MapEventOf,
|
||||
ErrorEvent,
|
||||
MapMouseEvent,
|
||||
MapTouchEvent,
|
||||
MapStyleDataEvent,
|
||||
MapSourceDataEvent,
|
||||
MapWheelEvent
|
||||
} from 'mapbox-gl';
|
||||
|
||||
export type {
|
||||
MapEvent,
|
||||
ErrorEvent,
|
||||
MapMouseEvent,
|
||||
MapTouchEvent,
|
||||
MapStyleDataEvent,
|
||||
MapSourceDataEvent,
|
||||
MapWheelEvent
|
||||
};
|
||||
|
||||
export type MapBoxZoomEvent =
|
||||
| MapEventOf<'boxzoomstart'>
|
||||
| MapEventOf<'boxzoomend'>
|
||||
| MapEventOf<'boxzoomcancel'>;
|
||||
|
||||
export type MapCallbacks = {
|
||||
onMouseDown?: (e: MapMouseEvent) => void;
|
||||
onMouseUp?: (e: MapMouseEvent) => void;
|
||||
onMouseOver?: (e: MapMouseEvent) => void;
|
||||
onMouseMove?: (e: MapMouseEvent) => void;
|
||||
onClick?: (e: MapMouseEvent) => void;
|
||||
onDblClick?: (e: MapMouseEvent) => void;
|
||||
onMouseEnter?: (e: MapMouseEvent) => void;
|
||||
onMouseLeave?: (e: MapMouseEvent) => void;
|
||||
onMouseOut?: (e: MapMouseEvent) => void;
|
||||
onContextMenu?: (e: MapMouseEvent) => void;
|
||||
onTouchStart?: (e: MapTouchEvent) => void;
|
||||
onTouchEnd?: (e: MapTouchEvent) => void;
|
||||
onTouchMove?: (e: MapTouchEvent) => void;
|
||||
onTouchCancel?: (e: MapTouchEvent) => 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;
|
||||
|
||||
onWheel?: (e: MapWheelEvent) => void;
|
||||
onBoxZoomStart?: (e: MapBoxZoomEvent) => void;
|
||||
onBoxZoomEnd?: (e: MapBoxZoomEvent) => void;
|
||||
onBoxZoomCancel?: (e: MapBoxZoomEvent) => void;
|
||||
|
||||
onResize?: (e: MapEvent) => void;
|
||||
onLoad?: (e: MapEvent) => void;
|
||||
onRender?: (e: MapEvent) => void;
|
||||
onIdle?: (e: MapEvent) => void;
|
||||
onError?: (e: ErrorEvent) => void;
|
||||
onRemove?: (e: MapEvent) => void;
|
||||
onData?: (e: MapStyleDataEvent | MapSourceDataEvent) => void;
|
||||
onStyleData?: (e: MapStyleDataEvent) => void;
|
||||
onSourceData?: (e: MapSourceDataEvent) => void;
|
||||
};
|
||||
|
||||
interface IMapEvent<SourceT, OriginalEventT = undefined> {
|
||||
type: string;
|
||||
target: SourceT;
|
||||
originalEvent: OriginalEventT;
|
||||
}
|
||||
|
||||
export interface Callbacks {
|
||||
[key: `on${string}`]: Function;
|
||||
}
|
||||
|
||||
export type ViewStateChangeEvent = MapEventOf<
|
||||
| 'movestart'
|
||||
| 'move'
|
||||
| 'moveend'
|
||||
| 'zoomstart'
|
||||
| 'zoom'
|
||||
| 'zoomend'
|
||||
| 'rotatestart'
|
||||
| 'rotate'
|
||||
| 'rotateend'
|
||||
| 'dragstart'
|
||||
| 'drag'
|
||||
| 'dragend'
|
||||
| 'pitchstart'
|
||||
| 'pitch'
|
||||
| 'pitchend'
|
||||
> & {
|
||||
viewState: ViewState;
|
||||
};
|
||||
|
||||
export type PopupEvent = {
|
||||
type: 'open' | 'close';
|
||||
target: Popup;
|
||||
};
|
||||
|
||||
export type MarkerEvent<OriginalEventT = undefined> = IMapEvent<Marker, OriginalEventT>;
|
||||
|
||||
export type MarkerDragEvent = MarkerEvent & {
|
||||
type: 'dragstart' | 'drag' | 'dragend';
|
||||
lngLat: LngLat;
|
||||
};
|
||||
|
||||
export type GeolocateEvent = IMapEvent<GeolocateControl>;
|
||||
|
||||
export type GeolocateResultEvent = GeolocateEvent & GeolocationPosition;
|
||||
|
||||
export type GeolocateErrorEvent = GeolocateEvent & GeolocationPositionError;
|
||||
34
modules/react-mapbox/src/types/internal.ts
Normal file
34
modules/react-mapbox/src/types/internal.ts
Normal file
@ -0,0 +1,34 @@
|
||||
// Internal types
|
||||
import type {
|
||||
Map,
|
||||
GeoJSONSource as GeoJSONSourceImplementation,
|
||||
ImageSource as ImageSourceImplemtation,
|
||||
CanvasSource as CanvasSourceImplemtation,
|
||||
VectorTileSource as VectorSourceImplementation,
|
||||
RasterTileSource as RasterSourceImplementation,
|
||||
RasterDemTileSource as RasterDemSourceImplementation,
|
||||
VideoSource as VideoSourceImplementation,
|
||||
Source
|
||||
} from 'mapbox-gl';
|
||||
|
||||
export type Transform = Map['transform'];
|
||||
|
||||
export type {
|
||||
GeoJSONSourceImplementation,
|
||||
ImageSourceImplemtation,
|
||||
CanvasSourceImplemtation,
|
||||
VectorSourceImplementation,
|
||||
RasterDemSourceImplementation,
|
||||
RasterSourceImplementation,
|
||||
VideoSourceImplementation
|
||||
};
|
||||
|
||||
export type AnySourceImplementation =
|
||||
| GeoJSONSourceImplementation
|
||||
| VideoSourceImplementation
|
||||
| ImageSourceImplemtation
|
||||
| CanvasSourceImplemtation
|
||||
| VectorSourceImplementation
|
||||
| RasterSourceImplementation
|
||||
| RasterDemSourceImplementation
|
||||
| Source;
|
||||
65
modules/react-mapbox/src/types/lib.ts
Normal file
65
modules/react-mapbox/src/types/lib.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type {
|
||||
Map,
|
||||
MapOptions,
|
||||
Marker,
|
||||
MarkerOptions,
|
||||
Popup,
|
||||
PopupOptions,
|
||||
AttributionControl,
|
||||
AttributionControlOptions,
|
||||
FullscreenControl,
|
||||
FullscreenControlOptions,
|
||||
GeolocateControl,
|
||||
GeolocateControlOptions,
|
||||
NavigationControl,
|
||||
NavigationControlOptions,
|
||||
ScaleControl,
|
||||
ScaleControlOptions
|
||||
} from 'mapbox-gl';
|
||||
|
||||
export type {
|
||||
ControlPosition,
|
||||
IControl,
|
||||
Map as MapInstance,
|
||||
MapOptions,
|
||||
Marker as MarkerInstance,
|
||||
MarkerOptions,
|
||||
Popup as PopupInstance,
|
||||
PopupOptions,
|
||||
AttributionControl as AttributionControlInstance,
|
||||
AttributionControlOptions,
|
||||
FullscreenControl as FullscreenControlInstance,
|
||||
FullscreenControlOptions,
|
||||
GeolocateControl as GeolocateControlInstance,
|
||||
GeolocateControlOptions,
|
||||
NavigationControl as NavigationControlInstance,
|
||||
NavigationControlOptions,
|
||||
ScaleControl as ScaleControlInstance,
|
||||
ScaleControlOptions,
|
||||
CustomLayerInterface
|
||||
} from 'mapbox-gl';
|
||||
|
||||
/**
|
||||
* A user-facing type that represents the minimal intersection between Mapbox and Maplibre
|
||||
* User provided `mapLib` is supposed to implement this interface
|
||||
* Only losely typed for compatibility
|
||||
*/
|
||||
export interface MapLib {
|
||||
supported?: (options: any) => boolean;
|
||||
|
||||
Map: {new (options: MapOptions): Map};
|
||||
|
||||
Marker: {new (options: MarkerOptions): Marker};
|
||||
|
||||
Popup: {new (options: PopupOptions): Popup};
|
||||
|
||||
AttributionControl: {new (options: AttributionControlOptions): AttributionControl};
|
||||
|
||||
FullscreenControl: {new (options: FullscreenControlOptions): FullscreenControl};
|
||||
|
||||
GeolocateControl: {new (options: GeolocateControlOptions): GeolocateControl};
|
||||
|
||||
NavigationControl: {new (options: NavigationControlOptions): NavigationControl};
|
||||
|
||||
ScaleControl: {new (options: ScaleControlOptions): ScaleControl};
|
||||
}
|
||||
84
modules/react-mapbox/src/types/style-spec.ts
Normal file
84
modules/react-mapbox/src/types/style-spec.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Mapbox Style Specification types
|
||||
*/
|
||||
// Layers
|
||||
import type {
|
||||
BackgroundLayerSpecification as BackgroundLayer,
|
||||
SkyLayerSpecification as SkyLayer,
|
||||
CircleLayerSpecification as CircleLayer,
|
||||
FillLayerSpecification as FillLayer,
|
||||
FillExtrusionLayerSpecification as FillExtrusionLayer,
|
||||
HeatmapLayerSpecification as HeatmapLayer,
|
||||
HillshadeLayerSpecification as HillshadeLayer,
|
||||
LineLayerSpecification as LineLayer,
|
||||
RasterLayerSpecification as RasterLayer,
|
||||
SymbolLayerSpecification as SymbolLayer,
|
||||
GeoJSONSourceSpecification as GeoJSONSourceRaw,
|
||||
VideoSourceSpecification as VideoSourceRaw,
|
||||
ImageSourceSpecification as ImageSourceRaw,
|
||||
VectorSourceSpecification as VectorSourceRaw,
|
||||
RasterSourceSpecification as RasterSource,
|
||||
RasterDEMSourceSpecification as RasterDemSource,
|
||||
ProjectionSpecification
|
||||
} from 'mapbox-gl';
|
||||
|
||||
type CanvasSourceRaw = {
|
||||
type: 'canvas';
|
||||
coordinates: [[number, number], [number, number], [number, number], [number, number]];
|
||||
animate?: boolean;
|
||||
canvas: string | HTMLCanvasElement;
|
||||
};
|
||||
|
||||
export type AnyLayer =
|
||||
| BackgroundLayer
|
||||
| CircleLayer
|
||||
| FillExtrusionLayer
|
||||
| FillLayer
|
||||
| HeatmapLayer
|
||||
| HillshadeLayer
|
||||
| LineLayer
|
||||
| RasterLayer
|
||||
| SymbolLayer
|
||||
| SkyLayer;
|
||||
|
||||
export type {
|
||||
BackgroundLayer,
|
||||
SkyLayer,
|
||||
CircleLayer,
|
||||
FillLayer,
|
||||
FillExtrusionLayer,
|
||||
HeatmapLayer,
|
||||
HillshadeLayer,
|
||||
LineLayer,
|
||||
RasterLayer,
|
||||
SymbolLayer
|
||||
};
|
||||
|
||||
export type AnySource =
|
||||
| GeoJSONSourceRaw
|
||||
| VideoSourceRaw
|
||||
| ImageSourceRaw
|
||||
| CanvasSourceRaw
|
||||
| VectorSourceRaw
|
||||
| RasterSource
|
||||
| RasterDemSource;
|
||||
|
||||
export type {
|
||||
GeoJSONSourceRaw,
|
||||
VideoSourceRaw,
|
||||
ImageSourceRaw,
|
||||
CanvasSourceRaw,
|
||||
VectorSourceRaw,
|
||||
RasterSource,
|
||||
RasterDemSource
|
||||
};
|
||||
|
||||
// Other
|
||||
export type {
|
||||
StyleSpecification as MapStyle,
|
||||
LightSpecification as Light,
|
||||
FogSpecification as Fog,
|
||||
TerrainSpecification as Terrain
|
||||
} from 'mapbox-gl';
|
||||
|
||||
export type Projection = ProjectionSpecification | ProjectionSpecification['name'];
|
||||
20
modules/react-mapbox/src/utils/apply-react-style.ts
Normal file
20
modules/react-mapbox/src/utils/apply-react-style.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
// This is a simplified version of
|
||||
// https://github.com/facebook/react/blob/4131af3e4bf52f3a003537ec95a1655147c81270/src/renderers/dom/shared/CSSPropertyOperations.js#L62
|
||||
const unitlessNumber = /box|flex|grid|column|lineHeight|fontWeight|opacity|order|tabSize|zIndex/;
|
||||
|
||||
export function applyReactStyle(element: HTMLElement, styles: React.CSSProperties) {
|
||||
if (!element || !styles) {
|
||||
return;
|
||||
}
|
||||
const style = element.style;
|
||||
|
||||
for (const key in styles) {
|
||||
const value = styles[key];
|
||||
if (Number.isFinite(value) && !unitlessNumber.test(key)) {
|
||||
style[key] = `${value}px`;
|
||||
} else {
|
||||
style[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
modules/react-mapbox/src/utils/assert.ts
Normal file
5
modules/react-mapbox/src/utils/assert.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default function assert(condition: any, message: string) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
61
modules/react-mapbox/src/utils/deep-equal.ts
Normal file
61
modules/react-mapbox/src/utils/deep-equal.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type {PointLike} from '../types/common';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/* eslint-disable complexity */
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
57
modules/react-mapbox/src/utils/set-globals.ts
Normal file
57
modules/react-mapbox/src/utils/set-globals.ts
Normal file
@ -0,0 +1,57 @@
|
||||
export type GlobalSettings = {
|
||||
/** The map's default API URL for requesting tiles, styles, sprites, and glyphs. */
|
||||
baseApiUrl?: string;
|
||||
/** The maximum number of images (raster tiles, sprites, icons) to load in parallel.
|
||||
* @default 16
|
||||
*/
|
||||
maxParallelImageRequests?: number;
|
||||
/** The map's RTL text plugin. Necessary for supporting the Arabic and Hebrew languages, which are written right-to-left. */
|
||||
RTLTextPlugin?: string | false;
|
||||
/** Provides an interface for external module bundlers such as Webpack or Rollup to package mapbox-gl's WebWorker into a separate class and integrate it with the library.
|
||||
Takes precedence over `workerUrl`. */
|
||||
workerClass?: any;
|
||||
/** The number of web workers instantiated on a page with mapbox-gl maps.
|
||||
* @default 2
|
||||
*/
|
||||
workerCount?: number;
|
||||
/** Provides an interface for loading mapbox-gl's WebWorker bundle from a self-hosted URL.
|
||||
* This is useful if your site needs to operate in a strict CSP (Content Security Policy) environment
|
||||
* wherein you are not allowed to load JavaScript code from a Blob URL, which is default behavior. */
|
||||
workerUrl?: string;
|
||||
};
|
||||
|
||||
const globalSettings = [
|
||||
'baseApiUrl',
|
||||
'maxParallelImageRequests',
|
||||
'workerClass',
|
||||
'workerCount',
|
||||
'workerUrl'
|
||||
] as const;
|
||||
|
||||
export default function setGlobals(mapLib: any, props: GlobalSettings) {
|
||||
for (const key of globalSettings) {
|
||||
if (key in props) {
|
||||
mapLib[key] = props[key];
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
RTLTextPlugin = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.3/mapbox-gl-rtl-text.js'
|
||||
} = props;
|
||||
if (
|
||||
RTLTextPlugin &&
|
||||
mapLib.getRTLTextPluginStatus &&
|
||||
mapLib.getRTLTextPluginStatus() === 'unavailable'
|
||||
) {
|
||||
mapLib.setRTLTextPlugin(
|
||||
RTLTextPlugin,
|
||||
(error?: Error) => {
|
||||
if (error) {
|
||||
// eslint-disable-next-line
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
60
modules/react-mapbox/src/utils/style-utils.ts
Normal file
60
modules/react-mapbox/src/utils/style-utils.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import {ImmutableLike} from '../types/common';
|
||||
import {MapStyle} from '../types/style-spec';
|
||||
|
||||
const refProps = ['type', 'source', 'source-layer', 'minzoom', 'maxzoom', 'filter', 'layout'];
|
||||
|
||||
// Prepare a map style object for diffing
|
||||
// If immutable - convert to plain object
|
||||
// Work around some issues in older styles that would fail Mapbox's diffing
|
||||
export function normalizeStyle(
|
||||
style: string | MapStyle | ImmutableLike<MapStyle>
|
||||
): string | MapStyle {
|
||||
if (!style) {
|
||||
return null;
|
||||
}
|
||||
if (typeof style === 'string') {
|
||||
return style;
|
||||
}
|
||||
if ('toJS' in style) {
|
||||
style = style.toJS();
|
||||
}
|
||||
if (!style.layers) {
|
||||
return style;
|
||||
}
|
||||
const layerIndex = {};
|
||||
|
||||
for (const layer of style.layers) {
|
||||
layerIndex[layer.id] = layer;
|
||||
}
|
||||
|
||||
const layers = style.layers.map(layer => {
|
||||
let normalizedLayer: typeof layer = null;
|
||||
|
||||
if ('interactive' in layer) {
|
||||
normalizedLayer = Object.assign({}, layer);
|
||||
// Breaks style diffing :(
|
||||
// @ts-ignore legacy field not typed
|
||||
delete normalizedLayer.interactive;
|
||||
}
|
||||
|
||||
// Style diffing doesn't work with refs so expand them out manually before diffing.
|
||||
// @ts-ignore legacy field not typed
|
||||
const layerRef = layerIndex[layer.ref];
|
||||
if (layerRef) {
|
||||
normalizedLayer = normalizedLayer || Object.assign({}, layer);
|
||||
// @ts-ignore
|
||||
delete normalizedLayer.ref;
|
||||
// https://github.com/mapbox/mapbox-gl-js/blob/master/src/style-spec/deref.js
|
||||
for (const propName of refProps) {
|
||||
if (propName in layerRef) {
|
||||
normalizedLayer[propName] = layerRef[propName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedLayer || layer;
|
||||
});
|
||||
|
||||
// Do not mutate the style object provided by the user
|
||||
return {...style, layers};
|
||||
}
|
||||
87
modules/react-mapbox/src/utils/transform.ts
Normal file
87
modules/react-mapbox/src/utils/transform.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type {MapboxProps} from '../mapbox/mapbox';
|
||||
import type {ViewState} from '../types/common';
|
||||
import type {Transform} from '../types/internal';
|
||||
import {deepEqual} from './deep-equal';
|
||||
|
||||
/**
|
||||
* Make a copy of a transform
|
||||
* @param tr
|
||||
*/
|
||||
export function cloneTransform(tr: Transform): Transform {
|
||||
const newTransform = tr.clone();
|
||||
// Work around mapbox bug - this value is not assigned in clone(), only in resize()
|
||||
newTransform.pixelsToGLUnits = tr.pixelsToGLUnits;
|
||||
return newTransform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy projection from one transform to another. This only applies to mapbox-gl transforms
|
||||
* @param src the transform to copy projection settings from
|
||||
* @param dest to transform to copy projection settings to
|
||||
*/
|
||||
export function syncProjection(src: Transform, dest: Transform): void {
|
||||
if (!src.getProjection) {
|
||||
return;
|
||||
}
|
||||
const srcProjection = src.getProjection();
|
||||
const destProjection = dest.getProjection();
|
||||
|
||||
if (!deepEqual(srcProjection, destProjection)) {
|
||||
dest.setProjection(srcProjection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
||||
/* eslint-disable complexity */
|
||||
/**
|
||||
* 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, props: MapboxProps): boolean {
|
||||
const v: Partial<ViewState> = props.viewState || props;
|
||||
let changed = false;
|
||||
|
||||
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;
|
||||
}
|
||||
if ('longitude' in v && 'latitude' in v) {
|
||||
const center = tr.center;
|
||||
// @ts-ignore
|
||||
tr.center = new center.constructor(v.longitude, v.latitude);
|
||||
changed = changed || center !== tr.center;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@ -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 document !== 'undefined' ? useLayoutEffect : useEffect;
|
||||
|
||||
export default useIsomorphicLayoutEffect;
|
||||
65
modules/react-mapbox/test/components/controls.spec.jsx
Normal file
65
modules/react-mapbox/test/components/controls.spec.jsx
Normal file
@ -0,0 +1,65 @@
|
||||
import {
|
||||
Map,
|
||||
AttributionControl,
|
||||
FullscreenControl,
|
||||
GeolocateControl,
|
||||
NavigationControl,
|
||||
ScaleControl
|
||||
} from '@vis.gl/react-mapbox';
|
||||
import * as React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import test from 'tape-promise/tape';
|
||||
import {sleep, waitForMapLoad} from '../utils/test-utils';
|
||||
|
||||
test('Controls', async t => {
|
||||
const rootContainer = document.createElement('div');
|
||||
const root = createRoot(rootContainer);
|
||||
const mapRef = {current: null};
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<AttributionControl />
|
||||
</Map>
|
||||
);
|
||||
await waitForMapLoad(mapRef);
|
||||
await sleep(1);
|
||||
t.ok(rootContainer.querySelector('.mapboxgl-ctrl-attrib'), 'Rendered <AttributionControl />');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<FullscreenControl />
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
t.ok(rootContainer.querySelector('.mapboxgl-ctrl-fullscreen'), 'Rendered <FullscreenControl />');
|
||||
|
||||
const geolocateControlRef = {current: null};
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<GeolocateControl ref={geolocateControlRef} />
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
t.ok(rootContainer.querySelector('.mapboxgl-ctrl-geolocate'), 'Rendered <GeolocateControl />');
|
||||
t.ok(geolocateControlRef.current, 'GeolocateControl created');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<NavigationControl />
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
t.ok(rootContainer.querySelector('.mapboxgl-ctrl-zoom-in'), 'Rendered <NavigationControl />');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<ScaleControl />
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
t.ok(rootContainer.querySelector('.mapboxgl-ctrl-scale'), 'Rendered <ScaleControl />');
|
||||
|
||||
root.unmount();
|
||||
|
||||
t.end();
|
||||
});
|
||||
7
modules/react-mapbox/test/components/index.js
vendored
Normal file
7
modules/react-mapbox/test/components/index.js
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
import './map.spec';
|
||||
import './controls.spec';
|
||||
import './source.spec';
|
||||
import './layer.spec';
|
||||
import './marker.spec';
|
||||
import './popup.spec';
|
||||
import './use-map.spec';
|
||||
75
modules/react-mapbox/test/components/layer.spec.jsx
Normal file
75
modules/react-mapbox/test/components/layer.spec.jsx
Normal file
@ -0,0 +1,75 @@
|
||||
import {Map, Source, Layer} from '@vis.gl/react-mapbox';
|
||||
import * as React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import test from 'tape-promise/tape';
|
||||
|
||||
import {sleep, waitForMapLoad} from '../utils/test-utils';
|
||||
|
||||
test('Source/Layer', async t => {
|
||||
const root = createRoot(document.createElement('div'));
|
||||
const mapRef = {current: null};
|
||||
|
||||
const mapStyle = {version: 8, sources: {}, layers: []};
|
||||
const geoJSON = {
|
||||
type: 'Point',
|
||||
coordinates: [0, 0]
|
||||
};
|
||||
const pointLayer = {
|
||||
type: 'circle',
|
||||
paint: {
|
||||
'circle-radius': 10,
|
||||
'circle-color': '#007cbf'
|
||||
}
|
||||
};
|
||||
const pointLayer2 = {
|
||||
type: 'circle',
|
||||
paint: {
|
||||
'circle-radius': 10,
|
||||
'circle-color': '#000000'
|
||||
},
|
||||
layout: {
|
||||
visibility: 'none'
|
||||
}
|
||||
};
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Source id="my-data" type="geojson" data={geoJSON}>
|
||||
<Layer id="my-layer" {...pointLayer} />
|
||||
</Source>
|
||||
</Map>
|
||||
);
|
||||
await waitForMapLoad(mapRef);
|
||||
await sleep(1);
|
||||
const layer = mapRef.current.getLayer('my-layer');
|
||||
t.ok(layer, 'Layer is added');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Source id="my-data" type="geojson" data={geoJSON}>
|
||||
<Layer id="my-layer" {...pointLayer2} />
|
||||
</Source>
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
t.is(layer.visibility, 'none', 'Layer is updated');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef} mapStyle={mapStyle}>
|
||||
<Source id="my-data" type="geojson" data={geoJSON}>
|
||||
<Layer id="my-layer" {...pointLayer2} />
|
||||
</Source>
|
||||
</Map>
|
||||
);
|
||||
await sleep(50);
|
||||
t.ok(mapRef.current.getLayer('my-layer'), 'Layer is added after style change');
|
||||
|
||||
root.render(<Map ref={mapRef} mapStyle={mapStyle} />);
|
||||
await sleep(1);
|
||||
t.notOk(mapRef.current.getSource('my-data'), 'Source is removed');
|
||||
t.notOk(mapRef.current.getLayer('my-layer'), 'Layer is removed');
|
||||
|
||||
root.unmount();
|
||||
|
||||
t.end();
|
||||
});
|
||||
183
modules/react-mapbox/test/components/map.spec.jsx
Normal file
183
modules/react-mapbox/test/components/map.spec.jsx
Normal file
@ -0,0 +1,183 @@
|
||||
/* global setTimeout */
|
||||
import {Map} from '@vis.gl/react-mapbox';
|
||||
import * as React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import test from 'tape-promise/tape';
|
||||
|
||||
import {sleep, waitForMapLoad} from '../utils/test-utils';
|
||||
|
||||
test('Map', async t => {
|
||||
t.ok(Map, 'Map is defined');
|
||||
|
||||
const root = createRoot(document.createElement('div'));
|
||||
const mapRef = {current: null};
|
||||
|
||||
let onloadCalled = 0;
|
||||
const onLoad = () => onloadCalled++;
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef} initialViewState={{longitude: -100, latitude: 40, zoom: 4}} onLoad={onLoad} />
|
||||
);
|
||||
|
||||
await waitForMapLoad(mapRef);
|
||||
|
||||
t.ok(mapRef.current, 'Map is created');
|
||||
t.is(mapRef.current.getCenter().lng, -100, 'longitude is set');
|
||||
t.is(mapRef.current.getCenter().lat, 40, 'latitude is set');
|
||||
t.is(mapRef.current.getZoom(), 4, 'zoom is set');
|
||||
|
||||
root.render(<Map ref={mapRef} longitude={-122} latitude={38} zoom={14} onLoad={onLoad} />);
|
||||
await sleep(1);
|
||||
|
||||
t.is(mapRef.current.getCenter().lng, -122, 'longitude is updated');
|
||||
t.is(mapRef.current.getCenter().lat, 38, 'latitude is updated');
|
||||
t.is(mapRef.current.getZoom(), 14, 'zoom is updated');
|
||||
|
||||
t.is(onloadCalled, 1, 'onLoad is called');
|
||||
|
||||
root.unmount();
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('Map#uncontrolled', t => {
|
||||
const root = createRoot(document.createElement('div'));
|
||||
const mapRef = {current: null};
|
||||
|
||||
function onLoad() {
|
||||
mapRef.current.easeTo({center: [-122, 38], zoom: 14, duration: 100});
|
||||
}
|
||||
let lastCenter;
|
||||
function onRender() {
|
||||
const center = mapRef.current.getCenter();
|
||||
if (lastCenter) {
|
||||
t.ok(lastCenter.lng > center.lng && lastCenter.lat > center.lat, `animated to ${center}`);
|
||||
}
|
||||
lastCenter = center;
|
||||
}
|
||||
function onMoveEnd() {
|
||||
root.unmount();
|
||||
t.end();
|
||||
}
|
||||
|
||||
root.render(
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{longitude: -100, latitude: 40, zoom: 4}}
|
||||
onLoad={onLoad}
|
||||
onRender={onRender}
|
||||
onMoveEnd={onMoveEnd}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
test('Map#controlled#no-update', t => {
|
||||
const root = createRoot(document.createElement('div'));
|
||||
const mapRef = {current: null};
|
||||
|
||||
function onLoad() {
|
||||
mapRef.current.easeTo({center: [-122, 38], zoom: 14, duration: 100});
|
||||
}
|
||||
function onRender() {
|
||||
const center = mapRef.current.getCenter();
|
||||
t.ok(center.lng === -100 && center.lat === 40, `map center should match props: ${center}`);
|
||||
}
|
||||
function onMoveEnd() {
|
||||
root.unmount();
|
||||
t.end();
|
||||
}
|
||||
|
||||
root.render(
|
||||
<Map
|
||||
ref={mapRef}
|
||||
longitude={-100}
|
||||
latitude={40}
|
||||
zoom={4}
|
||||
onLoad={onLoad}
|
||||
onMoveEnd={onMoveEnd}
|
||||
onRender={onRender}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
test('Map#controlled#mirror-back', t => {
|
||||
const root = createRoot(document.createElement('div'));
|
||||
const mapRef = {current: null};
|
||||
|
||||
function onLoad() {
|
||||
mapRef.current.easeTo({center: [-122, 38], zoom: 14, duration: 100});
|
||||
}
|
||||
function onRender(vs) {
|
||||
const center = mapRef.current.getCenter();
|
||||
t.ok(
|
||||
vs.longitude === center.lng && vs.latitude === center.lat,
|
||||
`map center should match state: ${center}`
|
||||
);
|
||||
}
|
||||
function onMoveEnd() {
|
||||
root.unmount();
|
||||
t.end();
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [viewState, setViewState] = React.useState({
|
||||
longitude: -100,
|
||||
latitude: 40,
|
||||
zoom: 4
|
||||
});
|
||||
|
||||
return (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
{...viewState}
|
||||
onLoad={onLoad}
|
||||
onMove={e => setViewState(e.viewState)}
|
||||
onRender={onRender.bind(null, viewState)}
|
||||
onMoveEnd={onMoveEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
root.render(<App />);
|
||||
});
|
||||
|
||||
test('Map#controlled#delayed-update', t => {
|
||||
const root = createRoot(document.createElement('div'));
|
||||
const mapRef = {current: null};
|
||||
|
||||
function onLoad() {
|
||||
mapRef.current.easeTo({center: [-122, 38], zoom: 14, duration: 100});
|
||||
}
|
||||
function onRender(vs) {
|
||||
const center = mapRef.current.getCenter();
|
||||
t.ok(
|
||||
vs.longitude === center.lng && vs.latitude === center.lat,
|
||||
`map center should match state: ${center}`
|
||||
);
|
||||
}
|
||||
function onMoveEnd() {
|
||||
root.unmount();
|
||||
t.end();
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [viewState, setViewState] = React.useState({
|
||||
longitude: -100,
|
||||
latitude: 40,
|
||||
zoom: 4
|
||||
});
|
||||
|
||||
return (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
{...viewState}
|
||||
onLoad={onLoad}
|
||||
onMove={e => setTimeout(() => setViewState(e.viewState))}
|
||||
onRender={onRender.bind(null, viewState)}
|
||||
onMoveEnd={onMoveEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
root.render(<App />);
|
||||
});
|
||||
93
modules/react-mapbox/test/components/marker.spec.jsx
Normal file
93
modules/react-mapbox/test/components/marker.spec.jsx
Normal file
@ -0,0 +1,93 @@
|
||||
import {Map, Marker} from '@vis.gl/react-mapbox';
|
||||
import * as React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import test from 'tape-promise/tape';
|
||||
|
||||
import {sleep, waitForMapLoad} from '../utils/test-utils';
|
||||
|
||||
test('Marker', async t => {
|
||||
const rootContainer = document.createElement('div');
|
||||
const root = createRoot(rootContainer);
|
||||
const markerRef = {current: null};
|
||||
const mapRef = {current: null};
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Marker ref={markerRef} longitude={-122} latitude={38} />
|
||||
</Map>
|
||||
);
|
||||
|
||||
await waitForMapLoad(mapRef);
|
||||
await sleep(1);
|
||||
|
||||
t.ok(rootContainer.querySelector('.mapboxgl-marker'), 'Marker is attached to DOM');
|
||||
t.ok(markerRef.current, 'Marker is created');
|
||||
|
||||
const marker = markerRef.current;
|
||||
const offset = marker.getOffset();
|
||||
const draggable = marker.isDraggable();
|
||||
const rotation = marker.getRotation();
|
||||
const pitchAlignment = marker.getPitchAlignment();
|
||||
const rotationAlignment = marker.getRotationAlignment();
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Marker ref={markerRef} longitude={-122} latitude={38} offset={[0, 0]} />
|
||||
</Map>
|
||||
);
|
||||
|
||||
t.is(offset, marker.getOffset(), 'offset did not change deeply');
|
||||
|
||||
let callbackType = '';
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Marker
|
||||
ref={markerRef}
|
||||
longitude={-122}
|
||||
latitude={38}
|
||||
offset={[0, 1]}
|
||||
rotation={30}
|
||||
draggable
|
||||
pitchAlignment="viewport"
|
||||
rotationAlignment="viewport"
|
||||
onDragStart={() => (callbackType = 'dragstart')}
|
||||
onDrag={() => (callbackType = 'drag')}
|
||||
onDragEnd={() => (callbackType = 'dragend')}
|
||||
/>
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
|
||||
t.not(offset, marker.getOffset(), 'offset is updated');
|
||||
t.not(draggable, marker.isDraggable(), 'draggable is updated');
|
||||
t.not(rotation, marker.getRotation(), 'rotation is updated');
|
||||
t.not(pitchAlignment, marker.getPitchAlignment(), 'pitchAlignment is updated');
|
||||
t.not(rotationAlignment, marker.getRotationAlignment(), 'rotationAlignment is updated');
|
||||
|
||||
marker.fire('dragstart');
|
||||
t.is(callbackType, 'dragstart', 'onDragStart called');
|
||||
marker.fire('drag');
|
||||
t.is(callbackType, 'drag', 'onDrag called');
|
||||
marker.fire('dragend');
|
||||
t.is(callbackType, 'dragend', 'onDragEnd called');
|
||||
|
||||
root.render(<Map ref={mapRef} />);
|
||||
await sleep(1);
|
||||
|
||||
t.notOk(markerRef.current, 'marker is removed');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Marker ref={markerRef} longitude={-100} latitude={40}>
|
||||
<div id="marker-content" />
|
||||
</Marker>
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
|
||||
t.ok(rootContainer.querySelector('#marker-content'), 'content is rendered');
|
||||
|
||||
root.unmount();
|
||||
|
||||
t.end();
|
||||
});
|
||||
74
modules/react-mapbox/test/components/popup.spec.jsx
Normal file
74
modules/react-mapbox/test/components/popup.spec.jsx
Normal file
@ -0,0 +1,74 @@
|
||||
import {Map, Popup} from '@vis.gl/react-mapbox';
|
||||
import * as React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import test from 'tape-promise/tape';
|
||||
import {sleep, waitForMapLoad} from '../utils/test-utils';
|
||||
|
||||
test('Popup', async t => {
|
||||
const rootContainer = document.createElement('div');
|
||||
const root = createRoot(rootContainer);
|
||||
const mapRef = {current: null};
|
||||
const popupRef = {current: null};
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Popup ref={popupRef} longitude={-122} latitude={38} offset={[0, 10]}>
|
||||
You are here
|
||||
</Popup>
|
||||
</Map>
|
||||
);
|
||||
await waitForMapLoad(mapRef);
|
||||
await sleep(1);
|
||||
|
||||
t.ok(rootContainer.querySelector('.mapboxgl-popup'), 'Popup is attached to DOM');
|
||||
t.ok(popupRef.current, 'Popup is created');
|
||||
|
||||
const popup = popupRef.current;
|
||||
const {anchor, offset, maxWidth} = popup.options;
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Popup ref={popupRef} longitude={-122} latitude={38} offset={[0, 10]}>
|
||||
<div id="popup-content">You are here</div>
|
||||
</Popup>
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
|
||||
t.is(offset, popup.options.offset, 'offset did not change deeply');
|
||||
t.ok(rootContainer.querySelector('#popup-content'), 'content is rendered');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Popup
|
||||
ref={popupRef}
|
||||
longitude={-122}
|
||||
latitude={38}
|
||||
offset={{top: [0, 0], left: [10, 0]}}
|
||||
anchor="top"
|
||||
maxWidth="100px"
|
||||
>
|
||||
<div id="popup-content">You are here</div>
|
||||
</Popup>
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
|
||||
t.not(offset, popup.options.offset, 'offset is updated');
|
||||
t.not(anchor, popup.options.anchor, 'anchor is updated');
|
||||
t.not(maxWidth, popup.options.maxWidth, 'maxWidth is updated');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Popup ref={popupRef} longitude={-122} latitude={38} className="classA">
|
||||
<div id="popup-content">You are here</div>
|
||||
</Popup>
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
|
||||
t.is(popup.options.className, 'classA', 'className is updated');
|
||||
|
||||
root.unmount();
|
||||
t.end();
|
||||
});
|
||||
54
modules/react-mapbox/test/components/source.spec.jsx
Normal file
54
modules/react-mapbox/test/components/source.spec.jsx
Normal file
@ -0,0 +1,54 @@
|
||||
import {Map, Source} from '@vis.gl/react-mapbox';
|
||||
import * as React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import test from 'tape-promise/tape';
|
||||
import {sleep, waitForMapLoad} from '../utils/test-utils';
|
||||
|
||||
test('Source/Layer', async t => {
|
||||
const root = createRoot(document.createElement('div'));
|
||||
const mapRef = {current: null};
|
||||
|
||||
const mapStyle = {version: 8, sources: {}, layers: []};
|
||||
const geoJSON = {
|
||||
type: 'Point',
|
||||
coordinates: [0, 0]
|
||||
};
|
||||
const geoJSON2 = {
|
||||
type: 'Point',
|
||||
coordinates: [1, 1]
|
||||
};
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef}>
|
||||
<Source id="my-data" type="geojson" data={geoJSON} />
|
||||
</Map>
|
||||
);
|
||||
await waitForMapLoad(mapRef);
|
||||
await sleep(1);
|
||||
t.ok(mapRef.current.getSource('my-data'), 'Source is added');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef} mapStyle={mapStyle}>
|
||||
<Source id="my-data" type="geojson" data={geoJSON} />
|
||||
</Map>
|
||||
);
|
||||
await sleep(50);
|
||||
t.ok(mapRef.current.getSource('my-data'), 'Source is added after style change');
|
||||
|
||||
root.render(
|
||||
<Map ref={mapRef} mapStyle={mapStyle}>
|
||||
<Source id="my-data" type="geojson" data={geoJSON2} />
|
||||
</Map>
|
||||
);
|
||||
await sleep(1);
|
||||
const sourceData = await mapRef.current.getSource('my-data')?._data;
|
||||
t.deepEqual(sourceData, geoJSON2, 'Source is updated');
|
||||
|
||||
root.render(<Map ref={mapRef} mapStyle={mapStyle} />);
|
||||
await sleep(1);
|
||||
t.notOk(mapRef.current.getSource('my-data'), 'Source is removed');
|
||||
|
||||
root.unmount();
|
||||
|
||||
t.end();
|
||||
});
|
||||
51
modules/react-mapbox/test/components/use-map.spec.jsx
Normal file
51
modules/react-mapbox/test/components/use-map.spec.jsx
Normal file
@ -0,0 +1,51 @@
|
||||
import {Map, MapProvider, useMap} from '@vis.gl/react-mapbox';
|
||||
import * as React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import test from 'tape-promise/tape';
|
||||
import {sleep, waitForMapLoad} from '../utils/test-utils';
|
||||
|
||||
test('useMap', async t => {
|
||||
const root = createRoot(document.createElement('div'));
|
||||
const mapRef = {current: null};
|
||||
|
||||
let maps = null;
|
||||
function TestControl() {
|
||||
maps = useMap();
|
||||
return null;
|
||||
}
|
||||
|
||||
root.render(
|
||||
<MapProvider>
|
||||
<Map id="mapA" />
|
||||
<Map id="mapB" ref={mapRef} />
|
||||
<TestControl />
|
||||
</MapProvider>
|
||||
);
|
||||
|
||||
await waitForMapLoad(mapRef);
|
||||
|
||||
t.ok(maps.mapA, 'Context has mapA');
|
||||
t.ok(maps.mapB, 'Context has mapB');
|
||||
|
||||
root.render(
|
||||
<MapProvider>
|
||||
<Map id="mapA" />
|
||||
<TestControl />
|
||||
</MapProvider>
|
||||
);
|
||||
await sleep(50);
|
||||
t.ok(maps.mapA, 'Context has mapA');
|
||||
t.notOk(maps.mapB, 'mapB is removed');
|
||||
|
||||
root.render(
|
||||
<MapProvider>
|
||||
<TestControl />
|
||||
</MapProvider>
|
||||
);
|
||||
await sleep(50);
|
||||
t.notOk(maps.mapA, 'mapA is removed');
|
||||
|
||||
root.unmount();
|
||||
|
||||
t.end();
|
||||
});
|
||||
26
modules/react-mapbox/test/utils/apply-react-style.spec.js
Normal file
26
modules/react-mapbox/test/utils/apply-react-style.spec.js
Normal file
@ -0,0 +1,26 @@
|
||||
import test from 'tape-promise/tape';
|
||||
import {applyReactStyle} from '@vis.gl/react-mapbox/utils/apply-react-style';
|
||||
|
||||
test('applyReactStyle', t => {
|
||||
/* global document */
|
||||
if (typeof document === 'undefined') {
|
||||
t.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const div = document.createElement('div');
|
||||
|
||||
t.doesNotThrow(() => applyReactStyle(null, {}), 'null element');
|
||||
|
||||
t.doesNotThrow(() => applyReactStyle(div, null), 'null style');
|
||||
|
||||
applyReactStyle(div, {marginLeft: 4, height: 24, lineHeight: 2, zIndex: 1, flexGrow: 0.5});
|
||||
|
||||
t.is(div.style.marginLeft, '4px', 'appended px to numeric value');
|
||||
t.is(div.style.height, '24px', 'appended px to numeric value');
|
||||
t.is(div.style.lineHeight, '2', 'unitless numeric property');
|
||||
t.is(div.style.zIndex, '1', 'unitless numeric property');
|
||||
t.is(div.style.flexGrow, '0.5', 'unitless numeric property');
|
||||
|
||||
t.end();
|
||||
});
|
||||
95
modules/react-mapbox/test/utils/deep-equal.spec.js
Normal file
95
modules/react-mapbox/test/utils/deep-equal.spec.js
Normal file
@ -0,0 +1,95 @@
|
||||
import test from 'tape-promise/tape';
|
||||
import {deepEqual, arePointsEqual} from '@vis.gl/react-mapbox/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();
|
||||
});
|
||||
4
modules/react-mapbox/test/utils/index.js
vendored
Normal file
4
modules/react-mapbox/test/utils/index.js
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import './deep-equal.spec';
|
||||
import './transform.spec';
|
||||
import './style-utils.spec';
|
||||
import './apply-react-style.spec';
|
||||
72
modules/react-mapbox/test/utils/mapbox-gl-mock/edge_insets.js
vendored
Normal file
72
modules/react-mapbox/test/utils/mapbox-gl-mock/edge_insets.js
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
// Generated with
|
||||
// flow-remove-types ./node_modules/mapbox-gl/src/geo/edge_insets.js
|
||||
|
||||
import Point from '@mapbox/point-geometry';
|
||||
import {clamp, number} from './util.js';
|
||||
|
||||
class EdgeInsets {
|
||||
constructor(top = 0, bottom = 0, left = 0, right = 0) {
|
||||
if (
|
||||
isNaN(top) ||
|
||||
top < 0 ||
|
||||
isNaN(bottom) ||
|
||||
bottom < 0 ||
|
||||
isNaN(left) ||
|
||||
left < 0 ||
|
||||
isNaN(right) ||
|
||||
right < 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Invalid value for edge-insets, top, bottom, left and right must all be numbers'
|
||||
);
|
||||
}
|
||||
|
||||
this.top = top;
|
||||
this.bottom = bottom;
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
}
|
||||
|
||||
interpolate(start, target, t) {
|
||||
if (target.top != null && start.top != null) this.top = number(start.top, target.top, t);
|
||||
if (target.bottom != null && start.bottom != null)
|
||||
this.bottom = number(start.bottom, target.bottom, t);
|
||||
if (target.left != null && start.left != null) this.left = number(start.left, target.left, t);
|
||||
if (target.right != null && start.right != null)
|
||||
this.right = number(start.right, target.right, t);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getCenter(width, height) {
|
||||
// Clamp insets so they never overflow width/height and always calculate a valid center
|
||||
const x = clamp((this.left + width - this.right) / 2, 0, width);
|
||||
const y = clamp((this.top + height - this.bottom) / 2, 0, height);
|
||||
|
||||
return new Point(x, y);
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
return (
|
||||
this.top === other.top &&
|
||||
this.bottom === other.bottom &&
|
||||
this.left === other.left &&
|
||||
this.right === other.right
|
||||
);
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new EdgeInsets(this.top, this.bottom, this.left, this.right);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
top: this.top,
|
||||
bottom: this.bottom,
|
||||
left: this.left,
|
||||
right: this.right
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default EdgeInsets;
|
||||
79
modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat.js
vendored
Normal file
79
modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat.js
vendored
Normal file
@ -0,0 +1,79 @@
|
||||
// Generated with
|
||||
// flow-remove-types ./node_modules/mapbox-gl/src/geo/lng_lat.js
|
||||
|
||||
import {wrap} from './util.js';
|
||||
import LngLatBounds from './lng_lat_bounds.js';
|
||||
|
||||
export const earthRadius = 6371008.8;
|
||||
|
||||
class LngLat {
|
||||
lng;
|
||||
lat;
|
||||
|
||||
constructor(lng, lat) {
|
||||
if (isNaN(lng) || isNaN(lat)) {
|
||||
throw new Error(`Invalid LngLat object: (${lng}, ${lat})`);
|
||||
}
|
||||
this.lng = Number(lng);
|
||||
this.lat = Number(lat);
|
||||
if (this.lat > 90 || this.lat < -90) {
|
||||
throw new Error('Invalid LngLat latitude value: must be between -90 and 90');
|
||||
}
|
||||
}
|
||||
|
||||
wrap() {
|
||||
return new LngLat(wrap(this.lng, -180, 180), this.lat);
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return [this.lng, this.lat];
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `LngLat(${this.lng}, ${this.lat})`;
|
||||
}
|
||||
|
||||
distanceTo(lngLat) {
|
||||
const rad = Math.PI / 180;
|
||||
const lat1 = this.lat * rad;
|
||||
const lat2 = lngLat.lat * rad;
|
||||
const a =
|
||||
Math.sin(lat1) * Math.sin(lat2) +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.cos((lngLat.lng - this.lng) * rad);
|
||||
|
||||
const maxMeters = earthRadius * Math.acos(Math.min(a, 1));
|
||||
return maxMeters;
|
||||
}
|
||||
|
||||
toBounds(radius = 0) {
|
||||
const earthCircumferenceInMetersAtEquator = 40075017;
|
||||
const latAccuracy = (360 * radius) / earthCircumferenceInMetersAtEquator;
|
||||
const lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat);
|
||||
|
||||
return new LngLatBounds(
|
||||
new LngLat(this.lng - lngAccuracy, this.lat - latAccuracy),
|
||||
new LngLat(this.lng + lngAccuracy, this.lat + latAccuracy)
|
||||
);
|
||||
}
|
||||
|
||||
static convert(input) {
|
||||
if (input instanceof LngLat) {
|
||||
return input;
|
||||
}
|
||||
if (Array.isArray(input) && (input.length === 2 || input.length === 3)) {
|
||||
return new LngLat(Number(input[0]), Number(input[1]));
|
||||
}
|
||||
if (!Array.isArray(input) && typeof input === 'object' && input !== null) {
|
||||
return new LngLat(
|
||||
// flow can't refine this to have one of lng or lat, so we have to cast to any
|
||||
Number('lng' in input ? input.lng : input.lon),
|
||||
Number(input.lat)
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
'`LngLatLike` argument must be specified as a LngLat instance, an object {lng: <lng>, lat: <lat>}, an object {lon: <lng>, lat: <lat>}, or an array of [<lng>, <lat>]'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LngLat;
|
||||
139
modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat_bounds.js
vendored
Normal file
139
modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat_bounds.js
vendored
Normal file
@ -0,0 +1,139 @@
|
||||
// Generated with
|
||||
// flow-remove-types ./node_modules/mapbox-gl/src/geo/lng_lat_bounds.js
|
||||
|
||||
import LngLat from './lng_lat.js';
|
||||
|
||||
class LngLatBounds {
|
||||
_ne;
|
||||
_sw;
|
||||
|
||||
// This constructor is too flexible to type. It should not be so flexible.
|
||||
constructor(sw, ne) {
|
||||
if (!sw) {
|
||||
// noop
|
||||
} else if (ne) {
|
||||
this.setSouthWest(sw).setNorthEast(ne);
|
||||
} else if (sw.length === 4) {
|
||||
this.setSouthWest([sw[0], sw[1]]).setNorthEast([sw[2], sw[3]]);
|
||||
} else {
|
||||
this.setSouthWest(sw[0]).setNorthEast(sw[1]);
|
||||
}
|
||||
}
|
||||
|
||||
setNorthEast(ne) {
|
||||
this._ne = ne instanceof LngLat ? new LngLat(ne.lng, ne.lat) : LngLat.convert(ne);
|
||||
return this;
|
||||
}
|
||||
|
||||
setSouthWest(sw) {
|
||||
this._sw = sw instanceof LngLat ? new LngLat(sw.lng, sw.lat) : LngLat.convert(sw);
|
||||
return this;
|
||||
}
|
||||
|
||||
extend(obj) {
|
||||
const sw = this._sw;
|
||||
const ne = this._ne;
|
||||
let ne2;
|
||||
let sw2;
|
||||
|
||||
if (obj instanceof LngLat) {
|
||||
sw2 = obj;
|
||||
ne2 = obj;
|
||||
} else if (obj instanceof LngLatBounds) {
|
||||
sw2 = obj._sw;
|
||||
ne2 = obj._ne;
|
||||
|
||||
if (!sw2 || !ne2) return this;
|
||||
} else {
|
||||
if (Array.isArray(obj)) {
|
||||
if (obj.length === 4 || obj.every(Array.isArray)) {
|
||||
const lngLatBoundsObj = obj;
|
||||
return this.extend(LngLatBounds.convert(lngLatBoundsObj));
|
||||
}
|
||||
const lngLatObj = obj;
|
||||
return this.extend(LngLat.convert(lngLatObj));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
if (!sw && !ne) {
|
||||
this._sw = new LngLat(sw2.lng, sw2.lat);
|
||||
this._ne = new LngLat(ne2.lng, ne2.lat);
|
||||
} else {
|
||||
sw.lng = Math.min(sw2.lng, sw.lng);
|
||||
sw.lat = Math.min(sw2.lat, sw.lat);
|
||||
ne.lng = Math.max(ne2.lng, ne.lng);
|
||||
ne.lat = Math.max(ne2.lat, ne.lat);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getCenter() {
|
||||
return new LngLat((this._sw.lng + this._ne.lng) / 2, (this._sw.lat + this._ne.lat) / 2);
|
||||
}
|
||||
|
||||
getSouthWest() {
|
||||
return this._sw;
|
||||
}
|
||||
|
||||
getNorthEast() {
|
||||
return this._ne;
|
||||
}
|
||||
|
||||
getNorthWest() {
|
||||
return new LngLat(this.getWest(), this.getNorth());
|
||||
}
|
||||
|
||||
getSouthEast() {
|
||||
return new LngLat(this.getEast(), this.getSouth());
|
||||
}
|
||||
|
||||
getWest() {
|
||||
return this._sw.lng;
|
||||
}
|
||||
|
||||
getSouth() {
|
||||
return this._sw.lat;
|
||||
}
|
||||
|
||||
getEast() {
|
||||
return this._ne.lng;
|
||||
}
|
||||
|
||||
getNorth() {
|
||||
return this._ne.lat;
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return [this._sw.toArray(), this._ne.toArray()];
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `LngLatBounds(${this._sw.toString()}, ${this._ne.toString()})`;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return !(this._sw && this._ne);
|
||||
}
|
||||
|
||||
contains(lnglat) {
|
||||
const {lng, lat} = LngLat.convert(lnglat);
|
||||
|
||||
const containsLatitude = this._sw.lat <= lat && lat <= this._ne.lat;
|
||||
let containsLongitude = this._sw.lng <= lng && lng <= this._ne.lng;
|
||||
if (this._sw.lng > this._ne.lng) {
|
||||
// wrapped coordinates
|
||||
containsLongitude = this._sw.lng >= lng && lng >= this._ne.lng;
|
||||
}
|
||||
|
||||
return containsLatitude && containsLongitude;
|
||||
}
|
||||
|
||||
static convert(input) {
|
||||
if (!input || input instanceof LngLatBounds) return input;
|
||||
return new LngLatBounds(input);
|
||||
}
|
||||
}
|
||||
|
||||
export default LngLatBounds;
|
||||
91
modules/react-mapbox/test/utils/mapbox-gl-mock/transform.js
vendored
Normal file
91
modules/react-mapbox/test/utils/mapbox-gl-mock/transform.js
vendored
Normal file
@ -0,0 +1,91 @@
|
||||
import {wrap, clamp} from './util';
|
||||
|
||||
import LngLat from './lng_lat';
|
||||
import EdgeInsets from './edge_insets';
|
||||
|
||||
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 = new LngLat(0, 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.interpolate(this._edgeInsets, padding, 1);
|
||||
}
|
||||
|
||||
clone() {
|
||||
const that = new Transform();
|
||||
that.center = this.center;
|
||||
that.zoom = this.zoom;
|
||||
that.bearing = this.bearing;
|
||||
that.pitch = this.pitch;
|
||||
that.padding = this.padding;
|
||||
return that;
|
||||
}
|
||||
|
||||
isPaddingEqual(padding) {
|
||||
return this._edgeInsets.equals(padding);
|
||||
}
|
||||
}
|
||||
25
modules/react-mapbox/test/utils/mapbox-gl-mock/util.js
vendored
Normal file
25
modules/react-mapbox/test/utils/mapbox-gl-mock/util.js
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
// Generated with
|
||||
// flow-remove-types ./node_modules/mapbox-gl/src/util/util.js
|
||||
|
||||
export function clamp(n, min, max) {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
export function wrap(n, min, max) {
|
||||
const d = max - min;
|
||||
const w = ((((n - min) % d) + d) % d) + min;
|
||||
return w === min ? max : w;
|
||||
}
|
||||
|
||||
export function extend(dest, ...sources) {
|
||||
for (const src of sources) {
|
||||
for (const k in src) {
|
||||
dest[k] = src[k];
|
||||
}
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
export function number(a, b, t) {
|
||||
return a * (1 - t) + b * t;
|
||||
}
|
||||
213
modules/react-mapbox/test/utils/style-utils.spec.js
Normal file
213
modules/react-mapbox/test/utils/style-utils.spec.js
Normal file
@ -0,0 +1,213 @@
|
||||
import test from 'tape-promise/tape';
|
||||
|
||||
import {normalizeStyle} from '@vis.gl/react-mapbox/utils/style-utils';
|
||||
|
||||
const testStyle = {
|
||||
version: 8,
|
||||
name: 'Test',
|
||||
sources: {
|
||||
mapbox: {
|
||||
url: 'mapbox://mapbox.mapbox-streets-v7',
|
||||
type: 'vector'
|
||||
}
|
||||
},
|
||||
sprite: 'mapbox://sprites/mapbox/basic-v8',
|
||||
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': '#dedede'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'park',
|
||||
type: 'fill',
|
||||
source: 'mapbox',
|
||||
'source-layer': 'landuse_overlay',
|
||||
filter: ['==', 'class', 'park'],
|
||||
paint: {
|
||||
'fill-color': '#d2edae',
|
||||
'fill-opacity': 0.75
|
||||
},
|
||||
interactive: true
|
||||
},
|
||||
{
|
||||
id: 'road',
|
||||
source: 'mapbox',
|
||||
'source-layer': 'road',
|
||||
layout: {
|
||||
'line-cap': 'butt',
|
||||
'line-join': 'miter'
|
||||
},
|
||||
filter: ['all', ['==', '$type', 'LineString']],
|
||||
type: 'line',
|
||||
paint: {
|
||||
'line-color': '#efefef',
|
||||
'line-width': {
|
||||
base: 1.55,
|
||||
stops: [
|
||||
[4, 0.25],
|
||||
[20, 30]
|
||||
]
|
||||
}
|
||||
},
|
||||
minzoom: 5,
|
||||
maxzoom: 20,
|
||||
interactive: true
|
||||
},
|
||||
{
|
||||
id: 'park-2',
|
||||
ref: 'park',
|
||||
paint: {
|
||||
'fill-color': '#00f080',
|
||||
'fill-opacity': 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-outline',
|
||||
ref: 'road',
|
||||
minzoom: 10,
|
||||
maxzoom: 12,
|
||||
paint: {
|
||||
'line-color': '#efefef',
|
||||
'line-width': {
|
||||
base: 2,
|
||||
stops: [
|
||||
[4, 0.5],
|
||||
[20, 40]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const expectedStyle = {
|
||||
version: 8,
|
||||
name: 'Test',
|
||||
sources: {
|
||||
mapbox: {
|
||||
url: 'mapbox://mapbox.mapbox-streets-v7',
|
||||
type: 'vector'
|
||||
}
|
||||
},
|
||||
sprite: 'mapbox://sprites/mapbox/basic-v8',
|
||||
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': '#dedede'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'park',
|
||||
type: 'fill',
|
||||
source: 'mapbox',
|
||||
'source-layer': 'landuse_overlay',
|
||||
filter: ['==', 'class', 'park'],
|
||||
paint: {
|
||||
'fill-color': '#d2edae',
|
||||
'fill-opacity': 0.75
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road',
|
||||
source: 'mapbox',
|
||||
'source-layer': 'road',
|
||||
layout: {
|
||||
'line-cap': 'butt',
|
||||
'line-join': 'miter'
|
||||
},
|
||||
filter: ['all', ['==', '$type', 'LineString']],
|
||||
type: 'line',
|
||||
paint: {
|
||||
'line-color': '#efefef',
|
||||
'line-width': {
|
||||
base: 1.55,
|
||||
stops: [
|
||||
[4, 0.25],
|
||||
[20, 30]
|
||||
]
|
||||
}
|
||||
},
|
||||
minzoom: 5,
|
||||
maxzoom: 20
|
||||
},
|
||||
{
|
||||
id: 'park-2',
|
||||
type: 'fill',
|
||||
source: 'mapbox',
|
||||
'source-layer': 'landuse_overlay',
|
||||
filter: ['==', 'class', 'park'],
|
||||
paint: {
|
||||
'fill-color': '#00f080',
|
||||
'fill-opacity': 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'road-outline',
|
||||
source: 'mapbox',
|
||||
'source-layer': 'road',
|
||||
layout: {
|
||||
'line-cap': 'butt',
|
||||
'line-join': 'miter'
|
||||
},
|
||||
filter: ['all', ['==', '$type', 'LineString']],
|
||||
type: 'line',
|
||||
minzoom: 5,
|
||||
maxzoom: 20,
|
||||
paint: {
|
||||
'line-color': '#efefef',
|
||||
'line-width': {
|
||||
base: 2,
|
||||
stops: [
|
||||
[4, 0.5],
|
||||
[20, 40]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
test('normalizeStyle', t => {
|
||||
// Make sure the style is not mutated
|
||||
freezeRecursive(testStyle);
|
||||
|
||||
t.is(normalizeStyle(null), null, 'Handles null');
|
||||
t.is(
|
||||
normalizeStyle('mapbox://styles/mapbox/light-v9'),
|
||||
'mapbox://styles/mapbox/light-v9',
|
||||
'Handles url string'
|
||||
);
|
||||
|
||||
let result = normalizeStyle(testStyle);
|
||||
t.notEqual(result, testStyle, 'style is not mutated');
|
||||
t.deepEqual(result, expectedStyle, 'plain object style is normalized');
|
||||
|
||||
// Immutable-like object
|
||||
result = normalizeStyle({toJS: () => testStyle});
|
||||
t.deepEqual(result, expectedStyle, 'immutable style is normalized');
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
function freezeRecursive(obj) {
|
||||
if (!obj) return;
|
||||
if (typeof obj === 'object') {
|
||||
if (Array.isArray(obj)) {
|
||||
for (const el of obj) {
|
||||
freezeRecursive(el);
|
||||
}
|
||||
} else {
|
||||
for (const key in obj) {
|
||||
freezeRecursive(obj[key]);
|
||||
}
|
||||
}
|
||||
Object.freeze(obj);
|
||||
}
|
||||
}
|
||||
17
modules/react-mapbox/test/utils/test-utils.jsx
Normal file
17
modules/react-mapbox/test/utils/test-utils.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
/* global setTimeout */
|
||||
export function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
export function waitForMapLoad(mapRef) {
|
||||
return new Promise(resolve => {
|
||||
const check = () => {
|
||||
if (mapRef.current && mapRef.current.getMap().isStyleLoaded()) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 50);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
103
modules/react-mapbox/test/utils/transform.spec.js
Normal file
103
modules/react-mapbox/test/utils/transform.spec.js
Normal file
@ -0,0 +1,103 @@
|
||||
import test from 'tape-promise/tape';
|
||||
import {
|
||||
transformToViewState,
|
||||
applyViewStateToTransform
|
||||
} from '@vis.gl/react-mapbox/utils/transform';
|
||||
|
||||
import Transform from './mapbox-gl-mock/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');
|
||||
|
||||
applyViewStateToTransform(tr, {longitude: 0, latitude: 0, zoom: 0});
|
||||
changed = applyViewStateToTransform(tr, {longitude: 12, latitude: 34, zoom: 15});
|
||||
t.ok(changed, 'center and zoom changed');
|
||||
t.equal(tr.zoom, 15, 'zoom is correct');
|
||||
t.equal(tr.center.lat, 34, 'center latitude is correct');
|
||||
|
||||
t.end();
|
||||
});
|
||||
@ -6,6 +6,8 @@ test.onFailure(window.browserTestDriver_fail);
|
||||
|
||||
import '../modules/main/test/components';
|
||||
import '../modules/main/test/utils';
|
||||
import '../modules/react-mapbox/test/components';
|
||||
import '../modules/react-mapbox/test/utils';
|
||||
import '../modules/react-maplibre/test/components';
|
||||
import '../modules/react-maplibre/test/utils';
|
||||
// import './render';
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import './src/exports';
|
||||
import '../modules/main/test/utils';
|
||||
import '../modules/react-mapbox/test/utils';
|
||||
import '../modules/react-maplibre/test/utils';
|
||||
|
||||
@ -31,6 +31,6 @@ function getMissingExports(module: any): null | string[] {
|
||||
test('Consistent component names#legacy', t => {
|
||||
t.notOk(getMissingExports(legacyComponents), 'Legacy endpoint contains all components');
|
||||
t.notOk(getMissingExports(maplibreComponents), 'Maplibre endpoint contains all components');
|
||||
// t.notOk(getMissingExports(mapboxComponents), 'Mapbox endpoint contains all components');
|
||||
t.notOk(getMissingExports(mapboxComponents), 'Mapbox endpoint contains all components');
|
||||
t.end();
|
||||
});
|
||||
|
||||
@ -5236,10 +5236,10 @@ mapbox-gl@1.13.0:
|
||||
tinyqueue "^2.0.3"
|
||||
vt-pbf "^3.1.1"
|
||||
|
||||
mapbox-gl@3.9.0:
|
||||
version "3.9.0"
|
||||
resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-3.9.0.tgz#b6d0720f80d9ef3c8091359f2840615c7e973f48"
|
||||
integrity sha512-QKAxLHcbdoqobXuhu2PP6HJDSy0/GhfZuO5O8BrmwfR0ihZbA5ihYD/u0wGqu2QTDWi/DbgCWJIlV2mXh2Sekg==
|
||||
mapbox-gl@^3.9.3:
|
||||
version "3.9.3"
|
||||
resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-3.9.3.tgz#07e67fd774af52b6e50e82172ee8f1f016d74618"
|
||||
integrity sha512-31mh95f35srpBMxAP32F9dKQXz7pT5VxQA5r6bFY6Aa5G6Z6NC/SVOTyWR+G/wY8wXWTHAnOaAAf5UkD5++/Kg==
|
||||
dependencies:
|
||||
"@mapbox/jsonlint-lines-primitives" "^2.0.2"
|
||||
"@mapbox/mapbox-gl-supported" "^3.0.0"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user