diff --git a/modules/main/src/mapbox-legacy/components/fullscreen-control.tsx b/modules/main/src/mapbox-legacy/components/fullscreen-control.ts similarity index 100% rename from modules/main/src/mapbox-legacy/components/fullscreen-control.tsx rename to modules/main/src/mapbox-legacy/components/fullscreen-control.ts diff --git a/modules/react-mapbox/package.json b/modules/react-mapbox/package.json index 722c46af4..7dceea4c6 100644 --- a/modules/react-mapbox/package.json +++ b/modules/react-mapbox/package.json @@ -32,7 +32,7 @@ "dependencies": { }, "devDependencies": { - "mapbox-gl": "3.9.0" + "mapbox-gl": "^3.9.0" }, "peerDependencies": { "mapbox-gl": ">=3.5.0", diff --git a/modules/react-mapbox/src/components/attribution-control.ts b/modules/react-mapbox/src/components/attribution-control.ts new file mode 100644 index 000000000..69145704d --- /dev/null +++ b/modules/react-mapbox/src/components/attribution-control.ts @@ -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); diff --git a/modules/react-mapbox/src/components/fullscreen-control.ts b/modules/react-mapbox/src/components/fullscreen-control.ts new file mode 100644 index 000000000..cf4d300b2 --- /dev/null +++ b/modules/react-mapbox/src/components/fullscreen-control.ts @@ -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 & { + /** 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); diff --git a/modules/react-mapbox/src/components/geolocate-control.ts b/modules/react-mapbox/src/components/geolocate-control.ts new file mode 100644 index 000000000..60e0510d9 --- /dev/null +++ b/modules/react-mapbox/src/components/geolocate-control.ts @@ -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) { + 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)); diff --git a/modules/react-mapbox/src/components/layer.ts b/modules/react-mapbox/src/components/layer.ts new file mode 100644 index 000000000..35e63cc5a --- /dev/null +++ b/modules/react-mapbox/src/components/layer.ts @@ -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 extends {id: string} ? Omit & {id?: string} : T; +type OptionalSource = T extends {source: string} ? Omit & {source?: string} : T; + +export type LayerProps = (OptionalSource> | 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; +} diff --git a/modules/react-mapbox/src/components/map.tsx b/modules/react-mapbox/src/components/map.tsx new file mode 100644 index 000000000..2e567b124 --- /dev/null +++ b/modules/react-mapbox/src/components/map.tsx @@ -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(null); + +type MapInitOptions = Omit< + MapOptions, + 'style' | 'container' | 'bounds' | 'fitBoundsOptions' | 'center' +>; + +export type MapProps = MapInitOptions & + MapboxProps & + GlobalSettings & { + mapLib?: MapLib | Promise; + reuseMaps?: boolean; + /** Map container id */ + id?: string; + /** Map container CSS style */ + style?: CSSProperties; + children?: any; + }; + +function _Map(props: MapProps, ref: React.Ref) { + const mountedMapsContext = useContext(MountedMapsContext); + const [mapInstance, setMapInstance] = useState(null); + const containerRef = useRef(); + + const {current: contextValue} = useRef({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 ( +
+ {mapInstance && ( + +
+ {props.children} +
+
+ )} +
+ ); +} + +export const Map = React.forwardRef(_Map); diff --git a/modules/react-mapbox/src/components/marker.ts b/modules/react-mapbox/src/components/marker.ts new file mode 100644 index 000000000..45406b419 --- /dev/null +++ b/modules/react-mapbox/src/components/marker.ts @@ -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) => 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) => { + 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()); + }) +); diff --git a/modules/react-mapbox/src/components/navigation-control.ts b/modules/react-mapbox/src/components/navigation-control.ts new file mode 100644 index 000000000..5014e9083 --- /dev/null +++ b/modules/react-mapbox/src/components/navigation-control.ts @@ -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); diff --git a/modules/react-mapbox/src/components/popup.ts b/modules/react-mapbox/src/components/popup.ts new file mode 100644 index 000000000..babee5ae5 --- /dev/null +++ b/modules/react-mapbox/src/components/popup.ts @@ -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) => { + 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); + }) +); diff --git a/modules/react-mapbox/src/components/scale-control.ts b/modules/react-mapbox/src/components/scale-control.ts new file mode 100644 index 000000000..db6066b9d --- /dev/null +++ b/modules/react-mapbox/src/components/scale-control.ts @@ -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(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); diff --git a/modules/react-mapbox/src/components/source.ts b/modules/react-mapbox/src/components/source.ts new file mode 100644 index 000000000..f6316cc29 --- /dev/null +++ b/modules/react-mapbox/src/components/source.ts @@ -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 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 + ); +} diff --git a/modules/react-mapbox/src/components/use-control.ts b/modules/react-mapbox/src/components/use-control.ts new file mode 100644 index 000000000..aa06fff4b --- /dev/null +++ b/modules/react-mapbox/src/components/use-control.ts @@ -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( + onCreate: (context: MapContextValue) => T, + opts?: ControlOptions +): T; + +export function useControl( + onCreate: (context: MapContextValue) => T, + onRemove: (context: MapContextValue) => void, + opts?: ControlOptions +): T; + +export function useControl( + onCreate: (context: MapContextValue) => T, + onAdd: (context: MapContextValue) => void, + onRemove: (context: MapContextValue) => void, + opts?: ControlOptions +): T; + +export function useControl( + 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; +} diff --git a/modules/react-mapbox/src/components/use-map.tsx b/modules/react-mapbox/src/components/use-map.tsx new file mode 100644 index 000000000..65353feff --- /dev/null +++ b/modules/react-mapbox/src/components/use-map.tsx @@ -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(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 ( + + {props.children} + + ); +}; + +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; +} diff --git a/modules/react-mapbox/src/index.ts b/modules/react-mapbox/src/index.ts index 6574cb8c2..e25d01827 100644 --- a/modules/react-mapbox/src/index.ts +++ b/modules/react-mapbox/src/index.ts @@ -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'; diff --git a/modules/react-mapbox/src/mapbox/create-ref.ts b/modules/react-mapbox/src/mapbox/create-ref.ts new file mode 100644 index 000000000..217a45b2e --- /dev/null +++ b/modules/react-mapbox/src/mapbox/create-ref.ts @@ -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; + +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(); + + 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); +} diff --git a/modules/react-mapbox/src/mapbox/mapbox.ts b/modules/react-mapbox/src/mapbox/mapbox.ts new file mode 100644 index 000000000..adb03147e --- /dev/null +++ b/modules/react-mapbox/src/mapbox/mapbox.ts @@ -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 & + MapCallbacks & { + // Init options + mapboxAccessToken?: string; + + /** Camera options used when constructing the Map instance */ + initialViewState?: Partial & { + /** 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; + /** 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; +} diff --git a/modules/react-mapbox/src/types/common.ts b/modules/react-mapbox/src/types/common.ts new file mode 100644 index 000000000..19245cf81 --- /dev/null +++ b/modules/react-mapbox/src/types/common.ts @@ -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 { + toJS: () => T; +} diff --git a/modules/react-mapbox/src/types/events.ts b/modules/react-mapbox/src/types/events.ts new file mode 100644 index 000000000..a608a2076 --- /dev/null +++ b/modules/react-mapbox/src/types/events.ts @@ -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 { + 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 = IMapEvent; + +export type MarkerDragEvent = MarkerEvent & { + type: 'dragstart' | 'drag' | 'dragend'; + lngLat: LngLat; +}; + +export type GeolocateEvent = IMapEvent; + +export type GeolocateResultEvent = GeolocateEvent & GeolocationPosition; + +export type GeolocateErrorEvent = GeolocateEvent & GeolocationPositionError; diff --git a/modules/react-mapbox/src/types/internal.ts b/modules/react-mapbox/src/types/internal.ts new file mode 100644 index 000000000..999b5b822 --- /dev/null +++ b/modules/react-mapbox/src/types/internal.ts @@ -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; diff --git a/modules/react-mapbox/src/types/lib.ts b/modules/react-mapbox/src/types/lib.ts new file mode 100644 index 000000000..aee3e346e --- /dev/null +++ b/modules/react-mapbox/src/types/lib.ts @@ -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}; +} diff --git a/modules/react-mapbox/src/types/style-spec.ts b/modules/react-mapbox/src/types/style-spec.ts new file mode 100644 index 000000000..4f28444a9 --- /dev/null +++ b/modules/react-mapbox/src/types/style-spec.ts @@ -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']; diff --git a/modules/react-mapbox/src/utils/apply-react-style.ts b/modules/react-mapbox/src/utils/apply-react-style.ts new file mode 100644 index 000000000..2ff1b9b64 --- /dev/null +++ b/modules/react-mapbox/src/utils/apply-react-style.ts @@ -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; + } + } +} diff --git a/modules/react-mapbox/src/utils/assert.ts b/modules/react-mapbox/src/utils/assert.ts new file mode 100644 index 000000000..5aabbd6ad --- /dev/null +++ b/modules/react-mapbox/src/utils/assert.ts @@ -0,0 +1,5 @@ +export default function assert(condition: any, message: string) { + if (!condition) { + throw new Error(message); + } +} diff --git a/modules/react-mapbox/src/utils/deep-equal.ts b/modules/react-mapbox/src/utils/deep-equal.ts new file mode 100644 index 000000000..879d98e8c --- /dev/null +++ b/modules/react-mapbox/src/utils/deep-equal.ts @@ -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; +} diff --git a/modules/react-mapbox/src/utils/set-globals.ts b/modules/react-mapbox/src/utils/set-globals.ts new file mode 100644 index 000000000..0f6d04923 --- /dev/null +++ b/modules/react-mapbox/src/utils/set-globals.ts @@ -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 + ); + } +} diff --git a/modules/react-mapbox/src/utils/style-utils.ts b/modules/react-mapbox/src/utils/style-utils.ts new file mode 100644 index 000000000..bb61d366f --- /dev/null +++ b/modules/react-mapbox/src/utils/style-utils.ts @@ -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 +): 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}; +} diff --git a/modules/react-mapbox/src/utils/transform.ts b/modules/react-mapbox/src/utils/transform.ts new file mode 100644 index 000000000..e9e8c962a --- /dev/null +++ b/modules/react-mapbox/src/utils/transform.ts @@ -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 = 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; +} diff --git a/modules/react-mapbox/src/utils/use-isomorphic-layout-effect.ts b/modules/react-mapbox/src/utils/use-isomorphic-layout-effect.ts new file mode 100644 index 000000000..9c1e39c42 --- /dev/null +++ b/modules/react-mapbox/src/utils/use-isomorphic-layout-effect.ts @@ -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; diff --git a/modules/react-mapbox/test/components/controls.spec.jsx b/modules/react-mapbox/test/components/controls.spec.jsx new file mode 100644 index 000000000..0b5b22653 --- /dev/null +++ b/modules/react-mapbox/test/components/controls.spec.jsx @@ -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( + + + + ); + await waitForMapLoad(mapRef); + await sleep(1); + t.ok(rootContainer.querySelector('.mapboxgl-ctrl-attrib'), 'Rendered '); + + root.render( + + + + ); + await sleep(1); + t.ok(rootContainer.querySelector('.mapboxgl-ctrl-fullscreen'), 'Rendered '); + + const geolocateControlRef = {current: null}; + root.render( + + + + ); + await sleep(1); + t.ok(rootContainer.querySelector('.mapboxgl-ctrl-geolocate'), 'Rendered '); + t.ok(geolocateControlRef.current, 'GeolocateControl created'); + + root.render( + + + + ); + await sleep(1); + t.ok(rootContainer.querySelector('.mapboxgl-ctrl-zoom-in'), 'Rendered '); + + root.render( + + + + ); + await sleep(1); + t.ok(rootContainer.querySelector('.mapboxgl-ctrl-scale'), 'Rendered '); + + root.unmount(); + + t.end(); +}); diff --git a/modules/react-mapbox/test/components/index.js b/modules/react-mapbox/test/components/index.js new file mode 100644 index 000000000..958e49fe8 --- /dev/null +++ b/modules/react-mapbox/test/components/index.js @@ -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'; diff --git a/modules/react-mapbox/test/components/layer.spec.jsx b/modules/react-mapbox/test/components/layer.spec.jsx new file mode 100644 index 000000000..5b1c29957 --- /dev/null +++ b/modules/react-mapbox/test/components/layer.spec.jsx @@ -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( + + + + + + ); + await waitForMapLoad(mapRef); + await sleep(1); + const layer = mapRef.current.getLayer('my-layer'); + t.ok(layer, 'Layer is added'); + + root.render( + + + + + + ); + await sleep(1); + t.is(layer.visibility, 'none', 'Layer is updated'); + + root.render( + + + + + + ); + await sleep(50); + t.ok(mapRef.current.getLayer('my-layer'), 'Layer is added after style change'); + + root.render(); + 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(); +}); diff --git a/modules/react-mapbox/test/components/map.spec.jsx b/modules/react-mapbox/test/components/map.spec.jsx new file mode 100644 index 000000000..de6b0c7be --- /dev/null +++ b/modules/react-mapbox/test/components/map.spec.jsx @@ -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( + + ); + + 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(); + 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( + + ); +}); + +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( + + ); +}); + +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 ( + setViewState(e.viewState)} + onRender={onRender.bind(null, viewState)} + onMoveEnd={onMoveEnd} + /> + ); + } + + root.render(); +}); + +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 ( + setTimeout(() => setViewState(e.viewState))} + onRender={onRender.bind(null, viewState)} + onMoveEnd={onMoveEnd} + /> + ); + } + + root.render(); +}); diff --git a/modules/react-mapbox/test/components/marker.spec.jsx b/modules/react-mapbox/test/components/marker.spec.jsx new file mode 100644 index 000000000..eccd75856 --- /dev/null +++ b/modules/react-mapbox/test/components/marker.spec.jsx @@ -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( + + + + ); + + 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( + + + + ); + + t.is(offset, marker.getOffset(), 'offset did not change deeply'); + + let callbackType = ''; + root.render( + + (callbackType = 'dragstart')} + onDrag={() => (callbackType = 'drag')} + onDragEnd={() => (callbackType = 'dragend')} + /> + + ); + 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(); + await sleep(1); + + t.notOk(markerRef.current, 'marker is removed'); + + root.render( + + +
+ + + ); + await sleep(1); + + t.ok(rootContainer.querySelector('#marker-content'), 'content is rendered'); + + root.unmount(); + + t.end(); +}); diff --git a/modules/react-mapbox/test/components/popup.spec.jsx b/modules/react-mapbox/test/components/popup.spec.jsx new file mode 100644 index 000000000..14cdb1f05 --- /dev/null +++ b/modules/react-mapbox/test/components/popup.spec.jsx @@ -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( + + + You are here + + + ); + 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( + + + + + + ); + 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( + + + + + + ); + 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( + + + + + + ); + await sleep(1); + + t.is(popup.options.className, 'classA', 'className is updated'); + + root.unmount(); + t.end(); +}); diff --git a/modules/react-mapbox/test/components/source.spec.jsx b/modules/react-mapbox/test/components/source.spec.jsx new file mode 100644 index 000000000..db99677d7 --- /dev/null +++ b/modules/react-mapbox/test/components/source.spec.jsx @@ -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( + + + + ); + await waitForMapLoad(mapRef); + await sleep(1); + t.ok(mapRef.current.getSource('my-data'), 'Source is added'); + + root.render( + + + + ); + await sleep(50); + t.ok(mapRef.current.getSource('my-data'), 'Source is added after style change'); + + root.render( + + + + ); + await sleep(1); + const sourceData = await mapRef.current.getSource('my-data')?._data; + t.deepEqual(sourceData, geoJSON2, 'Source is updated'); + + root.render(); + await sleep(1); + t.notOk(mapRef.current.getSource('my-data'), 'Source is removed'); + + root.unmount(); + + t.end(); +}); diff --git a/modules/react-mapbox/test/components/use-map.spec.jsx b/modules/react-mapbox/test/components/use-map.spec.jsx new file mode 100644 index 000000000..2edfd2e83 --- /dev/null +++ b/modules/react-mapbox/test/components/use-map.spec.jsx @@ -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( + + + + + + ); + + await waitForMapLoad(mapRef); + + t.ok(maps.mapA, 'Context has mapA'); + t.ok(maps.mapB, 'Context has mapB'); + + root.render( + + + + + ); + await sleep(50); + t.ok(maps.mapA, 'Context has mapA'); + t.notOk(maps.mapB, 'mapB is removed'); + + root.render( + + + + ); + await sleep(50); + t.notOk(maps.mapA, 'mapA is removed'); + + root.unmount(); + + t.end(); +}); diff --git a/modules/react-mapbox/test/utils/apply-react-style.spec.js b/modules/react-mapbox/test/utils/apply-react-style.spec.js new file mode 100644 index 000000000..d246dcb71 --- /dev/null +++ b/modules/react-mapbox/test/utils/apply-react-style.spec.js @@ -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(); +}); diff --git a/modules/react-mapbox/test/utils/deep-equal.spec.js b/modules/react-mapbox/test/utils/deep-equal.spec.js new file mode 100644 index 000000000..729266b4c --- /dev/null +++ b/modules/react-mapbox/test/utils/deep-equal.spec.js @@ -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(); +}); diff --git a/modules/react-mapbox/test/utils/index.js b/modules/react-mapbox/test/utils/index.js new file mode 100644 index 000000000..65ae66cec --- /dev/null +++ b/modules/react-mapbox/test/utils/index.js @@ -0,0 +1,4 @@ +import './deep-equal.spec'; +import './transform.spec'; +import './style-utils.spec'; +import './apply-react-style.spec'; diff --git a/modules/react-mapbox/test/utils/mapbox-gl-mock/edge_insets.js b/modules/react-mapbox/test/utils/mapbox-gl-mock/edge_insets.js new file mode 100644 index 000000000..fb8381ec6 --- /dev/null +++ b/modules/react-mapbox/test/utils/mapbox-gl-mock/edge_insets.js @@ -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; diff --git a/modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat.js b/modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat.js new file mode 100644 index 000000000..7c37b2e6a --- /dev/null +++ b/modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat.js @@ -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: , lat: }, an object {lon: , lat: }, or an array of [, ]' + ); + } +} + +export default LngLat; diff --git a/modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat_bounds.js b/modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat_bounds.js new file mode 100644 index 000000000..a99b8ff3b --- /dev/null +++ b/modules/react-mapbox/test/utils/mapbox-gl-mock/lng_lat_bounds.js @@ -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; diff --git a/modules/react-mapbox/test/utils/mapbox-gl-mock/transform.js b/modules/react-mapbox/test/utils/mapbox-gl-mock/transform.js new file mode 100644 index 000000000..593ecd7d3 --- /dev/null +++ b/modules/react-mapbox/test/utils/mapbox-gl-mock/transform.js @@ -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); + } +} diff --git a/modules/react-mapbox/test/utils/mapbox-gl-mock/util.js b/modules/react-mapbox/test/utils/mapbox-gl-mock/util.js new file mode 100644 index 000000000..54ac8e65a --- /dev/null +++ b/modules/react-mapbox/test/utils/mapbox-gl-mock/util.js @@ -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; +} diff --git a/modules/react-mapbox/test/utils/style-utils.spec.js b/modules/react-mapbox/test/utils/style-utils.spec.js new file mode 100644 index 000000000..f8c875dbd --- /dev/null +++ b/modules/react-mapbox/test/utils/style-utils.spec.js @@ -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); + } +} diff --git a/modules/react-mapbox/test/utils/test-utils.jsx b/modules/react-mapbox/test/utils/test-utils.jsx new file mode 100644 index 000000000..18f14a587 --- /dev/null +++ b/modules/react-mapbox/test/utils/test-utils.jsx @@ -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(); + }); +} diff --git a/modules/react-mapbox/test/utils/transform.spec.js b/modules/react-mapbox/test/utils/transform.spec.js new file mode 100644 index 000000000..b81941cd6 --- /dev/null +++ b/modules/react-mapbox/test/utils/transform.spec.js @@ -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(); +}); diff --git a/test/browser.js b/test/browser.js index cb4a4ae0a..327bdbb80 100644 --- a/test/browser.js +++ b/test/browser.js @@ -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'; diff --git a/test/node.js b/test/node.js index f91633b3e..0bc1ea538 100644 --- a/test/node.js +++ b/test/node.js @@ -1,3 +1,4 @@ import './src/exports'; import '../modules/main/test/utils'; +import '../modules/react-mapbox/test/utils'; import '../modules/react-maplibre/test/utils'; diff --git a/test/src/exports.ts b/test/src/exports.ts index ea4bb32e2..ccc6806ed 100644 --- a/test/src/exports.ts +++ b/test/src/exports.ts @@ -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(); }); diff --git a/yarn.lock b/yarn.lock index 9bc7cb928..f7f1bb813 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"