diff --git a/modules/react-maplibre/package.json b/modules/react-maplibre/package.json index a22c3c5ec..90c82b44c 100644 --- a/modules/react-maplibre/package.json +++ b/modules/react-maplibre/package.json @@ -33,7 +33,7 @@ "@maplibre/maplibre-gl-style-spec": "^19.2.1" }, "devDependencies": { - "maplibre-gl": "5.0.0" + "maplibre-gl": "^5.0.0" }, "peerDependencies": { "maplibre-gl": ">=4.0.0", diff --git a/modules/react-maplibre/src/components/attribution-control.ts b/modules/react-maplibre/src/components/attribution-control.ts new file mode 100644 index 000000000..69145704d --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/components/fullscreen-control.ts b/modules/react-maplibre/src/components/fullscreen-control.ts new file mode 100644 index 000000000..cf4d300b2 --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/components/geolocate-control.ts b/modules/react-maplibre/src/components/geolocate-control.ts new file mode 100644 index 000000000..0cd051f70 --- /dev/null +++ b/modules/react-maplibre/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; + gc._setupUI = () => { + if (!gc._container.hasChildNodes()) { + setupUI(); + } + }; + + 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-maplibre/src/components/layer.ts b/modules/react-maplibre/src/components/layer.ts new file mode 100644 index 000000000..27adb26f4 --- /dev/null +++ b/modules/react-maplibre/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, layout[key]); + } + } + for (const key in prevLayout) { + if (!layout.hasOwnProperty(key)) { + map.setLayoutProperty(id, key, undefined); + } + } + } + if (paint !== prevProps.paint) { + const prevPaint = prevProps.paint || {}; + for (const key in paint) { + if (!deepEqual(paint[key], prevPaint[key])) { + map.setPaintProperty(id, key, paint[key]); + } + } + for (const key in prevPaint) { + if (!paint.hasOwnProperty(key)) { + map.setPaintProperty(id, key, 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-maplibre/src/components/logo-control.ts b/modules/react-maplibre/src/components/logo-control.ts new file mode 100644 index 000000000..dcec9cc31 --- /dev/null +++ b/modules/react-maplibre/src/components/logo-control.ts @@ -0,0 +1,25 @@ +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, LogoControlOptions} from '../types/lib'; + +export type LogoControlProps = LogoControlOptions & { + /** Placement of the control relative to the map. */ + position?: ControlPosition; + /** CSS style override, applied to the control's container */ + style?: React.CSSProperties; +}; + +function _LogoControl(props: LogoControlProps) { + const ctrl = useControl(({mapLib}) => new mapLib.LogoControl(props), {position: props.position}); + + useEffect(() => { + applyReactStyle(ctrl._container, props.style); + }, [props.style]); + + return null; +} + +export const LogoControl = memo(_LogoControl); diff --git a/modules/react-maplibre/src/components/map.tsx b/modules/react-maplibre/src/components/map.tsx new file mode 100644 index 000000000..7d8fe2ae1 --- /dev/null +++ b/modules/react-maplibre/src/components/map.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import {useState, useRef, useEffect, useContext, useMemo, useImperativeHandle} from 'react'; + +import {MountedMapsContext} from './use-map'; +import Maplibre, {MaplibreProps} from '../maplibre/maplibre'; +import createRef, {MapRef} from '../maplibre/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 & + MaplibreProps & + 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 maplibre: Maplibre; + + Promise.resolve(mapLib || import('maplibre-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) { + maplibre = Maplibre.reuse(props, containerRef.current); + } + if (!maplibre) { + maplibre = new Maplibre(mapboxgl.Map, props, containerRef.current); + } + contextValue.map = createRef(maplibre); + contextValue.mapLib = mapboxgl; + + setMapInstance(maplibre); + 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, + originalEvent: null, + error + }); + } else { + console.error(error); // eslint-disable-line + } + }); + + return () => { + isMounted = false; + if (maplibre) { + mountedMapsContext?.onMapUnmount(props.id); + if (props.reuseMaps) { + maplibre.recycle(); + } else { + maplibre.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-maplibre/src/components/marker.ts b/modules/react-maplibre/src/components/marker.ts new file mode 100644 index 000000000..45406b419 --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/components/navigation-control.ts b/modules/react-maplibre/src/components/navigation-control.ts new file mode 100644 index 000000000..5014e9083 --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/components/popup.ts b/modules/react-maplibre/src/components/popup.ts new file mode 100644 index 000000000..babee5ae5 --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/components/scale-control.ts b/modules/react-maplibre/src/components/scale-control.ts new file mode 100644 index 000000000..db6066b9d --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/components/source.ts b/modules/react-maplibre/src/components/source.ts new file mode 100644 index 000000000..ef13f34ce --- /dev/null +++ b/modules/react-maplibre/src/components/source.ts @@ -0,0 +1,143 @@ +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} 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 { + switch (changedKey) { + case 'coordinates': + // @ts-ignore + source.setCoordinates?.(props.coordinates); + break; + case 'url': + // @ts-ignore + source.setUrl?.(props.url); + break; + case 'tiles': + // @ts-ignore + source.setTiles?.(props.tiles); + break; + default: + // 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-maplibre/src/components/terrain-control.ts b/modules/react-maplibre/src/components/terrain-control.ts new file mode 100644 index 000000000..69650e6e0 --- /dev/null +++ b/modules/react-maplibre/src/components/terrain-control.ts @@ -0,0 +1,28 @@ +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} from '../types/lib'; +import type {Terrain} from '../types/style-spec'; + +export type TerrainControlProps = Terrain & { + /** Placement of the control relative to the map. */ + position?: ControlPosition; + /** CSS style override, applied to the control's container */ + style?: React.CSSProperties; +}; + +function _TerrainControl(props: TerrainControlProps) { + const ctrl = useControl(({mapLib}) => new mapLib.TerrainControl(props), { + position: props.position + }); + + useEffect(() => { + applyReactStyle(ctrl._container, props.style); + }, [props.style]); + + return null; +} + +export const TerrainControl = memo(_TerrainControl); diff --git a/modules/react-maplibre/src/components/use-control.ts b/modules/react-maplibre/src/components/use-control.ts new file mode 100644 index 000000000..aa06fff4b --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/components/use-map.tsx b/modules/react-maplibre/src/components/use-map.tsx new file mode 100644 index 000000000..2804dec04 --- /dev/null +++ b/modules/react-maplibre/src/components/use-map.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import {useState, useCallback, useMemo, useContext} from 'react'; + +import {MapRef} from '../maplibre/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-maplibre/src/index.ts b/modules/react-maplibre/src/index.ts index 6574cb8c2..4704dc9ce 100644 --- a/modules/react-maplibre/src/index.ts +++ b/modules/react-maplibre/src/index.ts @@ -1 +1,37 @@ -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 {TerrainControl} from './components/terrain-control'; +export {LogoControl} from './components/logo-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 './maplibre/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 {TerrainControlProps} from './components/terrain-control'; +export type {LogoControlProps} from './components/logo-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-maplibre/src/maplibre/create-ref.ts b/modules/react-maplibre/src/maplibre/create-ref.ts new file mode 100644 index 000000000..6c5407ac2 --- /dev/null +++ b/modules/react-maplibre/src/maplibre/create-ref.ts @@ -0,0 +1,70 @@ +import type {MapInstance} from '../types/lib'; +import type Maplibre from './maplibre'; + +/** 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: Maplibre): MapRef | null { + if (!mapInstance) { + return null; + } + + const map = mapInstance.map; + const result: any = { + getMap: () => map + }; + + for (const key of getMethodNames(map)) { + // @ts-expect-error + if (!(key in result) && !skipMethods.includes(key)) { + result[key] = map[key].bind(map); + } + } + + return result; +} + +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-maplibre/src/maplibre/maplibre.ts b/modules/react-maplibre/src/maplibre/maplibre.ts new file mode 100644 index 000000000..e462c9823 --- /dev/null +++ b/modules/react-maplibre/src/maplibre/maplibre.ts @@ -0,0 +1,574 @@ +import {transformToViewState, applyViewStateToTransform} from '../utils/transform'; +import {normalizeStyle} from '../utils/style-utils'; +import {deepEqual} from '../utils/deep-equal'; + +import type {TransformLike} from '../types/internal'; +import type { + ViewState, + Point, + PointLike, + PaddingOptions, + ImmutableLike, + LngLatBoundsLike, + MapGeoJSONFeature +} from '../types/common'; +import type {MapStyle, Sky, Light, Terrain, Projection} from '../types/style-spec'; +import type {MapInstance} from '../types/lib'; +import type { + MapCallbacks, + ViewStateChangeEvent, + MapEvent, + ErrorEvent, + MapMouseEvent +} from '../types/events'; + +export type MaplibreProps = Partial & + MapCallbacks & { + /** 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; + /** 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; + /** Sky properties of the map. Must conform to the Sky Style Specification. */ + sky?: Sky; + + /** 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 Maplibre { + private _MapClass: {new (options: any): MapInstance}; + // mapboxgl.Map instance + private _map: MapInstance = null; + // User-supplied props + props: MaplibreProps; + + // Internal states + private _internalUpdate: boolean = false; + private _hoveredFeatures: MapGeoJSONFeature[] = null; + private _propsedCameraUpdate: ViewState | null = null; + private _styleComponents: { + light?: Light; + sky?: Sky; + projection?: Projection; + terrain?: Terrain | null; + } = {}; + + static savedMaps: Maplibre[] = []; + + constructor( + MapClass: {new (options: any): MapInstance}, + props: MaplibreProps, + container: HTMLDivElement + ) { + this._MapClass = MapClass; + this.props = props; + this._initialize(container); + } + + get map(): MapInstance { + return this._map; + } + + setProps(props: MaplibreProps) { + const oldProps = this.props; + this.props = props; + + const settingsChanged = this._updateSettings(props, oldProps); + const sizeChanged = this._updateSize(props); + const viewStateChanged = this._updateViewState(props); + this._updateStyle(props, oldProps); + this._updateStyleComponents(props); + 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: MaplibreProps, container: HTMLDivElement): Maplibre { + const that = Maplibre.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; + + // With maplibre-gl as mapLib, map uses ResizeObserver to observe when its container resizes. + // When reusing the saved map, we need to disconnect the observer and observe the new container. + // Step 3: telling the ResizeObserver to disconnect and observe the new container + // @ts-ignore + const resizeObserver = map._resizeObserver; + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver.observe(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); + } + } + + // Simulate load event + if (map.isStyleLoaded()) { + map.fire('load'); + } else { + map.once('style.load', () => map.fire('load')); + } + + // Force reload + // @ts-ignore + map._update(); + return that; + } + + /* eslint-disable complexity,max-statements */ + private _initialize(container: HTMLDivElement) { + const {props} = this; + const {mapStyle = DEFAULT_STYLE} = props; + const mapOptions = { + ...props, + ...props.initialViewState, + 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; + } + + // add listeners + map.transformCameraUpdate = this._onCameraUpdate; + map.on('style.load', () => { + // Map style has changed, this would have wiped out all settings from props + this._styleComponents = { + light: map.getLight(), + sky: map.getSky(), + // @ts-ignore getProjection() does not exist in v4 + projection: map.getProjection?.(), + terrain: map.getTerrain() + }; + this._updateStyleComponents(this.props); + }); + map.on('sourcedata', () => { + // Some sources have loaded, we may need them to attach terrain + 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(); + + Maplibre.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 (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(); + } + } + + /* Trigger map resize if size is controlled + @param {object} nextProps + @returns {bool} true if size has changed + */ + private _updateSize(nextProps: MaplibreProps): 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 + */ + private _updateViewState(nextProps: MaplibreProps): boolean { + const map = this._map; + const tr = map.transform; + const isMoving = map.isMoving(); + + // Avoid manipulating the real transform when interaction/animation is ongoing + // as it would interfere with Mapbox's handlers + if (!isMoving) { + const changes = applyViewStateToTransform(tr, nextProps); + if (Object.keys(changes).length > 0) { + this._internalUpdate = true; + map.jumpTo(changes); + this._internalUpdate = false; + return true; + } + } + + return false; + } + + /* Update camera constraints and projection settings to match props + @param {object} nextProps + @param {object} currProps + @returns {bool} true if anything is changed + */ + private _updateSettings(nextProps: MaplibreProps, currProps: MaplibreProps): 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 */ + private _updateStyle(nextProps: MaplibreProps, currProps: MaplibreProps): void { + 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); + } + } + + /* Update fog, light, projection and terrain to match props + * These props are special because + * 1. They can not be applied right away. Certain conditions (style loaded, source loaded, etc.) must be met + * 2. They can be overwritten by mapStyle + */ + private _updateStyleComponents({light, projection, sky, terrain}: MaplibreProps): void { + const map = this._map; + const currProps = this._styleComponents; + // We can safely manipulate map style once it's loaded + if (map.style._loaded) { + if (light && !deepEqual(light, currProps.light)) { + currProps.light = light; + map.setLight(light); + } + if ( + projection && + !deepEqual(projection, currProps.projection) && + // @ts-expect-error currProps.projection may be a string + projection !== currProps.projection?.type + ) { + currProps.projection = projection; + // @ts-ignore setProjection does not exist in v4 + map.setProjection?.(typeof projection === 'string' ? {type: projection} : projection); + } + if (sky && !deepEqual(sky, currProps.sky)) { + currProps.sky = sky; + map.setSky(sky); + } + if (terrain !== undefined && !deepEqual(terrain, currProps.terrain)) { + if (!terrain || map.getSource(terrain.source)) { + currProps.terrain = terrain; + map.setTerrain(terrain); + } + } + } + } + + /* Update interaction handlers to match props */ + private _updateHandlers(nextProps: MaplibreProps, currProps: MaplibreProps): void { + const map = this._map; + for (const propName of handlerNames) { + const newValue = nextProps[propName] ?? true; + const oldValue = currProps[propName] ?? true; + if (!deepEqual(newValue, oldValue)) { + if (newValue) { + map[propName].enable(newValue); + } else { + map[propName].disable(); + } + } + } + } + + private _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 _onCameraEvent = (e: ViewStateChangeEvent) => { + if (this._internalUpdate) { + return; + } + e.viewState = this._propsedCameraUpdate || transformToViewState(this._map.transform); + // @ts-ignore + const cb = this.props[cameraEvents[e.type]]; + if (cb) { + cb(e); + } + }; + + private _onCameraUpdate = (tr: TransformLike) => { + if (this._internalUpdate) { + return tr; + } + this._propsedCameraUpdate = transformToViewState(tr); + return applyViewStateToTransform(tr, this.props); + }; + + private _queryRenderedFeatures(point: Point) { + const map = this._map; + const {interactiveLayerIds = []} = this.props; + try { + return map.queryRenderedFeatures(point, { + layers: interactiveLayerIds.filter(map.getLayer.bind(map)) + }); + } catch { + // May fail if style is not loaded + return []; + } + } + + private _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; + } + } + + private _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; + } + }; +} diff --git a/modules/react-maplibre/src/types/common.ts b/modules/react-maplibre/src/types/common.ts new file mode 100644 index 000000000..880437b09 --- /dev/null +++ b/modules/react-maplibre/src/types/common.ts @@ -0,0 +1,34 @@ +import type {PaddingOptions} from 'maplibre-gl'; + +export type { + Point, + PointLike, + LngLat, + LngLatLike, + LngLatBounds, + LngLatBoundsLike, + PaddingOptions, + MapGeoJSONFeature +} from 'maplibre-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-maplibre/src/types/events.ts b/modules/react-maplibre/src/types/events.ts new file mode 100644 index 000000000..32cd10188 --- /dev/null +++ b/modules/react-maplibre/src/types/events.ts @@ -0,0 +1,130 @@ +import type {Point, LngLat, MapGeoJSONFeature, ViewState} from './common'; + +import type { + Map, + Marker, + Popup, + GeolocateControl, + MapLibreEvent, + MapMouseEvent as _MapMouseEvent, + MapLayerMouseEvent, + MapTouchEvent, + MapLayerTouchEvent, + MapStyleDataEvent, + MapSourceDataEvent, + MapWheelEvent, + MapLibreZoomEvent as MapBoxZoomEvent +} from 'maplibre-gl'; + +export type { + MapLibreEvent as MapEvent, + MapLayerMouseEvent, + MapTouchEvent, + MapLayerTouchEvent, + MapStyleDataEvent, + MapSourceDataEvent, + MapWheelEvent, + MapBoxZoomEvent +}; + +export type MapCallbacks = { + onMouseDown?: (e: MapLayerMouseEvent) => void; + onMouseUp?: (e: MapLayerMouseEvent) => void; + onMouseOver?: (e: MapLayerMouseEvent) => void; + onMouseMove?: (e: MapLayerMouseEvent) => void; + onClick?: (e: MapLayerMouseEvent) => void; + onDblClick?: (e: MapLayerMouseEvent) => void; + onMouseEnter?: (e: MapLayerMouseEvent) => void; + onMouseLeave?: (e: MapLayerMouseEvent) => void; + onMouseOut?: (e: MapLayerMouseEvent) => void; + onContextMenu?: (e: MapLayerMouseEvent) => void; + onTouchStart?: (e: MapLayerTouchEvent) => void; + onTouchEnd?: (e: MapLayerTouchEvent) => void; + onTouchMove?: (e: MapLayerTouchEvent) => void; + onTouchCancel?: (e: MapLayerTouchEvent) => void; + + onMoveStart?: (e: ViewStateChangeEvent) => void; + onMove?: (e: ViewStateChangeEvent) => void; + onMoveEnd?: (e: ViewStateChangeEvent) => void; + onDragStart?: (e: ViewStateChangeEvent) => void; + onDrag?: (e: ViewStateChangeEvent) => void; + onDragEnd?: (e: ViewStateChangeEvent) => void; + onZoomStart?: (e: ViewStateChangeEvent) => void; + onZoom?: (e: ViewStateChangeEvent) => void; + onZoomEnd?: (e: ViewStateChangeEvent) => void; + onRotateStart?: (e: ViewStateChangeEvent) => void; + onRotate?: (e: ViewStateChangeEvent) => void; + onRotateEnd?: (e: ViewStateChangeEvent) => void; + onPitchStart?: (e: ViewStateChangeEvent) => void; + onPitch?: (e: ViewStateChangeEvent) => void; + onPitchEnd?: (e: ViewStateChangeEvent) => void; + + onWheel?: (e: MapWheelEvent) => void; + onBoxZoomStart?: (e: MapBoxZoomEvent) => void; + onBoxZoomEnd?: (e: MapBoxZoomEvent) => void; + onBoxZoomCancel?: (e: MapBoxZoomEvent) => void; + + onResize?: (e: MapLibreEvent) => void; + onLoad?: (e: MapLibreEvent) => void; + onRender?: (e: MapLibreEvent) => void; + onIdle?: (e: MapLibreEvent) => void; + onError?: (e: ErrorEvent) => void; + onRemove?: (e: MapLibreEvent) => void; + onData?: (e: MapStyleDataEvent | MapSourceDataEvent) => void; + onStyleData?: (e: MapStyleDataEvent) => void; + onSourceData?: (e: MapSourceDataEvent) => void; +}; + +interface MapEvent { + type: string; + target: SourceT; + originalEvent: OriginalEventT; +} + +export type ErrorEvent = MapEvent & { + type: 'error'; + error: Error; +}; + +export type MapMouseEvent = _MapMouseEvent & { + point: Point; + lngLat: LngLat; + features?: MapGeoJSONFeature[]; +}; + +export type ViewStateChangeEvent = + | (MapEvent & { + type: 'movestart' | 'move' | 'moveend' | 'zoomstart' | 'zoom' | 'zoomend'; + viewState: ViewState; + }) + | (MapEvent & { + type: + | 'rotatestart' + | 'rotate' + | 'rotateend' + | 'dragstart' + | 'drag' + | 'dragend' + | 'pitchstart' + | 'pitch' + | 'pitchend'; + viewState: ViewState; + }); + +export type PopupEvent = { + type: 'open' | 'close'; + target: Popup; +}; + +export type MarkerEvent = MapEvent; + +export type MarkerDragEvent = MarkerEvent & { + type: 'dragstart' | 'drag' | 'dragend'; + lngLat: LngLat; +}; + +export type GeolocateEvent = MapEvent; + +export type GeolocateResultEvent = GeolocateEvent & GeolocationPosition; + +export type GeolocateErrorEvent = GeolocateEvent & GeolocationPositionError; diff --git a/modules/react-maplibre/src/types/internal.ts b/modules/react-maplibre/src/types/internal.ts new file mode 100644 index 000000000..ff4742a72 --- /dev/null +++ b/modules/react-maplibre/src/types/internal.ts @@ -0,0 +1,46 @@ +// Internal types +import type { + LngLat, + PaddingOptions, + GeoJSONSource as GeoJSONSourceImplementation, + ImageSource as ImageSourceImplemtation, + CanvasSource as CanvasSourceImplemtation, + VectorTileSource as VectorSourceImplementation, + RasterTileSource as RasterSourceImplementation, + RasterDEMTileSource as RasterDemSourceImplementation, + VideoSource as VideoSourceImplementation, + Source +} from 'maplibre-gl'; + +/** + * maplibre's Transform interface / CameraUpdateTransformFunction argument + */ +export type TransformLike = { + center: LngLat; + zoom: number; + roll?: number; + pitch: number; + bearing: number; + elevation: number; + padding?: PaddingOptions; +}; + +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-maplibre/src/types/lib.ts b/modules/react-maplibre/src/types/lib.ts new file mode 100644 index 000000000..34a9b5c07 --- /dev/null +++ b/modules/react-maplibre/src/types/lib.ts @@ -0,0 +1,76 @@ +import type { + Map, + MapOptions, + Marker, + MarkerOptions, + Popup, + PopupOptions, + AttributionControl, + AttributionControlOptions, + FullscreenControl, + FullscreenControlOptions, + GeolocateControl, + GeolocateControlOptions, + NavigationControl, + NavigationControlOptions, + ScaleControl, + ScaleControlOptions, + TerrainControl, + TerrainSpecification, + LogoControl, + LogoControlOptions +} from 'maplibre-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, + TerrainControl as TerrainControlInstance, + LogoControl as LogoControlInstance, + LogoControlOptions, + CustomLayerInterface +} from 'maplibre-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}; + + TerrainControl: {new (options: TerrainSpecification): TerrainControl}; + + LogoControl: {new (options: LogoControlOptions): LogoControl}; +} diff --git a/modules/react-maplibre/src/types/style-spec.ts b/modules/react-maplibre/src/types/style-spec.ts new file mode 100644 index 000000000..14bb0f4ef --- /dev/null +++ b/modules/react-maplibre/src/types/style-spec.ts @@ -0,0 +1,78 @@ +/* + * Maplibre Style Specification types + * Type names are aligned with mapbox + */ +import type { + BackgroundLayerSpecification as BackgroundLayer, + 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, + CanvasSourceSpecification as CanvasSourceRaw, + ProjectionSpecification +} from 'maplibre-gl'; + +// Layers +export type { + BackgroundLayer, + CircleLayer, + FillLayer, + FillExtrusionLayer, + HeatmapLayer, + HillshadeLayer, + LineLayer, + RasterLayer, + SymbolLayer +}; + +export type AnyLayer = + | BackgroundLayer + | CircleLayer + | FillLayer + | FillExtrusionLayer + | HeatmapLayer + | HillshadeLayer + | LineLayer + | RasterLayer + | SymbolLayer; + +// Sources +export type { + GeoJSONSourceRaw, + VideoSourceRaw, + ImageSourceRaw, + CanvasSourceRaw, + VectorSourceRaw, + RasterSource, + RasterDemSource +}; + +export type AnySource = + | GeoJSONSourceRaw + | VideoSourceRaw + | ImageSourceRaw + | CanvasSourceRaw + | VectorSourceRaw + | RasterSource + | RasterDemSource; + +// Other style types + +export type { + StyleSpecification as MapStyle, + LightSpecification as Light, + TerrainSpecification as Terrain, + SkySpecification as Sky +} from 'maplibre-gl'; + +export type Projection = ProjectionSpecification | ProjectionSpecification['type']; diff --git a/modules/react-maplibre/src/utils/apply-react-style.ts b/modules/react-maplibre/src/utils/apply-react-style.ts new file mode 100644 index 000000000..2ff1b9b64 --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/utils/assert.ts b/modules/react-maplibre/src/utils/assert.ts new file mode 100644 index 000000000..5aabbd6ad --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/utils/deep-equal.ts b/modules/react-maplibre/src/utils/deep-equal.ts new file mode 100644 index 000000000..879d98e8c --- /dev/null +++ b/modules/react-maplibre/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-maplibre/src/utils/set-globals.ts b/modules/react-maplibre/src/utils/set-globals.ts new file mode 100644 index 000000000..1e3080358 --- /dev/null +++ b/modules/react-maplibre/src/utils/set-globals.ts @@ -0,0 +1,50 @@ +export type GlobalSettings = { + /** 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; + /** The number of web workers instantiated on a page with maplibre-gl maps. + * @default 2 + */ + workerCount?: number; + /** Provides an interface for loading maplibre-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; +}; + +export default function setGlobals(mapLib: any, props: GlobalSettings) { + const { + RTLTextPlugin = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.3/mapbox-gl-rtl-text.js', + maxParallelImageRequests, + workerCount, + workerUrl + } = props; + if ( + RTLTextPlugin && + mapLib.getRTLTextPluginStatus && + mapLib.getRTLTextPluginStatus() === 'unavailable' + ) { + mapLib.setRTLTextPlugin( + RTLTextPlugin, + (error?: Error) => { + if (error) { + // eslint-disable-next-line + console.error(error); + } + }, + true + ); + } + if (maxParallelImageRequests !== undefined) { + mapLib.setMaxParallelImageRequests(maxParallelImageRequests); + } + if (workerCount !== undefined) { + mapLib.setWorkerCount(workerCount); + } + if (workerUrl !== undefined) { + mapLib.setWorkerUrl(workerUrl); + } +} diff --git a/modules/react-maplibre/src/utils/style-utils.ts b/modules/react-maplibre/src/utils/style-utils.ts new file mode 100644 index 000000000..a265d9682 --- /dev/null +++ b/modules/react-maplibre/src/utils/style-utils.ts @@ -0,0 +1,60 @@ +import type {MapStyle} from '../types/style-spec'; +import type {ImmutableLike} from '../types/common'; + +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-maplibre/src/utils/transform.ts b/modules/react-maplibre/src/utils/transform.ts new file mode 100644 index 000000000..bd7f74880 --- /dev/null +++ b/modules/react-maplibre/src/utils/transform.ts @@ -0,0 +1,58 @@ +import type {MaplibreProps} from '../maplibre/maplibre'; +import type {ViewState} from '../types/common'; +import type {TransformLike} from '../types/internal'; +import {deepEqual} from './deep-equal'; + +/** + * Capture a transform's current state + * @param transform + * @returns descriptor of the view state + */ +export function transformToViewState(tr: TransformLike): 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 */ +/** + * Applies requested view state to a transform + * @returns an object containing detected changes + */ +export function applyViewStateToTransform( + /** An object that describes Maplibre's camera state */ + tr: TransformLike, + /** Props from Map component */ + props: MaplibreProps +): Partial { + const v: Partial = props.viewState || props; + const changes: Partial = {}; + + if ( + 'longitude' in v && + 'latitude' in v && + (v.longitude !== tr.center.lng || v.latitude !== tr.center.lat) + ) { + const LngLat = tr.center.constructor; + // @ts-expect-error we should not import LngLat class from maplibre-gl because we don't know the source of mapLib + changes.center = new LngLat(v.longitude, v.latitude); + } + if ('zoom' in v && v.zoom !== tr.zoom) { + changes.zoom = v.zoom; + } + if ('bearing' in v && v.bearing !== tr.bearing) { + changes.bearing = v.bearing; + } + if ('pitch' in v && v.pitch !== tr.pitch) { + changes.pitch = v.pitch; + } + if (v.padding && tr.padding && !deepEqual(v.padding, tr.padding)) { + changes.padding = v.padding; + } + return changes; +} diff --git a/modules/react-maplibre/src/utils/use-isomorphic-layout-effect.ts b/modules/react-maplibre/src/utils/use-isomorphic-layout-effect.ts new file mode 100644 index 000000000..9c1e39c42 --- /dev/null +++ b/modules/react-maplibre/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-maplibre/test/.eslintrc b/modules/react-maplibre/test/.eslintrc new file mode 100644 index 000000000..5d8285464 --- /dev/null +++ b/modules/react-maplibre/test/.eslintrc @@ -0,0 +1,6 @@ +{ + "rules": { + "import/no-unresolved": 0, + "import/no-extraneous-dependencies": 0 + } +} diff --git a/modules/react-maplibre/test/components/controls.spec.jsx b/modules/react-maplibre/test/components/controls.spec.jsx new file mode 100644 index 000000000..f4bd367fe --- /dev/null +++ b/modules/react-maplibre/test/components/controls.spec.jsx @@ -0,0 +1,69 @@ +/* global document */ +import test from 'tape-promise/tape'; +import * as React from 'react'; +import {createRoot} from 'react-dom/client'; +import { + Map, + AttributionControl, + FullscreenControl, + GeolocateControl, + NavigationControl, + ScaleControl +} from '@vis.gl/react-maplibre'; +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('.maplibregl-ctrl-attrib'), 'Rendered '); + + root.render( + + + + ); + await sleep(1); + t.ok( + rootContainer.querySelector('.maplibregl-ctrl-fullscreen'), + 'Rendered ' + ); + + const geolocateControlRef = {current: null}; + root.render( + + + + ); + await sleep(1); + t.ok(rootContainer.querySelector('.maplibregl-ctrl-geolocate'), 'Rendered '); + t.ok(geolocateControlRef.current, 'GeolocateControl created'); + + root.render( + + + + ); + await sleep(1); + t.ok(rootContainer.querySelector('.maplibregl-ctrl-zoom-in'), 'Rendered '); + + root.render( + + + + ); + await sleep(1); + t.ok(rootContainer.querySelector('.maplibregl-ctrl-scale'), 'Rendered '); + + root.unmount(); + + t.end(); +}); diff --git a/modules/react-maplibre/test/components/index.js b/modules/react-maplibre/test/components/index.js new file mode 100644 index 000000000..958e49fe8 --- /dev/null +++ b/modules/react-maplibre/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-maplibre/test/components/layer.spec.jsx b/modules/react-maplibre/test/components/layer.spec.jsx new file mode 100644 index 000000000..ff59fed53 --- /dev/null +++ b/modules/react-maplibre/test/components/layer.spec.jsx @@ -0,0 +1,75 @@ +/* global document */ +import test from 'tape-promise/tape'; +import * as React from 'react'; +import {createRoot} from 'react-dom/client'; +import {Map, Source, Layer} from '@vis.gl/react-maplibre'; +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-maplibre/test/components/map.spec.jsx b/modules/react-maplibre/test/components/map.spec.jsx new file mode 100644 index 000000000..28a5a56cc --- /dev/null +++ b/modules/react-maplibre/test/components/map.spec.jsx @@ -0,0 +1,174 @@ +/* global setTimeout, document */ +import test from 'tape-promise/tape'; +import * as React from 'react'; +import {createRoot} from 'react-dom/client'; +import {Map} from '@vis.gl/react-maplibre'; +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')); + + function onLoad(e) { + e.target.easeTo({center: [-122, 38], zoom: 14, duration: 100}); + } + let lastCenter; + function onRender(e) { + const center = e.target.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')); + + function onLoad(e) { + e.target.easeTo({center: [-122, 38], zoom: 14, duration: 100}); + } + function onRender(e) { + const center = e.target.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')); + + function onLoad(e) { + e.target.easeTo({center: [-122, 38], zoom: 14, duration: 100}); + } + function onRender(vs, e) { + const center = e.target.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')); + + function onLoad(e) { + e.target.easeTo({center: [-122, 38], zoom: 14, duration: 100}); + } + function onRender(vs, e) { + const center = e.target.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-maplibre/test/components/marker.spec.jsx b/modules/react-maplibre/test/components/marker.spec.jsx new file mode 100644 index 000000000..57ea6751c --- /dev/null +++ b/modules/react-maplibre/test/components/marker.spec.jsx @@ -0,0 +1,93 @@ +/* global document */ +import test from 'tape-promise/tape'; +import * as React from 'react'; +import {createRoot} from 'react-dom/client'; +import {Map, Marker} from '@vis.gl/react-maplibre'; +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('.maplibregl-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-maplibre/test/components/popup.spec.jsx b/modules/react-maplibre/test/components/popup.spec.jsx new file mode 100644 index 000000000..bd711cc59 --- /dev/null +++ b/modules/react-maplibre/test/components/popup.spec.jsx @@ -0,0 +1,75 @@ +/* global document */ +import test from 'tape-promise/tape'; +import * as React from 'react'; +import {createRoot} from 'react-dom/client'; +import {Map, Popup} from '@vis.gl/react-maplibre'; +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('.maplibregl-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-maplibre/test/components/source.spec.jsx b/modules/react-maplibre/test/components/source.spec.jsx new file mode 100644 index 000000000..f0c3a5a28 --- /dev/null +++ b/modules/react-maplibre/test/components/source.spec.jsx @@ -0,0 +1,55 @@ +/* global document */ +import test from 'tape-promise/tape'; +import * as React from 'react'; +import {createRoot} from 'react-dom/client'; +import {Map, Source} from '@vis.gl/react-maplibre'; +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')?.getData(); + 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-maplibre/test/components/use-map.spec.jsx b/modules/react-maplibre/test/components/use-map.spec.jsx new file mode 100644 index 000000000..a4f7f6afd --- /dev/null +++ b/modules/react-maplibre/test/components/use-map.spec.jsx @@ -0,0 +1,52 @@ +/* global document */ +import test from 'tape-promise/tape'; +import * as React from 'react'; +import {createRoot} from 'react-dom/client'; +import {Map, MapProvider, useMap} from '@vis.gl/react-maplibre'; +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-maplibre/test/utils/apply-react-style.spec.js b/modules/react-maplibre/test/utils/apply-react-style.spec.js new file mode 100644 index 000000000..6250b2d01 --- /dev/null +++ b/modules/react-maplibre/test/utils/apply-react-style.spec.js @@ -0,0 +1,26 @@ +import test from 'tape-promise/tape'; +import {applyReactStyle} from '@vis.gl/react-maplibre/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-maplibre/test/utils/deep-equal.spec.js b/modules/react-maplibre/test/utils/deep-equal.spec.js new file mode 100644 index 000000000..a0cb47adf --- /dev/null +++ b/modules/react-maplibre/test/utils/deep-equal.spec.js @@ -0,0 +1,95 @@ +import test from 'tape-promise/tape'; +import {deepEqual, arePointsEqual} from '@vis.gl/react-maplibre/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-maplibre/test/utils/index.js b/modules/react-maplibre/test/utils/index.js new file mode 100644 index 000000000..65ae66cec --- /dev/null +++ b/modules/react-maplibre/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-maplibre/test/utils/style-utils.spec.js b/modules/react-maplibre/test/utils/style-utils.spec.js new file mode 100644 index 000000000..0cdf5e1ef --- /dev/null +++ b/modules/react-maplibre/test/utils/style-utils.spec.js @@ -0,0 +1,213 @@ +import test from 'tape-promise/tape'; + +import {normalizeStyle} from '@vis.gl/react-maplibre/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-maplibre/test/utils/test-utils.jsx b/modules/react-maplibre/test/utils/test-utils.jsx new file mode 100644 index 000000000..ce0215e0d --- /dev/null +++ b/modules/react-maplibre/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?.isStyleLoaded()) { + resolve(); + } else { + setTimeout(check, 50); + } + }; + check(); + }); +} diff --git a/modules/react-maplibre/test/utils/transform.spec.js b/modules/react-maplibre/test/utils/transform.spec.js new file mode 100644 index 000000000..25e575540 --- /dev/null +++ b/modules/react-maplibre/test/utils/transform.spec.js @@ -0,0 +1,66 @@ +import test from 'tape-promise/tape'; +import { + transformToViewState, + applyViewStateToTransform +} from '@vis.gl/react-maplibre/utils/transform'; +import maplibregl from 'maplibre-gl'; + +test('transformToViewState', t => { + const tr = { + center: new maplibregl.LngLat(-122.45, 37.78), + zoom: 10.5, + bearing: -70, + pitch: 30, + padding: {top: 0, left: 0, right: 0, bottom: 0} + }; + + t.deepEqual(transformToViewState(tr), { + longitude: -122.45, + latitude: 37.78, + zoom: 10.5, + bearing: -70, + pitch: 30, + padding: {top: 0, left: 0, right: 0, bottom: 0} + }); + + t.end(); +}); + +test('applyViewStateToTransform', t => { + const tr = { + center: new maplibregl.LngLat(-122.45, 37.78), + zoom: 10.5, + bearing: -70, + pitch: 30, + padding: {top: 0, left: 0, right: 0, bottom: 0} + }; + + let changed = applyViewStateToTransform(tr, {}); + t.deepEqual(changed, {}, 'no changes detected'); + + changed = applyViewStateToTransform(tr, {longitude: -10, latitude: 5}); + t.deepEqual( + changed, + { + center: new maplibregl.LngLat(-10, 5) + }, + 'center changed' + ); + + changed = applyViewStateToTransform(tr, {zoom: 11, pitch: 30, bearing: -70}); + t.deepEqual(changed, {zoom: 11}, 'zoom changed'); + + changed = applyViewStateToTransform(tr, {zoom: 10.5, pitch: 40, bearing: -70}); + t.deepEqual(changed, {pitch: 40}, 'pitch changed'); + + changed = applyViewStateToTransform(tr, {zoom: 10.5, pitch: 30, bearing: 270}); + t.deepEqual(changed, {bearing: 270}, 'bearing changed'); + + changed = applyViewStateToTransform(tr, {padding: {left: 10, right: 10, top: 10, bottom: 10}}); + t.deepEqual(changed, {padding: {left: 10, right: 10, top: 10, bottom: 10}}, 'bearing changed'); + + changed = applyViewStateToTransform(tr, {viewState: {pitch: 30}}); + t.deepEqual(changed, {}, 'nothing changed'); + + t.end(); +}); diff --git a/test/browser.js b/test/browser.js index b7982da34..cb4a4ae0a 100644 --- a/test/browser.js +++ b/test/browser.js @@ -6,4 +6,6 @@ test.onFailure(window.browserTestDriver_fail); import '../modules/main/test/components'; import '../modules/main/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 902591343..f91633b3e 100644 --- a/test/node.js +++ b/test/node.js @@ -1,2 +1,3 @@ import './src/exports'; import '../modules/main/test/utils'; +import '../modules/react-maplibre/test/utils'; diff --git a/test/src/exports.ts b/test/src/exports.ts index 3f574e1b8..ea4bb32e2 100644 --- a/test/src/exports.ts +++ b/test/src/exports.ts @@ -30,7 +30,7 @@ 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(maplibreComponents), 'Maplibre endpoint contains all components'); // t.notOk(getMissingExports(mapboxComponents), 'Mapbox endpoint contains all components'); t.end(); }); diff --git a/yarn.lock b/yarn.lock index c2e461b8f..9bc7cb928 100644 --- a/yarn.lock +++ b/yarn.lock @@ -585,10 +585,10 @@ rw "^1.3.3" sort-object "^3.0.3" -"@maplibre/maplibre-gl-style-spec@^22.0.1": - version "22.0.1" - resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-22.0.1.tgz#49210dd9c08853130c453b2acb9439216ab81402" - integrity sha512-V7bSw7Ui6+NhpeeuYqGoqamvKuy+3+uCvQ/t4ZJkwN8cx527CAlQQQ2kp+w5R9q+Tw6bUAH+fsq+mPEkicgT8g== +"@maplibre/maplibre-gl-style-spec@^23.0.0": + version "23.1.0" + resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.1.0.tgz#ad59731b0547ee0986ba4ccff699894dd60f0650" + integrity sha512-R6/ihEuC5KRexmKIYkWqUv84Gm+/QwsOUgHyt1yy2XqCdGdLvlBWVWIIeTZWN4NGdwmY6xDzdSGU2R9oBLNg2w== dependencies: "@mapbox/jsonlint-lines-primitives" "~2.0.2" "@mapbox/unitbezier" "^0.0.1" @@ -5270,10 +5270,10 @@ mapbox-gl@3.9.0: tinyqueue "^3.0.0" vt-pbf "^3.1.3" -maplibre-gl@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.0.0.tgz#d9120b6ced7df5d1c791497f25bbe4edd5039d96" - integrity sha512-WG8IYFK2gfJYXvWjlqg1yavo/YO/JlNkblAJMt19sjIafP5oJzTgXFiOLUIYkjtrv5pKiAWuSYsx4CD3ithJqw== +maplibre-gl@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.0.1.tgz#5eeb520de26dca820a12f270164c5eb5db1d16e2" + integrity sha512-kNvod1Tq0BcZvn43UAciA3DrzaEGmowqMoI6nh3kUo9rf+7m89mFJI9dELxkWzJ/N9Pgnkp7xF1jzTP08PGpCw== dependencies: "@mapbox/geojson-rewind" "^0.5.2" "@mapbox/jsonlint-lines-primitives" "^2.0.2" @@ -5282,7 +5282,7 @@ maplibre-gl@5.0.0: "@mapbox/unitbezier" "^0.0.1" "@mapbox/vector-tile" "^1.3.1" "@mapbox/whoots-js" "^3.1.0" - "@maplibre/maplibre-gl-style-spec" "^22.0.1" + "@maplibre/maplibre-gl-style-spec" "^23.0.0" "@types/geojson" "^7946.0.15" "@types/geojson-vt" "3.2.5" "@types/mapbox__point-geometry" "^0.1.4"