appbaseio/reactivesearch

View on GitHub
packages/maps/src/components/result/ReactiveMap.js

Summary

Maintainability
F
2 wks
Test Coverage
import React, { Component } from 'react';
import { withGoogleMap, GoogleMap, Marker, InfoWindow } from 'react-google-maps';
import MarkerClusterer from 'react-google-maps/lib/components/addons/MarkerClusterer';
import { MarkerWithLabel } from 'react-google-maps/lib/components/addons/MarkerWithLabel';
import {
    addComponent,
    removeComponent,
    setStreaming,
    watchComponent,
    setQueryOptions,
    updateQuery,
    loadMore,
    setMapData,
    setQueryListener,
} from '@appbaseio/reactivecore/lib/actions';
import {
    isEqual,
    getQueryOptions,
    pushToAndClause,
    parseHits,
    getInnerKey,
    getClassName,
} from '@appbaseio/reactivecore/lib/utils/helper';
import types from '@appbaseio/reactivecore/lib/utils/types';

import Dropdown from '@appbaseio/reactivesearch/lib/components/shared/Dropdown';
import { connect } from '@appbaseio/reactivesearch/lib/utils';
import Pagination from '@appbaseio/reactivesearch/lib/components/result/addons/Pagination';
import { Checkbox } from '@appbaseio/reactivesearch/lib/styles/FormControlList';
import { MapPin, MapPinArrow, mapPinWrapper } from './addons/styles/MapPin';

const Standard = require('./addons/styles/Standard');
const BlueEssence = require('./addons/styles/BlueEssence');
const BlueWater = require('./addons/styles/BlueWater');
const FlatMap = require('./addons/styles/FlatMap');
const LightMonochrome = require('./addons/styles/LightMonochrome');
const MidnightCommander = require('./addons/styles/MidnightCommander');
const UnsaturatedBrowns = require('./addons/styles/UnsaturatedBrowns');

const MAP_CENTER = {
    lat: 37.7749,
    lng: 122.4194,
};

const MapComponent = withGoogleMap((props) => {
    const { children, onMapMounted, ...allProps } = props;

    return (
        <GoogleMap ref={onMapMounted} {...allProps}>
            {children}
        </GoogleMap>
    );
});

function getPrecision(a) {
    if (isNaN(a)) return 0; // eslint-disable-line
    let e = 1;
    let p = 0;
    while (Math.round(a * e) / e !== a) {
        e *= 10;
        p += 1;
    }
    return p;
}

function withDistinctLat(loc, count) {
    const length = getPrecision(loc.lat);
    const noiseFactor = length >= 6 ? 4 : length - 2;
    const suffix = (1 / (10 ** noiseFactor)) * count;
    const location = {
        ...loc,
        lat: parseFloat((loc.lat + suffix).toFixed(length)),
    };
    return location;
}

class ReactiveMap extends Component {
    constructor(props) {
        super(props);

        this.mapStyles = [
            { label: 'Standard', value: Standard },
            { label: 'Blue Essence', value: BlueEssence },
            { label: 'Blue Water', value: BlueWater },
            { label: 'Flat Map', value: FlatMap },
            { label: 'Light Monochrome', value: LightMonochrome },
            { label: 'Midnight Commander', value: MidnightCommander },
            { label: 'Unsaturated Browns', value: UnsaturatedBrowns },
        ];

        const currentMapStyle
            = this.mapStyles.find(style => style.label === props.defaultMapStyle)
            || this.mapStyles[0];

        this.state = {
            currentMapStyle,
            from: props.currentPage * props.size || 0,
            isLoading: false,
            totalPages: 0,
            currentPage: props.currentPage,
            mapBoxBounds: null,
            searchAsMove: props.searchAsMove,
            zoom: props.defaultZoom,
            openMarkers: {},
            preserveCenter: false,
            markerOnTop: null,
        };
        this.mapRef = null;
        this.internalComponent = `${props.componentId}__internal`;
        props.setQueryListener(props.componentId, props.onQueryChange, null);
    }

