import React, { useCallback, useState } from "react"; import { Map, MapProps } from "react-map-gl/maplibre"; import maplibregl, { MapLayerMouseEvent } from "maplibre-gl"; import { useIntl } from "react-intl"; import * as Styled from "./styled"; import * as util from "./util"; import MarkerWithPopup from "./MarkerWithPopup"; import generateMapControlTranslations from "./mapControlLocale"; /** * The BaseMap component renders a MapLibre map * as well as markers that are declared as child elements of the BaseMap element. * * As BaseMap wraps a react-map-gl Map component, any control which can be added as a child of a react-map-gl map is supported. * See https://visgl.github.io/react-map-gl/docs/api-reference/map to see which react-map-gl * children are shipped by default. Others are also supported. * * Overlays are groups of similar MapLibre markers, e.g. vehicle location * markers, bus stop markers, etc. * * Overlays are automatically added to the overlay control displayed by the * BaseMap. The user uses that control to turn overlays on or off. Only overlays * with an id are added to the control. */ type Props = React.ComponentPropsWithoutRef & { /** A URL, or list of URLs pointing to the vector tile specification which should be used as the main map. */ baseLayer?: string | string[]; /** A list of names to match onto the base layers. Used only if there are multiple entries defined for `BaseLayer` */ baseLayerNames?: string[]; /** A [lat, lon] position to center the map at. */ center?: [number, number]; /** A unique identifier for the map (useful when using MapProvider) */ id?: string; /** An object of props which should be passed down to MapLibre */ mapLibreProps?: MapProps; /** The maximum zoom level the map should allow */ maxZoom?: number; /** A callback method which is fired when the map is clicked with the left mouse button/tapped */ onClick?: (e: MapLayerMouseEvent) => void; /** A callback method which is fired when the layer selector component is clicked with the left mouse button/tapped */ onClickLayerSelector?: () => void; /** A callback method which is fired when the map is clicked with the right mouse button/long tapped */ onContextMenu?: (e: MapLayerMouseEvent) => void; /** A callback method which is fired when the map zoom or map bounds change */ onViewportChanged?: (e: State) => void; /** When set to true, all hidden layers will be removed. No layers will be uncheckable until * it is set to false */ showEverything?: boolean; /** An initial zoom value for the map */ zoom?: number; }; type State = { latitude: number; longitude: number; zoom: number; }; const BaseMap = ({ // These tiles are free to use, but not in production baseLayer = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", baseLayerNames, center, children, id, mapLibreProps, maxZoom, onClick, onClickLayerSelector, onContextMenu, showEverything, onViewportChanged, style, zoom: initZoom = 12 }: Props): JSX.Element => { const intl = useIntl(); // Firefox and Safari on iOS: hover is not triggered when the user touches the layer selector // (unlike Firefox or Chromium on Android), so we have to detect touch and trigger hover ourselves. const [fakeMobileHover, setFakeHover] = useState(false); const [longPressTimer, setLongPressTimer] = useState(null); const toggleableLayers = Array.isArray(children) ? children .flat(10) .filter( child => child?.props?.id !== undefined && // Some sources will not have layers as children, and should be ignored // from the list. child?.props?.alwaysShow !== true ) .map(child => { const { id: layerId, name, visible } = child.props; return { id: layerId, name, visible }; }) : []; const [hiddenLayers, setHiddenLayers] = useState( toggleableLayers.filter(layer => !layer?.visible).map(layer => layer.id) ); const computedHiddenLayers = showEverything && hiddenLayers.length > 0 ? [] : hiddenLayers; const [activeBaseLayer, setActiveBaseLayer] = useState( typeof baseLayer === "object" ? baseLayer?.[0] : baseLayer ); const clearLongPressTimer = useCallback(() => clearTimeout(longPressTimer), [ longPressTimer ]); const handleViewportChange = useCallback( evt => { onViewportChanged && onViewportChanged(evt.viewState); clearLongPressTimer(); }, [clearLongPressTimer, onViewportChanged] ); return ( { setFakeHover(false); // Start detecting long presses on screens when there is only one touch point. // If the user is pinching the map or does other multi-touch actions, cancel long-press detection. const touchPointCount = e.points.length; if (touchPointCount === 1) { setLongPressTimer( setTimeout( // In practice, MapLayerTouchEvent and MapTouchEvent behave as if they were // subclasses of MapLayerMouseEvent and MapMouseEvent, respectively, // so the conversion from touch to mouse event works, with the caveat that // the `type` prop takes different string values between mouse and touch events. () => onContextMenu((e as unknown) as MapLayerMouseEvent), 600 ) ); } else { clearLongPressTimer(); } }} onTouchCancel={clearLongPressTimer} onTouchEnd={clearLongPressTimer} onZoom={handleViewportChange} style={style} > {(toggleableLayers.length > 0 || (!!baseLayer && typeof baseLayer === "object" && baseLayer.length > 1)) && ( setFakeHover(false)} onClick={onClickLayerSelector} onFocus={() => { setFakeHover(true); if (onClickLayerSelector) onClickLayerSelector(); }} onTouchEnd={() => { setFakeHover(true); if (onClickLayerSelector) onClickLayerSelector(); }} >
    {!!baseLayer && typeof baseLayer === "object" && baseLayer.map((layer: string, index: number) => { return (
  • ); })} {toggleableLayers.map((layer: LayerProps, index: number) => { return (
  • ); })}
)} {Array.isArray(children) ? children .flat(10) .filter(child => !computedHiddenLayers.includes(child?.props?.id)) : children}
); }; export default BaseMap; type LayerProps = React.ComponentPropsWithoutRef & { id: string; name?: string; visible?: boolean; }; const LayerWrapper = (props: LayerProps): JSX.Element => { const { children, visible } = props; return <>{visible && children}; }; export const Popup = Styled.Popup; export { LayerWrapper, MarkerWithPopup, Styled, util };