diff --git a/app/component/map/ClusterNumberMarker.jsx b/app/component/map/ClusterNumberMarker.jsx index 0c441eb30c..001a57eb81 100644 --- a/app/component/map/ClusterNumberMarker.jsx +++ b/app/component/map/ClusterNumberMarker.jsx @@ -29,7 +29,6 @@ export default function ClusterNumberMarker({ position, number }, { config }) { dominant-baseline="middle" fill="#fff" font-size="${radius * 0.8}px" - font-family="Gotham XNarrow A, Gotham Rounded A, Gotham Rounded B, Roboto Condensed, Roboto, Arial, sans-serif" > ${number} diff --git a/app/component/map/GenericMarker.jsx b/app/component/map/GenericMarker.jsx index 1bb3386d9c..88d13399dc 100644 --- a/app/component/map/GenericMarker.jsx +++ b/app/component/map/GenericMarker.jsx @@ -1,131 +1,108 @@ import isFunction from 'lodash/isFunction'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { withLeaflet } from 'react-leaflet/es/context'; import Marker from 'react-leaflet/es/Marker'; import Popup from 'react-leaflet/es/Popup'; import { default as L } from 'leaflet'; -import { configShape, locationShape } from '../../../utils/client/shapes'; +import { locationShape } from '../../../utils/client/shapes'; +import { useConfigContext } from '../../client/ConfigContext'; -class GenericMarker extends React.Component { - static displayName = 'GenericMarker'; +function GenericMarker({ + shouldRender = () => true, + position, + getIcon, + renderName = false, + name = '', + maxWidth, + minWidth, + children, + leaflet, + onClick = () => {}, + zIndexOffset, +}) { + const config = useConfigContext(); + const [zoom, setZoom] = useState(() => leaflet.map.getZoom()); - static contextTypes = { - config: configShape.isRequired, - }; + useEffect(() => { + const onMapMove = () => setZoom(leaflet.map.getZoom()); + leaflet.map.on('zoomend', onMapMove); + return () => leaflet.map.off('zoomend', onMapMove); + }, [leaflet.map]); - static propTypes = { - shouldRender: PropTypes.func, - position: locationShape.isRequired, - getIcon: PropTypes.func.isRequired, - renderName: PropTypes.bool, - name: PropTypes.string, - maxWidth: PropTypes.number, - minWidth: PropTypes.number, - children: PropTypes.node, - leaflet: PropTypes.shape({ - map: PropTypes.shape({ - getZoom: PropTypes.func.isRequired, - on: PropTypes.func.isRequired, - off: PropTypes.func.isRequired, - }).isRequired, - }).isRequired, - onClick: PropTypes.func, - zIndexOffset: PropTypes.number, - }; - - static defaultProps = { - shouldRender: () => true, - onClick: () => {}, - renderName: false, - name: '', - maxWidth: undefined, - minWidth: undefined, - children: undefined, - zIndexOffset: undefined, - }; - - state = { zoom: this.props.leaflet.map.getZoom() }; - - componentDidMount() { - this.props.leaflet.map.on('zoomend', this.onMapMove); + if (isFunction(shouldRender) && !shouldRender(zoom)) { + return null; } - componentWillUnmount() { - this.props.leaflet.map.off('zoomend', this.onMapMove); - } - - onMapMove = () => this.setState({ zoom: this.props.leaflet.map.getZoom() }); - - getMarker = () => ( + const marker = ( - {this.props.children && ( + {children && ( - {this.props.children} + {children} )} ); - getNameMarker() { - if ( - !this.props.renderName || - this.props.leaflet.map.getZoom() < - this.context.config.map.genericMarker.nameMarkerMinZoom - ) { - return false; - } - return ( + const nameMarker = renderName && + leaflet.map.getZoom() >= config.map.genericMarker.nameMarkerMinZoom && ( ${this.props.name}`, + html: `
${name}
`, className: 'popup', iconSize: [150, 0], iconAnchor: [-8, 7], })} keyboard={false} - zIndexOffset={this.props.zIndexOffset} + zIndexOffset={zIndexOffset} /> ); - } - render() { - const { shouldRender } = this.props; - const { zoom } = this.state; - if (isFunction(shouldRender) && !shouldRender(zoom)) { - return null; - } - - return ( - - {this.getMarker()} - {this.getNameMarker()} - - ); - } + return ( + <> + {marker} + {nameMarker} + + ); } +GenericMarker.displayName = 'GenericMarker'; + +GenericMarker.propTypes = { + shouldRender: PropTypes.func, + position: locationShape.isRequired, + getIcon: PropTypes.func.isRequired, + renderName: PropTypes.bool, + name: PropTypes.string, + maxWidth: PropTypes.number, + minWidth: PropTypes.number, + children: PropTypes.node, + leaflet: PropTypes.shape({ + map: PropTypes.shape({ + getZoom: PropTypes.func.isRequired, + on: PropTypes.func.isRequired, + off: PropTypes.func.isRequired, + }).isRequired, + }).isRequired, + onClick: PropTypes.func, + zIndexOffset: PropTypes.number, +}; + const leafletComponent = withLeaflet(GenericMarker); export { leafletComponent as default, GenericMarker as Component }; diff --git a/app/component/map/IconMarker.jsx b/app/component/map/IconMarker.jsx index 1c791cc940..aebc7c98d3 100644 --- a/app/component/map/IconMarker.jsx +++ b/app/component/map/IconMarker.jsx @@ -1,16 +1,18 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { default as L } from 'leaflet'; import Marker from 'react-leaflet/es/Marker'; -/* eslint-disable no-underscore-dangle */ -export default class IconMarker extends React.Component { - constructor(props, ...args) { - super(props, ...args); - const _this = this; +export default function IconMarker({ icon, zIndexOffset, children, ...rest }) { + const [div, setDiv] = useState(undefined); + const hasMounted = useRef(false); - this.Icon = L.Icon.extend({ + // The leaflet icon instance is created once and kept stable for the + // lifetime of the component; subsequent icon prop changes are applied via + // icon.initialize() below, mirroring the previous componentDidUpdate. + const iconInstance = useMemo(() => { + const DivIcon = L.Icon.extend({ options: { // @section // @aka DivIcon options @@ -27,16 +29,17 @@ export default class IconMarker extends React.Component { }, createIcon(oldIcon) { - const div = + const newDiv = oldIcon && oldIcon.tagName === 'DIV' ? oldIcon : document.createElement('div'); - _this.setState({ div }); + setDiv(newDiv); - this._setIconStyles(div, 'icon'); + // eslint-disable-next-line no-underscore-dangle + this._setIconStyles(newDiv, 'icon'); - return div; + return newDiv; }, createShadow() { @@ -44,28 +47,30 @@ export default class IconMarker extends React.Component { }, }); - this.state = { icon: new this.Icon(props.icon) }; - } + return new DivIcon(icon); + // Intentionally created only once (empty deps) - see comment above. + }, []); - componentDidUpdate() { - this.state.icon.initialize(this.props.icon); - } + useEffect(() => { + if (hasMounted.current) { + iconInstance.initialize(icon); + } else { + hasMounted.current = true; + } + }, [icon, iconInstance]); - render() { - return [ - this.state.div && - createPortal(this.props.icon.element, this.state.div, 'icon'), - - {this.props.children} - , - ]; - } + return [ + div && createPortal(icon.element, div, 'icon'), + + {children} + , + ]; } IconMarker.propTypes = { @@ -79,8 +84,3 @@ IconMarker.propTypes = { zIndexOffset: PropTypes.number, children: PropTypes.node, }; - -IconMarker.defaultProps = { - zIndexOffset: undefined, - children: undefined, -}; diff --git a/app/component/map/Line.jsx b/app/component/map/Line.jsx index b4a9975372..75079ad7e2 100644 --- a/app/component/map/Line.jsx +++ b/app/component/map/Line.jsx @@ -1,143 +1,128 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import cx from 'classnames'; import Polyline from 'react-leaflet/es/Polyline'; -import { configShape } from '../../../utils/client/shapes'; +import { useConfigContext } from '../../client/ConfigContext'; -export default class Line extends React.Component { - static propTypes = { - thin: PropTypes.bool, - opaque: PropTypes.bool, - passive: PropTypes.bool, - color: PropTypes.string, - mode: PropTypes.string.isRequired, - geometry: PropTypes.arrayOf( - PropTypes.oneOfType([ - PropTypes.object, - PropTypes.arrayOf(PropTypes.number), - ]), - ).isRequired, - appendClass: PropTypes.string, - }; +// https://github.com/Leaflet/Leaflet/issues/2662 +// updating className does not work currently :( - static defaultProps = { - thin: false, - opaque: false, - passive: false, - color: undefined, - appendClass: undefined, - }; +export default function Line({ + thin = false, + opaque = false, + passive = false, + color, + mode, + geometry, + appendClass, +}) { + const config = useConfigContext(); + const line = useRef(null); + const halo = useRef(null); + const hasMounted = useRef(false); - static contextTypes = { - config: configShape.isRequired, - }; - - componentDidMount() { + useEffect(() => { // If we accidently draw the thin line over a normal one, // the halo will block it completely and we only see the thin one. // So we send the thin line layers (Leaflet calls every polyline its // own layer) to bottom. Note that all polylines do render inside the // same SVG, so CSS z-index can't be used. - if (this.props.thin) { - if (this.line) { - this.line.leafletElement.bringToBack(); + // Run only on mount, mirroring the previous componentDidMount. + if (thin) { + if (line.current) { + line.current.leafletElement.bringToBack(); } - if (this.halo) { - this.halo.leafletElement.bringToBack(); + if (halo.current) { + halo.current.leafletElement.bringToBack(); } } - } + }, []); - componentDidUpdate() { - if ( - !this.props.passive && - !this.props.thin && - !this.props.opaque && - this.line - ) { - this.line.leafletElement.bringToFront(); + useEffect(() => { + if (hasMounted.current) { + if (!passive && !thin && !opaque && line.current) { + line.current.leafletElement.bringToFront(); + } + } else { + hasMounted.current = true; } - } - - // https://github.com/Leaflet/Leaflet/issues/2662 - // updating className does not work currently :( + }); - render() { - const className = cx([ - this.props.mode, - { thin: this.props.thin }, - { opaque: this.props.opaque }, - 'map-line', - ]); - let filteredPoints; - if (this.props.geometry) { - filteredPoints = this.props.geometry.filter( - point => - (typeof point.lat === 'number' && typeof point.lon === 'number') || - (typeof point[0] === 'number' && typeof point[1] === 'number'), - ); - } + const className = cx([mode, { thin }, { opaque }, 'map-line']); + const filteredPoints = + geometry && + geometry.filter( + point => + (typeof point.lat === 'number' && typeof point.lon === 'number') || + (typeof point[0] === 'number' && typeof point[1] === 'number'), + ); - if (!filteredPoints || filteredPoints.length === 0) { - return null; - } + if (!filteredPoints || filteredPoints.length === 0) { + return null; + } - const lineConfig = this.context.config.map.line; + const lineConfig = config.map.line; - let color = this.props.color ? this.props.color : 'currentColor'; - let haloWeight = this.props.thin - ? lineConfig.halo.thinWeight - : lineConfig.halo.weight; - let legWeight = this.props.thin - ? lineConfig.leg.thinWeight - : lineConfig.leg.weight; + let lineColor = color || 'currentColor'; + let haloWeight = thin ? lineConfig.halo.thinWeight : lineConfig.halo.weight; + let legWeight = thin ? lineConfig.leg.thinWeight : lineConfig.leg.weight; - if (this.props.mode === 'walk') { - legWeight *= 0.8; - } - if (this.props.mode === 'walk-inside') { - legWeight *= 0.8; - } - if (this.props.mode === 'ferry-external') { - haloWeight *= 0.6; - legWeight *= 0.6; - } - if (this.props.passive) { - haloWeight *= 0.5; - legWeight *= 0.5; - if (lineConfig.passiveColor) { - color = lineConfig.passiveColor; - } - } - if (this.props.opaque) { - haloWeight *= 0.65; - legWeight *= 0.5; + if (mode === 'walk') { + legWeight *= 0.8; + } + if (mode === 'walk-inside') { + legWeight *= 0.8; + } + if (mode === 'ferry-external') { + haloWeight *= 0.6; + legWeight *= 0.6; + } + if (passive) { + haloWeight *= 0.5; + legWeight *= 0.5; + if (lineConfig.passiveColor) { + lineColor = lineConfig.passiveColor; } - - return ( -
- { - this.halo = el; - }} - positions={filteredPoints} - className={`leg-halo ${className} ${this.props.appendClass}`} - weight={haloWeight} - interactive={false} - /> - { - this.line = el; - }} - positions={filteredPoints} - className={`leg ${className} ${this.props.appendClass}`} - color={color} - weight={legWeight} - interactive={false} - /> -
- ); } + if (opaque) { + haloWeight *= 0.65; + legWeight *= 0.5; + } + + return ( +
+ + +
+ ); } + +Line.propTypes = { + thin: PropTypes.bool, + opaque: PropTypes.bool, + passive: PropTypes.bool, + color: PropTypes.string, + mode: PropTypes.string.isRequired, + geometry: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.object, + PropTypes.arrayOf(PropTypes.number), + ]), + ).isRequired, + appendClass: PropTypes.string, +}; diff --git a/app/component/map/MapWithTracking.jsx b/app/component/map/MapWithTracking.jsx index fe543fb9ce..b432f23c19 100644 --- a/app/component/map/MapWithTracking.jsx +++ b/app/component/map/MapWithTracking.jsx @@ -1,13 +1,11 @@ import PropTypes from 'prop-types'; -import React, { memo } from 'react'; +import React, { memo, useEffect, useRef, useState } from 'react'; import connectToStores from 'fluxible-addons-react/connectToStores'; +import { useIntl } from 'react-intl'; import isEqual from 'lodash/isEqual'; import cloneDeep from 'lodash/cloneDeep'; import isEmpty from 'lodash/isEmpty'; -import { - mapLayerOptionsShape, - configShape, -} from '../../../utils/client/shapes'; +import { mapLayerOptionsShape } from '../../../utils/client/shapes'; import { startLocationWatch } from '../../action/PositionActions'; import MapContainer from './MapContainer'; import MapControlButton from './MapControlButton'; @@ -16,6 +14,7 @@ import { mapLayerShape } from '../../store/MapLayerStore'; import MapLayersDialogContent from './MapLayersDialogContent'; import MenuDrawer from '../MenuDrawer'; import withBreakpoint from '../../../utils/client/withBreakpoint'; +import { useConfigContext } from '../../client/ConfigContext'; const onlyUpdateCoordChanges = (prevProps, nextProps) => prevProps.lat === nextProps.lat && @@ -31,7 +30,7 @@ const onlyUpdateCoordChanges = (prevProps, nextProps) => const MapCont = memo(MapContainer, onlyUpdateCoordChanges); -const getForcedLayersFromMapLayerOptions = mapLayerOptions => { +export const getForcedLayersFromMapLayerOptions = mapLayerOptions => { const forcedLayers = {}; Object.keys(mapLayerOptions).forEach(key => { const layer = mapLayerOptions[key]; @@ -51,177 +50,121 @@ const getForcedLayersFromMapLayerOptions = mapLayerOptions => { return forcedLayers; }; -class MapWithTrackingStateHandler extends React.Component { - static contextTypes = { - executeAction: PropTypes.func, - getStore: PropTypes.func, - intl: PropTypes.object.isRequired, - config: configShape.isRequired, - }; - - static propTypes = { - lat: PropTypes.number, - lon: PropTypes.number, - zoom: PropTypes.number, - position: PropTypes.shape({ - hasLocation: PropTypes.bool.isRequired, - locationingFailed: PropTypes.bool, - lat: PropTypes.number.isRequired, - lon: PropTypes.number.isRequired, - }).isRequired, - bounds: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)), - children: PropTypes.arrayOf(PropTypes.node), - leafletObjs: PropTypes.arrayOf(PropTypes.node), - renderCustomButtons: PropTypes.func, - mapLayers: mapLayerShape.isRequired, - mapLayerOptions: mapLayerOptionsShape, - mapTracking: PropTypes.bool, - locationPopup: PropTypes.string, - onSelectLocation: PropTypes.func, - onStartNavigation: PropTypes.func, - onEndNavigation: PropTypes.func, - onMapTracking: PropTypes.func, - setMWTRef: PropTypes.func, - mapRef: PropTypes.func, - // eslint-disable-next-line - leafletEvents: PropTypes.object, - breakpoint: PropTypes.string.isRequired, - topButtons: PropTypes.node, - }; - - static defaultProps = { - lat: undefined, - lon: undefined, - zoom: undefined, - bounds: undefined, - setMWTRef: undefined, - mapRef: undefined, - children: undefined, - leafletObjs: undefined, - mapTracking: undefined, - onStartNavigation: undefined, - onEndNavigation: undefined, - onMapTracking: undefined, - renderCustomButtons: undefined, - locationPopup: undefined, - onSelectLocation: () => null, - leafletEvents: {}, - mapLayerOptions: null, - topButtons: null, - }; - - constructor(props) { - super(props); - this.state = { - mapTracking: props.mapTracking, - settingsOpen: false, - }; - this.naviProps = {}; - this.mounted = false; - } +function MapWithTrackingStateHandler( + { + lat, + lon, + zoom, + position, + bounds, + children, + renderCustomButtons, + mapLayers, + mapLayerOptions = null, + mapTracking, + locationPopup, + onSelectLocation = () => null, + onStartNavigation, + onEndNavigation, + onMapTracking, + setMWTRef, + mapRef, + // eslint-disable-next-line react/prop-types + leafletEvents = {}, + breakpoint, + topButtons = null, + ...rest + }, + context, +) { + const config = useConfigContext(); + const intl = useIntl(); + const [mapTrackingState, setMapTrackingState] = useState(mapTracking); + const [settingsOpen, setSettingsOpen] = useState(false); - async componentDidMount() { - this.mounted = true; - - if (this.props.setMWTRef) { - this.props.setMWTRef(this); - } - } - - componentWillUnmount() { - this.mounted = false; - } + // Mutable, render-time-only bookkeeping that previously lived on the class + // instance. These do not need to trigger re-renders when changed. + const naviProps = useRef({}).current; + const mounted = useRef(false); + const mapElement = useRef(null); + const ignoreNavigation = useRef(false); + const refresh = useRef(false); + const oldBounds = useRef(undefined); + const oldLat = useRef(undefined); + const oldLon = useRef(undefined); + const navigated = useRef(false); - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(newProps) { - if ( - newProps.mapTracking !== undefined && - newProps.mapTracking !== this.state.mapTracking && - this.mounted - ) { - this.setState({ mapTracking: newProps.mapTracking }); + const setMapElementRef = element => { + if (element && mapElement.current !== element && mounted.current) { + mapElement.current = element; + if (mapRef) { + mapRef(element); + } } - } + }; - setMapElementRef = element => { - if (element && this.mapElement !== element && this.mounted) { - this.mapElement = element; - if (this.props.mapRef) { - this.props.mapRef(element); - } + const disableMapTracking = () => { + if (!mounted.current) { + return; } + setMapTrackingState(false); }; - enableMapTracking = () => { - if (!this.props.position.hasLocation) { - this.context.executeAction(startLocationWatch); + const enableMapTracking = () => { + if (!position.hasLocation) { + context.executeAction(startLocationWatch); } - if (!this.state.mapTracking) { + if (!mapTrackingState) { // enabling tracking will trigger same navigation events as user navigation // this hack prevents those events from clearing tracking - this.ignoreNavigation = true; + ignoreNavigation.current = true; setTimeout(() => { - this.ignoreNavigation = false; + ignoreNavigation.current = false; }, 500); - this.setState({ mapTracking: true }); + setMapTrackingState(true); } - if (this.props.onMapTracking) { - this.props.onMapTracking(); + if (onMapTracking) { + onMapTracking(); } }; - disableMapTracking = () => { - if (!this.mounted) { - return; - } - - this.setState({ - mapTracking: false, - }); - }; - // this is used outside of this component - // eslint-disable-next-line react/no-unused-class-component-methods - forceRefresh = () => { - this.refresh = true; + const forceRefresh = () => { + refresh.current = true; }; - startNavigation = e => { - if (this.props.onStartNavigation) { - this.props.onStartNavigation(this.mapElement, e); + const startNavigation = e => { + if (onStartNavigation) { + onStartNavigation(mapElement.current, e); } - if (this.state.mapTracking && !this.ignoreNavigation) { - this.disableMapTracking(); + if (mapTrackingState && !ignoreNavigation.current) { + disableMapTracking(); } }; - endNavigation = e => { - if (this.props.onEndNavigation) { - this.props.onEndNavigation(this.mapElement, e); + const endNavigation = e => { + if (onEndNavigation) { + onEndNavigation(mapElement.current, e); } - this.navigated = true; + navigated.current = true; }; - setSettingsOpen = () => { - this.setState(prevState => ({ settingsOpen: !prevState.settingsOpen })); + const toggleSettingsOpen = () => { + setSettingsOpen(prev => !prev); }; - getMapLayers = () => { + const getMapLayers = () => { let forcedLayers; - if (this.props.mapLayerOptions) { - forcedLayers = getForcedLayersFromMapLayerOptions( - this.props.mapLayerOptions, - ); + if (mapLayerOptions) { + forcedLayers = getForcedLayersFromMapLayerOptions(mapLayerOptions); } if (isEmpty(forcedLayers)) { - return this.props.mapLayers; + return mapLayers; } const merged = { - ...this.props.mapLayers, + ...mapLayers, ...forcedLayers, - vehicles: !this.props.mapLayerOptions - ? this.props.mapLayers.vehicles - : false, + vehicles: !mapLayerOptions ? mapLayers.vehicles : false, }; if (isEmpty(forcedLayers.stop)) { return merged; @@ -229,171 +172,220 @@ class MapWithTrackingStateHandler extends React.Component { return { ...merged, stop: { - ...this.props.mapLayers.stop, + ...mapLayers.stop, ...forcedLayers.stop, }, }; }; - render() { - const { - lat, - lon, - zoom, - position, - children, - renderCustomButtons, - mapLayerOptions, - bounds, - leafletEvents, - topButtons, - ...rest - } = this.props; - const { config } = this.context; + // Exposed to the parent via setMWTRef as a stable object reference, whose + // methods are refreshed on every render below so callers always invoke the + // latest closures (e.g. reading the current position/mapTrackingState) + // instead of the ones captured when setMWTRef was first called. + const exposedInstance = useRef({}).current; + exposedInstance.enableMapTracking = enableMapTracking; + exposedInstance.disableMapTracking = disableMapTracking; + exposedInstance.forceRefresh = forceRefresh; - const btnClassName = 'map-with-tracking-buttons'; - // eslint-disable-next-line no-underscore-dangle - const currentZoom = this.mapElement?.leafletElement?._zoom || zoom || 16; + useEffect(() => { + mounted.current = true; + if (setMWTRef) { + setMWTRef(exposedInstance); + } + return () => { + mounted.current = false; + }; + // Runs only once, mirroring componentDidMount/componentWillUnmount. + // eslint-disable-next-line + }, []); - if (this.state.mapTracking && position.hasLocation) { - this.naviProps.lat = position.lat; - this.naviProps.lon = position.lon; - if (zoom) { - this.naviProps.zoom = zoom; - } else if (!this.naviProps.zoom) { - this.naviProps.zoom = currentZoom; - } - if (this.navigated) { - // force map update by changing the coordinate slightly. looks crazy but is the easiest way - this.naviProps.lat += 0.000001 * Math.random(); - this.navigated = false; - } - delete this.naviProps.bounds; - } else if ( - this.props.bounds && - (!isEqual(this.oldBounds, this.props.bounds) || this.refresh) - ) { - this.naviProps.bounds = cloneDeep(this.props.bounds); - delete this.naviProps.zoom; - if (this.refresh) { - // bounds is defined by [min, max] point pair. Substract min lat a bit - this.naviProps.bounds[0][0] -= 0.000001 * Math.random(); - } - this.oldBounds = cloneDeep(this.props.bounds); - } else if ( - lat && - lon && - ((lat !== this.oldLat && lon !== this.oldLon) || this.refresh) + useEffect(() => { + if ( + mapTracking !== undefined && + mapTracking !== mapTrackingState && + mounted.current ) { - this.naviProps.lat = lat; - if (this.refresh) { - this.naviProps.lat += 0.000001 * Math.random(); - } - this.naviProps.lon = lon; - this.oldLat = lat; - this.oldLon = lon; - if (zoom) { - this.naviProps.zoom = zoom; - } - delete this.naviProps.bounds; + setMapTrackingState(mapTracking); } - this.refresh = false; + // eslint-disable-next-line + }, [mapTracking]); + + const btnClassName = 'map-with-tracking-buttons'; + // eslint-disable-next-line no-underscore-dangle + const currentZoom = mapElement.current?.leafletElement?._zoom || zoom || 16; - let img; - let color; - if (position.locationingFailed) { - img = 'icon-tracking-off'; - color = '#888'; - } else { - img = 'icon-tracking'; - color = this.state.mapTracking ? '#007ac9' : '#78909c'; + if (mapTrackingState && position.hasLocation) { + naviProps.lat = position.lat; + naviProps.lon = position.lon; + if (zoom) { + naviProps.zoom = zoom; + } else if (!naviProps.zoom) { + naviProps.zoom = currentZoom; + } + if (navigated.current) { + // force map update by changing the coordinate slightly. looks crazy but is the easiest way + naviProps.lat += 0.000001 * Math.random(); + navigated.current = false; + } + delete naviProps.bounds; + } else if ( + bounds && + (!isEqual(oldBounds.current, bounds) || refresh.current) + ) { + naviProps.bounds = cloneDeep(bounds); + delete naviProps.zoom; + if (refresh.current) { + // bounds is defined by [min, max] point pair. Substract min lat a bit + naviProps.bounds[0][0] -= 0.000001 * Math.random(); + } + oldBounds.current = cloneDeep(bounds); + } else if ( + lat && + lon && + ((lat !== oldLat.current && lon !== oldLon.current) || refresh.current) + ) { + naviProps.lat = lat; + if (refresh.current) { + naviProps.lat += 0.000001 * Math.random(); } - // eslint-disable-next-line no-nested-ternary - const ariaLabel = position.locationingFailed - ? this.context.intl.formatMessage({ id: 'tracking-button-offline' }) - : this.state.mapTracking - ? this.context.intl.formatMessage({ id: 'tracking-button-on' }) - : this.context.intl.formatMessage({ id: 'tracking-button-off' }); + naviProps.lon = lon; + oldLat.current = lat; + oldLon.current = lon; + if (zoom) { + naviProps.zoom = zoom; + } + delete naviProps.bounds; + } + refresh.current = false; + + let img; + let color; + if (position.locationingFailed) { + img = 'icon-tracking-off'; + color = '#888'; + } else { + img = 'icon-tracking'; + color = mapTrackingState ? '#007ac9' : '#78909c'; + } + // eslint-disable-next-line no-nested-ternary + const ariaLabel = position.locationingFailed + ? intl.formatMessage({ id: 'tracking-button-offline' }) + : mapTrackingState + ? intl.formatMessage({ id: 'tracking-button-on' }) + : intl.formatMessage({ id: 'tracking-button-off' }); - const mergedMapLayers = this.getMapLayers(); - return ( - <> - - {config.map.showLayerSelector && ( - - )} - {renderCustomButtons && renderCustomButtons()} + const mergedMapLayers = getMapLayers(); + return ( + <> + + {config.map.showLayerSelector && ( { - if (this.state.mapTracking) { - this.disableMapTracking(); - } else { - this.enableMapTracking(); - } - }} + img="icon_map-layers" + handleClick={toggleSettingsOpen} + color={config.colors.primary} + ariaLabel={intl.formatMessage({ + id: 'maplayers', + })} /> - - } - topButtons={topButtons} - mapLayers={mergedMapLayers} + )} + {renderCustomButtons && renderCustomButtons()} + { + if (mapTrackingState) { + disableMapTracking(); + } else { + enableMapTracking(); + } + }} + /> + + } + topButtons={topButtons} + mapLayers={mergedMapLayers} + > + {children} + + {config.map.showLayerSelector && ( + - {children} - - {config.map.showLayerSelector && ( - + - - )} - - ); - } + {intl.formatMessage({ + id: 'close', + defaultMessage: 'Close', + })} + + + )} + + ); } +MapWithTrackingStateHandler.contextTypes = { + executeAction: PropTypes.func.isRequired, +}; + +MapWithTrackingStateHandler.propTypes = { + lat: PropTypes.number, + lon: PropTypes.number, + zoom: PropTypes.number, + position: PropTypes.shape({ + hasLocation: PropTypes.bool.isRequired, + locationingFailed: PropTypes.bool, + lat: PropTypes.number.isRequired, + lon: PropTypes.number.isRequired, + }).isRequired, + bounds: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)), + children: PropTypes.arrayOf(PropTypes.node), + leafletObjs: PropTypes.arrayOf(PropTypes.node), + renderCustomButtons: PropTypes.func, + mapLayers: mapLayerShape.isRequired, + mapLayerOptions: mapLayerOptionsShape, + mapTracking: PropTypes.bool, + locationPopup: PropTypes.string, + onSelectLocation: PropTypes.func, + onStartNavigation: PropTypes.func, + onEndNavigation: PropTypes.func, + onMapTracking: PropTypes.func, + setMWTRef: PropTypes.func, + mapRef: PropTypes.func, + // eslint-disable-next-line + leafletEvents: PropTypes.object, + breakpoint: PropTypes.string.isRequired, + topButtons: PropTypes.node, +}; + const MapWithTrackingStateHandlerapWithBreakpoint = withBreakpoint( MapWithTrackingStateHandler, ); diff --git a/app/component/map/MarkerPopupBottom.jsx b/app/component/map/MarkerPopupBottom.jsx index ca6a5617cc..b57bf38a58 100644 --- a/app/component/map/MarkerPopupBottom.jsx +++ b/app/component/map/MarkerPopupBottom.jsx @@ -5,83 +5,82 @@ import { withLeaflet } from 'react-leaflet/es/context'; import { locationShape } from '../../../utils/client/shapes'; import { addAnalyticsEvent } from '../../../utils/shared/analyticsUtils'; -class MarkerPopupBottom extends React.Component { - static displayName = 'MarkerPopupBottom'; - - static propTypes = { - location: locationShape.isRequired, - leaflet: PropTypes.shape({ - map: PropTypes.shape({ - closePopup: PropTypes.func.isRequired, - }).isRequired, - }).isRequired, - onSelectLocation: PropTypes.func.isRequired, - locationPopup: PropTypes.string, - }; - - static defaultProps = { - locationPopup: 'all', // show add via point by default - }; - - routeFrom = () => { +/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ +function MarkerPopupBottom({ + location, + leaflet, + onSelectLocation, + locationPopup = 'all', // show add via point by default +}) { + const routeFrom = () => { addAnalyticsEvent({ action: 'EditJourneyStartPoint', category: 'ItinerarySettings', name: 'MapPopup', }); - this.props.onSelectLocation(this.props.location, 'origin'); - this.props.leaflet.map.closePopup(); + onSelectLocation(location, 'origin'); + leaflet.map.closePopup(); }; - routeTo = () => { + const routeTo = () => { addAnalyticsEvent({ action: 'EditJourneyEndPoint', category: 'ItinerarySettings', name: 'MapPopup', }); - this.props.onSelectLocation(this.props.location, 'destination'); - this.props.leaflet.map.closePopup(); + onSelectLocation(location, 'destination'); + leaflet.map.closePopup(); }; - routeAddViaPoint = () => { + const routeAddViaPoint = () => { addAnalyticsEvent({ action: 'AddJourneyViaPoint', category: 'ItinerarySettings', name: 'MapPopup', }); - this.props.onSelectLocation(this.props.location, 'via'); - this.props.leaflet.map.closePopup(); + onSelectLocation(location, 'via'); + leaflet.map.closePopup(); }; - /* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ - render() { - return ( -
-
this.routeFrom()} className="route cursor-pointer"> + return ( +
+
routeFrom()} className="route cursor-pointer"> + +
+ {locationPopup === 'all' && ( +
routeAddViaPoint()} + className="route cursor-pointer route-add-viapoint" + >
- {this.props.locationPopup === 'all' && ( -
this.routeAddViaPoint()} - className="route cursor-pointer route-add-viapoint" - > - -
- )} -
this.routeTo()} className="route cursor-pointer"> - -
+ )} +
routeTo()} className="route cursor-pointer"> +
- ); - } +
+ ); } +MarkerPopupBottom.displayName = 'MarkerPopupBottom'; + +MarkerPopupBottom.propTypes = { + location: locationShape.isRequired, + leaflet: PropTypes.shape({ + map: PropTypes.shape({ + closePopup: PropTypes.func.isRequired, + }).isRequired, + }).isRequired, + onSelectLocation: PropTypes.func.isRequired, + locationPopup: PropTypes.string, +}; + const markerPopupBottomWithLeaflet = withLeaflet(MarkerPopupBottom); export { diff --git a/app/component/map/SelectFromMap.jsx b/app/component/map/SelectFromMap.jsx index ba4a6f93c9..8c649b3f9f 100644 --- a/app/component/map/SelectFromMap.jsx +++ b/app/component/map/SelectFromMap.jsx @@ -1,10 +1,10 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useRef, useState } from 'react'; import get from 'lodash/get'; -import { matchShape } from 'found'; +import { useRouter } from 'found'; import connectToStores from 'fluxible-addons-react/connectToStores'; +import { useIntl } from 'react-intl'; import getLabel from '@digitransit-search-util/digitransit-search-util-get-label'; -import { configShape } from '../../../utils/client/shapes'; import LocationMarker from './LocationMarker'; import MapWithTracking from './MapWithTracking'; import { otpToLocation } from '../../../utils/shared/otpStrings'; @@ -13,6 +13,7 @@ import { mapLayerShape } from '../../store/MapLayerStore'; import withBreakpoint from '../../../utils/client/withBreakpoint'; import LocationMarkerWithPermanentTooltip from './LocationMarkerWithPermanentTooltip'; import ConfirmLocationFromMapButton from './ConfirmLocationFromMapButton'; +import { useConfigContext } from '../../client/ConfigContext'; const DESKTOP_BREAKPOINT = 'large'; @@ -37,135 +38,107 @@ const markLocation = (markerType, position) => { return null; }; -class SelectFromMap extends React.Component { - static contextTypes = { - match: matchShape, - config: configShape, - intl: PropTypes.object, - }; - - static propTypes = { - breakpoint: PropTypes.string, - language: PropTypes.string, - type: PropTypes.string.isRequired, - onConfirm: PropTypes.func.isRequired, - mapLayers: mapLayerShape.isRequired, - }; - - static defaultProps = { - breakpoint: undefined, - language: undefined, - }; - - constructor(props) { - super(props); - this.state = {}; - } +function SelectFromMap({ breakpoint, language, type, onConfirm, mapLayers }) { + const config = useConfigContext(); + const intl = useIntl(); + const { match } = useRouter(); + const map = useRef(null); + const [mapCenter, setMapCenter] = useState(undefined); - setMapElementRef = element => { - this.map = get(element, 'leafletElement', null); + const setMapElementRef = element => { + map.current = get(element, 'leafletElement', null); }; - setAddress = (lat, lon) => { - const { intl } = this.context; - + const setAddress = (lat, lon) => { const searchParams = { 'point.lat': lat, 'point.lon': lon, 'boundary.circle.radius': 0.1, // 100m - lang: this.props.language, + lang: language, size: 1, layers: 'address', zones: 1, }; - if (this.context.config.searchParams['boundary.country']) { + if (config.searchParams['boundary.country']) { searchParams['boundary.country'] = - this.context.config.searchParams['boundary.country']; + config.searchParams['boundary.country']; } - getJson(this.context.config.URL.PELIAS_REVERSE_GEOCODER, searchParams).then( + getJson(config.URL.PELIAS_REVERSE_GEOCODER, searchParams).then( data => { if (data.features != null && data.features.length > 0) { - const match = data.features[0].properties; - this.setState(prevState => ({ - mapCenter: { - ...prevState.mapCenter, - address: getLabel(match), - lat, - lon, - onlyCoordinates: false, - }, + const { properties } = data.features[0]; + setMapCenter(prevMapCenter => ({ + ...prevMapCenter, + address: getLabel(properties), + lat, + lon, + onlyCoordinates: false, })); } else { - this.setState(prevState => ({ - mapCenter: { - ...prevState.mapCenter, - address: intl.formatMessage({ - id: 'location-from-map', - defaultMessage: 'Selected location', - }), // + ', ' + JSON.stringify(centerOfMap.lat).match(/[0-9]{1,3}.[0-9]{6}/) + ' ' + JSON.stringify(centerOfMap.lng).match(/[0-9]{1,3}.[0-9]{6}/), - lat, - lon, - onlyCoordinates: true, - }, - })); - } - }, - () => { - this.setState({ - mapCenter: { + setMapCenter(prevMapCenter => ({ + ...prevMapCenter, address: intl.formatMessage({ id: 'location-from-map', defaultMessage: 'Selected location', - }), // + ', ' + JSON.stringify(centerOfMap.lat).match(/[0-9]{1,3}.[0-9]{6}/) + ' ' + JSON.stringify(centerOfMap.lng).match(/[0-9]{1,3}.[0-9]{6}/), + }), lat, lon, onlyCoordinates: true, - }, + })); + } + }, + () => { + setMapCenter({ + address: intl.formatMessage({ + id: 'location-from-map', + defaultMessage: 'Selected location', + }), + lat, + lon, + onlyCoordinates: true, }); }, ); }; - onClick = e => { + const onClick = e => { const clickedDiv = e.originalEvent.target; if (clickedDiv.tagName === 'BUTTON') { return; } - this.setState({ - mapCenter: { - address: '', - lat: e.latlng.lat, - lon: e.latlng.lng, - }, + setMapCenter({ + address: '', + lat: e.latlng.lat, + lon: e.latlng.lng, }); - this.setAddress(e.latlng.lat, e.latlng.lng); + setAddress(e.latlng.lat, e.latlng.lng); }; - setMapLocation = () => { - if (!this.map) { + const setMapLocation = () => { + if (!map.current) { return; } - const centerOfMap = this.map.getCenter(); + const centerOfMap = map.current.getCenter(); if ( - this.state.mapCenter && - this.state.mapCenter.lat === centerOfMap.lat && - this.state.mapCenter.lon === centerOfMap.lng + mapCenter && + mapCenter.lat === centerOfMap.lat && + mapCenter.lon === centerOfMap.lng ) { return; } - this.setAddress(centerOfMap.lat, centerOfMap.lng); + setAddress(centerOfMap.lat, centerOfMap.lng); }; - createAddress = (address, position) => { + const createAddress = (address, position) => { if (address !== '') { const newAddress = address.split(', '); let strippedAddress = newAddress[0]; - if (!this.state.mapCenter.onlyCoordinates) { + if (!mapCenter.onlyCoordinates) { strippedAddress = `${strippedAddress}, ${newAddress[1]}`; } strippedAddress = `${strippedAddress}::${JSON.stringify( @@ -176,125 +149,122 @@ class SelectFromMap extends React.Component { return ''; }; - confirmButton = (isEnabled, mapCenter, positionSelectingFromMap) => { - const { intl, config } = this.context; + const confirmButton = (isEnabled, center, positionSelectingFromMap) => ( + + ); - return ( - - ); - }; + const defaultLocation = config.defaultEndpoint; + const isDesktop = breakpoint === DESKTOP_BREAKPOINT; - render() { - const { config, match } = this.context; - const { type } = this.props; - const { mapCenter } = this.state; - const defaultLocation = config.defaultEndpoint; - const isDesktop = this.props.breakpoint === DESKTOP_BREAKPOINT; + const leafletObjs = []; - const leafletObjs = []; + if (!mapCenter && type === 'origin' && !isDesktop) { + leafletObjs.push( + , + ); + } - if (!mapCenter && type === 'origin' && !isDesktop) { - leafletObjs.push( - , - ); - } + if (!mapCenter && type === 'destination' && !isDesktop) { + leafletObjs.push( + , + ); + } - if (!mapCenter && type === 'destination' && !isDesktop) { + if (match.location.query && match.location.query.intermediatePlaces) { + if (Array.isArray(match.location.query.intermediatePlaces)) { + match.location.query.intermediatePlaces + .map(otpToLocation) + .forEach((markerLocation, i) => { + leafletObjs.push( + , + ); + }); + } else { leafletObjs.push( , ); } + } - if (match.location.query && match.location.query.intermediatePlaces) { - if (Array.isArray(match.location.query.intermediatePlaces)) { - match.location.query.intermediatePlaces - .map(otpToLocation) - .forEach((markerLocation, i) => { - leafletObjs.push( - , - ); - }); - } else { - leafletObjs.push( - , - ); - } - } - - const positionSelectingFromMap = mapCenter || defaultLocation; + const positionSelectingFromMap = mapCenter || defaultLocation; - if (!mapCenter) { - leafletObjs.push(this.confirmButton(false)); - } else { - leafletObjs.push(markLocation(this.props.type, positionSelectingFromMap)); - leafletObjs.push( - , - ); - leafletObjs.push( - this.confirmButton(true, mapCenter, positionSelectingFromMap), - ); - } - const eventHooks = {}; - if (isDesktop) { - eventHooks.leafletEvents = { - onClick: this.onClick, - }; - } else { - eventHooks.onEndNavigation = this.setMapLocation; - } - - return ( - + if (!mapCenter) { + leafletObjs.push(confirmButton(false)); + } else { + leafletObjs.push(markLocation(type, positionSelectingFromMap)); + leafletObjs.push( + , ); + leafletObjs.push(confirmButton(true, mapCenter, positionSelectingFromMap)); } + const eventHooks = {}; + if (isDesktop) { + eventHooks.leafletEvents = { + onClick, + }; + } else { + eventHooks.onEndNavigation = setMapLocation; + } + + return ( + + ); } +SelectFromMap.propTypes = { + breakpoint: PropTypes.string, + language: PropTypes.string, + type: PropTypes.string.isRequired, + onConfirm: PropTypes.func.isRequired, + mapLayers: mapLayerShape.isRequired, +}; + export default connectToStores( withBreakpoint(SelectFromMap), ['MapLayerStore'], diff --git a/app/component/map/non-tile-layer/LegMarker.jsx b/app/component/map/non-tile-layer/LegMarker.jsx index d96b0d7b61..ae39e90ab9 100644 --- a/app/component/map/non-tile-layer/LegMarker.jsx +++ b/app/component/map/non-tile-layer/LegMarker.jsx @@ -4,85 +4,85 @@ import Marker from 'react-leaflet/es/Marker'; import { default as L } from 'leaflet'; import cx from 'classnames'; import Icon from '../../Icon'; -import { legShape, configShape } from '../../../../utils/client/shapes'; +import { legShape } from '../../../../utils/client/shapes'; import { renderAsString } from '../../../../utils/client/mapIconUtils'; +import { useConfigContext } from '../../../client/ConfigContext'; -class LegMarker extends React.Component { - static propTypes = { - leg: legShape.isRequired, - mode: PropTypes.string.isRequired, - color: PropTypes.string, - zIndexOffset: PropTypes.number, - wide: PropTypes.bool, - style: PropTypes.string, - appendClass: PropTypes.string, - }; +// The functions below compute plain values (icon name, route number markup, +// visibility) with no Leaflet dependency. They are exported for unit testing +// and can be reused as-is if the underlying map engine changes. +export const getLegMarkerIconName = mode => + mode === 'bus-express' ? 'icon_bus' : `icon_${mode}`; - static defaultProps = { - color: 'currentColor', - zIndexOffset: undefined, - wide: false, - style: undefined, - appendClass: undefined, - }; +// Do not display route number if it is an external route and the route number is empty. +export const shouldDisplayLegRouteNumber = (config, mode, legName) => + !( + config.externalFeedIds !== undefined && + mode.includes('external') && + legName === '' + ); - static contextTypes = { - config: configShape.isRequired, - }; +export const getLegRouteNumberHtml = (mode, legName, displayRouteNumber) => + displayRouteNumber + ? `${legName}` + : ''; - // An arrow marker will be displayed if the normal marker can't fit - getLegMarker() { - const color = this.props.color ? this.props.color : 'currentColor'; - const className = this.props.wide ? 'wide' : ''; - const iconName = - this.props.mode === 'bus-express' - ? 'icon_bus' - : `icon_${this.props.mode}`; - // Do not display route number if it is an external route and the route number is empty. - const displayRouteNumber = !( - this.context.config.externalFeedIds !== undefined && - this.props.mode.includes('external') && - this.props.leg.name === '' - ); - const routeNumber = displayRouteNumber - ? ` - ${this.props.leg.name.toLowerCase()}` - : ''; - return ( - - ${renderAsString( - , - )} - ${routeNumber} -
`, - className: cx( - this.props.style ? `arrow-${this.props.style}` : 'legmarker', - this.props.mode, - { 'only-icon': !displayRouteNumber }, - this.props.appendClass, - ), - iconSize: null, - })} - zIndexOffset={this.props.zIndexOffset} - keyboard={false} - /> - ); - } +// An arrow marker will be displayed if the normal marker can't fit +export default function LegMarker({ + leg, + mode, + color = 'currentColor', + zIndexOffset, + wide = false, + style, + appendClass, +}) { + const config = useConfigContext(); + const className = wide ? 'wide' : ''; + const iconName = getLegMarkerIconName(mode); + const displayRouteNumber = shouldDisplayLegRouteNumber( + config, + mode, + leg.name, + ); + const routeNumber = getLegRouteNumberHtml(mode, leg.name, displayRouteNumber); - render() { - return
{this.getLegMarker()}
; - } + return ( + + ${renderAsString( + , + )} + ${routeNumber} +
`, + className: cx( + style ? `arrow-${style}` : 'legmarker', + mode, + { 'only-icon': !displayRouteNumber }, + appendClass, + ), + iconSize: null, + })} + zIndexOffset={zIndexOffset} + keyboard={false} + /> + ); } -export default LegMarker; +LegMarker.propTypes = { + leg: legShape.isRequired, + mode: PropTypes.string.isRequired, + color: PropTypes.string, + zIndexOffset: PropTypes.number, + wide: PropTypes.bool, + style: PropTypes.string, + appendClass: PropTypes.string, +}; diff --git a/app/component/map/non-tile-layer/StopMarker.jsx b/app/component/map/non-tile-layer/StopMarker.jsx index 32d7e70ca1..5483da8e4d 100644 --- a/app/component/map/non-tile-layer/StopMarker.jsx +++ b/app/component/map/non-tile-layer/StopMarker.jsx @@ -1,9 +1,9 @@ import PropTypes from 'prop-types'; import React from 'react'; import cx from 'classnames'; -import { routerShape } from 'found'; +import { useRouter } from 'found'; import { default as L } from 'leaflet'; -import { stopShape, configShape } from '../../../../utils/client/shapes'; +import { stopShape } from '../../../../utils/client/shapes'; import GenericMarker from '../GenericMarker'; import Icon from '../../Icon'; import { @@ -14,6 +14,7 @@ import { } from '../../../../utils/client/mapIconUtils'; import { addAnalyticsEvent } from '../../../../utils/shared/analyticsUtils'; import { PREFIX_STOPS } from '../../../../utils/shared/path'; +import { useConfigContext } from '../../../client/ConfigContext'; export const getStopMarkerAnalytics = (pathname, indexPath, mode) => { if (pathname.includes('bike') || pathname.includes('walk')) { @@ -36,141 +37,178 @@ export const getStopMarkerAnalytics = (pathname, indexPath, mode) => { export const getStopMarkerPath = gtfsId => `/${PREFIX_STOPS}/${encodeURIComponent(gtfsId)}`; -class StopMarker extends React.Component { - static propTypes = { - stop: stopShape.isRequired, - mode: PropTypes.string.isRequired, - renderName: PropTypes.bool, - disableModeIcons: PropTypes.bool, - disableIconBorder: PropTypes.bool, - limitZoom: PropTypes.number, - selected: PropTypes.bool, - colorOverride: PropTypes.string, - appendClass: PropTypes.string, - }; +// The functions below compute plain values (icon size, class names, SVG +// markup) with no Leaflet dependency. They are exported for unit testing +// and can be reused as-is if the underlying map engine changes. +export const getModeIconSize = (zoom, config, selected) => { + if (zoom <= config.stopsSmallMaxZoom) { + return config.stopsIconSize.small; + } + if (selected) { + return config.stopsIconSize.selected; + } + return config.stopsIconSize.default; +}; - static defaultProps = { - renderName: false, - disableModeIcons: false, - disableIconBorder: false, - limitZoom: undefined, - selected: false, - colorOverride: undefined, - appendClass: undefined, - }; +export const getModeIconClassName = ( + mode, + size, + config, + selected, + disableIconBorder, +) => + cx('cursor-pointer', mode, { + small: size === config.stopsIconSize.small, + selected, + 'disable-icon-border': disableIconBorder, + }); + +export const getStopIconRadii = (zoom, { limitZoom, transfer, selected }) => { + const scale = transfer || selected ? 1.5 : 1; + + let calcZoom; + if (limitZoom) { + calcZoom = Math.min(zoom, limitZoom); + } else { + calcZoom = transfer || selected ? Math.max(zoom, 15) : zoom || 15; + } - static contextTypes = { - getStore: PropTypes.func.isRequired, - config: configShape.isRequired, - router: routerShape.isRequired, - }; + const radius = getCaseRadius(calcZoom) * scale; + const stopRadius = getStopRadius(calcZoom) * scale; + const hubRadius = getHubRadius(calcZoom) * scale; + + const inner = (stopRadius + hubRadius) / 2; + const stroke = stopRadius - hubRadius; + + return { radius, inner, stroke }; +}; + +// see utils/client/mapIconUtils.js for the canvas version +export const buildStopIconSvg = ({ + radius, + inner, + stroke, + appendClass, + colorOverride, + platformCode, +}) => { + if (radius === 0) { + return ''; + } + return ` + + + ${ + inner > 7 && platformCode + ? `${platformCode}` + : '' + } + + `; +}; - redirectToStopPage = () => { +export const getStopIconClassName = (mode, disableIconBorder) => + cx(mode, 'cursor-pointer', { + 'disable-icon-border': disableIconBorder, + }); + +export default function StopMarker({ + stop, + mode, + renderName = false, + disableModeIcons = false, + disableIconBorder = false, + limitZoom, + selected = false, + colorOverride, + appendClass, +}) { + const config = useConfigContext(); + const { router } = useRouter(); + + const redirectToStopPage = () => { const analyticsEvent = getStopMarkerAnalytics( window.location.pathname, - this.context.config.indexPath, - this.props.mode, + config.indexPath, + mode, ); if (analyticsEvent) { addAnalyticsEvent(analyticsEvent); } - this.context.router.push(getStopMarkerPath(this.props.stop.gtfsId)); + router.push(getStopMarkerPath(stop.gtfsId)); }; - getModeIcon = zoom => { - const iconId = `icon_${this.props.mode}`; - let size; - if (zoom <= this.context.config.stopsSmallMaxZoom) { - size = this.context.config.stopsIconSize.small; - } else if (this.props.selected) { - size = this.context.config.stopsIconSize.selected; - } else { - size = this.context.config.stopsIconSize.default; - } + const getModeIcon = zoom => { + const iconId = `icon_${mode}`; + const size = getModeIconSize(zoom, config, selected); return L.divIcon({ html: renderAsString(), iconSize: [size, size], - className: cx('cursor-pointer', this.props.mode, { - small: size === this.context.config.stopsIconSize.small, - selected: this.props.selected, - 'disable-icon-border': this.props.disableIconBorder, - }), + className: getModeIconClassName( + mode, + size, + config, + selected, + disableIconBorder, + ), }); }; - getIcon = zoom => { - const scale = this.props.stop.transfer || this.props.selected ? 1.5 : 1; - - let calcZoom; - if (this.props.limitZoom) { - calcZoom = Math.min(zoom, this.props.limitZoom); - } else { - calcZoom = - this.props.stop.transfer || this.props.selected - ? Math.max(zoom, 15) - : zoom || 15; - } - - const radius = getCaseRadius(calcZoom) * scale; - const stopRadius = getStopRadius(calcZoom) * scale; - const hubRadius = getHubRadius(calcZoom) * scale; - - const inner = (stopRadius + hubRadius) / 2; - const stroke = stopRadius - hubRadius; - - // see utils/client/mapIconUtils.js for the canvas version - let iconSvg = ` - - - ${ - inner > 7 && this.props.stop.platformCode - ? `${this.props.stop.platformCode}` - : '' - } - - `; + const getIcon = zoom => { + const { radius, inner, stroke } = getStopIconRadii(zoom, { + limitZoom, + transfer: stop.transfer, + selected, + }); - if (radius === 0) { - iconSvg = ''; - } + const iconSvg = buildStopIconSvg({ + radius, + inner, + stroke, + appendClass, + colorOverride, + platformCode: stop.platformCode, + }); return L.divIcon({ html: iconSvg, iconSize: [radius * 2, radius * 2], - className: cx(this.props.mode, 'cursor-pointer', { - 'disable-icon-border': this.props.disableIconBorder, - }), + className: getStopIconClassName(mode, disableIconBorder), }); }; - render() { - return ( - - ); - } + return ( + + ); } -export default StopMarker; +StopMarker.propTypes = { + stop: stopShape.isRequired, + mode: PropTypes.string.isRequired, + renderName: PropTypes.bool, + disableModeIcons: PropTypes.bool, + disableIconBorder: PropTypes.bool, + limitZoom: PropTypes.number, + selected: PropTypes.bool, + colorOverride: PropTypes.string, + appendClass: PropTypes.string, +}; diff --git a/app/component/map/non-tile-layer/TransitLegMarkers.jsx b/app/component/map/non-tile-layer/TransitLegMarkers.jsx index 4c42d6fd85..5e21a2e10b 100644 --- a/app/component/map/non-tile-layer/TransitLegMarkers.jsx +++ b/app/component/map/non-tile-layer/TransitLegMarkers.jsx @@ -1,20 +1,25 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useReducer } from 'react'; +import { useIntl } from 'react-intl'; import { withLeaflet } from 'react-leaflet/es/context'; import polyUtil from 'polyline-encoded'; -import { configShape, legShape } from '../../../../utils/client/shapes'; +import { legShape } from '../../../../utils/client/shapes'; import { isLocalCallAgency, legTime } from '../../../../utils/client/legUtils'; import { getMiddleOf } from '../../../../utils/shared/geo-utils'; import LegMarker from './LegMarker'; import SpeechBubble from '../SpeechBubble'; import { durationToString } from '../../../../utils/client/timeUtils'; +import { useConfigContext } from '../../../client/ConfigContext'; const offsetNormal = { x: 22.5, y: 0 }; const offsetArrow = { x: 55, y: 15 }; const offsetSpeechBubble = { x: 15, y: 40 }; const minDistanceToShow = 64; -const doMarkersOverlap = (proposedPosition, existingPositions) => { +// The functions below operate purely on plain pixel-position objects and +// contain no Leaflet-specific logic, so they are exported for unit testing +// and can be reused as-is if the underlying map engine changes. +export const doMarkersOverlap = (proposedPosition, existingPositions) => { const l1 = proposedPosition.topLeft; const r1 = proposedPosition.bottomRight; for (let i = 0; i < existingPositions.length; i++) { @@ -36,7 +41,7 @@ const doMarkersOverlap = (proposedPosition, existingPositions) => { return false; }; -const getArrowMarkerStyle = (leg, pixelPositions) => { +export const getArrowMarkerStyle = (leg, pixelPositions) => { // Initial style is bottomLeft, try that const proposedPosition = { topLeft: leg.topLeft, @@ -79,7 +84,7 @@ const getArrowMarkerStyle = (leg, pixelPositions) => { return { style: 'topLeft', pixelPosition: proposedPosition }; }; -const getSpeechBubbleStyle = (position, pixelPositions) => { +export const getSpeechBubbleStyle = (position, pixelPositions) => { const proposedPosition = { ...position }; let overlap = doMarkersOverlap(proposedPosition, pixelPositions); // The area used to calculate overlaps excludes the arrow part for simplicity. This offset x and y are caused by the area that the arrow takes @@ -113,30 +118,26 @@ const getSpeechBubbleStyle = (position, pixelPositions) => { return { style: 'bottomRight', position: proposedPosition }; }; -class TransitLegMarkers extends React.Component { - static propTypes = { - transitLegs: PropTypes.arrayOf(legShape).isRequired, - leaflet: PropTypes.shape({ - map: PropTypes.shape({ - latLngToLayerPoint: PropTypes.func.isRequired, - on: PropTypes.func.isRequired, - off: PropTypes.func.isRequired, - }).isRequired, - }).isRequired, - realtimeTransfers: PropTypes.bool, - }; +function TransitLegMarkers({ + transitLegs, + leaflet, + realtimeTransfers = false, +}) { + const config = useConfigContext(); + const intl = useIntl(); + // Used only to force a re-render on zoomend, mirroring the previous + // onMapZoom -> this.forceUpdate(). + const [, forceUpdate] = useReducer(count => count + 1, 0); - static defaultProps = { - realtimeTransfers: false, - }; + useEffect(() => { + const { map } = leaflet; + map.on('zoomend', forceUpdate); + return () => map.off('zoomend', forceUpdate); + }, [leaflet]); - static contextTypes = { - config: configShape.isRequired, - intl: PropTypes.object.isRequired, - }; + const { map } = leaflet; - getLegMarkerPixelPosition(leg) { - const { map } = this.props.leaflet; + function getLegMarkerPixelPosition(leg) { const p1 = map.latLngToLayerPoint(leg.from); const p2 = map.latLngToLayerPoint(leg.to); const middle = getMiddleOf(polyUtil.decode(leg.legGeometry.points)); @@ -171,8 +172,7 @@ class TransitLegMarkers extends React.Component { return truePixelPosition; } - getSpeechbubblePixelPosition({ lat, lon }) { - const { map } = this.props.leaflet; + function getSpeechbubblePixelPosition({ lat, lon }) { const leafletPixelPosition = { ...map.latLngToLayerPoint({ lat, lon }), width: 105, @@ -199,139 +199,131 @@ class TransitLegMarkers extends React.Component { return truePixelPosition; } - getSpeechBubbleText(leg, nextLeg, realtime) { + function getSpeechBubbleText(leg, nextLeg, realtime) { const duration = durationToString( - this.context.intl, + intl, legTime(nextLeg.start) - legTime(leg.end), ); const style = realtime ? 'color:#3b7f00' : ''; return ` - ${this.context.intl.formatMessage({ id: 'transfer' })}: + ${intl.formatMessage({ id: 'transfer' })}: ${duration} `; } - componentDidMount() { - this.props.leaflet.map.on('zoomend', this.onMapZoom); - } + const objs = []; + const pixelPositions = []; + const legsWithPositions = transitLegs.map(leg => ({ + ...leg, + ...getLegMarkerPixelPosition(leg), + })); - componentWillUnmount() { - this.props.leaflet.map.off('zoomend', this.onMapZoom); - } - - onMapZoom = () => { - this.forceUpdate(); - }; - - render() { - const objs = []; - const pixelPositions = []; - const legsWithPositions = this.props.transitLegs.map(leg => ({ - ...leg, - ...this.getLegMarkerPixelPosition(leg), - })); - - // Draw regular legmarkers first, no tweaking needed - const legsRegular = legsWithPositions.filter(leg => leg.type === 'regular'); - legsRegular.forEach(leg => { - objs.push( - , - ); - pixelPositions.push({ - topLeft: leg.topLeft, - bottomRight: leg.bottomRight, - }); + // Draw regular legmarkers first, no tweaking needed + const legsRegular = legsWithPositions.filter(leg => leg.type === 'regular'); + legsRegular.forEach(leg => { + objs.push( + , + ); + pixelPositions.push({ + topLeft: leg.topLeft, + bottomRight: leg.bottomRight, }); + }); - // Then, draw leg markers with arrows - const arrowLegs = legsWithPositions.filter(leg => leg.type === 'arrow'); - arrowLegs.forEach(leg => { - // Find style that doesn't cause the marker to overlap with anything - const styleAndPosition = getArrowMarkerStyle(leg, pixelPositions); - objs.push( - , - ); - pixelPositions.push(styleAndPosition.pixelPosition); - }); + // Then, draw leg markers with arrows + const arrowLegs = legsWithPositions.filter(leg => leg.type === 'arrow'); + arrowLegs.forEach(leg => { + // Find style that doesn't cause the marker to overlap with anything + const styleAndPosition = getArrowMarkerStyle(leg, pixelPositions); + objs.push( + , + ); + pixelPositions.push(styleAndPosition.pixelPosition); + }); - // Finally, draw transfer stop speechbubbles - const legsWithTransferStops = [...this.props.transitLegs]; - legsWithTransferStops.pop(); // Excluding the finishing leg - legsWithTransferStops.forEach((leg, index) => { - const speechBubblePixelPosition = this.getSpeechbubblePixelPosition( - leg.to, - ); - const styleAndPosition = getSpeechBubbleStyle( - speechBubblePixelPosition, - pixelPositions, - ); - const text = this.getSpeechBubbleText( - leg, - this.props.transitLegs[index + 1], - this.props.realtimeTransfers, - ); - objs.push( - , - ); - pixelPositions.push(styleAndPosition.position); - }); + // Finally, draw transfer stop speechbubbles + const legsWithTransferStops = [...transitLegs]; + legsWithTransferStops.pop(); // Excluding the finishing leg + legsWithTransferStops.forEach((leg, index) => { + const speechBubblePixelPosition = getSpeechbubblePixelPosition(leg.to); + const styleAndPosition = getSpeechBubbleStyle( + speechBubblePixelPosition, + pixelPositions, + ); + const text = getSpeechBubbleText( + leg, + transitLegs[index + 1], + realtimeTransfers, + ); + objs.push( + , + ); + pixelPositions.push(styleAndPosition.position); + }); - return
{objs}
; - } + return
{objs}
; } +TransitLegMarkers.propTypes = { + transitLegs: PropTypes.arrayOf(legShape).isRequired, + leaflet: PropTypes.shape({ + map: PropTypes.shape({ + latLngToLayerPoint: PropTypes.func.isRequired, + on: PropTypes.func.isRequired, + off: PropTypes.func.isRequired, + }).isRequired, + }).isRequired, + realtimeTransfers: PropTypes.bool, +}; + export default withLeaflet(TransitLegMarkers); diff --git a/app/component/map/non-tile-layer/VehicleMarker.jsx b/app/component/map/non-tile-layer/VehicleMarker.jsx index 03365f7b15..4793c9d2d1 100644 --- a/app/component/map/non-tile-layer/VehicleMarker.jsx +++ b/app/component/map/non-tile-layer/VehicleMarker.jsx @@ -1,12 +1,11 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { routerShape } from 'found'; +import { useRouter } from 'found'; import { default as L } from 'leaflet'; import { TransportMode } from '../../../../utils/shared/constants'; import { vehicleRentalStationShape, rentalVehicleShape, - configShape, } from '../../../../utils/client/shapes'; import Icon from '../../Icon'; import GenericMarker from '../GenericMarker'; @@ -27,6 +26,7 @@ import { } from '../../../../utils/shared/path'; import { renderAsString } from '../../../../utils/client/mapIconUtils'; import IconBadge from '../../icon/IconBadge'; +import { useConfigContext } from '../../../client/ConfigContext'; // Small icon for zoom levels <= 15 const smallIconSvg = ` @@ -35,34 +35,20 @@ const smallIconSvg = ` `; -export default class VehicleMarker extends React.Component { - static displayName = 'VehicleMarker'; +export default function VehicleMarker({ + showBikeAvailability = false, + rental, + transit = false, + mode, +}) { + const config = useConfigContext(); + const { router } = useRouter(); - static propTypes = { - showBikeAvailability: PropTypes.bool, - rental: PropTypes.oneOfType([vehicleRentalStationShape, rentalVehicleShape]) - .isRequired, - transit: PropTypes.bool, - mode: PropTypes.string.isRequired, + const handleClick = (id, prefix) => { + router.push(`/${prefix}/${encodeURIComponent(id)}`); }; - static contextTypes = { - config: configShape.isRequired, - router: routerShape.isRequired, - }; - - static defaultProps = { - showBikeAvailability: false, - transit: false, - }; - - handleClick = (id, prefix) => { - this.context.router.push(`/${prefix}/${encodeURIComponent(id)}`); - }; - - getIcon = zoom => { - const { showBikeAvailability, rental, transit } = this.props; - const { config } = this.context; + const getIcon = zoom => { const vehicleCapacity = getVehicleCapacity(config, rental?.network); const iconName = `${getRentalNetworkIcon( getRentalNetworkConfig(rental.network, config), @@ -108,24 +94,32 @@ export default class VehicleMarker extends React.Component { }); }; - render() { - return ( - - this.handleClick( - this.props.rental.id, - this.props.mode === TransportMode.Scooter - ? PREFIX_RENTALVEHICLES - : PREFIX_BIKESTATIONS, - ) - } - getIcon={this.getIcon} - id={this.props.rental?.id} - /> - ); - } + return ( + + handleClick( + rental.id, + mode === TransportMode.Scooter + ? PREFIX_RENTALVEHICLES + : PREFIX_BIKESTATIONS, + ) + } + getIcon={getIcon} + id={rental?.id} + /> + ); } + +VehicleMarker.displayName = 'VehicleMarker'; + +VehicleMarker.propTypes = { + showBikeAvailability: PropTypes.bool, + rental: PropTypes.oneOfType([vehicleRentalStationShape, rentalVehicleShape]) + .isRequired, + transit: PropTypes.bool, + mode: PropTypes.string.isRequired, +}; diff --git a/app/component/map/popups/LocationPopup.jsx b/app/component/map/popups/LocationPopup.jsx index 3b6d1a3815..4274bf769e 100644 --- a/app/component/map/popups/LocationPopup.jsx +++ b/app/component/map/popups/LocationPopup.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; import getLabel from '@digitransit-search-util/digitransit-search-util-get-label'; -import { configShape } from '../../../../utils/client/shapes'; import MarkerPopupBottom from '../MarkerPopupBottom'; import Card from '../../Card'; import Loading from '../../Loading'; @@ -11,45 +11,31 @@ import { addAnalyticsEvent } from '../../../../utils/shared/analyticsUtils'; import { splitStringToAddressAndPlace } from '../../../../utils/shared/otpStrings'; import getZoneId from '../../../../utils/client/zoneIconUtils'; import PopupHeader from '../PopupHeader'; +import { useConfigContext } from '../../../client/ConfigContext'; -class LocationPopup extends React.Component { - static contextTypes = { - config: configShape.isRequired, - intl: PropTypes.object.isRequired, - }; - - static propTypes = { - lat: PropTypes.number.isRequired, - lon: PropTypes.number.isRequired, - locationPopup: PropTypes.string, - onSelectLocation: PropTypes.func, - }; - - static defaultProps = { - locationPopup: undefined, - onSelectLocation: () => {}, - }; - - constructor(props) { - super(props); - this.state = { - loading: true, - location: { - lat: this.props.lat, - lon: this.props.lon, - }, - }; - } - - componentDidMount() { - const { lat, lon } = this.props; - const { config } = this.context; +export default function LocationPopup({ + lat, + lon, + locationPopup, + onSelectLocation = () => {}, +}) { + const config = useConfigContext(); + const intl = useIntl(); + // loading and location are updated together from a Promise callback, which + // React 16 doesn't batch outside of event handlers. Keeping them in a + // single state object avoids rendering with loading=false before location + // has been updated with an address. + const [state, setState] = useState({ + loading: true, + location: { lat, lon }, + }); + useEffect(() => { const searchParams = { 'point.lat': lat, 'point.lon': lon, 'boundary.circle.radius': 0.1, // 100m - lang: this.context.config.language, + lang: config.language, size: 1, layers: 'address', zones: 1, @@ -64,7 +50,7 @@ class LocationPopup extends React.Component { let pointName; if (data.features != null && data.features.length > 0) { const match = data.features[0].properties; - this.setState(prevState => ({ + setState(prevState => ({ loading: false, location: { ...prevState.location, @@ -74,11 +60,11 @@ class LocationPopup extends React.Component { })); pointName = 'FreeAddress'; } else { - this.setState(prevState => ({ + setState(prevState => ({ loading: false, location: { ...prevState.location, - address: this.context.intl.formatMessage({ + address: intl.formatMessage({ id: 'location-from-map', defaultMessage: 'Selected location', }), @@ -102,10 +88,10 @@ class LocationPopup extends React.Component { }); }, () => { - this.setState({ + setState({ loading: false, location: { - address: this.context.intl.formatMessage({ + address: intl.formatMessage({ id: 'location-from-map', defaultMessage: 'Selected location', }), @@ -113,38 +99,39 @@ class LocationPopup extends React.Component { }); }, ); - } + }, []); - render() { - if (this.state.loading) { - return ( -
- -
- ); - } - const { zoneId } = this.state.location; - const [address, place] = splitStringToAddressAndPlace( - this.state.location.address, - ); + const { loading, location } = state; + if (loading) { return ( - - - {zoneId && zoneId !== place && ( - - )} - - {(this.props.locationPopup === 'all' || - this.props.locationPopup === 'origindestination') && ( - - )} - +
+ +
); } + const { zoneId } = location; + const [address, place] = splitStringToAddressAndPlace(location.address); + return ( + + + {zoneId && zoneId !== place && ( + + )} + + {(locationPopup === 'all' || locationPopup === 'origindestination') && ( + + )} + + ); } -export default LocationPopup; +LocationPopup.propTypes = { + lat: PropTypes.number.isRequired, + lon: PropTypes.number.isRequired, + locationPopup: PropTypes.string, + onSelectLocation: PropTypes.func, +}; diff --git a/app/component/map/tile-layer/TileLayerContainer.jsx b/app/component/map/tile-layer/TileLayerContainer.jsx index 04c8de3956..af36d1ef90 100644 --- a/app/component/map/tile-layer/TileLayerContainer.jsx +++ b/app/component/map/tile-layer/TileLayerContainer.jsx @@ -8,7 +8,7 @@ import lodashFilter from 'lodash/filter'; import isEqual from 'lodash/isEqual'; import Popup from 'react-leaflet/es/Popup'; import { withLeaflet } from 'react-leaflet/es/context'; -import { matchShape, routerShape } from 'found'; +import { useRouter, routerShape } from 'found'; import { relayShape, configShape, @@ -31,6 +31,7 @@ import { } from '../../../../utils/shared/path'; import SelectVehicleContainer from './SelectVehicleContainer'; import { withCurrentTime } from '../../../hooks/TimeContext'; +import { useConfigContext } from '../../../client/ConfigContext'; const initialState = { selectableTargets: undefined, @@ -67,24 +68,15 @@ class TileLayerContainer extends GridLayer { objectsToHide: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), vehicles: PropTypes.objectOf(vehicleShape), currentTime: PropTypes.number.isRequired, + config: configShape.isRequired, + router: routerShape.isRequired, }; static defaultProps = { - onSelectLocation: undefined, - locationPopup: undefined, objectsToHide: { vehicleRentalStations: [] }, - highlightedStops: undefined, - stopsToShow: undefined, - vehicles: undefined, mergeStops: true, }; - static contextTypes = { - config: configShape.isRequired, - match: matchShape.isRequired, - router: routerShape.isRequired, - }; - PopupOptions = { offset: [0, 0], autoPanPaddingTopLeft: [5, 125], @@ -102,8 +94,6 @@ class TileLayerContainer extends GridLayer { constructor(props, context) { super(props, context); - // Required as it is not passed upwards through the whole inherittance chain - this.context = context; this.state = { ...initialState, }; @@ -147,7 +137,7 @@ class TileLayerContainer extends GridLayer { tile.el.layers && tile.el.layers.forEach(layer => { if (layer.onTimeChange) { - layer.onTimeChange(this.context.config.language); + layer.onTimeChange(this.props.config.language); } }), ); @@ -175,14 +165,14 @@ class TileLayerContainer extends GridLayer { tileCoords, done, this.props, - this.context.config, + this.props.config, this.props.mergeStops, this.props.relayEnvironment, this.props.highlightedStops, this.props.vehicles, this.props.stopsToShow, this.props.objectsToHide, - this.context.config.language, + this.props.config.language, ); tile.onSelectableTargetClicked = ( selectableTargets, @@ -200,7 +190,7 @@ class TileLayerContainer extends GridLayer { selectableTargets.length === 1 && selectableTargets[0].layer === 'citybike' ) { - this.context.router.push( + this.props.router.push( `/${PREFIX_BIKESTATIONS}/${encodeURIComponent( selectableTargets[0].feature.properties.id, )}`, @@ -223,7 +213,7 @@ class TileLayerContainer extends GridLayer { ? cluster.feature.properties.scooterId : selectableTargets[0].feature.properties.id; // adding networks directs to scooter cluster view - this.context.router.push( + this.props.router.push( `/${PREFIX_RENTALVEHICLES}/${encodeURIComponent(id)}/${[ ...networks, ]}`, @@ -235,7 +225,7 @@ class TileLayerContainer extends GridLayer { selectableTargets.length === 1 && selectableTargets[0].layer === 'stop' ) { - this.context.router.push( + this.props.router.push( stopPagePath( selectableTargets[0].feature.properties.stops, selectableTargets[0].feature.properties.gtfsId, @@ -267,7 +257,7 @@ class TileLayerContainer extends GridLayer { parkingId = selectableTargets[0].feature.properties?.id; } if (parkingId) { - this.context.router.push( + this.props.router.push( `/${ layer === 'parkAndRide' ? PREFIX_CARPARK : PREFIX_BIKEPARK }/${encodeURIComponent(parkingId)}`, @@ -330,7 +320,7 @@ class TileLayerContainer extends GridLayer { } const pathPrefixMatch = window.location.pathname.match(/^\/([a-z]{2,})\//); const context = - pathPrefixMatch && pathPrefixMatch[1] !== this.context.config.indexPath + pathPrefixMatch && pathPrefixMatch[1] !== this.props.config.indexPath ? pathPrefixMatch[1] : 'index'; addAnalyticsEvent({ @@ -401,7 +391,7 @@ class TileLayerContainer extends GridLayer { ); } else if (this.state.selectableTargets.length > 1) { if ( - !this.context.config.map.showStopMarkerPopupOnMobile && + !this.props.config.map.showStopMarkerPopupOnMobile && breakpoint === 'small' ) { showPopup = false; @@ -423,7 +413,7 @@ class TileLayerContainer extends GridLayer { ); } else if (this.state.selectableTargets.length === 0) { if ( - !this.context.config.map.showStopMarkerPopupOnMobile && + !this.props.config.map.showStopMarkerPopupOnMobile && breakpoint === 'small' ) { showPopup = false; @@ -453,15 +443,28 @@ class TileLayerContainer extends GridLayer { } } +// Wraps the class component and supplies config/router via hooks instead of +// legacy React context, since class components cannot use hooks directly. +function TileLayerContainerWithContext(props) { + const config = useConfigContext(); + const { router } = useRouter(); + return ( + + {({ environment }) => ( + + )} + + ); +} + const connectedComponent = withLeaflet( connectToStores( - withCurrentTime(props => ( - - {({ environment }) => ( - - )} - - )), + withCurrentTime(TileLayerContainerWithContext), [RealTimeInformationStore], context => ({ vehicles: context.getStore(RealTimeInformationStore).vehicles, diff --git a/test/unit/component/map/MapWithTracking.test.jsx b/test/unit/component/map/MapWithTracking.test.jsx index b67675802b..eff9037854 100644 --- a/test/unit/component/map/MapWithTracking.test.jsx +++ b/test/unit/component/map/MapWithTracking.test.jsx @@ -1,7 +1,10 @@ import React from 'react'; import { renderWithProviders } from '../../helpers/mock-providers'; import { mockContext } from '../../helpers/mock-context'; -import { Component as MapWithTracking } from '../../../../app/component/map/MapWithTracking'; +import { + Component as MapWithTracking, + getForcedLayersFromMapLayerOptions, +} from '../../../../app/component/map/MapWithTracking'; const defaultProps = { getGeoJsonConfig: () => {}, @@ -50,4 +53,64 @@ describe('', () => { ); expect(container.innerHTML).not.toBe(''); }); + + it('should return selected values for locked map layers', () => { + expect( + getForcedLayersFromMapLayerOptions({ + vehicles: { isLocked: true, isSelected: false }, + stop: { + bus: { isLocked: true, isSelected: true }, + tram: { isLocked: false, isSelected: false }, + }, + }), + ).toEqual({ + vehicles: false, + stop: { bus: true }, + }); + }); + + it('should show that tracking is off by default', () => { + const { getByRole } = renderWithProviders( + , + { + config: { + ...mockContext.config, + map: { ...mockContext.config.map, showLayerSelector: false }, + }, + }, + ); + + expect(getByRole('button', { name: 'tracking off' })).not.to.equal(null); + }); + + it('should show that tracking is on when enabled', () => { + const { getByRole } = renderWithProviders( + , + { + config: { + ...mockContext.config, + map: { ...mockContext.config.map, showLayerSelector: false }, + }, + }, + ); + + expect(getByRole('button', { name: 'tracking on' })).not.to.equal(null); + }); + + it('should show a failed location label when locationing fails', () => { + const { getByRole } = renderWithProviders( + , + { + config: { + ...mockContext.config, + map: { ...mockContext.config.map, showLayerSelector: false }, + }, + }, + ); + + expect(getByRole('button', { name: 'tracking failed' })).not.to.equal(null); + }); }); diff --git a/test/unit/component/map/tile-layer/TileLayerContainer.test.jsx b/test/unit/component/map/tile-layer/TileLayerContainer.test.jsx index 248f9061b5..675e321d09 100644 --- a/test/unit/component/map/tile-layer/TileLayerContainer.test.jsx +++ b/test/unit/component/map/tile-layer/TileLayerContainer.test.jsx @@ -21,6 +21,8 @@ describe('', () => { }, lang: 'fi', currentTime: 123457890, + config: { ...mockContext.config, vehicleRental: {} }, + router: mockContext.router, }; it('should send analytics for a terminal stop target', () => { @@ -30,7 +32,6 @@ describe('', () => { , { config: { ...mockContext.config, vehicleRental: {} }, - context: { popupContainer: { openPopup: () => {} } }, }, ); componentRef.current.state.selectableTargets = [ @@ -62,7 +63,6 @@ describe('', () => { , { config: { ...mockContext.config, vehicleRental: {} }, - context: { popupContainer: { openPopup: () => {} } }, }, ); componentRef.current.state.selectableTargets = []; diff --git a/test/unit/component/map/tile-layer/non-tile-layer/LegMarker.test.js b/test/unit/component/map/tile-layer/non-tile-layer/LegMarker.test.js new file mode 100644 index 0000000000..c58f62797e --- /dev/null +++ b/test/unit/component/map/tile-layer/non-tile-layer/LegMarker.test.js @@ -0,0 +1,61 @@ +import { + getLegMarkerIconName, + shouldDisplayLegRouteNumber, + getLegRouteNumberHtml, +} from '../../../../../../app/component/map/non-tile-layer/LegMarker'; + +describe('LegMarker', () => { + describe('getLegMarkerIconName', () => { + it('should use the bus icon for express buses', () => { + expect(getLegMarkerIconName('bus-express')).to.equal('icon_bus'); + }); + + it('should prefix the mode with icon_ for other modes', () => { + expect(getLegMarkerIconName('rail')).to.equal('icon_rail'); + expect(getLegMarkerIconName('subway')).to.equal('icon_subway'); + }); + }); + + describe('shouldDisplayLegRouteNumber', () => { + it('should display the route number for a normal route', () => { + expect(shouldDisplayLegRouteNumber({}, 'bus', '55')).to.equal(true); + }); + + it('should display the route number when the name is not empty even for external routes', () => { + expect( + shouldDisplayLegRouteNumber( + { externalFeedIds: ['foo'] }, + 'bus-external', + '55', + ), + ).to.equal(true); + }); + + it('should hide an empty route number for an external route', () => { + expect( + shouldDisplayLegRouteNumber( + { externalFeedIds: ['foo'] }, + 'bus-external', + '', + ), + ).to.equal(false); + }); + + it('should display an empty route number when externalFeedIds is not configured', () => { + expect(shouldDisplayLegRouteNumber({}, 'bus-external', '')).to.equal( + true, + ); + }); + }); + + describe('getLegRouteNumberHtml', () => { + it('should return an empty string when the route number should not be displayed', () => { + expect(getLegRouteNumberHtml('bus', 'U', false)).to.equal(''); + }); + + it('should render the route number markup', () => { + const html = getLegRouteNumberHtml('rail', 'U', true); + expect(html).to.equal('U'); + }); + }); +}); diff --git a/test/unit/component/map/tile-layer/non-tile-layer/StopMarker.test.js b/test/unit/component/map/tile-layer/non-tile-layer/StopMarker.test.js index 1626460f15..ac62cbd7a7 100644 --- a/test/unit/component/map/tile-layer/non-tile-layer/StopMarker.test.js +++ b/test/unit/component/map/tile-layer/non-tile-layer/StopMarker.test.js @@ -1,6 +1,11 @@ import { getStopMarkerAnalytics, getStopMarkerPath, + getModeIconSize, + getModeIconClassName, + getStopIconRadii, + buildStopIconSvg, + getStopIconClassName, } from '../../../../../../app/component/map/non-tile-layer/StopMarker'; describe('StopMarker', () => { @@ -36,4 +41,145 @@ describe('StopMarker', () => { expect(getStopMarkerPath('HSL:1541157')).toBe('/pysakit/HSL%3A1541157'); }); }); + + describe('getModeIconSize', () => { + const config = { + stopsSmallMaxZoom: 14, + stopsIconSize: { small: 12, selected: 24, default: 18 }, + }; + + it('should return the small size below the small max zoom', () => { + expect(getModeIconSize(10, config, false)).to.equal(12); + }); + + it('should return the selected size when selected above the small max zoom', () => { + expect(getModeIconSize(16, config, true)).to.equal(24); + }); + + it('should return the default size otherwise', () => { + expect(getModeIconSize(16, config, false)).to.equal(18); + }); + }); + + describe('getModeIconClassName', () => { + const config = { stopsIconSize: { small: 12, selected: 24, default: 18 } }; + + it('should mark the icon small when the size matches the small size', () => { + expect(getModeIconClassName('BUS', 12, config, false, false)).to.equal( + 'cursor-pointer BUS small', + ); + }); + + it('should mark the icon selected and border-disabled when requested', () => { + expect(getModeIconClassName('BUS', 18, config, true, true)).to.equal( + 'cursor-pointer BUS selected disable-icon-border', + ); + }); + }); + + describe('getStopIconRadii', () => { + it('should scale up the radii for a transfer or selected stop', () => { + const normal = getStopIconRadii(15, { + limitZoom: undefined, + transfer: false, + selected: false, + }); + const transfer = getStopIconRadii(15, { + limitZoom: undefined, + transfer: true, + selected: false, + }); + expect(transfer.radius).to.be.above(normal.radius); + expect(transfer.inner).to.be.above(normal.inner); + }); + + it('should cap the effective zoom to limitZoom when provided', () => { + const limited = getStopIconRadii(20, { + limitZoom: 12, + transfer: false, + selected: false, + }); + const unlimited = getStopIconRadii(20, { + limitZoom: undefined, + transfer: false, + selected: false, + }); + expect(limited.radius).to.be.below(unlimited.radius); + }); + }); + + describe('buildStopIconSvg', () => { + it('should return an empty string when the radius is zero', () => { + expect( + buildStopIconSvg({ + radius: 0, + inner: 0, + stroke: 0, + appendClass: '', + colorOverride: undefined, + platformCode: undefined, + }), + ).to.equal(''); + }); + + it('should not render a color attribute when there is no color override', () => { + const svg = buildStopIconSvg({ + radius: 10, + inner: 5, + stroke: 2, + appendClass: 'foo', + colorOverride: undefined, + platformCode: undefined, + }); + expect(svg).to.not.contain('color='); + }); + + it('should render a color attribute when a color override is given', () => { + const svg = buildStopIconSvg({ + radius: 10, + inner: 5, + stroke: 2, + appendClass: 'foo', + colorOverride: '#ff0000', + platformCode: undefined, + }); + expect(svg).to.contain('color="#ff0000"'); + }); + + it('should render the platform code label when there is enough room', () => { + const svg = buildStopIconSvg({ + radius: 10, + inner: 8, + stroke: 2, + appendClass: 'foo', + colorOverride: undefined, + platformCode: '3', + }); + expect(svg).to.contain('>3'); + }); + + it('should not render the platform code label when there is not enough room', () => { + const svg = buildStopIconSvg({ + radius: 10, + inner: 5, + stroke: 2, + appendClass: 'foo', + colorOverride: undefined, + platformCode: '3', + }); + expect(svg).to.not.contain(' { + it('should combine the mode and cursor-pointer classes', () => { + expect(getStopIconClassName('BUS', false)).to.equal('BUS cursor-pointer'); + }); + + it('should add disable-icon-border when requested', () => { + expect(getStopIconClassName('BUS', true)).to.equal( + 'BUS cursor-pointer disable-icon-border', + ); + }); + }); }); diff --git a/test/unit/component/map/tile-layer/non-tile-layer/TransitLegMarkers.test.js b/test/unit/component/map/tile-layer/non-tile-layer/TransitLegMarkers.test.js new file mode 100644 index 0000000000..a8838314b6 --- /dev/null +++ b/test/unit/component/map/tile-layer/non-tile-layer/TransitLegMarkers.test.js @@ -0,0 +1,75 @@ +import { + doMarkersOverlap, + getArrowMarkerStyle, + getSpeechBubbleStyle, +} from '../../../../../../app/component/map/non-tile-layer/TransitLegMarkers'; + +const box = (x1, y1, x2, y2) => ({ + topLeft: { x: x1, y: y1 }, + bottomRight: { x: x2, y: y2 }, +}); + +describe('TransitLegMarkers', () => { + describe('doMarkersOverlap', () => { + it('should return false when there are no existing positions', () => { + expect(doMarkersOverlap(box(0, 0, 10, 10), [])).to.equal(false); + }); + + it('should return false when boxes do not intersect horizontally', () => { + const existing = [box(20, 0, 30, 10)]; + expect(doMarkersOverlap(box(0, 0, 10, 10), existing)).to.equal(false); + }); + + it('should return false when boxes do not intersect vertically', () => { + const existing = [box(0, 20, 10, 30)]; + expect(doMarkersOverlap(box(0, 0, 10, 10), existing)).to.equal(false); + }); + + it('should return true when boxes overlap', () => { + const existing = [box(5, 5, 15, 15)]; + expect(doMarkersOverlap(box(0, 0, 10, 10), existing)).to.equal(true); + }); + }); + + describe('getArrowMarkerStyle', () => { + const baseLeg = () => ({ + topLeft: { x: 0, y: 0 }, + bottomRight: { x: 45, y: 15 }, + width: 45, + height: 15, + }); + + it('should pick bottomLeft when there is nothing to overlap with', () => { + expect(getArrowMarkerStyle(baseLeg(), []).style).to.equal('bottomLeft'); + }); + + it('should fall back to bottomRight when bottomLeft overlaps', () => { + const pixelPositions = [box(0, 0, 45, 15)]; + expect(getArrowMarkerStyle(baseLeg(), pixelPositions).style).to.equal( + 'bottomRight', + ); + }); + }); + + describe('getSpeechBubbleStyle', () => { + const basePosition = () => ({ + topLeft: { x: 0, y: 0 }, + bottomRight: { x: 105, y: 30 }, + width: 105, + height: 30, + }); + + it('should pick topRight when there is nothing to overlap with', () => { + expect(getSpeechBubbleStyle(basePosition(), []).style).to.equal( + 'topRight', + ); + }); + + it('should fall back to topLeft when topRight overlaps', () => { + const pixelPositions = [box(0, 0, 105, 30)]; + expect( + getSpeechBubbleStyle(basePosition(), pixelPositions).style, + ).to.equal('topLeft'); + }); + }); +});