    componentDidMount() {
        this.props.addComponent(this.internalComponent);
        this.props.addComponent(this.props.componentId);

        if (this.props.stream) {
            this.props.setStreaming(this.props.componentId, true);
        }

        const options = getQueryOptions(this.props);
        options.from = this.state.from;
        if (this.props.sortBy) {
            options.sort = [
                {
                    [this.props.dataField]: {
                        order: this.props.sortBy,
                    },
                },
            ];
        }

        this.defaultQuery = null;
        if (this.props.defaultQuery) {
            this.defaultQuery = this.props.defaultQuery();
            // Override sort query with defaultQuery's sort if defined
            if (this.defaultQuery.sort) {
                options.sort = this.defaultQuery.sort;
            }

            // since we want defaultQuery to be executed anytime
            // map component's query is being executed
            const persistMapQuery = true;
            // no need to forceExecute because setReact() will capture the main query
            // and execute the defaultQuery along with it
            const forceExecute = false;

            this.props.setMapData(
                this.props.componentId,
                this.defaultQuery.query,
                persistMapQuery,
                forceExecute,
            );
        } else {
            // only apply geo-distance when defaultQuery prop is not set
            const query = this.getGeoDistanceQuery();
            if (query) {
                // - only persist the map query if center prop is set
                // - ideally, persist the map query if you want to keep executing it
                //   whenever there is a change (due to subscription) in the component query
                const persistMapQuery = !!this.props.center;

                // - forceExecute will make sure that the component query + Map query gets executed
                //   irrespective of the changes in the component query
                // - forceExecute will only come into play when searchAsMove is true
                // - kindly note that forceExecute may result in one additional network request
                //   since it bypasses the gatekeeping
                const forceExecute = this.state.searchAsMove;
                this.props.setMapData(this.props.componentId, query, persistMapQuery, forceExecute);
            }
        }

        this.props.setQueryOptions(
            this.props.componentId,
            options,
            !(this.defaultQuery && this.defaultQuery.query),
        );
        this.setReact(this.props);
    }

