diff --git a/x-pack/plugins/maps/common/constants.js b/x-pack/plugins/maps/common/constants.js index 0af229569e5d1..98de5ad588863 100644 --- a/x-pack/plugins/maps/common/constants.js +++ b/x-pack/plugins/maps/common/constants.js @@ -21,3 +21,5 @@ export const APP_ID = 'maps'; export const APP_ICON = 'gisApp'; export const SOURCE_DATA_ID_ORIGIN = 'source'; + +export const FEATURE_ID_PROPERTY_NAME = '__kbn__feature_id__'; diff --git a/x-pack/plugins/maps/public/actions/store_actions.js b/x-pack/plugins/maps/public/actions/store_actions.js index e38c5e25c086a..7f042d409ee27 100644 --- a/x-pack/plugins/maps/public/actions/store_actions.js +++ b/x-pack/plugins/maps/public/actions/store_actions.js @@ -389,18 +389,22 @@ export function updateSourceDataRequest(layerId, newData) { } export function endDataLoad(layerId, dataId, requestToken, data, meta) { - return ({ - type: LAYER_DATA_LOAD_ENDED, - layerId, - dataId, - data, - meta, - requestToken - }); + return (dispatch) => { + dispatch(clearTooltipStateForLayer(layerId)); + dispatch({ + type: LAYER_DATA_LOAD_ENDED, + layerId, + dataId, + data, + meta, + requestToken + }); + }; } export function onDataLoadError(layerId, dataId, requestToken, errorMessage) { return async (dispatch) => { + dispatch(clearTooltipStateForLayer(layerId)); dispatch({ type: LAYER_DATA_LOAD_ERROR, layerId, diff --git a/x-pack/plugins/maps/public/components/map/feature_tooltip.js b/x-pack/plugins/maps/public/components/map/feature_tooltip.js index 426d7009a7c59..4c89e15eced25 100644 --- a/x-pack/plugins/maps/public/components/map/feature_tooltip.js +++ b/x-pack/plugins/maps/public/components/map/feature_tooltip.js @@ -4,18 +4,50 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; -export function FeatureTooltip({ properties }) { - return Object.keys(properties).map(propertyName => { +export class FeatureTooltip extends React.Component { + + + _renderProperties() { + return Object.keys(this.props.properties).map(propertyName => { + return ( +
+ {propertyName} + {' '} + {this.props.properties[propertyName]} +
+ ); + }); + } + + render() { return ( -
- {propertyName} - {' '} - {properties[propertyName]} -
+ + + + +   + + + + + + + {this._renderProperties()} + + + ); - }); + } } diff --git a/x-pack/plugins/maps/public/components/map/mb/view.js b/x-pack/plugins/maps/public/components/map/mb/view.js index 6421ca54ee66c..1f11e7d807ab3 100644 --- a/x-pack/plugins/maps/public/components/map/mb/view.js +++ b/x-pack/plugins/maps/public/components/map/mb/view.js @@ -9,10 +9,16 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { ResizeChecker } from 'ui/resize_checker'; import { syncLayerOrder, removeOrphanedSourcesAndLayers, createMbMapInstance } from './utils'; -import { DECIMAL_DEGREES_PRECISION, ZOOM_PRECISION } from '../../../../common/constants'; +import { DECIMAL_DEGREES_PRECISION, FEATURE_ID_PROPERTY_NAME, ZOOM_PRECISION } from '../../../../common/constants'; import mapboxgl from 'mapbox-gl'; import { FeatureTooltip } from '../feature_tooltip'; + +const TOOLTIP_TYPE = { + HOVER: 'HOVER', + LOCKED: 'LOCKED' +}; + export class MBMapContainer extends React.Component { constructor() { @@ -25,6 +31,10 @@ export class MBMapContainer extends React.Component { }); } + _onTooltipClose = () => { + this.props.setTooltipState(null); + }; + _debouncedSync = _.debounce(() => { if (this._isMounted) { this._syncMbMapWithLayerList(); @@ -32,55 +42,78 @@ export class MBMapContainer extends React.Component { } }, 256); - _updateTooltipState = _.debounce(async (e) => { - const mbLayerIds = this._getMbLayerIdsForTooltips(); - const features = this._mbMap.queryRenderedFeatures(e.point, { layers: mbLayerIds }); + _lockTooltip = (e) => { + + this._updateHoverTooltipState.cancel();//ignore any possible moves + const features = this._getFeaturesUnderPointer(e.point); if (!features.length) { this.props.setTooltipState(null); return; } const targetFeature = features[0]; + const layer = this._getLayer(targetFeature.layer.id); + const popupAnchorLocation = this._justifyAnchorLocation(e.lngLat, targetFeature); + this.props.setTooltipState({ + type: TOOLTIP_TYPE.LOCKED, + layerId: layer.getId(), + featureId: targetFeature.properties[FEATURE_ID_PROPERTY_NAME], + location: popupAnchorLocation + }); + + }; + + _updateHoverTooltipState = _.debounce((e) => { + + if (this.props.tooltipState && this.props.tooltipState.type === TOOLTIP_TYPE.LOCKED) { + //ignore hover events when tooltip is locked + return; + } + + const features = this._getFeaturesUnderPointer(e.point); + if (!features.length) { + this.props.setTooltipState(null); + return; + } + + const targetFeature = features[0]; + if (this.props.tooltipState) { - const propertiesUnchanged = _.isEqual(this.props.tooltipState.activeFeature.properties, targetFeature.properties); - const geometryUnchanged = _.isEqual(this.props.tooltipState.activeFeature.geometry, targetFeature.geometry); - if(propertiesUnchanged && geometryUnchanged) { + if (targetFeature.properties[FEATURE_ID_PROPERTY_NAME] === this.props.tooltipState.featureId) { return; } } const layer = this._getLayer(targetFeature.layer.id); - const formattedProperties = await layer.getPropertiesForTooltip(targetFeature.properties); + const popupAnchorLocation = this._justifyAnchorLocation(e.lngLat, targetFeature); + + this.props.setTooltipState({ + type: TOOLTIP_TYPE.HOVER, + featureId: targetFeature.properties[FEATURE_ID_PROPERTY_NAME], + layerId: layer.getId(), + location: popupAnchorLocation + }); - let popupAnchorLocation = [e.lngLat.lng, e.lngLat.lat]; // default popup location to mouse location + }, 100); + + _justifyAnchorLocation(mbLngLat, targetFeature) { + let popupAnchorLocation = [mbLngLat.lng, mbLngLat.lat]; // default popup location to mouse location if (targetFeature.geometry.type === 'Point') { const coordinates = targetFeature.geometry.coordinates.slice(); // Ensure that if the map is zoomed out such that multiple // copies of the feature are visible, the popup appears // over the copy being pointed to. - while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { - coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; + while (Math.abs(mbLngLat.lng - coordinates[0]) > 180) { + coordinates[0] += mbLngLat.lng > coordinates[0] ? 360 : -360; } popupAnchorLocation = coordinates; } - - this.props.setTooltipState({ - activeFeature: { - properties: targetFeature.properties, - geometry: targetFeature.geometry - }, - formattedProperties: formattedProperties, - layerId: layer.getId(), - location: popupAnchorLocation - }); - - }, 100); - - + return popupAnchorLocation; + } _getMbLayerIdsForTooltips() { return this.props.layerList.reduce((mbLayerIds, layer) => { return layer.canShowTooltip() ? mbLayerIds.concat(layer.getMbLayerIds()) : mbLayerIds; @@ -106,6 +139,11 @@ export class MBMapContainer extends React.Component { }; } + _getFeaturesUnderPointer(mbLngLatPoint) { + const mbLayerIds = this._getMbLayerIdsForTooltips(); + return this._mbMap.queryRenderedFeatures(mbLngLatPoint, { layers: mbLayerIds }); + } + componentDidUpdate() { // do not debounce syncing of map-state and tooltip this._syncMbMapWithMapState(); @@ -162,7 +200,8 @@ export class MBMapContainer extends React.Component { }); - this._mbMap.on('mousemove', this._updateTooltipState); + this._mbMap.on('mousemove', this._updateHoverTooltipState); + this._mbMap.on('click', this._lockTooltip); this.props.onMapReady(this._getMapState()); } @@ -181,22 +220,27 @@ export class MBMapContainer extends React.Component { } } - _showTooltip() { - //todo: can still be optimized. No need to rerender if content remains identical - ReactDOM.render( - React.createElement( - FeatureTooltip, { - properties: this.props.tooltipState.formattedProperties, - } - ), - this._tooltipContainer - ); - - this._mbPopup.setLngLat(this.props.tooltipState.location) + _renderContentToTooltip(content, location) { + if (!this._isMounted) { + return; + } + ReactDOM.render((), this._tooltipContainer); + + this._mbPopup.setLngLat(location) .setDOMContent(this._tooltipContainer) .addTo(this._mbMap); } + + async _showTooltip() { + const tooltipLayer = this.props.layerList.find(layer => { + return layer.getId() === this.props.tooltipState.layerId; + }); + const targetFeature = tooltipLayer.getFeatureByFeatureById(this.props.tooltipState.featureId); + const formattedProperties = await tooltipLayer.getPropertiesForTooltip(targetFeature.properties); + this._renderContentToTooltip(formattedProperties, this.props.tooltipState.location); + } + _syncTooltipState() { if (this.props.tooltipState) { this._showTooltip(); diff --git a/x-pack/plugins/maps/public/shared/layers/vector_layer.js b/x-pack/plugins/maps/public/shared/layers/vector_layer.js index d3076de4852f3..49e4180974c0c 100644 --- a/x-pack/plugins/maps/public/shared/layers/vector_layer.js +++ b/x-pack/plugins/maps/public/shared/layers/vector_layer.js @@ -8,7 +8,7 @@ import turf from 'turf'; import { AbstractLayer } from './layer'; import { VectorStyle } from './styles/vector_style'; import { LeftInnerJoin } from './joins/left_inner_join'; -import { SOURCE_DATA_ID_ORIGIN } from '../../../common/constants'; +import { FEATURE_ID_PROPERTY_NAME, SOURCE_DATA_ID_ORIGIN } from '../../../common/constants'; import _ from 'lodash'; const EMPTY_FEATURE_COLLECTION = { @@ -285,11 +285,12 @@ export class VectorLayer extends AbstractLayer { try { startLoading(SOURCE_DATA_ID_ORIGIN, requestToken, searchFilters); const layerName = await this.getDisplayName(); - const { data, meta } = await this._source.getGeoJsonWithMeta(layerName, searchFilters); - stopLoading(SOURCE_DATA_ID_ORIGIN, requestToken, data, meta); + const { data: featureCollection, meta } = await this._source.getGeoJsonWithMeta(layerName, searchFilters); + this._assignIdsToFeatures(featureCollection); + stopLoading(SOURCE_DATA_ID_ORIGIN, requestToken, featureCollection, meta); return { refreshed: true, - featureCollection: data + featureCollection: featureCollection }; } catch (error) { onLoadError(SOURCE_DATA_ID_ORIGIN, requestToken, error.message); @@ -299,6 +300,14 @@ export class VectorLayer extends AbstractLayer { } } + + _assignIdsToFeatures(featureCollection) { + for (let i = 0; i < featureCollection.features.length; i++) { + const feature = featureCollection.features[i]; + feature.properties[FEATURE_ID_PROPERTY_NAME] = (typeof feature.id === 'string' || typeof feature.id === 'number') ? feature.id : i; + } + } + _joinToFeatureCollection(sourceResult, joinState, updateSourceData) { if (!sourceResult.refreshed && !joinState.shouldJoin) { return false; @@ -509,4 +518,15 @@ export class VectorLayer extends AbstractLayer { return this._source.canFormatFeatureProperties(); } + getFeatureByFeatureById(id) { + const featureCollection = this._getSourceFeatureCollection(id); + if (!featureCollection) { + return; + } + + return featureCollection.features.find((feature) => { + return feature.properties[FEATURE_ID_PROPERTY_NAME] === id; + }); + } + }