mirror of
https://github.com/HSLdevcom/digitransit-ui
synced 2025-07-27 15:05:15 +02:00
398 lines
12 KiB
JavaScript
398 lines
12 KiB
JavaScript
import PropTypes from 'prop-types';
|
|
import React from 'react';
|
|
import LeafletMap from 'react-leaflet/es/Map';
|
|
import TileLayer from 'react-leaflet/es/TileLayer';
|
|
import AttributionControl from 'react-leaflet/es/AttributionControl';
|
|
import ScaleControl from 'react-leaflet/es/ScaleControl';
|
|
import ZoomControl from 'react-leaflet/es/ZoomControl';
|
|
import { intlShape } from 'react-intl';
|
|
import L from 'leaflet';
|
|
import get from 'lodash/get';
|
|
import isString from 'lodash/isString';
|
|
import isEmpty from 'lodash/isEmpty';
|
|
import { configShape } from '../../util/shapes';
|
|
import VehicleMarkerContainer from './VehicleMarkerContainer';
|
|
import {
|
|
startRealTimeClient,
|
|
stopRealTimeClient,
|
|
} from '../../action/realTimeClientAction';
|
|
import PositionMarker from './PositionMarker';
|
|
import VectorTileLayerContainer from './tile-layer/VectorTileLayerContainer';
|
|
import { boundWithMinimumArea } from '../../util/geo-utils';
|
|
import events from '../../util/events';
|
|
import { getLayerBaseUrl } from '../../util/mapLayerUtils';
|
|
import GeoJSON from './GeoJSON';
|
|
import { mapLayerShape } from '../../store/MapLayerStore';
|
|
|
|
const zoomOutText = `<svg class="icon"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#icon-icon_minus"/></svg>`;
|
|
const zoomInText = `<svg class="icon"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#icon-icon_plus"/></svg>`;
|
|
const EXTRA_PADDING = 100;
|
|
/* foo-eslint-disable react/sort-comp */
|
|
|
|
const startClient = context => {
|
|
const { realTime } = context.config;
|
|
let feedId;
|
|
/* handle multiple feedid case */
|
|
context.config.feedIds.forEach(f => {
|
|
if (!feedId && realTime[f]) {
|
|
feedId = f;
|
|
}
|
|
});
|
|
const source = feedId && realTime[feedId];
|
|
if (source && source.active) {
|
|
const config = {
|
|
...source,
|
|
feedId,
|
|
options: context.config.feedIds
|
|
.filter(f => realTime[f]?.active)
|
|
.map(f => ({ feedId: f })),
|
|
};
|
|
context.executeAction(startRealTimeClient, config);
|
|
}
|
|
};
|
|
|
|
const onPopupopen = () => events.emit('popupOpened');
|
|
|
|
export default class Map extends React.Component {
|
|
static propTypes = {
|
|
animate: PropTypes.bool,
|
|
lat: PropTypes.number,
|
|
lon: PropTypes.number,
|
|
zoom: PropTypes.number,
|
|
bounds: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)),
|
|
highlightedStops: PropTypes.arrayOf(PropTypes.string),
|
|
stopsToShow: PropTypes.arrayOf(PropTypes.string),
|
|
objectsToHide: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)),
|
|
lang: PropTypes.string.isRequired,
|
|
// eslint-disable-next-line
|
|
leafletEvents: PropTypes.object,
|
|
leafletObjs: PropTypes.arrayOf(PropTypes.node),
|
|
mergeStops: PropTypes.bool,
|
|
leafletMapRef: PropTypes.func,
|
|
mapRef: PropTypes.func,
|
|
mapLayerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
|
|
locationPopup: PropTypes.string,
|
|
onSelectLocation: PropTypes.func,
|
|
bottomPadding: PropTypes.number,
|
|
bottomButtons: PropTypes.node,
|
|
topButtons: PropTypes.node,
|
|
// eslint-disable-next-line
|
|
geoJson: PropTypes.object,
|
|
mapLayers: mapLayerShape,
|
|
breakpoint: PropTypes.string,
|
|
};
|
|
|
|
static defaultProps = {
|
|
animate: true,
|
|
mapRef: null,
|
|
mapLayerRef: null,
|
|
leafletMapRef: null,
|
|
lat: undefined,
|
|
lon: undefined,
|
|
zoom: undefined,
|
|
bounds: undefined,
|
|
locationPopup: 'reversegeocoding',
|
|
bottomPadding: undefined,
|
|
bottomButtons: null,
|
|
topButtons: null,
|
|
mergeStops: true,
|
|
mapLayers: { geoJson: {} },
|
|
highlightedStops: undefined,
|
|
stopsToShow: undefined,
|
|
onSelectLocation: undefined,
|
|
geoJson: undefined,
|
|
leafletEvents: undefined,
|
|
leafletObjs: undefined,
|
|
objectsToHide: { vehicleRentalStations: [] },
|
|
breakpoint: undefined,
|
|
};
|
|
|
|
static contextTypes = {
|
|
executeAction: PropTypes.func.isRequired,
|
|
getStore: PropTypes.func,
|
|
config: configShape.isRequired,
|
|
intl: intlShape.isRequired,
|
|
};
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = { zoom: 14 };
|
|
if (props.mapRef) {
|
|
props.mapRef(this);
|
|
}
|
|
if (this.props.breakpoint !== 'large') {
|
|
this.boundsOptions = {
|
|
paddingTopLeft: [0, EXTRA_PADDING],
|
|
paddingBottomRight: [0, (window.innerHeight - 64) / 2],
|
|
};
|
|
}
|
|
this.mounted = false;
|
|
}
|
|
|
|
updateZoom = () => {
|
|
if (!this.mounted) {
|
|
return;
|
|
}
|
|
// eslint-disable-next-line no-underscore-dangle
|
|
const zoom = this.map?.leafletElement?._zoom || this.props.zoom || 16;
|
|
if (zoom !== this.state.zoom) {
|
|
this.setState({ zoom });
|
|
}
|
|
};
|
|
|
|
componentDidMount() {
|
|
this.mounted = true;
|
|
if (this.props.mapLayers.vehicles) {
|
|
startClient(this.context);
|
|
}
|
|
this.updateZoom();
|
|
}
|
|
|
|
// eslint-disable-next-line camelcase
|
|
UNSAFE_componentWillReceiveProps(newProps) {
|
|
this.updateZoom();
|
|
if (newProps.mapLayers.vehicles) {
|
|
if (!this.props.mapLayers.vehicles) {
|
|
startClient(this.context);
|
|
}
|
|
} else if (this.props.mapLayers.vehicles) {
|
|
const { client } = this.context.getStore('RealTimeInformationStore');
|
|
if (client) {
|
|
this.context.executeAction(stopRealTimeClient, client);
|
|
}
|
|
}
|
|
}
|
|
|
|
componentDidUpdate() {
|
|
// move leaflet attribution control elements according to given padding
|
|
// leaflet api doesn't allow controlling element position so have to use this hack
|
|
if (this.props.bottomPadding !== undefined) {
|
|
const bottomControls = document.getElementsByClassName('leaflet-bottom');
|
|
Array.prototype.forEach.call(bottomControls, elem => {
|
|
// eslint-disable-next-line no-param-reassign
|
|
elem.style.transform = `translate(0, -${this.props.bottomPadding}px)`;
|
|
});
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.mounted = false;
|
|
const { client } = this.context.getStore('RealTimeInformationStore');
|
|
if (client) {
|
|
this.context.executeAction(stopRealTimeClient, client);
|
|
}
|
|
}
|
|
|
|
zoomEnd = () => {
|
|
this.props.leafletEvents?.onZoomend?.(); // pass event to parent
|
|
this.updateZoom();
|
|
};
|
|
|
|
// eslint-disable-next-line react/no-unused-class-component-methods
|
|
setBottomPadding = padding => {
|
|
if (this.boundsOptions) {
|
|
this.boundsOptions.paddingBottomRight = [
|
|
0,
|
|
Math.max(
|
|
Math.min(padding, window.innerHeight - 2 * EXTRA_PADDING),
|
|
EXTRA_PADDING,
|
|
),
|
|
];
|
|
}
|
|
};
|
|
|
|
render() {
|
|
const {
|
|
zoom,
|
|
lat,
|
|
lon,
|
|
locationPopup,
|
|
onSelectLocation,
|
|
leafletObjs,
|
|
geoJson,
|
|
mapLayers,
|
|
bottomPadding,
|
|
mapLayerRef,
|
|
} = this.props;
|
|
const { config } = this.context;
|
|
|
|
const naviProps = {}; // these define map center and zoom
|
|
if (bottomPadding !== undefined && this.boundsOptions) {
|
|
this.boundsOptions.paddingBottomRight = [
|
|
0,
|
|
Math.min(bottomPadding + EXTRA_PADDING, window.innerHeight - 60),
|
|
];
|
|
}
|
|
if (this.props.bounds) {
|
|
// bounds overrule center & zoom
|
|
naviProps.bounds = boundWithMinimumArea(this.props.bounds); // validate
|
|
} else if (lat && lon) {
|
|
if (this.boundsOptions?.paddingBottomRight !== undefined) {
|
|
// bounds fitting can take account the wanted padding, so convert to bounds
|
|
naviProps.bounds = boundWithMinimumArea([[lat, lon]], zoom);
|
|
} else {
|
|
naviProps.center = [lat, lon];
|
|
if (zoom) {
|
|
naviProps.zoom = zoom;
|
|
}
|
|
}
|
|
}
|
|
|
|
// When this option is set, the map restricts the view to the given geographical bounds,
|
|
// bouncing the user back if the user tries to pan outside the view.
|
|
const mapAreaBounds = L.latLngBounds(
|
|
L.latLng(
|
|
config.map.areaBounds.corner1[0],
|
|
config.map.areaBounds.corner1[1],
|
|
),
|
|
L.latLng(
|
|
config.map.areaBounds.corner2[0],
|
|
config.map.areaBounds.corner2[1],
|
|
),
|
|
);
|
|
naviProps.maxBounds = mapAreaBounds;
|
|
|
|
if (naviProps.bounds || (naviProps.center && naviProps.zoom)) {
|
|
this.ready = true;
|
|
}
|
|
|
|
if (!this.ready) {
|
|
return null;
|
|
}
|
|
|
|
const mapBaseUrl = getLayerBaseUrl(config.URL.MAP, this.props.lang);
|
|
const mapUrl = config.hasAPISubscriptionQueryParameter
|
|
? `${mapBaseUrl}{z}/{x}/{y}{size}.png?${config.API_SUBSCRIPTION_QUERY_PARAMETER_NAME}=${config.API_SUBSCRIPTION_TOKEN}`
|
|
: `${mapBaseUrl}{z}/{x}/{y}{size}.png`;
|
|
|
|
const leafletObjNew = leafletObjs.concat([
|
|
<VectorTileLayerContainer
|
|
key="vectorTileLayerContainer"
|
|
highlightedStops={this.props.highlightedStops}
|
|
mergeStops={this.props.mergeStops}
|
|
stopsToShow={this.props.stopsToShow}
|
|
objectsToHide={this.props.objectsToHide}
|
|
locationPopup={locationPopup}
|
|
onSelectLocation={onSelectLocation}
|
|
mapLayers={this.props.mapLayers}
|
|
/>,
|
|
]);
|
|
|
|
if (this.props.mapLayers.vehicles) {
|
|
const useLargeIcon = this.state.zoom >= config.stopsMinZoom;
|
|
leafletObjNew.push(
|
|
<VehicleMarkerContainer key="vehicles" useLargeIcon={useLargeIcon} />,
|
|
);
|
|
}
|
|
|
|
let attribution = get(config, 'map.attribution');
|
|
if (!isString(attribution) || isEmpty(attribution)) {
|
|
attribution = false;
|
|
}
|
|
|
|
if (geoJson) {
|
|
Object.keys(geoJson)
|
|
.filter(
|
|
key =>
|
|
mapLayers.geoJson[key] !== false &&
|
|
(mapLayers.geoJson[key] === true ||
|
|
geoJson[key].isOffByDefault !== true),
|
|
)
|
|
.forEach((key, i) => {
|
|
leafletObjNew.push(
|
|
<GeoJSON
|
|
key={key.concat(i)}
|
|
data={geoJson[key].data}
|
|
geoJsonZoomLevel={this.state.zoom}
|
|
locationPopup={locationPopup}
|
|
onSelectLocation={onSelectLocation}
|
|
/>,
|
|
);
|
|
});
|
|
}
|
|
|
|
const leafletEvents = {
|
|
...this.props.leafletEvents,
|
|
onZoomend: this.zoomEnd,
|
|
};
|
|
|
|
return (
|
|
<div ref={mapLayerRef}>
|
|
<span>{this.props.topButtons}</span>
|
|
<span
|
|
className="overlay-mover"
|
|
style={{
|
|
transform: `translate(0, -${this.props.bottomPadding}px)`,
|
|
}}
|
|
>
|
|
{this.props.bottomButtons}
|
|
</span>
|
|
<div aria-hidden="true">
|
|
<LeafletMap
|
|
{...naviProps}
|
|
className={`z${this.state.zoom}`}
|
|
keyboard={false}
|
|
ref={el => {
|
|
this.map = el;
|
|
if (this.props.leafletMapRef) {
|
|
this.props.leafletMapRef(el);
|
|
}
|
|
}}
|
|
minZoom={config.map.minZoom}
|
|
maxZoom={config.map.maxZoom}
|
|
zoomControl={false}
|
|
attributionControl={false}
|
|
animate={this.props.animate}
|
|
boundsOptions={this.boundsOptions}
|
|
{...leafletEvents}
|
|
onPopupopen={onPopupopen}
|
|
closePopupOnClick={false}
|
|
>
|
|
<TileLayer
|
|
url={mapUrl}
|
|
tileSize={config.map.tileSize || 256}
|
|
zoomOffset={config.map.zoomOffset || 0}
|
|
updateWhenIdle={false}
|
|
size={config.map.useRetinaTiles && L.Browser.retina ? '@2x' : ''}
|
|
minZoom={config.map.minZoom}
|
|
maxZoom={config.map.maxZoom}
|
|
attribution={attribution}
|
|
/>
|
|
{attribution && (
|
|
<AttributionControl
|
|
position={
|
|
this.props.breakpoint === 'large'
|
|
? 'bottomright'
|
|
: 'bottomleft'
|
|
}
|
|
prefix=""
|
|
/>
|
|
)}
|
|
{config.map.showScaleBar && (
|
|
<ScaleControl
|
|
imperial={false}
|
|
position={config.map.controls.scale.position}
|
|
/>
|
|
)}
|
|
{this.props.breakpoint === 'large' &&
|
|
config.map.showZoomControl && (
|
|
<ZoomControl
|
|
position={config.map.controls.zoom.position}
|
|
zoomInText={zoomInText}
|
|
zoomOutText={zoomOutText}
|
|
zoomInTitle={this.context.intl.formatMessage({
|
|
id: 'map-zoom-in-button',
|
|
})}
|
|
zoomOutTitle={this.context.intl.formatMessage({
|
|
id: 'map-zoom-out-button',
|
|
})}
|
|
/>
|
|
)}
|
|
{leafletObjNew}
|
|
<PositionMarker key="position" />
|
|
</LeafletMap>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|