    componentWillReceiveProps(nextProps) {
        if (
            this.props.sortBy !== nextProps.sortBy
            || this.props.size !== nextProps.size
            || !isEqual(this.props.dataField, nextProps.dataField)
        ) {
            const options = getQueryOptions(nextProps);
            options.from = 0;
            if (nextProps.sortBy) {
                options.sort = [
                    {
                        [nextProps.dataField]: {
                            order: nextProps.sortBy,
                        },
                    },
                ];
            }
            this.setState({
                from: 0,
                currentPage: 0,
            });
            this.props.setQueryOptions(this.props.componentId, options, true);
        }

        if (!isEqual(this.props.center, nextProps.center)) {
            const persistMapQuery = !!nextProps.center;
            // we need to forceExecute the query because the center has changed
            const forceExecute = true;

            this.props.setMapData(
                this.props.componentId,
                this.getGeoQuery(nextProps),
                persistMapQuery,
                forceExecute,
            );
        }

        if (!isEqual(this.props.hits, nextProps.hits)) {
            this.setState({
                openMarkers: {},
            });
        }

        if (nextProps.defaultQuery && !isEqual(nextProps.defaultQuery(), this.defaultQuery)) {
            const options = getQueryOptions(nextProps);
            options.from = this.state.from;
            this.defaultQuery = nextProps.defaultQuery();

            const { sort, query } = this.defaultQuery;

            if (sort) {
                options.sort = this.defaultQuery.sort;
                nextProps.setQueryOptions(nextProps.componentId, options, !query);
            }

            const persistMapQuery = true;
            const forceExecute = true;

            this.props.setMapData(this.props.componentId, query, persistMapQuery, forceExecute);
        }

        if (this.props.stream !== nextProps.stream) {
            this.props.setStreaming(nextProps.componentId, nextProps.stream);
        }

        if (!isEqual(nextProps.react, this.props.react)) {
            this.setReact(nextProps);
        }

        // called when page is changed
        if (this.props.pagination && this.state.isLoading) {
            if (nextProps.onPageChange) {
                nextProps.onPageChange();
            }
            this.setState({
                isLoading: false,
            });
        }

        if (
            !nextProps.pagination
            && this.props.hits
            && nextProps.hits
            && (this.props.hits.length < nextProps.hits.length
                || nextProps.hits.length === nextProps.total)
        ) {
            this.setState({
                isLoading: false,
            });
        }

        if (
            !nextProps.pagination
            && nextProps.hits
            && this.props.hits
            && nextProps.hits.length < this.props.hits.length
        ) {
            if (nextProps.onPageChange) {
                nextProps.onPageChange();
            }
            this.setState({
                from: 0,
                isLoading: false,
            });
        }

        if (nextProps.pagination && nextProps.total !== this.props.total) {
            this.setState({
                totalPages: Math.ceil(nextProps.total / nextProps.size),
                currentPage: this.props.total ? 0 : this.state.currentPage,
            });
        }

        if (this.props.searchAsMove !== nextProps.searchAsMove) {
            this.setState({
                searchAsMove: nextProps.searchAsMove,
            });
            // no need to execute the map query since the component will
            // get re-rendered and the new query will be automatically evaluated
        }

        if (
            this.props.defaultZoom !== nextProps.defaultZoom
            && !isNaN(nextProps.defaultZoom) && // eslint-disable-line
            nextProps.defaultZoom
        ) {
            this.setState({
                zoom: nextProps.defaultZoom,
            });
        }

        if (this.props.defaultMapStyle !== nextProps.defaultMapStyle) {
            this.setState({
                currentMapStyle:
                    this.mapStyles.find(style => style.label === nextProps.defaultMapStyle)
                    || this.mapStyles[0],
            });
        }
    }

    shouldComponentUpdate(nextProps, nextState) {
        if (
            this.state.searchAsMove !== nextState.searchAsMove
            || this.state.markerOnTop !== nextState.markerOnTop
            || this.props.showMapStyles !== nextProps.showMapStyles
            || this.props.autoCenter !== nextProps.autoCenter
            || this.props.streamAutoCenter !== nextProps.streamAutoCenter
            || this.props.defaultZoom !== nextProps.defaultZoom
            || this.props.showMarkerClusters !== nextProps.showMarkerClusters
            || !isEqual(this.state.currentMapStyle, nextState.currentMapStyle)
            || !isEqual(this.state.openMarkers, nextState.openMarkers)
        ) {
            return true;
        }

        if (
            isEqual(this.props.hits, nextProps.hits)
            && isEqual(this.props.streamHits, nextProps.streamHits)
        ) {
            return false;
        }
        return true;
    }

    componentWillUnmount() {
        this.props.removeComponent(this.props.componentId);
        this.props.removeComponent(this.internalComponent);
    }

    setReact = (props) => {
        const { react } = props;
        if (react) {
            const newReact = pushToAndClause(react, this.internalComponent);
            props.watchComponent(props.componentId, newReact);
        } else {
            props.watchComponent(props.componentId, { and: this.internalComponent });
        }
    };

