components/Visualization/GeoReferencedMap/index.js
import React from 'react';
import PropTypes from 'prop-types';
import { getDifferenceInDays } from '@togglecorp/fujs';
import mapboxgl from 'mapbox-gl';
import LoadingAnimation from '../../View/LoadingAnimation';
import styles from './styles.scss';
const propTypes = {
className: PropTypes.string,
geoLocations: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string,
title: PropTypes.string,
// eslint-disable-next-line react/forbid-prop-types
geoJson: PropTypes.object,
})),
geoPoints: PropTypes.arrayOf(PropTypes.shape({
coordinates: PropTypes.arrayOf(PropTypes.number),
title: PropTypes.string,
date: PropTypes.string,
})),
loading: PropTypes.bool,
};
const defaultProps = {
className: '',
geoLocations: [],
geoPoints: [],
loading: false,
};
export default class GeoReferencedMap extends React.PureComponent {
static propTypes = propTypes;
static defaultProps = defaultProps;
constructor(props) {
super(props);
this.state = {
pendingMap: true,
};
}
componentDidMount() {
const {
geoLocations,
geoPoints,
} = this.props;
this.mounted = true;
mapboxgl.accessToken = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN;
const map = new mapboxgl.Map({
center: [50, 10],
container: this.mapContainer,
style: process.env.REACT_APP_MAPBOX_STYLE,
zoom: 2,
scrollZoom: false,
});
map.addControl(new mapboxgl.NavigationControl(), 'bottom-right');
map.on('load', () => {
if (!this.mounted) {
return;
}
this.map = map;
this.loadGeoRegions(geoLocations);
this.loadGeoPoints(geoPoints);
this.setState({ pendingMap: false });
});
const popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
});
map.on('mousemove', 'region-layer', (e) => {
const feature = e.features[0];
map.getCanvas().style.cursor = 'pointer';
popup.setLngLat([
e.lngLat.lng,
e.lngLat.lat + 0.1,
]).setHTML(feature.properties.name);
});
map.on('mouseenter', 'region-layer', (e) => {
const feature = e.features[0];
popup
.setHTML(feature.properties.name)
.addTo(map);
});
map.on('mouseleave', 'region-layer', () => {
map.getCanvas().style.cursor = '';
popup.remove();
});
map.on('mouseenter', 'unclustered-point-circle', (e) => {
const feature = e.features[0];
map.getCanvas().style.cursor = 'pointer';
popup
.setLngLat(feature.geometry.coordinates)
.setHTML(feature.properties.title)
.addTo(map);
});
map.on('mouseleave', 'unclustered-point-circle', () => {
map.getCanvas().style.cursor = '';
popup.remove();
});
window.addEventListener('keydown', this.handleCtrlDown);
window.addEventListener('keyup', this.handleCtrlUp);
window.addEventListener('wheel', this.handleScroll);
this.mapResizeTimeout = setTimeout(() => { map.resize(); }, 900);
}
// eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(nextProps) {
const {
geoLocations,
geoPoints,
} = this.props;
if (geoLocations !== nextProps.geoLocations) {
this.loadGeoRegions(nextProps.geoLocations);
}
if (geoPoints !== nextProps.geoPoints) {
this.loadGeoPoints(nextProps.geoPoints);
}
}
componentWillUnmount() {
if (this.map) {
this.map.removeLayer('region-layer');
this.map.removeLayer('points-layer');
this.map.removeLayer('clustered-point-symbol');
this.map.removeLayer('unclustered-point-symbol');
this.map.removeLayer('unclustered-point-circle');
this.map.removeSource('geojson');
this.map.remove();
this.map = undefined;
}
this.mounted = false;
window.removeEventListener('keyup', this.handleCtrlUp);
window.removeEventListener('keydown', this.handleCtrlDown);
window.removeEventListener('wheel', this.handleScroll);
clearTimeout(this.mapResizeTimeout);
clearTimeout(this.mapOverlayTimeout);
}
getColorForMarker = (date) => {
const today = new Date();
const daysDifference = getDifferenceInDays(today, new Date(date));
if (daysDifference < 30) {
return '#fbb4b9';
}
if (daysDifference < 180) {
return '#f768a1';
}
return '#ae017e';
}
addPointsLayers = () => {
this.map.addLayer({
id: 'points-layer',
type: 'circle',
source: 'points',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#bdc9e1',
10,
'#74a9cf',
20,
'#0570b0',
],
'circle-radius': [
'step',
['get', 'point_count'],
15,
10,
20,
20,
30,
],
},
});
this.map.addLayer({
id: 'clustered-point-symbol',
type: 'symbol',
source: 'points',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-size': 12,
},
});
this.map.addLayer({
id: 'unclustered-point-symbol',
type: 'symbol',
source: 'points',
filter: ['!has', 'point_count'],
layout: {
'icon-allow-overlap': true,
'text-allow-overlap': true,
'text-size': 12,
'text-offset': [0, 0.6],
'text-anchor': 'top',
},
});
this.map.addLayer({
id: 'unclustered-point-circle',
type: 'circle',
source: 'points',
filter: ['!has', 'point_count'],
paint: {
'circle-color': ['get', 'color'],
'circle-radius': {
stops: [
[0, 10],
[20, 100],
],
base: 2,
},
},
});
}
addRegionsLayer = () => {
this.map.addLayer({
id: 'region-layer',
type: 'fill',
source: 'geojson',
paint: {
'fill-outline-color': '#ffffff',
'fill-color': '#088',
'fill-opacity': 0.5,
},
});
}
loadGeoRegions = (geoLocations) => {
if (!geoLocations || !this.map) {
return;
}
const source = this.map.getSource('geojson');
const countriesData = {
type: 'FeatureCollection',
features: geoLocations.map(selection => (
selection.geoJson.features)[0]),
};
if (source) {
source.setData(countriesData);
return;
}
this.map.addSource('geojson', {
type: 'geojson',
data: countriesData,
});
this.addRegionsLayer();
}
loadGeoPoints = (geoPoints) => {
if (!geoPoints || !this.map) {
return;
}
const pointsData = {
type: 'FeatureCollection',
features: geoPoints.map(points => (
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: points.coordinates,
},
properties: {
title: points.title,
icon: 'marker',
color: this.getColorForMarker(points.date),
},
}
)),
};
const source = this.map.getSource('points');
if (source) {
source.setData(pointsData);
return;
}
this.map.addSource('points', {
type: 'geojson',
data: pointsData,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 15,
});
this.addPointsLayers();
}
handleCtrlUp = (event) => {
if (this.map && event.key === 'Control') {
this.map.scrollZoom.disable();
}
}
handleCtrlDown = (event) => {
if (this.map && event.key === 'Control') {
if (this.overlay) {
this.overlay.style.display = 'none';
}
this.map.scrollZoom.enable();
}
}
handleScroll = (event) => {
if (!this.map) {
return;
}
if (!this.map.scrollZoom.isEnabled() && !event.ctrlKey) {
if (this.overlay) {
this.overlay.style.display = 'flex';
}
}
if (this.mapOverlayTimeout) {
clearTimeout(this.mapOverlayTimeout);
}
this.mapOverlayTimout = setTimeout(
() => { this.overlay.style.display = 'none'; },
1000,
);
}
render() {
const {
className,
loading,
} = this.props;
const { pendingMap } = this.state;
return (
<div
className={`${className} ${styles.container}`}
ref={(el) => { this.mapContainer = el; }}
>
{
(pendingMap || loading) && (
<LoadingAnimation />
)
}
<div
className={`${styles.overlay} overlay`}
ref={(el) => { this.overlay = el; }}
>
Use ctrl + scroll to zoom the map
</div>
</div>
);
}
}