toggle-corp/react-store

View on GitHub
components/Visualization/Map/MapLayer/index.js

Summary

Maintainability
A
45 mins
Test Coverage
import React from 'react';
import PropTypes from 'prop-types';
import mapboxgl from 'mapbox-gl';
import ReactDOM from 'react-dom';

import { isDefined, difference, isNotDefined } from '@togglecorp/fujs';

import MapChild from '../MapChild';
import { forEach } from '../../../../utils/common';

const emptyList = [];

const propTypes = {
    // eslint-disable-next-line react/forbid-prop-types
    map: PropTypes.object.isRequired,
    // eslint-disable-next-line react/no-unused-prop-types
    type: PropTypes.string.isRequired,
    // eslint-disable-next-line react/forbid-prop-types
    paint: PropTypes.object.isRequired,
    // eslint-disable-next-line react/forbid-prop-types
    layout: PropTypes.object,
    // eslint-disable-next-line react/forbid-prop-types
    filter: PropTypes.array,

    layerKey: PropTypes.string.isRequired,
    // eslint-disable-next-line react/no-unused-prop-types
    sourceKey: PropTypes.string.isRequired,
    sourceLayer: PropTypes.string,

    setDestroyer: PropTypes.func,
    mapStyle: PropTypes.string.isRequired,

    // eslint-disable-next-line react/no-unused-prop-types
    onClick: PropTypes.func,
    // eslint-disable-next-line react/no-unused-prop-types
    onDoubleClick: PropTypes.func,

    showToolTipOnHover: PropTypes.bool,
    showToolTipOnDoubleClick: PropTypes.bool,
    selectionOnDoubleClick: PropTypes.bool,

    // eslint-disable-next-line react/forbid-prop-types, react/no-unused-prop-types
    mapState: PropTypes.array,

    // eslint-disable-next-line react/no-unused-prop-types
    enableHover: PropTypes.bool,
    // eslint-disable-next-line react/no-unused-prop-types
    hoveredId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
    // eslint-disable-next-line react/no-unused-prop-types
    onHoverChange: PropTypes.func,

    // eslint-disable-next-line react/no-unused-prop-types
    enableSelection: PropTypes.bool,
    // eslint-disable-next-line react/no-unused-prop-types, react/forbid-prop-types
    selectedIds: PropTypes.array,
    // eslint-disable-next-line react/no-unused-prop-types
    onSelectionChange: PropTypes.func,

    // eslint-disable-next-line react/no-unused-prop-types
    minzoom: PropTypes.number,
    // eslint-disable-next-line react/no-unused-prop-types
    maxzoom: PropTypes.number,

    onAnimationKeyframe: PropTypes.func,
};

const defaultProps = {
    layout: undefined,
    filter: undefined,
    onClick: undefined,
    onDoubleClick: undefined,
    sourceLayer: undefined,
    setDestroyer: undefined,
    onHoverChange: undefined,
    enableHover: undefined,
    hoveredId: undefined,
    onSelectionChange: undefined,
    enableSelection: undefined,
    selectedIds: emptyList,
    minzoom: undefined,
    maxzoom: undefined,
    mapState: undefined,
    onAnimationKeyframe: undefined,
    showToolTipOnHover: false,
    showToolTipOnDoubleClick: false,
    selectionOnDoubleClick: false,
};

const changeSelectionState = (map, sourceKey, sourceLayer, oldSelectedIds, newSelectedIds) => {
    const oldSelectionIdSet = new Set(oldSelectedIds);
    const newSelectionIdSet = new Set(newSelectedIds);

    const toRemove = difference(oldSelectionIdSet, newSelectionIdSet);
    const toAdd = difference(newSelectionIdSet, oldSelectionIdSet);

    toRemove.forEach((id) => {
        map.removeFeatureState(
            { source: sourceKey, id, sourceLayer },
            'selected',
        );
    });
    toAdd.forEach((id) => {
        map.setFeatureState(
            { source: sourceKey, id, sourceLayer },
            { selected: true },
        );
    });
};