    getHitsCenter = (hits) => {
        const data = hits.map(hit => hit[this.props.dataField]);

        if (data.length) {
            const numCoords = data.length;

            let X = 0.0;
            let Y = 0.0;
            let Z = 0.0;

            data.forEach((location) => {
                if (location) {
                    let lat = 0.0;
                    let lng = 0.0;

                    if (Array.isArray(location)) {
                        lat = (location[0] * Math.PI) / 180;
                        lng = (location[1] * Math.PI) / 180;
                    } else {
                        lat = (location.lat * Math.PI) / 180;
                        lng
                            = ((location.lng !== undefined ? location.lng : location.lon) * Math.PI)
                            / 180;
                    }

                    const a = Math.cos(lat) * Math.cos(lng);
                    const b = Math.cos(lat) * Math.sin(lng);
                    const c = Math.sin(lat);

                    X += a;
                    Y += b;
                    Z += c;
                }
            });

            X /= numCoords;
            Y /= numCoords;
            Z /= numCoords;

            const lng = Math.atan2(Y, X);
            const hyp = Math.sqrt((X * X) + (Y * Y));
            const lat = Math.atan2(Z, hyp);

            const newX = (lat * 180) / Math.PI;
            const newY = (lng * 180) / Math.PI;

            return {
                lat: newX,
                lng: newY,
            };
        }
        return false;
    };

    // getArrPosition = location => [location.lat, location.lon || location.lng];
    getArrPosition = location => ({ lat: location.lat, lon: location.lon || location.lng });

    getGeoDistanceQuery = () => {
        const center = this.props.center || this.props.defaultCenter;
        if (center && this.props.defaultRadius) {
            // skips geo bounding box query on initial load
            this.skipBoundingBox = true;
            return {
                geo_distance: {
                    distance: `${this.props.defaultRadius}${this.props.unit}`,
                    [this.props.dataField]: this.getArrPosition(center),
                },
            };
        }
        return null;
    };

    getGeoQuery = (props = this.props) => {
        this.defaultQuery = props.defaultQuery ? props.defaultQuery() : null;

        if (this.mapRef) {
            const mapBounds = this.mapRef.getBounds();
            const north = mapBounds.getNorthEast().lat();
            const south = mapBounds.getSouthWest().lat();
            const east = mapBounds.getNorthEast().lng();
            const west = mapBounds.getSouthWest().lng();
            const boundingBoxCoordinates = {
                top_left: [west, north],
                bottom_right: [east, south],
            };

            this.setState({
                mapBoxBounds: boundingBoxCoordinates,
            });

            const geoQuery = {
                geo_bounding_box: {
                    [this.props.dataField]: boundingBoxCoordinates,
                },
            };

            if (this.defaultQuery) {
                const { query } = this.defaultQuery;

                if (query) {
                    // adds defaultQuery's query to geo-query
                    // to generate a map query

                    return {
                        must: [geoQuery, query],
                    };
                }
            }

            return geoQuery;
        }

        // return the defaultQuery (if set) or null when map query not available
        return this.defaultQuery ? this.defaultQuery.query : null;
    };

    setGeoQuery = (executeUpdate = false) => {
        // execute a new query on theinitial mount
        // or whenever searchAsMove is true and the map is dragged
        if (executeUpdate || (!this.skipBoundingBox && !this.state.mapBoxBounds)) {
            this.defaultQuery = this.getGeoQuery();

            const persistMapQuery = !!this.props.center;
            const forceExecute = this.state.searchAsMove;

            this.props.setMapData(
                this.props.componentId,
                this.defaultQuery,
                persistMapQuery,
                forceExecute,
            );
        }
        this.skipBoundingBox = false;
    };

    loadMore = () => {
        if (
            this.props.hits
            && !this.props.pagination
            && this.props.total !== this.props.hits.length
        ) {
            const value = this.state.from + this.props.size;
            const options = getQueryOptions(this.props);

            this.setState({
                from: value,
                isLoading: true,
            });
            this.props.loadMore(
                this.props.componentId,
                {
                    ...options,
                    from: value,
                },
                true,
            );
        } else if (this.state.isLoading) {
            this.setState({
                isLoading: false,
            });
        }
    };

    setPage = (page) => {
        const value = this.props.size * page;
        const options = getQueryOptions(this.props);
        options.from = this.state.from;
        this.setState({
            from: value,
            isLoading: true,
            currentPage: page,
        });
        this.props.loadMore(
            this.props.componentId,
            {
                ...options,
                from: value,
            },
            false,
        );

        if (this.props.URLParams) {
            this.props.setPageURL(
                `${this.props.componentId}-page`,
                page + 1,
                `${this.props.componentId}-page`,
                false,
                true,
            );
        }
    };

