toggle-corp/react-store

View on GitHub
components/Visualization/GeoReferencedMap/index.js

Summary

Maintainability
A
0 mins
Test Coverage
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>
        );
    }
}