const clearHoverState = (map, sourceKey, sourceLayer, oldHoveredId) => {
    if (oldHoveredId) {
        map.removeFeatureState(
            { source: sourceKey, id: oldHoveredId, sourceLayer },
            'hover',
        );
    }
};

const changeHoverState = (map, sourceKey, sourceLayer, oldHoveredId, newHoveredId) => {
    clearHoverState(map, sourceKey, sourceLayer, oldHoveredId);

    if (newHoveredId) {
        map.setFeatureState(
            { source: sourceKey, id: newHoveredId, sourceLayer },
            { hover: true },
        );
    }
};

const setMapState = (
    map,
    sourceKey,
    sourceLayer,
    newMapState,
    newSelectedIds,
    newHoveredId,
) => {
    // NOTE: map state is shared between every map layers of a map source
    // So, if mapState is not defined, it will not update the map state internally
    // To clear out mapState, we should implicitly set mapState to emptyArray
    if (isNotDefined(newMapState)) {
        return;
    }

    // Remove everything for a source
    map.removeFeatureState({
        source: sourceKey,
        sourceLayer,
    });

    // add new map state
    newMapState.forEach((item) => {
        map.setFeatureState(
            { source: sourceKey, id: item.id, sourceLayer },
            item.value,
        );
    });

    // add hoverId
    changeSelectionState(map, sourceKey, sourceLayer, undefined, newSelectedIds);
    // add selectedIds
    changeHoverState(map, sourceKey, sourceLayer, undefined, newHoveredId);
};

class MapLayer extends React.PureComponent {
    static propTypes = propTypes;

    static defaultProps = defaultProps;

    constructor(props) {
        super(props);

        if (props.setDestroyer) {
            props.setDestroyer(props.layerKey, this.destroy);
        }

        this.eventHandlers = {};
        this.layer = undefined;
        this.popup = undefined;
    }

    componentDidMount() {
        this.tooltipContainer = document.createElement('div');
        this.create(this.props);
    }

    // eslint-disable-next-line camelcase
    UNSAFE_componentWillReceiveProps(nextProps) {
        const {
            map: oldMap,
            mapStyle: oldMapStyle,
            layout: oldLayout,
            paint: oldPaint,
            filter: oldFilter,
            hoveredId: oldHoveredId,
            selectedIds: oldSelectedIds,
            sourceLayer: oldSourceLayer,
            mapState: oldMapState,
        } = this.props;
        const {
            map: newMap,
            mapStyle: newMapStyle,
            layout: newLayout,
            paint: newPaint,
            filter: newFilter,
            hoveredId: newHoveredId,
            selectedIds: newSelectedIds,
            sourceKey,
            sourceLayer: newSourceLayer,
            mapState: newMapState,
        } = nextProps;

        if (
            oldMap !== newMap
            || oldMapStyle !== newMapStyle
            || oldSourceLayer !== newSourceLayer
        ) {
            this.destroy();
            this.create(nextProps);
            return;
        }

        if (oldMapState !== newMapState) {
            setMapState(
                newMap, sourceKey, newSourceLayer, newMapState, newSelectedIds, newHoveredId,
            );
        }

        if (
            oldHoveredId !== newHoveredId
            && this.stateHoveredId !== newHoveredId
        ) {
            // if mapState has changed, then it will clear out the hovered state
            // and set it appropriately, so no need to handle it again
            if (oldMapState === newMapState) {
                changeHoverState(
                    newMap, sourceKey, newSourceLayer, this.stateHoveredId, newHoveredId,
                );
            }
            this.stateHoveredId = newHoveredId;
        }

        if (
            oldSelectedIds !== newSelectedIds
            && this.stateSelectedIds !== newSelectedIds
        ) {
            // if mapState has changed, then it will clear out the selected state
            // and set it appropriately, so no need to handle it again
            if (oldMapState === newMapState) {
                changeSelectionState(
                    newMap, sourceKey, newSourceLayer, this.stateSelectedIds, newSelectedIds,
                );
            }
            this.stateSelectedIds = newSelectedIds;
        }

        if (this.layer && oldLayout !== newLayout) {
            this.reloadLayout(nextProps);
        }
        if (this.layer && oldPaint !== newPaint) {
            this.reloadPaint(nextProps);
        }
        if (this.layer && oldFilter !== newFilter) {
            this.reloadFilter(nextProps);
        }
    }