    getPosition = (result) => {
        if (result) {
            return this.parseLocation(result[this.props.dataField]);
        }
        return null;
    };

    parseLocation(location) {
        if (Array.isArray(location)) {
            return {
                lat: Number(location[0]),
                lng: Number(location[1]),
            };
        }
        return {
            lat: location ? Number(location.lat) : this.props.defaultCenter.lat,
            lng: location
                ? Number(location.lon === undefined ? location.lng : location.lon)
                : this.props.defaultCenter.lng,
        };
    }

    setMapStyle = (currentMapStyle) => {
        this.setState({
            currentMapStyle,
        });
    };

    getCenter = (hits) => {
        if (this.props.center) {
            return this.parseLocation(this.props.center);
        }

        if (
            (!!this.mapRef && this.state.preserveCenter)
            || (this.props.stream && this.props.streamHits.length && !this.props.streamAutoCenter)
        ) {
            const currentCenter = this.mapRef.getCenter();
            setTimeout(() => {
                this.setState({
                    preserveCenter: false,
                });
            }, 100);
            return this.parseLocation({
                lat: currentCenter.lat(),
                lng: currentCenter.lng(),
            });
        }

        if (hits && hits.length) {
            if (this.props.autoCenter || this.props.streamAutoCenter) {
                return this.getHitsCenter(hits) || this.getDefaultCenter();
            }
            return hits[0] && hits[0][this.props.dataField]
                ? this.getPosition(hits[0])
                : this.getDefaultCenter();
        }
        return this.getDefaultCenter();
    };

    getDefaultCenter = () => {
        if (this.props.defaultCenter) return this.parseLocation(this.props.defaultCenter);
        return this.parseLocation(MAP_CENTER);
    };

    handleOnIdle = () => {
        // only make the geo_bounding query if we have hits data
        if (this.props.hits.length && this.state.searchAsMove) {
            // always execute geo-bounds query when center is set
            // to improve the specificity of search results
            const executeUpdate = !!this.props.center;
            this.setGeoQuery(executeUpdate);
        }
        if (this.props.mapProps.onIdle) this.props.mapProps.onIdle();
    };

    handleOnDragEnd = () => {
        if (this.state.searchAsMove) {
            this.setState(
                {
                    preserveCenter: true,
                },
                () => {
                    this.setGeoQuery(true);
                },
            );
        }
        if (this.props.mapProps.onDragEnd) this.props.mapProps.onDragEnd();
    };

    handleZoomChange = () => {
        const zoom = this.mapRef.getZoom();
        if (this.state.searchAsMove) {
            this.setState(
                {
                    zoom,
                    preserveCenter: true,
                },
                () => {
                    this.setGeoQuery(true);
                },
            );
        } else {
            this.setState({
                zoom,
            });
        }
        if (this.props.mapProps.onZoomChanged) this.props.mapProps.onZoomChanged();
    };

    toggleSearchAsMove = () => {
        this.setState({
            searchAsMove: !this.state.searchAsMove,
        });
    };

    renderSearchAsMove = () => {
        if (this.props.showSearchAsMove) {
            return (
                <div
                    style={{
                        position: 'absolute',
                        bottom: 30,
                        left: 10,
                        width: 240,
                        backgroundColor: '#fff',
                        padding: '8px 10px',
                        boxShadow: 'rgba(0,0,0,0.3) 0px 1px 4px -1px',
                        borderRadius: 2,
                    }}
                    className={getClassName(this.props.innerClass, 'checkboxContainer') || null}
                >
                    <Checkbox
                        className={getClassName(this.props.innerClass, 'checkbox') || null}
                        id="searchasmove"
                        onChange={this.toggleSearchAsMove}
                        checked={this.state.searchAsMove}
                    />
                    <label
                        className={getClassName(this.props.innerClass, 'label') || null}
                        htmlFor="searchasmove"
                    >
                        Search as I move the map
                    </label>
                </div>
            );
        }

        return null;
    };

