[v8] react-mapbox module (#2467)

This commit is contained in:
Xiaoji Chen 2025-01-22 10:57:14 -08:00
parent 8885f5f4f4
commit 2785a32263
52 changed files with 3969 additions and 7 deletions

View File

@ -32,7 +32,7 @@
"dependencies": {
},
"devDependencies": {
"mapbox-gl": "3.9.0"
"mapbox-gl": "^3.9.0"
},
"peerDependencies": {
"mapbox-gl": ">=3.5.0",

View 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);

View 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);

View 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));

View 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;
}

View 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);

View 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());
})
);

View 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);

View 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);
})
);

View 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);

View 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
);
}

View 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;
}

View 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;
}

View File

@ -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';

View 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);
}

View 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;
}

View 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;
}

View 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;

View 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;

View 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};
}

View 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'];

View 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;
}
}
}

View File

@ -0,0 +1,5 @@
export default function assert(condition: any, message: string) {
if (!condition) {
throw new Error(message);
}
}

View 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;
}

View 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
);
}
}

View 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};
}

View 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;
}

View File

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

View 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();
});

View 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';

View 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();
});

View 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 />);
});

View 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();
});

View 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();
});

View 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();
});

View 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();
});

View 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();
});

View 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();
});

View File

@ -0,0 +1,4 @@
import './deep-equal.spec';
import './transform.spec';
import './style-utils.spec';
import './apply-react-style.spec';

View 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;

View 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;

View 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;

View 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);
}
}

View 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;
}

View 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);
}
}

View 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();
});
}

View 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();
});

View File

@ -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';

View File

@ -1,3 +1,4 @@
import './src/exports';
import '../modules/main/test/utils';
import '../modules/react-mapbox/test/utils';
import '../modules/react-maplibre/test/utils';

View File

@ -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();
});

View File

@ -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"