[Maps] Move tooltips to store (#32333)

This is an internal refactor:
- move tooltip management out of layers, and to the mapbox-component
- use global handler iso of multiple handlers on individual layers (this did remove the cursor-pointer change, since we no longer are explicitly handling on-enter/leave events).
- put tooltip state in store

Fixes bugs:
- when layer is removed, any corresponding tooltip should be removed as well
- when layer is made invisible, any corresponding tooltip should be removed as well
This commit is contained in:
Thomas Neirynck 2019-03-06 22:31:12 -05:00 committed by GitHub
parent 89a89e3ad4
commit 6b6e670586
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 221 additions and 190 deletions

View file

@ -14,6 +14,7 @@ import {
getMapReady,
getWaitingForMapReadyLayerListRaw,
getTransientLayerId,
getTooltipState
} from '../selectors/map_selectors';
import { updateFlyout, FLYOUT_STATE } from '../store/ui';
import { SOURCE_DATA_ID_ORIGIN } from '../../common/constants';
@ -49,6 +50,7 @@ export const CLEAR_GOTO = 'CLEAR_GOTO';
export const TRACK_CURRENT_LAYER_STATE = 'TRACK_CURRENT_LAYER_STATE';
export const ROLLBACK_TO_TRACKED_LAYER_STATE = 'ROLLBACK_TO_TRACKED_LAYER_STATE';
export const REMOVE_TRACKED_LAYER_STATE = 'REMOVE_TRACKED_LAYER_STATE';
export const SET_TOOLTIP_STATE = 'SET_TOOLTIP_STATE';
function getLayerLoadingCallbacks(dispatch, layerId) {
return {
@ -146,6 +148,15 @@ export function setLayerErrorStatus(layerId, errorMessage) {
};
}
export function clearTooltipStateForLayer(layerId) {
return (dispatch, getState) => {
const tooltipState = getTooltipState(getState());
if (tooltipState && tooltipState.layerId === layerId) {
dispatch(setTooltipState(null));
}
};
}
export function toggleLayerVisible(layerId) {
return async (dispatch, getState) => {
//if the current-state is invisible, we also want to sync data
@ -157,6 +168,11 @@ export function toggleLayerVisible(layerId) {
return;
}
const makeVisible = !layer.isVisible();
if (!makeVisible) {
dispatch(clearTooltipStateForLayer(layerId));
}
await dispatch({
type: TOGGLE_LAYER_VISIBLE,
layerId
@ -195,7 +211,7 @@ export function removeTransientLayer() {
}
export function setTransientLayer(layerId) {
return {
return {
type: SET_TRANSIENT_LAYER,
transientLayerId: layerId,
};
@ -284,11 +300,18 @@ export function mapExtentChanged(newMapConstants) {
...newMapConstants
}
});
const newDataFilters = { ...dataFilters, ...newMapConstants };
const newDataFilters = { ...dataFilters, ...newMapConstants };
await syncDataForAllLayers(getState, dispatch, newDataFilters);
};
}
export function setTooltipState(tooltipState) {
return {
type: 'SET_TOOLTIP_STATE',
tooltipState: tooltipState
};
}
export function setMouseCoordinates({ lat, lon }) {
let safeLon = lon;
if (lon > 180) {
@ -463,18 +486,19 @@ export function removeSelectedLayer() {
};
}
export function removeLayer(id) {
export function removeLayer(layerId) {
return (dispatch, getState) => {
const layerGettingRemoved = getLayerList(getState()).find(layer => {
return id === layer.getId();
return layerId === layer.getId();
});
if (layerGettingRemoved) {
layerGettingRemoved.destroy();
if (!layerGettingRemoved) {
return;
}
dispatch(clearTooltipStateForLayer(layerId));
layerGettingRemoved.destroy();
dispatch({
type: REMOVE_LAYER,
id
id: layerId
});
};
}

View file

@ -13,9 +13,9 @@ import {
setMouseCoordinates,
clearMouseCoordinates,
clearGoto,
setLayerErrorStatus,
setTooltipState
} from '../../../actions/store_actions';
import { getLayerList, getMapReady, getGoto } from '../../../selectors/map_selectors';
import { getTooltipState, getLayerList, getMapReady, getGoto } from '../../../selectors/map_selectors';
import { getInspectorAdapters } from '../../../store/non_serializable_instances';
function mapStateToProps(state = {}) {
@ -24,6 +24,7 @@ function mapStateToProps(state = {}) {
layerList: getLayerList(state),
goto: getGoto(state),
inspectorAdapters: getInspectorAdapters(state),
tooltipState: getTooltipState(state)
};
}
@ -49,8 +50,10 @@ function mapDispatchToProps(dispatch) {
clearGoto: () => {
dispatch(clearGoto());
},
setLayerErrorStatus: (id, msg) =>
dispatch(setLayerErrorStatus(id, msg))
setTooltipState(tooltipState) {
dispatch(setTooltipState(tooltipState));
}
};
}

View file

@ -6,17 +6,23 @@
import _ from 'lodash';
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 mapboxgl from 'mapbox-gl';
import { FeatureTooltip } from '../feature_tooltip';
export class MBMapContainer extends React.Component {
constructor() {
super();
this._mbMap = null;
this._listeners = new Map(); // key is mbLayerId, value eventHandlers map
this._tooltipContainer = document.createElement('div');
this._mbPopup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
});
}
_debouncedSync = _.debounce(() => {
@ -26,6 +32,61 @@ export class MBMapContainer extends React.Component {
}
}, 256);
_updateTooltipState = _.debounce(async (e) => {
const mbLayerIds = this._getMbLayerIdsForTooltips();
const features = this._mbMap.queryRenderedFeatures(e.point, { layers: mbLayerIds });
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) {
return;
}
}
const layer = this._getLayer(targetFeature.layer.id);
const formattedProperties = await layer.getPropertiesForTooltip(targetFeature.properties);
let popupAnchorLocation = [e.lngLat.lng, e.lngLat.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;
}
popupAnchorLocation = coordinates;
}
this.props.setTooltipState({
activeFeature: {
properties: targetFeature.properties,
geometry: targetFeature.geometry
},
formattedProperties: formattedProperties,
layerId: layer.getId(),
location: popupAnchorLocation
});
}, 100);
_getMbLayerIdsForTooltips() {
return this.props.layerList.reduce((mbLayerIds, layer) => {
return layer.canShowTooltip() ? mbLayerIds.concat(layer.getMbLayerIds()) : mbLayerIds;
}, []);
}
_getMapState() {
const zoom = this._mbMap.getZoom();
const mbCenter = this._mbMap.getCenter();
@ -45,6 +106,13 @@ export class MBMapContainer extends React.Component {
};
}
componentDidUpdate() {
// do not debounce syncing of map-state and tooltip
this._syncMbMapWithMapState();
this._syncTooltipState();
this._debouncedSync();
}
componentDidMount() {
this._initializeMap();
this._isMounted = true;
@ -58,6 +126,7 @@ export class MBMapContainer extends React.Component {
if (this._mbMap) {
this._mbMap.remove();
this._mbMap = null;
this._tooltipContainer = null;
}
this.props.onMapDestroyed();
}
@ -70,29 +139,6 @@ export class MBMapContainer extends React.Component {
return;
}
// Override mapboxgl.Map "on" and "removeLayer" methods so we can track layer listeners
// Tracked layer listerners are used to clean up event handlers
const originalMbBoxOnFunc = this._mbMap.on;
const originalMbBoxRemoveLayerFunc = this._mbMap.removeLayer;
this._mbMap.on = (...args) => {
// args do not identify layer so there is nothing to track
if (args.length <= 2) {
originalMbBoxOnFunc.apply(this._mbMap, args);
return;
}
const eventType = args[0];
const mbLayerId = args[1];
const handler = args[2];
this._addListener(eventType, mbLayerId, handler);
originalMbBoxOnFunc.apply(this._mbMap, args);
};
this._mbMap.removeLayer = (id) => {
this._removeListeners(id);
originalMbBoxRemoveLayerFunc.apply(this._mbMap, [id]);
};
this._initResizerChecker();
// moveend callback is debounced to avoid updating map extent state while map extent is still changing
@ -115,39 +161,12 @@ export class MBMapContainer extends React.Component {
this.props.clearMouseCoordinates();
});
this._mbMap.on('mousemove', this._updateTooltipState);
this.props.onMapReady(this._getMapState());
}
_addListener(eventType, mbLayerId, handler) {
this._removeListener(eventType, mbLayerId);
const eventHandlers = !this._listeners.has(mbLayerId)
? new Map()
: this._listeners.get(mbLayerId);
eventHandlers.set(eventType, handler);
this._listeners.set(mbLayerId, eventHandlers);
}
_removeListeners(mbLayerId) {
if (this._listeners.has(mbLayerId)) {
const eventHandlers = this._listeners.get(mbLayerId);
eventHandlers.forEach((value, eventType) => {
this._removeListener(eventType, mbLayerId);
});
this._listeners.delete(mbLayerId);
}
}
_removeListener(eventType, mbLayerId) {
if (this._listeners.has(mbLayerId)) {
const eventHandlers = this._listeners.get(mbLayerId);
if (eventHandlers.has(eventType)) {
this._mbMap.off(eventType, mbLayerId, eventHandlers.get(eventType));
eventHandlers.delete(eventType);
}
}
}
_initResizerChecker() {
this._checker = new ResizeChecker(this.refs.mapContainer);
this._checker.on('resize', () => {
@ -155,6 +174,37 @@ export class MBMapContainer extends React.Component {
});
}
_hideTooltip() {
if (this._mbPopup.isOpen()) {
this._mbPopup.remove();
ReactDOM.unmountComponentAtNode(this._tooltipContainer);
}
}
_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)
.setDOMContent(this._tooltipContainer)
.addTo(this._mbMap);
}
_syncTooltipState() {
if (this.props.tooltipState) {
this._showTooltip();
} else {
this._hideTooltip();
}
}
_syncMbMapWithMapState = () => {
const {
isMapReady,
@ -188,21 +238,25 @@ export class MBMapContainer extends React.Component {
};
_syncMbMapWithLayerList = () => {
const {
isMapReady,
layerList,
} = this.props;
_getLayer(mbLayerId) {
return this.props.layerList.find((layer) => {
const mbLayerIds = layer.getMbLayerIds();
return mbLayerIds.indexOf(mbLayerId) > -1;
});
}
if (!isMapReady) {
_syncMbMapWithLayerList = () => {
if (!this.props.isMapReady) {
return;
}
removeOrphanedSourcesAndLayers(this._mbMap, layerList);
layerList.forEach(layer => {
removeOrphanedSourcesAndLayers(this._mbMap, this.props.layerList);
this.props.layerList.forEach(layer => {
layer.syncLayerWithMB(this._mbMap);
});
syncLayerOrder(this._mbMap, layerList);
syncLayerOrder(this._mbMap, this.props.layerList);
};
_syncMbMapWithInspector = () => {
@ -222,12 +276,7 @@ export class MBMapContainer extends React.Component {
};
render() {
// do not debounce syncing zoom and center
this._syncMbMapWithMapState();
this._debouncedSync();
return (
<div id={'mapContainer'} className="mapContainer" ref="mapContainer"/>
);
return (<div id={'mapContainer'} className="mapContainer" ref="mapContainer"/>);
}
}

View file

@ -61,6 +61,10 @@ function createStyleInstance(styleDescriptor) {
}
}
export const getTooltipState = ({ map }) => {
return map.tooltipState;
};
export const getMapReady = ({ map }) => map && map.ready;
export const getGoto = ({ map }) => map && map.goto;

View file

@ -45,10 +45,19 @@ export class HeatmapLayer extends AbstractLayer {
return metricfields[0].propertyKey;
}
_getMbLayerId() {
return this.getId() + '_heatmap';
}
getMbLayerIds() {
return [this._getMbLayerId()];
}
syncLayerWithMB(mbMap) {
const mbSource = mbMap.getSource(this.getId());
const mbLayerId = this.getId() + '_heatmap';
const mbLayerId = this._getMbLayerId();
if (!mbSource) {
mbMap.addSource(this.getId(), {

View file

@ -165,6 +165,14 @@ export class AbstractLayer {
//no-op by default
}
getMbLayerIds() {
throw new Error('Should implement AbstractLayer#getMbLayerIds');
}
canShowTooltip() {
return false;
}
syncLayerWithMb() {
//no-op by default
}

View file

@ -49,10 +49,18 @@ export class TileLayer extends AbstractLayer {
}
}
_getMbLayerId() {
return this.getId() + '_raster';
}
getMbLayerIds() {
return [this._getMbLayerId()];
}
syncLayerWithMB(mbMap) {
const source = mbMap.getSource(this.getId());
const mbLayerId = this.getId() + '_raster';
const mbLayerId = this._getMbLayerId();
if (!source) {
const sourceDataRequest = this.getSourceDataRequest();

View file

@ -4,16 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import mapboxgl from 'mapbox-gl';
import turf from 'turf';
import React from 'react';
import ReactDOM from 'react-dom';
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 { FeatureTooltip } from '../../components/map/feature_tooltip';
import _ from 'lodash';
const EMPTY_FEATURE_COLLECTION = {
@ -25,13 +20,6 @@ export class VectorLayer extends AbstractLayer {
static type = 'VECTOR';
static popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
});
static tooltipContainer = document.createElement('div');
static createDescriptor(options, mapColors) {
const layerDescriptor = super.createDescriptor(options);
layerDescriptor.type = VectorLayer.type;
@ -171,10 +159,7 @@ export class VectorLayer extends AbstractLayer {
!isGeoGridPrecisionAware
) {
const sourceDataRequest = this._findDataRequestForSource(sourceDataId);
if (sourceDataRequest && sourceDataRequest.hasDataOrRequestInProgress()) {
return true;
}
return false;
return (sourceDataRequest && sourceDataRequest.hasDataOrRequestInProgress());
}
const sourceDataRequest = this._findDataRequestForSource(sourceDataId);
@ -397,7 +382,7 @@ export class VectorLayer extends AbstractLayer {
_setMbPointsProperties(mbMap) {
const sourceId = this.getId();
const pointLayerId = this.getId() + '_circle';
const pointLayerId = this._getMbPointLayerId();
const pointLayer = mbMap.getLayer(pointLayerId);
if (!pointLayer) {
mbMap.addLayer({
@ -415,13 +400,12 @@ export class VectorLayer extends AbstractLayer {
});
mbMap.setLayoutProperty(pointLayerId, 'visibility', this.isVisible() ? 'visible' : 'none');
mbMap.setLayerZoomRange(pointLayerId, this._descriptor.minZoom, this._descriptor.maxZoom);
this._addTooltipListeners(mbMap, pointLayerId);
}
_setMbLinePolygonProperties(mbMap) {
const sourceId = this.getId();
const fillLayerId = this.getId() + '_fill';
const lineLayerId = this.getId() + '_line';
const fillLayerId = this._getMbPolygonLayerId();
const lineLayerId = this._getMbLineLayerId();
if (!mbMap.getLayer(fillLayerId)) {
mbMap.addLayer({
id: fillLayerId,
@ -462,7 +446,6 @@ export class VectorLayer extends AbstractLayer {
mbMap.setLayoutProperty(lineLayerId, 'visibility', this.isVisible() ? 'visible' : 'none');
mbMap.setLayerZoomRange(lineLayerId, this._descriptor.minZoom, this._descriptor.maxZoom);
mbMap.setLayerZoomRange(fillLayerId, this._descriptor.minZoom, this._descriptor.maxZoom);
this._addTooltipListeners(mbMap, fillLayerId);
}
_syncStylePropertiesWithMb(mbMap) {
@ -493,101 +476,37 @@ export class VectorLayer extends AbstractLayer {
});
}
_canShowTooltips() {
return this._source.canFormatFeatureProperties();
_getMbPointLayerId() {
return this.getId() + '_circle';
}
async _getPropertiesForTooltip(feature) {
const tooltipsFromSource = await this._source.filterAndFormatProperties(feature.properties);
_getMbLineLayerId() {
return this.getId() + '_line';
}
_getMbPolygonLayerId() {
return this.getId() + '_fill';
}
getMbLayerIds() {
return [this._getMbPointLayerId(), this._getMbLineLayerId(), this._getMbPolygonLayerId()];
}
async getPropertiesForTooltip(properties) {
const tooltipsFromSource = await this._source.filterAndFormatProperties(properties);
//add tooltips from joins
const allProps = this._joins.reduce((acc, join) => {
const propsFromJoin = join.filterAndFormatPropertiesForTooltip(feature.properties);
return this._joins.reduce((acc, join) => {
const propsFromJoin = join.filterAndFormatPropertiesForTooltip(properties);
return {
...propsFromJoin,
...acc,
};
}, { ...tooltipsFromSource });
return allProps;
}
_addTooltipListeners(mbMap, mbLayerId) {
if (!this._canShowTooltips()) {
return;
}
const showTooltip = async (feature, eventLngLat) => {
let popupAnchorLocation = eventLngLat; // default popup location to mouse location
if (feature.geometry.type === 'Point') {
const coordinates = feature.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(eventLngLat.lng - coordinates[0]) > 180) {
coordinates[0] += eventLngLat.lng > coordinates[0] ? 360 : -360;
}
popupAnchorLocation = coordinates;
}
const properties = await this._getPropertiesForTooltip(feature);
ReactDOM.render(
React.createElement(
FeatureTooltip, {
properties: properties,
}
),
VectorLayer.tooltipContainer
);
VectorLayer.popup.setLngLat(popupAnchorLocation)
.setDOMContent(VectorLayer.tooltipContainer)
.addTo(mbMap);
};
let activeFeature;
let isTooltipOpen = false;
mbMap.on('mousemove', mbLayerId, _.debounce((e) => {
if (!isTooltipOpen) {
return;
}
const features = mbMap.queryRenderedFeatures(e.point)
.filter(feature => {
return feature.layer.source === this.getId();
});
if (features.length === 0) {
return;
}
const propertiesUnchanged = _.isEqual(activeFeature.properties, features[0].properties);
const geometryUnchanged = _.isEqual(activeFeature.geometry, features[0].geometry);
if(propertiesUnchanged && geometryUnchanged) {
// mouse over same feature, no need to update tooltip
return;
}
activeFeature = features[0];
showTooltip(activeFeature, e.lngLat);
}, 100));
mbMap.on('mouseenter', mbLayerId, (e) => {
isTooltipOpen = true;
mbMap.getCanvas().style.cursor = 'pointer';
activeFeature = e.features[0];
showTooltip(activeFeature, e.lngLat);
});
mbMap.on('mouseleave', mbLayerId, () => {
isTooltipOpen = false;
mbMap.getCanvas().style.cursor = '';
VectorLayer.popup.remove();
ReactDOM.unmountComponentAtNode(VectorLayer.tooltipContainer);
});
canShowTooltip() {
return this._source.canFormatFeatureProperties();
}
}

View file

@ -35,7 +35,8 @@ import {
TRACK_CURRENT_LAYER_STATE,
ROLLBACK_TO_TRACKED_LAYER_STATE,
REMOVE_TRACKED_LAYER_STATE,
UPDATE_SOURCE_DATA_REQUEST
UPDATE_SOURCE_DATA_REQUEST,
SET_TOOLTIP_STATE
} from '../actions/store_actions';
import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from './util';
@ -84,6 +85,7 @@ const updateLayerSourceDescriptorProp = (state, layerId, propName, value) => {
const INITIAL_STATE = {
ready: false,
goto: null,
tooltipState: null,
mapState: {
zoom: 4,
center: {
@ -114,6 +116,11 @@ export function map(state = INITIAL_STATE, action) {
return trackCurrentLayerState(state, action.layerId);
case ROLLBACK_TO_TRACKED_LAYER_STATE:
return rollbackTrackedLayerState(state, action.layerId);
case SET_TOOLTIP_STATE:
return {
...state,
tooltipState: action.tooltipState
};
case SET_MOUSE_COORDINATES:
return {
...state,