    openMarkerInfo = (id) => {
        const openMarkers = this.props.autoClosePopover
            ? { [id]: true }
            : { ...this.state.openMarkers, [id]: true };
        this.setState({
            openMarkers,
            preserveCenter: true,
        });
    };

    closeMarkerInfo = (id) => {
        const { [id]: del, ...activeMarkers } = this.state.openMarkers;
        const openMarkers = this.props.autoClosePopover ? {} : activeMarkers;

        this.setState({
            openMarkers,
            preserveCenter: true,
        });
    };

    renderPopover = (item, includeExternalSettings = false) => {
        let additionalProps = {};

        if (includeExternalSettings) {
            // to render pop-over correctly with MarkerWithLabel
            additionalProps = {
                position: this.getPosition(item),
                defaultOptions: {
                    pixelOffset: new window.google.maps.Size(0, -30),
                },
            };
        }

        if (item._id in this.state.openMarkers) {
            return (
                <InfoWindow
                    zIndex={500}
                    key={`${item._id}-InfoWindow`}
                    onCloseClick={() => this.closeMarkerInfo(item._id)}
                    {...additionalProps}
                >
                    {this.props.onPopoverClick(item)}
                </InfoWindow>
            );
        }
        return null;
    };

    increaseMarkerZIndex = (id) => {
        this.setState({
            markerOnTop: id,
            preserveCenter: true,
        });
    };

    removeMarkerZIndex = () => {
        this.setState({
            markerOnTop: null,
            preserveCenter: true,
        });
    };

    addNoise = (hits) => {
        const hitMap = {};
        let updatedHits = [];

        hits.forEach((item) => {
            const updatedItem = { ...item };
            const location = this.parseLocation(item[this.props.dataField]);
            const key = JSON.stringify(location);
            const count = hitMap[key] || 0;

            updatedItem[this.props.dataField] = count ? withDistinctLat(location, count) : location;
            updatedHits = [...updatedHits, updatedItem];

            hitMap[key] = count + 1;
        });
        return updatedHits;
    };

    getMarkers = (resultsToRender) => {
        let markers = [];
        if (this.props.showMarkers) {
            markers = resultsToRender.map((item) => {
                const markerProps = {
                    position: this.getPosition(item),
                };

                if (this.state.markerOnTop === item._id) {
                    markerProps.zIndex = window.google.maps.Marker.MAX_ZINDEX + 1;
                }

                if (this.props.onData) {
                    const data = this.props.onData(item);

                    if ('label' in data) {
                        return (
                            <MarkerWithLabel
                                key={item._id}
                                labelAnchor={new window.google.maps.Point(0, 30)}
                                icon="https://i.imgur.com/h81muef.png" // blank png to remove the icon
                                onClick={() => this.openMarkerInfo(item._id)}
                                onMouseOver={() => this.increaseMarkerZIndex(item._id)}
                                onFocus={() => this.increaseMarkerZIndex(item._id)}
                                onMouseOut={this.removeMarkerZIndex}
                                onBlur={this.removeMarkerZIndex}
                                {...markerProps}
                                {...this.props.markerProps}
                            >
                                <div className={mapPinWrapper}>
                                    <MapPin>{data.label}</MapPin>
                                    <MapPinArrow />
                                    {this.props.onPopoverClick
                                        ? this.renderPopover(item, true)
                                        : null}
                                </div>
                            </MarkerWithLabel>
                        );
                    } else if ('icon' in data) {
                        markerProps.icon = data.icon;
                    } else {
                        return (
                            <MarkerWithLabel
                                key={item._id}
                                labelAnchor={new window.google.maps.Point(0, 0)}
                                onMouseOver={() => this.increaseMarkerZIndex(item._id)}
                                onFocus={() => this.increaseMarkerZIndex(item._id)}
                                onMouseOut={this.removeMarkerZIndex}
                                onBlur={this.removeMarkerZIndex}
                                {...markerProps}
                                {...this.props.markerProps}
                            >
                                {data.custom}
                            </MarkerWithLabel>
                        );
                    }
                } else if (this.props.defaultPin) {
                    markerProps.icon = this.props.defaultPin;
                }

                return (
                    <Marker
                        key={item._id}
                        onClick={() => this.openMarkerInfo(item._id)}
                        {...markerProps}
                        {...this.props.markerProps}
                    >
                        {this.props.onPopoverClick ? this.renderPopover(item) : null}
                    </Marker>
                );
            });
        }
        return markers;
    };