    componentWillUnmount() {
        if (this.tooltipContainer) {
            this.tooltipContainer.remove();
        }

        cancelAnimationFrame(this.animationKey);

        this.destroy();
    }

    reloadLayout = (props) => {
        const {
            map,
            layerKey,
            layout,
        } = props;

        forEach(layout, (key, lay) => {
            map.setLayoutProperty(layerKey, key, lay);
        });
    }

    reloadPaint = (props) => {
        const {
            map,
            layerKey,
            paint,
        } = props;

        forEach(paint, (key, pai) => {
            map.setPaintProperty(layerKey, key, pai);
        });
    }

    reloadFilter = (props) => {
        const {
            map,
            layerKey,
            filter,
        } = props;

        map.setFilter(layerKey, filter);
    }

    destroyHandlers = (map, layerKey) => {
        forEach(this.eventHandlers, (type, listener) => {
            // console.info('Removing layer event handler', layerKey);
            map.off(type, layerKey, listener);
        });
        this.eventHandlers = {};
    }

    destroy = () => {
        const {
            map,
            layerKey,
        } = this.props;
        if (!map) {
            return;
        }

        this.destroyHandlers(map, layerKey);

        if (this.layer) {
            // console.info('Removing layer', layerKey);
            map.removeLayer(this.layer);
            this.layer = undefined;
        }
        if (this.popup) {
            // console.info('Removing popup layer', layerKey);
            this.popup.remove();
            this.popup = undefined;
        }
    }

