import { Popup } from "@opentripplanner/base-map"; import { MapLocationActionArg, Stop, StopEventHandler } from "@opentripplanner/types"; import { MapLayerMouseEvent } from "maplibre-gl"; import { Layer, Source, useMap } from "react-map-gl/maplibre"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import StopPopup from "@opentripplanner/map-popup"; import { hasCoordinates, isGeoJsonFlex } from "./utils"; type Props = { /** * Custom stop color passed from user config */ color?: string; /** * An optional id to override the active stop with */ activeStop?: string; /** * An optional id to highlight a stop on the map */ highlightedStop?: string; /** * A color to use for the highlighted stop */ highlightedStopColor?: string; /** * The list of stops to create stop markers for. */ stops?: Stop[]; /** * Whether or not to display the overlay */ visible?: boolean; /** * The lowest zoom level the stop markers should be visible at */ minZoom?: number | null; /** * A method to be fired when the map is moved */ refreshStops?: () => void; /** * A method fired when a stop is selected as from or to in the popup */ setLocation?: (location: MapLocationActionArg) => void; /** * A method fired when the stop viewer is opened in the popup */ setViewedStop?: StopEventHandler; }; /** * An overlay to view a collection of stops. */ const StopsOverlay = (props: Props): JSX.Element | null => { const { current: map } = useMap(); if (!map) return null; const { activeStop, color, highlightedStop, highlightedStopColor, minZoom, refreshStops, setLocation, setViewedStop, stops, visible } = props; const [userClickedStopId, setUserClickedStopId] = useState(); const onLayerEnter = useCallback(() => { map.getCanvas().style.cursor = "pointer"; }, [map]); const onLayerLeave = useCallback(() => { map.getCanvas().style.cursor = ""; }, [map]); const onLayerClick = useCallback( (event: MapLayerMouseEvent) => { // We have to cast this because the properties on geojson geatures are not defined const id = event.features?.[0]?.properties?.id as string | undefined; if (id) setUserClickedStopId(id); }, [setUserClickedStopId] ); const onZoomEnd = useCallback(() => { if (minZoom !== null && minZoom !== undefined && map.getZoom() < minZoom) { setUserClickedStopId(undefined); } }, [map, setUserClickedStopId, minZoom]); const shownStopId = activeStop ?? userClickedStopId; useEffect(() => { const STOP_LAYERS = ["stops", "flex-stops"]; STOP_LAYERS.forEach(stopLayer => { map?.on("mouseenter", stopLayer, onLayerEnter); map?.on("mouseleave", stopLayer, onLayerLeave); map?.on("click", stopLayer, onLayerClick); }); if (visible && refreshStops) refreshStops(); map?.on("zoomend", onZoomEnd); // Remove event handlers when component unmounts // (prevents error messages about performing state updates on unmounted component) return () => { STOP_LAYERS.forEach(stopLayer => { map?.off("mouseenter", stopLayer, onLayerEnter); map?.off("mouseleave", stopLayer, onLayerLeave); map?.off("click", stopLayer, onLayerClick); }); map?.off("zoomend", onZoomEnd); }; }, [map, visible]); const clearClickedStop = useCallback(() => { setUserClickedStopId(undefined); }, []); const shownStop = useMemo(() => stops?.find(s => s.id === shownStopId), [ stops, shownStopId ]); const flexStops = useMemo( () => stops ? stops.filter(stop => isGeoJsonFlex(stop?.geometries?.geoJson)) : [], [stops] ); const stopsGeoJSON: GeoJSON.FeatureCollection = useMemo( () => ({ type: "FeatureCollection", features: (stops ?? []).filter(hasCoordinates).map(stop => ({ type: "Feature", properties: { ...stop, flex: isGeoJsonFlex(stop?.geometries?.geoJson), highlighted: stop.id === highlightedStop }, geometry: { type: "Point", coordinates: [stop.lon, stop.lat] } })) }), [stops, highlightedStop] ); // Don't render if no map or no stops are defined. // (ZoomBasedMarkers will also not render below the minimum zoom threshold defined in the symbols prop.) if (visible === false || !stops || stops.length === 0) { // Null can't be returned here -- react-map-gl dislikes null values as children return <>; } return ( <> {shownStop?.lat && shownStop?.lon && ( { clearClickedStop(); setLocation(location); }) } setViewedStop={ setViewedStop && (stop => { clearClickedStop(); setViewedStop(stop); }) } /> )} {flexStops.map( stop => stop.geometries?.geoJson && ( {/* TODO: add support for overriding layer styles */} ) )} ); }; export default StopsOverlay; export { isGeoJsonFlex, Props as StopProps };