From 4491b05b283ce667c63d705b02d01487555dce00 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:42:22 +0300 Subject: [PATCH 01/25] refactor: convert IconMarker to a function component Preserves behavior: the leaflet Icon instance is still created once and subsequent icon prop updates are applied via icon.initialize(), previously done in componentDidUpdate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/IconMarker.jsx | 77 +++++++++++++++++--------------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/app/component/map/IconMarker.jsx b/app/component/map/IconMarker.jsx index 1c791cc940..9542afac95 100644 --- a/app/component/map/IconMarker.jsx +++ b/app/component/map/IconMarker.jsx @@ -1,16 +1,23 @@ 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 = undefined, + children = undefined, + ...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 +34,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 +52,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 +89,3 @@ IconMarker.propTypes = { zIndexOffset: PropTypes.number, children: PropTypes.node, }; - -IconMarker.defaultProps = { - zIndexOffset: undefined, - children: undefined, -}; From 6aed27a1d6b0df6cc9c80fbb210e3b6b55cf278e Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:43:33 +0300 Subject: [PATCH 02/25] refactor: convert LegMarker to a function component Replaces the legacy React context API (static contextTypes) with the useConfigContext() hook for reading the app config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../map/non-tile-layer/LegMarker.jsx | 105 ++++++++---------- 1 file changed, 47 insertions(+), 58 deletions(-) diff --git a/app/component/map/non-tile-layer/LegMarker.jsx b/app/component/map/non-tile-layer/LegMarker.jsx index d96b0d7b61..4a48f9f58f 100644 --- a/app/component/map/non-tile-layer/LegMarker.jsx +++ b/app/component/map/non-tile-layer/LegMarker.jsx @@ -4,58 +4,43 @@ 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, - }; +// An arrow marker will be displayed if the normal marker can't fit +export default function LegMarker({ + leg, + mode, + color = 'currentColor', + zIndexOffset = undefined, + wide = false, + style = undefined, + appendClass = undefined, +}) { + const config = useConfigContext(); + const className = wide ? 'wide' : ''; + const iconName = mode === 'bus-express' ? 'icon_bus' : `icon_${mode}`; + // Do not display route number if it is an external route and the route number is empty. + const displayRouteNumber = !( + config.externalFeedIds !== undefined && + mode.includes('external') && + leg.name === '' + ); + const routeNumber = displayRouteNumber + ? ` + ${leg.name.toLowerCase()}` + : ''; - static defaultProps = { - color: 'currentColor', - zIndexOffset: undefined, - wide: false, - style: undefined, - appendClass: undefined, - }; - - static contextTypes = { - config: configShape.isRequired, - }; - - // 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 ( + return ( +
`, className: cx( - this.props.style ? `arrow-${this.props.style}` : 'legmarker', - this.props.mode, + style ? `arrow-${style}` : 'legmarker', + mode, { 'only-icon': !displayRouteNumber }, - this.props.appendClass, + appendClass, ), iconSize: null, })} - zIndexOffset={this.props.zIndexOffset} + zIndexOffset={zIndexOffset} keyboard={false} /> - ); - } - - render() { - return
{this.getLegMarker()}
; - } +
+ ); } -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, +}; From 4bdde4e54bd19df9778c96ec8dd2349d573435ae Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:44:10 +0300 Subject: [PATCH 03/25] refactor: convert MarkerPopupBottom to a function component Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/MarkerPopupBottom.jsx | 99 ++++++++++++------------- 1 file changed, 49 insertions(+), 50 deletions(-) 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 { From 334480acf71870c711875266751c5bb6849faacb Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:45:01 +0300 Subject: [PATCH 04/25] refactor: convert GenericMarker to a function component Replaces the legacy React context API (static contextTypes) with the useConfigContext() hook, and componentDidMount/componentWillUnmount's zoomend listener with a useEffect. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/GenericMarker.jsx | 161 ++++++++++++---------------- 1 file changed, 69 insertions(+), 92 deletions(-) diff --git a/app/component/map/GenericMarker.jsx b/app/component/map/GenericMarker.jsx index 1bb3386d9c..3c4af63b59 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 = undefined, + minWidth = undefined, + children = undefined, + leaflet, + onClick = () => {}, + zIndexOffset = undefined, +}) { + 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 }; From ebf569e9a66d3f146025e080d024b2a3f7d85673 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:45:47 +0300 Subject: [PATCH 05/25] refactor: convert non-tile-layer VehicleMarker to a function component Replaces the legacy React context API (static contextTypes) with the useConfigContext() and useRouter() hooks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../map/non-tile-layer/VehicleMarker.jsx | 88 +++++++++---------- 1 file changed, 41 insertions(+), 47 deletions(-) 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, +}; From 0f6927c1008acc742c2c0b6ad1e77a33c7bfc332 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:47:02 +0300 Subject: [PATCH 06/25] refactor: convert Line to a function component Replaces the legacy React context API (static contextTypes) with the useConfigContext() hook. componentDidMount and componentDidUpdate are mirrored with two useEffect calls (one with an empty dependency array for mount-only behavior, one running after every render to match the previous componentDidUpdate semantics). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/Line.jsx | 223 +++++++++++++++++-------------------- 1 file changed, 104 insertions(+), 119 deletions(-) diff --git a/app/component/map/Line.jsx b/app/component/map/Line.jsx index b4a9975372..239539c22f 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 = undefined, + mode, + geometry, + appendClass = undefined, +}) { + 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, +}; From 0df98c2b058af2a14767d68677013d05fdb79aa6 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:48:08 +0300 Subject: [PATCH 07/25] refactor: convert LocationPopup to a function component Replaces the legacy React context API (static contextTypes) with the useConfigContext() hook and react-intl's useIntl() hook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/popups/LocationPopup.jsx | 153 +++++++++------------ 1 file changed, 64 insertions(+), 89 deletions(-) diff --git a/app/component/map/popups/LocationPopup.jsx b/app/component/map/popups/LocationPopup.jsx index 3b6d1a3815..ce74878574 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,25 @@ 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 = undefined, + onSelectLocation = () => {}, +}) { + const config = useConfigContext(); + const intl = useIntl(); + const [loading, setLoading] = useState(true); + const [location, setLocation] = useState({ 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,26 +44,22 @@ class LocationPopup extends React.Component { let pointName; if (data.features != null && data.features.length > 0) { const match = data.features[0].properties; - this.setState(prevState => ({ - loading: false, - location: { - ...prevState.location, - address: getLabel(match), - zoneId: getZoneId(config, match.zones, data.zones), - }, + setLoading(false); + setLocation(prevLocation => ({ + ...prevLocation, + address: getLabel(match), + zoneId: getZoneId(config, match.zones, data.zones), })); pointName = 'FreeAddress'; } else { - this.setState(prevState => ({ - loading: false, - location: { - ...prevState.location, - address: this.context.intl.formatMessage({ - id: 'location-from-map', - defaultMessage: 'Selected location', - }), - zoneId: getZoneId(config, data.zones), - }, + setLoading(false); + setLocation(prevLocation => ({ + ...prevLocation, + address: intl.formatMessage({ + id: 'location-from-map', + defaultMessage: 'Selected location', + }), + zoneId: getZoneId(config, data.zones), })); pointName = 'NoAddress'; } @@ -102,49 +78,48 @@ class LocationPopup extends React.Component { }); }, () => { - this.setState({ - loading: false, - location: { - address: this.context.intl.formatMessage({ - id: 'location-from-map', - defaultMessage: 'Selected location', - }), - }, + setLoading(false); + setLocation({ + address: intl.formatMessage({ + id: 'location-from-map', + defaultMessage: 'Selected location', + }), }); }, ); - } + // Run only on mount, mirroring the previous componentDidMount. + }, []); - render() { - if (this.state.loading) { - return ( -
- -
- ); - } - const { zoneId } = this.state.location; - const [address, place] = splitStringToAddressAndPlace( - this.state.location.address, - ); + 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, +}; From 9b642549e43d4bc9f7aa89480f64628f6694b043 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:49:09 +0300 Subject: [PATCH 08/25] refactor: convert StopMarker to a function component Replaces the legacy React context API (static contextTypes) with the useConfigContext() and useRouter() hooks. Also drops the unused getStore context type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../map/non-tile-layer/StopMarker.jsx | 154 ++++++++---------- 1 file changed, 70 insertions(+), 84 deletions(-) diff --git a/app/component/map/non-tile-layer/StopMarker.jsx b/app/component/map/non-tile-layer/StopMarker.jsx index 32d7e70ca1..d9c5e44096 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,80 +37,62 @@ 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, - }; - - static defaultProps = { - renderName: false, - disableModeIcons: false, - disableIconBorder: false, - limitZoom: undefined, - selected: false, - colorOverride: undefined, - appendClass: undefined, - }; - - static contextTypes = { - getStore: PropTypes.func.isRequired, - config: configShape.isRequired, - router: routerShape.isRequired, - }; - - redirectToStopPage = () => { +export default function StopMarker({ + stop, + mode, + renderName = false, + disableModeIcons = false, + disableIconBorder = false, + limitZoom = undefined, + selected = false, + colorOverride = undefined, + appendClass = undefined, +}) { + 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}`; + const getModeIcon = zoom => { + const iconId = `icon_${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; + if (zoom <= config.stopsSmallMaxZoom) { + size = config.stopsIconSize.small; + } else if (selected) { + size = config.stopsIconSize.selected; } else { - size = this.context.config.stopsIconSize.default; + size = config.stopsIconSize.default; } 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: cx('cursor-pointer', mode, { + small: size === config.stopsIconSize.small, + selected, + 'disable-icon-border': disableIconBorder, }), }); }; - getIcon = zoom => { - const scale = this.props.stop.transfer || this.props.selected ? 1.5 : 1; + const getIcon = zoom => { + const scale = stop.transfer || selected ? 1.5 : 1; let calcZoom; - if (this.props.limitZoom) { - calcZoom = Math.min(zoom, this.props.limitZoom); + if (limitZoom) { + calcZoom = Math.min(zoom, limitZoom); } else { - calcZoom = - this.props.stop.transfer || this.props.selected - ? Math.max(zoom, 15) - : zoom || 15; + calcZoom = stop.transfer || selected ? Math.max(zoom, 15) : zoom || 15; } const radius = getCaseRadius(calcZoom) * scale; @@ -122,17 +105,13 @@ class StopMarker extends React.Component { // see utils/client/mapIconUtils.js for the canvas version let iconSvg = ` - + ${ - inner > 7 && this.props.stop.platformCode + inner > 7 && stop.platformCode ? `${this.props.stop.platformCode}` + >${stop.platformCode}` : '' } @@ -145,32 +124,39 @@ class StopMarker extends React.Component { return L.divIcon({ html: iconSvg, iconSize: [radius * 2, radius * 2], - className: cx(this.props.mode, 'cursor-pointer', { - 'disable-icon-border': this.props.disableIconBorder, + className: cx(mode, 'cursor-pointer', { + 'disable-icon-border': 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, +}; From efb748add63df9eeda174715f70f10fe30906a0b Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:50:47 +0300 Subject: [PATCH 09/25] refactor: convert SelectFromMap to a function component Replaces the legacy React context API (static contextTypes) with the useConfigContext() hook, react-intl's useIntl(), and found's useRouter() for match. The map instance ref is now tracked with useRef instead of an instance property. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/SelectFromMap.jsx | 344 +++++++++++++--------------- 1 file changed, 160 insertions(+), 184 deletions(-) diff --git a/app/component/map/SelectFromMap.jsx b/app/component/map/SelectFromMap.jsx index ba4a6f93c9..af10ee1b2f 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,113 @@ 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 = undefined, + 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 +155,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'], From 4397fc778f219ed186183a527dcb8af081baf09a Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:52:05 +0300 Subject: [PATCH 10/25] refactor: convert TransitLegMarkers to a function component Replaces the legacy React context API (static contextTypes) with the useConfigContext() hook and react-intl's useIntl(). The componentDidMount/componentWillUnmount zoomend listener is now a useEffect, and forceUpdate() is replaced with a useReducer-based re-render trigger. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../map/non-tile-layer/TransitLegMarkers.jsx | 275 +++++++++--------- 1 file changed, 132 insertions(+), 143 deletions(-) diff --git a/app/component/map/non-tile-layer/TransitLegMarkers.jsx b/app/component/map/non-tile-layer/TransitLegMarkers.jsx index 4c42d6fd85..027abd6962 100644 --- a/app/component/map/non-tile-layer/TransitLegMarkers.jsx +++ b/app/component/map/non-tile-layer/TransitLegMarkers.jsx @@ -1,13 +1,15 @@ 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 }; @@ -113,30 +115,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 +169,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 +196,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); From 0bf716468a41bbd81098e66fb08f87e776e8237a Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 10:54:29 +0300 Subject: [PATCH 11/25] refactor: convert MapWithTracking to a function component Replaces class state/instance fields with useState/useRef, and lifecycle methods with useEffect. The config context uses useConfigContext(), intl uses react-intl's useIntl(), and executeAction continues to use the legacy (props, context) API since no hook equivalent exists yet (matches the pattern already used in NearYouMap.jsx). setMWTRef now receives a plain object exposing enableMapTracking/disableMapTracking/forceRefresh instead of the class instance, matching how callers (RoutePageMap, ItineraryPage, NaviContainer) actually use the ref. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/MapWithTracking.jsx | 567 +++++++++++++------------- 1 file changed, 277 insertions(+), 290 deletions(-) diff --git a/app/component/map/MapWithTracking.jsx b/app/component/map/MapWithTracking.jsx index fe543fb9ce..e598080e49 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 && @@ -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,215 @@ 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; - - 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({ + enableMapTracking, + disableMapTracking, + forceRefresh, + }); + } + 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]); - let img; - let color; - if (position.locationingFailed) { - img = 'icon-tracking-off'; - color = '#888'; - } else { - img = 'icon-tracking'; - color = this.state.mapTracking ? '#007ac9' : '#78909c'; + const btnClassName = 'map-with-tracking-buttons'; + // eslint-disable-next-line no-underscore-dangle + const currentZoom = mapElement.current?.leafletElement?._zoom || zoom || 16; + + 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(); } - // 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' }); + 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(); + } + naviProps.lon = lon; + oldLat.current = lat; + oldLon.current = lon; + if (zoom) { + naviProps.zoom = zoom; + } + delete naviProps.bounds; + } + refresh.current = false; - const mergedMapLayers = this.getMapLayers(); - return ( - <> - - {config.map.showLayerSelector && ( - - )} - {renderCustomButtons && renderCustomButtons()} + 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 = 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, ); From eb6a5f4309d2a37fd679f83324a4ad9bc5c031b5 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 11:06:16 +0300 Subject: [PATCH 12/25] refactor: remove pointless '= undefined' parameter defaults Defaulting a destructured parameter to undefined is a no-op (same as omitting the default), so drop these from the converted map function components. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/GenericMarker.jsx | 8 ++++---- app/component/map/IconMarker.jsx | 7 +------ app/component/map/Line.jsx | 4 ++-- app/component/map/SelectFromMap.jsx | 8 +------- app/component/map/non-tile-layer/LegMarker.jsx | 6 +++--- app/component/map/non-tile-layer/StopMarker.jsx | 6 +++--- app/component/map/popups/LocationPopup.jsx | 2 +- 7 files changed, 15 insertions(+), 26 deletions(-) diff --git a/app/component/map/GenericMarker.jsx b/app/component/map/GenericMarker.jsx index 3c4af63b59..00bd4a9484 100644 --- a/app/component/map/GenericMarker.jsx +++ b/app/component/map/GenericMarker.jsx @@ -14,12 +14,12 @@ function GenericMarker({ getIcon, renderName = false, name = '', - maxWidth = undefined, - minWidth = undefined, - children = undefined, + maxWidth, + minWidth, + children, leaflet, onClick = () => {}, - zIndexOffset = undefined, + zIndexOffset, }) { const config = useConfigContext(); const [zoom, setZoom] = useState(() => leaflet.map.getZoom()); diff --git a/app/component/map/IconMarker.jsx b/app/component/map/IconMarker.jsx index 9542afac95..aebc7c98d3 100644 --- a/app/component/map/IconMarker.jsx +++ b/app/component/map/IconMarker.jsx @@ -4,12 +4,7 @@ import { createPortal } from 'react-dom'; import { default as L } from 'leaflet'; import Marker from 'react-leaflet/es/Marker'; -export default function IconMarker({ - icon, - zIndexOffset = undefined, - children = undefined, - ...rest -}) { +export default function IconMarker({ icon, zIndexOffset, children, ...rest }) { const [div, setDiv] = useState(undefined); const hasMounted = useRef(false); diff --git a/app/component/map/Line.jsx b/app/component/map/Line.jsx index 239539c22f..75079ad7e2 100644 --- a/app/component/map/Line.jsx +++ b/app/component/map/Line.jsx @@ -11,10 +11,10 @@ export default function Line({ thin = false, opaque = false, passive = false, - color = undefined, + color, mode, geometry, - appendClass = undefined, + appendClass, }) { const config = useConfigContext(); const line = useRef(null); diff --git a/app/component/map/SelectFromMap.jsx b/app/component/map/SelectFromMap.jsx index af10ee1b2f..8c649b3f9f 100644 --- a/app/component/map/SelectFromMap.jsx +++ b/app/component/map/SelectFromMap.jsx @@ -38,13 +38,7 @@ const markLocation = (markerType, position) => { return null; }; -function SelectFromMap({ - breakpoint = undefined, - language, - type, - onConfirm, - mapLayers, -}) { +function SelectFromMap({ breakpoint, language, type, onConfirm, mapLayers }) { const config = useConfigContext(); const intl = useIntl(); const { match } = useRouter(); diff --git a/app/component/map/non-tile-layer/LegMarker.jsx b/app/component/map/non-tile-layer/LegMarker.jsx index 4a48f9f58f..b009869bb4 100644 --- a/app/component/map/non-tile-layer/LegMarker.jsx +++ b/app/component/map/non-tile-layer/LegMarker.jsx @@ -13,10 +13,10 @@ export default function LegMarker({ leg, mode, color = 'currentColor', - zIndexOffset = undefined, + zIndexOffset, wide = false, - style = undefined, - appendClass = undefined, + style, + appendClass, }) { const config = useConfigContext(); const className = wide ? 'wide' : ''; diff --git a/app/component/map/non-tile-layer/StopMarker.jsx b/app/component/map/non-tile-layer/StopMarker.jsx index d9c5e44096..e460893048 100644 --- a/app/component/map/non-tile-layer/StopMarker.jsx +++ b/app/component/map/non-tile-layer/StopMarker.jsx @@ -43,10 +43,10 @@ export default function StopMarker({ renderName = false, disableModeIcons = false, disableIconBorder = false, - limitZoom = undefined, + limitZoom, selected = false, - colorOverride = undefined, - appendClass = undefined, + colorOverride, + appendClass, }) { const config = useConfigContext(); const { router } = useRouter(); diff --git a/app/component/map/popups/LocationPopup.jsx b/app/component/map/popups/LocationPopup.jsx index ce74878574..cc41450957 100644 --- a/app/component/map/popups/LocationPopup.jsx +++ b/app/component/map/popups/LocationPopup.jsx @@ -16,7 +16,7 @@ import { useConfigContext } from '../../../client/ConfigContext'; export default function LocationPopup({ lat, lon, - locationPopup = undefined, + locationPopup, onSelectLocation = () => {}, }) { const config = useConfigContext(); From 8e12557eac21aeb5b0a611c865f95f93cb77068a Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 11:16:22 +0300 Subject: [PATCH 13/25] fix: merge LocationPopup loading/location state to avoid crash React 16 doesn't batch state updates outside of event handlers (e.g. inside a Promise .then() callback). Calling setLoading(false) and setLocation(...) as two separate updates caused an intermediate render with loading=false but location.address still undefined, crashing splitStringToAddressAndPlace(). Combining loading and location into a single state object (matching the original class's single setState call) fixes the race. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/popups/LocationPopup.jsx | 55 +++++++++++++--------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/app/component/map/popups/LocationPopup.jsx b/app/component/map/popups/LocationPopup.jsx index cc41450957..47e7a713f9 100644 --- a/app/component/map/popups/LocationPopup.jsx +++ b/app/component/map/popups/LocationPopup.jsx @@ -21,8 +21,14 @@ export default function LocationPopup({ }) { const config = useConfigContext(); const intl = useIntl(); - const [loading, setLoading] = useState(true); - const [location, setLocation] = useState({ lat, lon }); + // 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 = { @@ -44,22 +50,26 @@ export default function LocationPopup({ let pointName; if (data.features != null && data.features.length > 0) { const match = data.features[0].properties; - setLoading(false); - setLocation(prevLocation => ({ - ...prevLocation, - address: getLabel(match), - zoneId: getZoneId(config, match.zones, data.zones), + setState(prevState => ({ + loading: false, + location: { + ...prevState.location, + address: getLabel(match), + zoneId: getZoneId(config, match.zones, data.zones), + }, })); pointName = 'FreeAddress'; } else { - setLoading(false); - setLocation(prevLocation => ({ - ...prevLocation, - address: intl.formatMessage({ - id: 'location-from-map', - defaultMessage: 'Selected location', - }), - zoneId: getZoneId(config, data.zones), + setState(prevState => ({ + loading: false, + location: { + ...prevState.location, + address: intl.formatMessage({ + id: 'location-from-map', + defaultMessage: 'Selected location', + }), + zoneId: getZoneId(config, data.zones), + }, })); pointName = 'NoAddress'; } @@ -78,18 +88,21 @@ export default function LocationPopup({ }); }, () => { - setLoading(false); - setLocation({ - address: intl.formatMessage({ - id: 'location-from-map', - defaultMessage: 'Selected location', - }), + setState({ + loading: false, + location: { + address: intl.formatMessage({ + id: 'location-from-map', + defaultMessage: 'Selected location', + }), + }, }); }, ); // Run only on mount, mirroring the previous componentDidMount. }, []); + const { loading, location } = state; if (loading) { return (
From 5bd9fba298fe5ccf59e379f05bba29993ad21d84 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Tue, 15 Sep 2026 11:38:51 +0300 Subject: [PATCH 14/25] fix: keep MapWithTracking's exposed ref methods up to date setMWTRef was only called once on mount, handing the parent enableMapTracking/disableMapTracking/forceRefresh closures captured from the first render. Those closures kept referencing stale position/mapTrackingState values forever, which made geolocation tracking behave inconsistently for callers (NaviContainer, ItineraryPage, RoutePageMap) depending on when they invoked the ref. Fix: expose a stable object whose methods are reassigned to the latest closures on every render, so callers always get current behavior while the object identity handed to the parent never changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/MapWithTracking.jsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/component/map/MapWithTracking.jsx b/app/component/map/MapWithTracking.jsx index e598080e49..c1ddb05a8d 100644 --- a/app/component/map/MapWithTracking.jsx +++ b/app/component/map/MapWithTracking.jsx @@ -178,14 +178,19 @@ function MapWithTrackingStateHandler( }; }; + // 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; + useEffect(() => { mounted.current = true; if (setMWTRef) { - setMWTRef({ - enableMapTracking, - disableMapTracking, - forceRefresh, - }); + setMWTRef(exposedInstance); } return () => { mounted.current = false; From 230906be8324420474cd95ac7da8f5c1dc2e37cd Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Wed, 16 Sep 2026 09:36:59 +0300 Subject: [PATCH 15/25] chore: remove old context from TileLayerContainer --- .../map/tile-layer/TileLayerContainer.jsx | 65 ++++++++++--------- .../map/tile-layer/TileLayerContainer.test.js | 4 +- 2 files changed, 36 insertions(+), 33 deletions(-) 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/tile-layer/TileLayerContainer.test.js b/test/unit/component/map/tile-layer/TileLayerContainer.test.js index 6e7cfa02ae..593523510f 100644 --- a/test/unit/component/map/tile-layer/TileLayerContainer.test.js +++ b/test/unit/component/map/tile-layer/TileLayerContainer.test.js @@ -23,6 +23,8 @@ describe('', () => { }, lang: 'fi', currentTime: 123457890, + config: { ...mockContext.config, vehicleRental: {} }, + router: mockContext.router, }; it('should send analytics for a terminal stop target', () => { @@ -32,7 +34,6 @@ describe('', () => { , { config: { ...mockContext.config, vehicleRental: {} }, - context: { popupContainer: { openPopup: () => {} } }, }, ); componentRef.current.state.selectableTargets = [ @@ -65,7 +66,6 @@ describe('', () => { , { config: { ...mockContext.config, vehicleRental: {} }, - context: { popupContainer: { openPopup: () => {} } }, }, ); componentRef.current.state.selectableTargets = []; From 5de96704cda5b5c23c2afdac4582db09de4a46c5 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Wed, 16 Sep 2026 10:12:37 +0300 Subject: [PATCH 16/25] Fix LegMarker background-color CSS var when route has no color Pass undefined instead of null for the color prop so LegMarker's default 'currentColor' applies, instead of literally rendering --background-color: null in the inline style. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/non-tile-layer/TransitLegMarkers.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/component/map/non-tile-layer/TransitLegMarkers.jsx b/app/component/map/non-tile-layer/TransitLegMarkers.jsx index 027abd6962..b5850cc01a 100644 --- a/app/component/map/non-tile-layer/TransitLegMarkers.jsx +++ b/app/component/map/non-tile-layer/TransitLegMarkers.jsx @@ -228,7 +228,7 @@ function TransitLegMarkers({ leg.nextLeg?.interlineWithPreviousLeg && leg.interliningWithRoute !== leg.route.shortName } - color={leg.route && leg.route.color ? `#${leg.route.color}` : null} + color={leg.route && leg.route.color ? `#${leg.route.color}` : undefined} leg={{ from: leg.from, to: leg.nextLeg?.interlineWithPreviousLeg ? leg.nextLeg.to : leg.to, @@ -264,7 +264,7 @@ function TransitLegMarkers({ leg.nextLeg?.interlineWithPreviousLeg && leg.interliningWithRoute !== leg.route.shortName } - color={leg.route && leg.route.color ? `#${leg.route.color}` : null} + color={leg.route && leg.route.color ? `#${leg.route.color}` : undefined} leg={{ from: leg.from, to: leg.nextLeg?.interlineWithPreviousLeg ? leg.nextLeg.to : leg.to, From 3e3a1ff353db2935dc05e7ab7e3e00061a9c971c Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Wed, 16 Sep 2026 10:19:20 +0300 Subject: [PATCH 17/25] Avoid rendering invalid color attribute on stop markers Only emit the SVG color attribute when colorOverride is set, instead of interpolating undefined/null directly, which previously produced an invalid literal color="undefined"/color="null" attribute. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/non-tile-layer/StopMarker.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/component/map/non-tile-layer/StopMarker.jsx b/app/component/map/non-tile-layer/StopMarker.jsx index e460893048..7a8f56f1fb 100644 --- a/app/component/map/non-tile-layer/StopMarker.jsx +++ b/app/component/map/non-tile-layer/StopMarker.jsx @@ -105,7 +105,9 @@ export default function StopMarker({ // see utils/client/mapIconUtils.js for the canvas version let iconSvg = ` - + ${ inner > 7 && stop.platformCode ? ` Date: Wed, 16 Sep 2026 14:50:58 +0300 Subject: [PATCH 18/25] Extract engine-agnostic logic from Leaflet marker components Separate pure, Leaflet-independent logic from the Leaflet-specific rendering in LegMarker, StopMarker and TransitLegMarkers so it can be unit tested and reused if the map engine is ever changed: - LegMarker: extract getLegMarkerIconName, shouldDisplayLegRouteNumber and getLegRouteNumberHtml. - StopMarker: extract getModeIconSize, getModeIconClassName, getStopIconRadii, buildStopIconSvg and getStopIconClassName. - TransitLegMarkers: export the already-pure doMarkersOverlap, getArrowMarkerStyle and getSpeechBubbleStyle geometry helpers. No behavioral changes; the components still produce identical output. Added unit tests for all newly exported functions, including regression coverage for the null/undefined color handling fixed earlier on this branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../map/non-tile-layer/LegMarker.jsx | 38 +++-- .../map/non-tile-layer/StopMarker.jsx | 151 ++++++++++++------ .../map/non-tile-layer/TransitLegMarkers.jsx | 9 +- .../non-tile-layer/LegMarker.test.js | 64 ++++++++ .../non-tile-layer/StopMarker.test.js | 146 +++++++++++++++++ .../non-tile-layer/TransitLegMarkers.test.js | 75 +++++++++ 6 files changed, 418 insertions(+), 65 deletions(-) create mode 100644 test/unit/component/map/tile-layer/non-tile-layer/LegMarker.test.js create mode 100644 test/unit/component/map/tile-layer/non-tile-layer/TransitLegMarkers.test.js diff --git a/app/component/map/non-tile-layer/LegMarker.jsx b/app/component/map/non-tile-layer/LegMarker.jsx index b009869bb4..8f8e5771e8 100644 --- a/app/component/map/non-tile-layer/LegMarker.jsx +++ b/app/component/map/non-tile-layer/LegMarker.jsx @@ -8,6 +8,26 @@ import { legShape } from '../../../../utils/client/shapes'; import { renderAsString } from '../../../../utils/client/mapIconUtils'; import { useConfigContext } from '../../../client/ConfigContext'; +// 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}`; + +// 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 === '' + ); + +export const getLegRouteNumberHtml = (mode, legName, displayRouteNumber) => + displayRouteNumber + ? ` + ${legName.toLowerCase()}` + : ''; + // An arrow marker will be displayed if the normal marker can't fit export default function LegMarker({ leg, @@ -20,19 +40,13 @@ export default function LegMarker({ }) { const config = useConfigContext(); const className = wide ? 'wide' : ''; - const iconName = mode === 'bus-express' ? 'icon_bus' : `icon_${mode}`; - // Do not display route number if it is an external route and the route number is empty. - const displayRouteNumber = !( - config.externalFeedIds !== undefined && - mode.includes('external') && - leg.name === '' + const iconName = getLegMarkerIconName(mode); + const displayRouteNumber = shouldDisplayLegRouteNumber( + config, + mode, + leg.name, ); - const routeNumber = displayRouteNumber - ? ` - ${leg.name.toLowerCase()}` - : ''; + const routeNumber = getLegRouteNumberHtml(mode, leg.name, displayRouteNumber); return (
diff --git a/app/component/map/non-tile-layer/StopMarker.jsx b/app/component/map/non-tile-layer/StopMarker.jsx index 7a8f56f1fb..44d68b15b2 100644 --- a/app/component/map/non-tile-layer/StopMarker.jsx +++ b/app/component/map/non-tile-layer/StopMarker.jsx @@ -37,6 +37,86 @@ export const getStopMarkerAnalytics = (pathname, indexPath, mode) => { export const getStopMarkerPath = gtfsId => `/${PREFIX_STOPS}/${encodeURIComponent(gtfsId)}`; +// 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; +}; + +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; + } + + 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}` + : '' + } + + `; +}; + +export const getStopIconClassName = (mode, disableIconBorder) => + cx(mode, 'cursor-pointer', { + 'disable-icon-border': disableIconBorder, + }); + export default function StopMarker({ stop, mode, @@ -65,70 +145,41 @@ export default function StopMarker({ const getModeIcon = zoom => { const iconId = `icon_${mode}`; - let size; - if (zoom <= config.stopsSmallMaxZoom) { - size = config.stopsIconSize.small; - } else if (selected) { - size = config.stopsIconSize.selected; - } else { - size = config.stopsIconSize.default; - } + const size = getModeIconSize(zoom, config, selected); return L.divIcon({ html: renderAsString(), iconSize: [size, size], - className: cx('cursor-pointer', mode, { - small: size === config.stopsIconSize.small, + className: getModeIconClassName( + mode, + size, + config, selected, - 'disable-icon-border': disableIconBorder, - }), + disableIconBorder, + ), }); }; const getIcon = zoom => { - const scale = stop.transfer || selected ? 1.5 : 1; - - let calcZoom; - if (limitZoom) { - calcZoom = Math.min(zoom, limitZoom); - } else { - calcZoom = stop.transfer || 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 && stop.platformCode - ? `${stop.platformCode}` - : '' - } - - `; + 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(mode, 'cursor-pointer', { - 'disable-icon-border': disableIconBorder, - }), + className: getStopIconClassName(mode, disableIconBorder), }); }; diff --git a/app/component/map/non-tile-layer/TransitLegMarkers.jsx b/app/component/map/non-tile-layer/TransitLegMarkers.jsx index b5850cc01a..5e21a2e10b 100644 --- a/app/component/map/non-tile-layer/TransitLegMarkers.jsx +++ b/app/component/map/non-tile-layer/TransitLegMarkers.jsx @@ -16,7 +16,10 @@ 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++) { @@ -38,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, @@ -81,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 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..e87e846bbd --- /dev/null +++ b/test/unit/component/map/tile-layer/non-tile-layer/LegMarker.test.js @@ -0,0 +1,64 @@ +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 and a lower-cased screen reader label', () => { + const html = getLegRouteNumberHtml('rail', 'U', true); + expect(html).to.contain( + '', + ); + expect(html).to.contain('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 a7a421fc80..a7da00ce13 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', () => { @@ -40,4 +45,145 @@ describe('StopMarker', () => { ); }); }); + + 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'); + }); + }); +}); From 49eaa8c667db60eafcd311b5f1c7002767dead83 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Wed, 16 Sep 2026 15:02:47 +0300 Subject: [PATCH 19/25] Remove dead screen-reader-only markup from LegMarker The whole Leaflet map is rendered inside a ` + + ${renderAsString( + , + )} + ${routeNumber} +
`, + className: cx( + style ? `arrow-${style}` : 'legmarker', + mode, + { 'only-icon': !displayRouteNumber }, + appendClass, + ), + iconSize: null, + })} + zIndexOffset={zIndexOffset} + keyboard={false} + /> ); } From a0f6f52cdd0c7d6e13f2c978e927959b8b1dd3c6 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Thu, 24 Sep 2026 13:42:02 +0300 Subject: [PATCH 23/25] fix: remove hard-coded unnecessary font styles --- app/component/map/ClusterNumberMarker.jsx | 1 - app/component/map/non-tile-layer/StopMarker.jsx | 1 - 2 files changed, 2 deletions(-) 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/non-tile-layer/StopMarker.jsx b/app/component/map/non-tile-layer/StopMarker.jsx index 44d68b15b2..5483da8e4d 100644 --- a/app/component/map/non-tile-layer/StopMarker.jsx +++ b/app/component/map/non-tile-layer/StopMarker.jsx @@ -104,7 +104,6 @@ export const buildStopIconSvg = ({ inner > 7 && platformCode ? `${platformCode}` : '' } From f5672c618e71ca2358e925ffbbd72c4316b70bb6 Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Thu, 24 Sep 2026 13:44:16 +0300 Subject: [PATCH 24/25] chore: remove unclear comment --- app/component/map/popups/LocationPopup.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/component/map/popups/LocationPopup.jsx b/app/component/map/popups/LocationPopup.jsx index 47e7a713f9..4274bf769e 100644 --- a/app/component/map/popups/LocationPopup.jsx +++ b/app/component/map/popups/LocationPopup.jsx @@ -99,7 +99,6 @@ export default function LocationPopup({ }); }, ); - // Run only on mount, mirroring the previous componentDidMount. }, []); const { loading, location } = state; From 20e8002afccb7f6616e78e334f5ebbbf56a47fad Mon Sep 17 00:00:00 2001 From: Vesa Meskanen Date: Thu, 24 Sep 2026 13:51:43 +0300 Subject: [PATCH 25/25] Add MapWithTracking unit coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/component/map/MapWithTracking.jsx | 2 +- .../component/map/MapWithTracking.test.jsx | 65 ++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/app/component/map/MapWithTracking.jsx b/app/component/map/MapWithTracking.jsx index c1ddb05a8d..b432f23c19 100644 --- a/app/component/map/MapWithTracking.jsx +++ b/app/component/map/MapWithTracking.jsx @@ -30,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]; 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); + }); });