    renderMap = () => {
        const results = parseHits(this.props.hits) || [];
        const streamResults = parseHits(this.props.streamHits) || [];
        let filteredResults = results.filter(item => !!item[this.props.dataField]);

        if (streamResults.length) {
            const ids = streamResults.map(item => item._id);
            filteredResults = filteredResults.filter(item => !ids.includes(item._id));
        }

        const resultsToRender = this.addNoise([...streamResults, ...filteredResults]);
        const markers = this.getMarkers(resultsToRender);

        const style = {
            width: '100%',
            height: '100%',
            position: 'relative',
        };

        return (
            <div style={style}>
                <MapComponent
                    containerElement={<div style={style} />}
                    mapElement={<div style={{ height: '100%' }} />}
                    onMapMounted={(ref) => {
                        this.mapRef = ref;
                        if (this.props.innerRef && ref) {
                            const map = Object.values(ref.context)[0];
                            const mapRef = { ...ref, map };
                            this.props.innerRef(mapRef);
                        }
                    }}
                    zoom={this.state.zoom}
                    center={this.getCenter(resultsToRender)}
                    {...this.props.mapProps}
                    onIdle={this.handleOnIdle}
                    onZoomChanged={this.handleZoomChange}
                    onDragEnd={this.handleOnDragEnd}
                    options={{
                        styles: this.state.currentMapStyle.value,
                        ...getInnerKey(this.props.mapProps, 'options'),
                    }}
                >
                    {this.props.showMarkers && this.props.showMarkerClusters ? (
                        <MarkerClusterer averageCenter enableRetinaIcons gridSize={60}>
                            {markers}
                        </MarkerClusterer>
                    ) : (
                        markers
                    )}
                    {this.props.showMarkers && this.props.markers}
                    {this.renderSearchAsMove()}
                </MapComponent>
                {this.props.showMapStyles ? (
                    <div
                        style={{
                            position: 'absolute',
                            top: 10,
                            right: 46,
                            width: 120,
                            zIndex: window.google.maps.Marker.MAX_ZINDEX + 1,
                        }}
                    >
                        <Dropdown
                            innerClass={this.props.innerClass}
                            items={this.mapStyles}
                            onChange={this.setMapStyle}
                            selectedItem={this.state.currentMapStyle}
                            keyField="label"
                            returnsObject
                            small
                        />
                    </div>
                ) : null}
            </div>
        );
    };

    renderPagination = () => (
        <Pagination
            pages={this.props.pages}
            totalPages={this.state.totalPages}
            currentPage={this.state.currentPage}
            setPage={this.setPage}
            innerClass={this.props.innerClass}
        />
    );

    render() {
        const style = {
            width: '100%',
            height: '100vh',
            position: 'relative',
        };

        return (
            <div style={{ ...style, ...this.props.style }} className={this.props.className}>
                {this.props.onAllData
                    ? this.props.onAllData(
                        parseHits(this.props.hits),
                        parseHits(this.props.streamHits),
                        this.loadMore,
                        this.renderMap,
                        this.renderPagination,
                    ) // prettier-ignore
                    : this.renderMap()}
            </div>
        );
    }
}