    create = (props) => {
        const {
            map,
            sourceKey,
            layerKey,
            sourceLayer,
            type,
            paint,
            layout,
            filter,
            // enableHover,
            // enableSelection,
            tooltipRenderer,
            tooltipRendererParams,
            onClick,
            onDoubleClick,
            mapState,
            hoveredId,
            selectedIds,
            onHoverChange,
            onSelectionChange,
            minzoom,
            maxzoom,
            onAnimationKeyframe,
        } = props;

        if (this.animationKey) {
            cancelAnimationFrame(this.animationKey);
        }

        const layerInfo = {
            id: layerKey,
            source: sourceKey,
            type,
        };
        if (paint) {
            layerInfo.paint = paint;
        }
        if (layout) {
            layerInfo.layout = layout;
        }
        if (filter) {
            layerInfo.filter = filter;
        }
        if (sourceLayer) {
            layerInfo['source-layer'] = sourceLayer;
        }
        if (isDefined(minzoom)) {
            layerInfo.minzoom = minzoom;
        }
        if (isDefined(maxzoom)) {
            layerInfo.maxzoom = maxzoom;
        }
        // NOTE: we use this information later to identify clickable layers
        layerInfo.metadata = {
            selectionEnabled: !!onSelectionChange,
            hoverEnabled: !!onHoverChange,
        };
        // console.info('Adding layer', layerKey);
        map.addLayer(layerInfo);

        this.layer = layerKey;

        // FIXME: remove this.stateHoveredId and this.state.stateSelectedIds
        this.stateHoveredId = undefined;
        this.stateSelectedIds = emptyList;

        // Change the cursor to a pointer when the mouse is over the places layer.
        this.eventHandlers.mouseenter = (e) => {
            const {
                enableSelection,
                enableHover,
                showToolTipOnHover,
            } = this.props;

            const selectionEnabled = !!onSelectionChange && enableSelection;
            const hoverEnabled = !!onHoverChange && enableHover;

            if (selectionEnabled || hoverEnabled || tooltipRenderer || onClick || onDoubleClick) {
                // eslint-disable-next-line no-param-reassign
                map.getCanvas().style.cursor = 'pointer';
            }

            const {
                lngLat: coordinates,
                features,
            } = e;
            // Get first feature (it looks to be the top-most)
            const { id, properties } = features[0];

            if (tooltipRenderer && showToolTipOnHover) {
                const Tooltip = tooltipRenderer;
                const params = tooltipRendererParams(id, properties);

                ReactDOM.render(
                    React.createElement(Tooltip, params),
                    this.tooltipContainer,
                );

                if (this.popup) {
                    this.popup.remove();
                    this.popup = undefined;
                }

                this.popup = new mapboxgl.Popup()
                    .setLngLat(coordinates)
                    .setDOMContent(this.tooltipContainer);
                this.popup.addTo(map);
            }
        };

        // When the mouse leaves the state-fill layer, update the feature state of the
        // previously hovered feature.
        this.eventHandlers.mouseleave = () => {
            const {
                enableSelection,
                enableHover,
                showToolTipOnHover,
            } = this.props;

            const selectionEnabled = !!onSelectionChange && enableSelection;
            const hoverEnabled = !!onHoverChange && enableHover;

            if (hoverEnabled && this.stateHoveredId) {
                clearHoverState(map, sourceKey, sourceLayer, this.stateHoveredId);
                this.stateHoveredId = undefined;
                onHoverChange(undefined);
            }

            // FIXME: this has problem
            if (hoverEnabled || selectionEnabled || tooltipRenderer || onClick || onDoubleClick) {
                // Change it back to a pointer when it leaves.
                // eslint-disable-next-line no-param-reassign
                map.getCanvas().style.cursor = '';
            }

            if (this.popup && showToolTipOnHover) {
                this.popup.remove();
                this.popup = undefined;
            }
        };

        // When the user moves their mouse over the state-fill layer, we'll update the
        // feature state for the feature under the mouse.
        this.eventHandlers.mousemove = (e) => {
            const { enableHover } = this.props;

            const hoverEnabled = !!onHoverChange && enableHover;

            const { features } = e;
            if (features.length > 0 && hoverEnabled) {
                // Get first feature (it looks to be the top-most)
                const { id } = features[0];

                if (id !== this.stateHoveredId) {
                    changeHoverState(map, sourceKey, sourceLayer, this.stateHoveredId, id);
                    this.stateHoveredId = id;
                    onHoverChange(id);
                }
            }
        };

        // When a click event occurs on a feature in the places layer, open a popup at the
        // location of the feature, with description HTML from its properties.
        this.eventHandlers.click = (e) => {
            const {
                enableSelection,
                selectionOnDoubleClick,
                showToolTipOnDoubleClick,
            } = this.props;
            const selectionEnabled = !!onSelectionChange && enableSelection;

            const {
                lngLat: coordinates,
                features,
            } = e;

            const clickedFeatures = map.queryRenderedFeatures(e.point);
            const topmostClickableFeature = clickedFeatures.find((feature) => {
                const {
                    layer: {
                        metadata,
                    },
                } = feature;
                return metadata && metadata.selectionEnabled;
            });

            // Get first feature (it looks to be the top-most)
            const { id, properties } = features[0];

            if (
                topmostClickableFeature
                && topmostClickableFeature.layer.id === layerKey
                && topmostClickableFeature.source === sourceKey
            ) {
                if (selectionEnabled && !selectionOnDoubleClick) {
                    const index = this.stateSelectedIds.findIndex(selectedId => selectedId === id);

                    let newSelectedIds;
                    if (index === -1) {
                        newSelectedIds = [...this.stateSelectedIds, id];
                    } else {
                        newSelectedIds = [...this.stateSelectedIds];
                        newSelectedIds.splice(index, 1);
                    }

                    changeSelectionState(
                        map, sourceKey, sourceLayer, this.stateSelectedIds, newSelectedIds,
                    );
                    this.stateSelectedIds = newSelectedIds;
                    onSelectionChange(newSelectedIds, id);
                }
            }

            if (tooltipRenderer && !showToolTipOnDoubleClick) {
                const Tooltip = tooltipRenderer;
                const params = tooltipRendererParams(id, properties);

                ReactDOM.render(
                    React.createElement(Tooltip, params),
                    this.tooltipContainer,
                );

                if (this.popup) {
                    this.popup.remove();
                    this.popup = undefined;
                }

                this.popup = new mapboxgl.Popup()
                    .setLngLat(coordinates)
                    .setDOMContent(this.tooltipContainer);
                this.popup.addTo(map);
            }

            if (onClick) {
                onClick(id, properties);
            }
        };

        this.eventHandlers.dblclick = (e) => {
            const {
                enableSelection,
                selectionOnDoubleClick,
                showToolTipOnDoubleClick,
            } = this.props;

            const selectionEnabled = !!onSelectionChange && enableSelection;

            const {
                lngLat: coordinates,
                features,
            } = e;

            const clickedFeatures = map.queryRenderedFeatures(e.point);
            const topmostClickableFeature = clickedFeatures.find((feature) => {
                const {
                    layer: {
                        metadata,
                    },
                } = feature;
                return metadata && metadata.selectionEnabled;
            });

            // Get first feature (it looks to be the top-most)
            const { id, properties } = features[0];

            if (
                topmostClickableFeature
                && topmostClickableFeature.layer.id === layerKey
                && topmostClickableFeature.source === sourceKey
            ) {
                if (selectionEnabled && selectionOnDoubleClick) {
                    const index = this.stateSelectedIds.findIndex(selectedId => selectedId === id);

                    let newSelectedIds;
                    if (index === -1) {
                        newSelectedIds = [...this.stateSelectedIds, id];
                    } else {
                        newSelectedIds = [...this.stateSelectedIds];
                        newSelectedIds.splice(index, 1);
                    }

                    changeSelectionState(
                        map, sourceKey, sourceLayer, this.stateSelectedIds, newSelectedIds,
                    );
                    this.stateSelectedIds = newSelectedIds;
                    onSelectionChange(newSelectedIds);
                }
            }

            if (tooltipRenderer && showToolTipOnDoubleClick) {
                const Tooltip = tooltipRenderer;
                const params = tooltipRendererParams(id, properties);

                ReactDOM.render(
                    React.createElement(Tooltip, params),
                    this.tooltipContainer,
                );

                if (this.popup) {
                    this.popup.remove();
                    this.popup = undefined;
                }

                this.popup = new mapboxgl.Popup()
                    .setLngLat(coordinates)
                    .setDOMContent(this.tooltipContainer);
                this.popup.addTo(map);
            }

            if (onDoubleClick) {
                onDoubleClick(id, properties);
            }
        };

        forEach(this.eventHandlers, (eventType, listener) => {
            // console.info('Adding layer event handler', layerKey);
            map.on(eventType, layerKey, listener);
        });

        if (isDefined(hoveredId)) {
            changeHoverState(map, sourceKey, sourceLayer, this.stateHoveredId, hoveredId);
            this.stateHoveredId = hoveredId;
        }

        if (isDefined(selectedIds) && selectedIds.length > 0) {
            changeSelectionState(map, sourceKey, sourceLayer, this.stateSelectedIds, selectedIds);
            this.stateSelectedIds = selectedIds;
        }

        setMapState(
            map, sourceKey, sourceLayer, mapState, selectedIds, hoveredId,
        );

        if (onAnimationKeyframe) {
            this.animationKey = requestAnimationFrame(this.animate);
        }
    }

    animate = (timestamp) => {
        // TODO: handle UNSAFE_componentWillReceiveProps
        const {
            onAnimationKeyframe,
            map,
            layerKey,
        } = this.props;

        const values = onAnimationKeyframe(timestamp);
        if (values) {
            forEach(values, (key, pai) => {
                map.setPaintProperty(layerKey, key, pai);
            });
        }

        this.animationKey = requestAnimationFrame(this.animate);
    }

    render() {
        return null;
    }
}

export default MapChild(MapLayer);