ReactiveMap.propTypes = {
    addComponent: types.funcRequired,
    setMapData: types.funcRequired,
    loadMore: types.funcRequired,
    removeComponent: types.funcRequired,
    setQueryListener: types.funcRequired,
    onQueryChange: types.func,
    setPageURL: types.func,
    setQueryOptions: types.funcRequired,
    setStreaming: types.func,
    updateQuery: types.funcRequired,
    watchComponent: types.funcRequired,
    currentPage: types.number,
    hits: types.hits,
    isLoading: types.bool,
    streamHits: types.hits,
    time: types.number,
    total: types.number,
    url: types.string,
    // component props
    autoCenter: types.bool,
    center: types.location,
    className: types.string,
    componentId: types.stringRequired,
    dataField: types.stringRequired,
    defaultCenter: types.location,
    defaultMapStyle: types.string,
    defaultPin: types.string,
    defaultQuery: types.func,
    defaultZoom: types.number,
    innerClass: types.style,
    innerRef: types.func,
    loader: types.title,
    mapProps: types.props,
    markerProps: types.props,
    markers: types.children,
    onAllData: types.func,
    onData: types.func,
    onPageChange: types.func,
    onPopoverClick: types.func,
    pages: types.number,
    pagination: types.bool,
    react: types.react,
    searchAsMove: types.bool,
    showMapStyles: types.bool,
    showMarkerClusters: types.bool,
    showMarkers: types.bool,
    showSearchAsMove: types.bool,
    size: types.number,
    sortBy: types.sortBy,
    stream: types.bool,
    streamAutoCenter: types.bool,
    style: types.style,
    URLParams: types.bool,
    defaultRadius: types.number,
    unit: types.string,
    autoClosePopover: types.bool,
};

ReactiveMap.defaultProps = {
    size: 10,
    style: {},
    className: null,
    pages: 5,
    pagination: false,
    defaultMapStyle: 'Standard',
    autoCenter: false,
    streamAutoCenter: false,
    defaultZoom: 8,
    mapProps: {},
    markerProps: {},
    markers: null,
    showMapStyles: false,
    showSearchAsMove: true,
    searchAsMove: false,
    showMarkers: true,
    showMarkerClusters: true,
    unit: 'mi',
    defaultRadius: 100,
    autoClosePopover: false,
};

const mapStateToProps = (state, props) => ({
    mapKey: state.config.mapKey,
    hits: (state.hits[props.componentId] && state.hits[props.componentId].hits) || [],
    streamHits: state.streamHits[props.componentId] || [],
    currentPage:
        (state.selectedValues[`${props.componentId}-page`]
            && state.selectedValues[`${props.componentId}-page`].value - 1)
        || 0,
    time: (state.hits[props.componentId] && state.hits[props.componentId].time) || 0,
    total: state.hits[props.componentId] && state.hits[props.componentId].total,
});

const mapDispatchtoProps = dispatch => ({
    addComponent: component => dispatch(addComponent(component)),
    removeComponent: component => dispatch(removeComponent(component)),
    setStreaming: (component, stream) => dispatch(setStreaming(component, stream)),
    watchComponent: (component, react) => dispatch(watchComponent(component, react)),
    setQueryOptions: (component, props, execute) =>
        dispatch(setQueryOptions(component, props, execute)),
    setQueryListener: (component, onQueryChange, beforeQueryChange) =>
        dispatch(setQueryListener(component, onQueryChange, beforeQueryChange)),
    updateQuery: updateQueryObject => dispatch(updateQuery(updateQueryObject)),
    loadMore: (component, options, append) => dispatch(loadMore(component, options, append)),
    setMapData: (component, geoQuery, persistMapQuery, forceExecute = false) =>
        dispatch(setMapData(component, geoQuery, persistMapQuery, forceExecute)),
});

export default connect(
    mapStateToProps,
    mapDispatchtoProps,
)(ReactiveMap);