[maps] convert TileStatusTracker to redux connected react component (#135943)

* [maps] convert TileStatusTracker to redux connected react component

* eslint

* Update x-pack/plugins/maps/public/connected_components/mb_map/tile_status_tracker/tile_status_tracker.tsx

Co-authored-by: Nick Peihl <nickpeihl@gmail.com>

Co-authored-by: Nick Peihl <nickpeihl@gmail.com>
This commit is contained in:
Nathan Reese 2022-07-08 11:15:53 -06:00 committed by GitHub
parent 3891aeb95f
commit a349512b1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 191 additions and 169 deletions

View file

@ -15,11 +15,8 @@ import {
mapDestroyed,
mapExtentChanged,
mapReady,
setAreTilesLoaded,
setLayerDataLoadErrorStatus,
setMapInitError,
setMouseCoordinates,
updateMetaFromTiles,
} from '../../actions';
import {
getCustomIcons,
@ -34,7 +31,6 @@ import { getDrawMode, getIsFullScreen } from '../../selectors/ui_selectors';
import { getInspectorAdapters, getOnMapMove } from '../../reducers/non_serializable_instances';
import { MapStoreState } from '../../reducers/store';
import { DRAW_MODE } from '../../../common/constants';
import { TileMetaFeature } from '../../../common/descriptor_types';
import type { MapExtentState } from '../../reducers/map/types';
function mapStateToProps(state: MapStoreState) {
@ -80,18 +76,6 @@ function mapDispatchToProps(dispatch: ThunkDispatch<MapStoreState, void, AnyActi
setMapInitError(errorMessage: string) {
dispatch(setMapInitError(errorMessage));
},
setAreTilesLoaded(layerId: string, areTilesLoaded: boolean) {
dispatch(setAreTilesLoaded(layerId, areTilesLoaded));
},
updateMetaFromTiles(layerId: string, features: TileMetaFeature[]) {
dispatch(updateMetaFromTiles(layerId, features));
},
clearTileLoadError(layerId: string) {
dispatch(setLayerDataLoadErrorStatus(layerId, null));
},
setTileLoadError(layerId: string, errorMessage: string) {
dispatch(setLayerDataLoadErrorStatus(layerId, errorMessage));
},
};
}

View file

@ -20,15 +20,8 @@ import { clampToLatBounds, clampToLonBounds } from '../../../common/elasticsearc
import { getInitialView } from './get_initial_view';
import { getPreserveDrawingBuffer, isScreenshotMode } from '../../kibana_services';
import { ILayer } from '../../classes/layers/layer';
import { IVectorSource } from '../../classes/sources/vector_source';
import { MapSettings } from '../../reducers/map';
import {
CustomIcon,
Goto,
MapCenterAndZoom,
TileMetaFeature,
Timeslice,
} from '../../../common/descriptor_types';
import { CustomIcon, Goto, MapCenterAndZoom, Timeslice } from '../../../common/descriptor_types';
import {
CUSTOM_ICON_SIZE,
DECIMAL_DEGREES_PRECISION,
@ -39,7 +32,7 @@ import {
import { getGlyphUrl } from '../../util';
import { syncLayerOrder } from './sort_layers';
import { getTileMetaFeatures, removeOrphanedSourcesAndLayers } from './utils';
import { removeOrphanedSourcesAndLayers } from './utils';
import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property';
import { TileStatusTracker } from './tile_status_tracker';
import { DrawFeatureControl } from './draw_control/draw_feature_control';
@ -70,13 +63,9 @@ export interface Props {
getActionContext?: () => ActionExecutionContext;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
renderTooltipContent?: RenderToolTipContent;
setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void;
timeslice?: Timeslice;
updateMetaFromTiles: (layerId: string, features: TileMetaFeature[]) => void;
featureModeActive: boolean;
filterModeActive: boolean;
setTileLoadError(layerId: string, errorMessage: string): void;
clearTileLoadError(layerId: string): void;
onMapMove?: (lat: number, lon: number, zoom: number) => void;
}
@ -93,7 +82,6 @@ export class MbMap extends Component<Props, State> {
private _prevLayerList?: ILayer[];
private _prevTimeslice?: Timeslice;
private _navigationControl = new maplibregl.NavigationControl({ showCompass: false });
private _tileStatusTracker?: TileStatusTracker;
state: State = {
mbMap: undefined,
@ -114,9 +102,6 @@ export class MbMap extends Component<Props, State> {
if (this._checker) {
this._checker.destroy();
}
if (this._tileStatusTracker) {
this._tileStatusTracker.destroy();
}
if (this.state.mbMap) {
this.state.mbMap.remove();
this.state.mbMap = undefined;
@ -124,21 +109,6 @@ export class MbMap extends Component<Props, State> {
this.props.onMapDestroyed();
}
// This keeps track of the latest update calls, per layerId
_queryForMeta = (layer: ILayer) => {
const source = layer.getSource();
if (
this.state.mbMap &&
layer.isVisible() &&
source.isESSource() &&
typeof (source as IVectorSource).isMvt === 'function' &&
(source as IVectorSource).isMvt()
) {
const features = getTileMetaFeatures(this.state.mbMap, layer.getMbSourceId());
this.props.updateMetaFromTiles(layer.getId(), features);
}
};
_debouncedSync = _.debounce(() => {
if (this._isMounted && this.props.isMapReady && this.state.mbMap) {
const hasLayerListChanged = this._prevLayerList !== this.props.layerList; // Comparing re-select memoized instance so no deep equals needed
@ -203,22 +173,6 @@ export class MbMap extends Component<Props, State> {
mbMap.dragRotate.disable();
mbMap.touchZoomRotate.disableRotation();
this._tileStatusTracker = new TileStatusTracker({
mbMap,
getCurrentLayerList: () => this.props.layerList,
updateTileStatus: (layer: ILayer, areTilesLoaded: boolean, errorMessage?: string) => {
this.props.setAreTilesLoaded(layer.getId(), areTilesLoaded);
if (errorMessage) {
this.props.setTileLoadError(layer.getId(), errorMessage);
} else {
this.props.clearTileLoadError(layer.getId());
}
this._queryForMeta(layer);
},
});
let emptyImage: HTMLImageElement;
mbMap.on('styleimagemissing', (e: unknown) => {
if (emptyImage) {
@ -472,6 +426,7 @@ export class MbMap extends Component<Props, State> {
let tooltipControl;
let scaleControl;
let keydownScrollZoomControl;
let tileStatusTrackerControl;
if (this.state.mbMap) {
drawFilterControl =
this.props.addFilters && this.props.filterModeActive ? (
@ -496,6 +451,7 @@ export class MbMap extends Component<Props, State> {
keydownScrollZoomControl = this.props.settings.keydownScrollZoom ? (
<KeydownScrollZoom mbMap={this.state.mbMap} />
) : null;
tileStatusTrackerControl = <TileStatusTracker mbMap={this.state.mbMap} />;
}
return (
<div
@ -509,6 +465,7 @@ export class MbMap extends Component<Props, State> {
{keydownScrollZoomControl}
{scaleControl}
{tooltipControl}
{tileStatusTrackerControl}
</div>
);
}

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { connect } from 'react-redux';
import { TileMetaFeature } from '../../../../common/descriptor_types';
import {
setAreTilesLoaded,
setLayerDataLoadErrorStatus,
updateMetaFromTiles,
} from '../../../actions';
import { getLayerList } from '../../../selectors/map_selectors';
import { MapStoreState } from '../../../reducers/store';
import { TileStatusTracker } from './tile_status_tracker';
function mapStateToProps(state: MapStoreState) {
return {
layerList: getLayerList(state),
};
}
function mapDispatchToProps(dispatch: ThunkDispatch<MapStoreState, void, AnyAction>) {
return {
setAreTilesLoaded(layerId: string, areTilesLoaded: boolean) {
dispatch(setAreTilesLoaded(layerId, areTilesLoaded));
},
updateMetaFromTiles(layerId: string, features: TileMetaFeature[]) {
dispatch(updateMetaFromTiles(layerId, features));
},
clearTileLoadError(layerId: string) {
dispatch(setLayerDataLoadErrorStatus(layerId, null));
},
setTileLoadError(layerId: string, errorMessage: string) {
dispatch(setLayerDataLoadErrorStatus(layerId, errorMessage));
},
};
}
const connected = connect(mapStateToProps, mapDispatchToProps)(TileStatusTracker);
export { connected as TileStatusTracker };

View file

@ -6,9 +6,11 @@
*/
// eslint-disable-next-line max-classes-per-file
import { TileStatusTracker } from './tile_status_tracker';
import React from 'react';
import { mount } from 'enzyme';
import type { Map as MbMap } from '@kbn/mapbox-gl';
import { ILayer } from '../../classes/layers/layer';
import { TileStatusTracker } from './tile_status_tracker';
import { ILayer } from '../../../classes/layers/layer';
class MockMbMap {
public listeners: Array<{ type: string; callback: (e: unknown) => void }> = [];
@ -49,6 +51,18 @@ class MockLayer {
ownsMbSourceId(mbSourceId: string) {
return this._mbSourceId === mbSourceId;
}
isVisible() {
return true;
}
getSource() {
return {
isESSource() {
return false;
},
};
}
}
function createMockLayer(id: string, mbSourceId: string): ILayer {
@ -83,23 +97,35 @@ async function sleep(timeout: number) {
});
}
const mockMbMap = new MockMbMap();
const defaultProps = {
mbMap: mockMbMap as unknown as MbMap,
layerList: [],
setAreTilesLoaded: () => {},
updateMetaFromTiles: () => {},
clearTileLoadError: () => {},
setTileLoadError: () => {},
};
describe('TileStatusTracker', () => {
test('should add and remove tiles', async () => {
const mockMbMap = new MockMbMap();
test('should set tile load status', async () => {
const layerList = [
createMockLayer('foo', 'foosource'),
createMockLayer('bar', 'barsource'),
createMockLayer('foobar', 'foobarsource'),
];
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
new TileStatusTracker({
mbMap: mockMbMap as unknown as MbMap,
updateTileStatus: (layer, areTilesLoaded) => {
loadedMap.set(layer.getId(), areTilesLoaded);
},
getCurrentLayerList: () => {
return [
createMockLayer('foo', 'foosource'),
createMockLayer('bar', 'barsource'),
createMockLayer('foobar', 'foobarsource'),
];
},
});
const setAreTilesLoaded = (layerId: string, areTilesLoaded: boolean) => {
loadedMap.set(layerId, areTilesLoaded);
};
const component = mount(
<TileStatusTracker
{...defaultProps}
layerList={layerList}
setAreTilesLoaded={setAreTilesLoaded}
/>
);
mockMbMap.emit('sourcedataloading', createMockMbDataEvent('foosource', 'aa11'));
@ -126,20 +152,9 @@ describe('TileStatusTracker', () => {
expect(loadedMap.get('foo')).toBe(false); // still outstanding tile requests
expect(loadedMap.get('bar')).toBe(true); // tiles were aborted or errored
expect(loadedMap.has('foobar')).toBe(false); // never received tile requests, status should not have been reported for layer
});
test('should cleanup listeners on destroy', async () => {
const mockMbMap = new MockMbMap();
const tileStatusTracker = new TileStatusTracker({
mbMap: mockMbMap as unknown as MbMap,
updateTileStatus: () => {},
getCurrentLayerList: () => {
return [];
},
});
component.unmount();
expect(mockMbMap.listeners.length).toBe(4);
tileStatusTracker.destroy();
expect(mockMbMap.listeners.length).toBe(0);
});
});

View file

@ -5,12 +5,16 @@
* 2.0.
*/
import type { Map as MapboxMap, MapSourceDataEvent } from '@kbn/mapbox-gl';
import _ from 'lodash';
import { Component } from 'react';
import type { Map as MbMap, MapSourceDataEvent } from '@kbn/mapbox-gl';
import { i18n } from '@kbn/i18n';
import { ILayer } from '../../classes/layers/layer';
import { SPATIAL_FILTERS_LAYER_ID } from '../../../common/constants';
import { getTileKey } from '../../classes/util/geo_tile_utils';
import { TileMetaFeature } from '../../../../common/descriptor_types';
import { SPATIAL_FILTERS_LAYER_ID } from '../../../../common/constants';
import { ILayer } from '../../../classes/layers/layer';
import { IVectorSource } from '../../../classes/sources/vector_source';
import { getTileKey } from '../../../classes/util/geo_tile_utils';
import { ES_MVT_META_LAYER_NAME } from '../../../classes/util/tile_meta_feature_utils';
interface MbTile {
// references internal object from mapbox
@ -28,32 +32,49 @@ interface Tile {
mbTile: MbTile;
}
export class TileStatusTracker {
export interface Props {
mbMap: MbMap;
layerList: ILayer[];
setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void;
updateMetaFromTiles: (layerId: string, features: TileMetaFeature[]) => void;
clearTileLoadError: (layerId: string) => void;
setTileLoadError: (layerId: string, errorMessage: string) => void;
}
export class TileStatusTracker extends Component<Props> {
private _isMounted = false;
// Tile cache tracks active tile requests
// 'sourcedataloading' event adds tile request to cache
// 'sourcedata' and 'error' events remove tile request from cache
// Tile requests with 'aborted' status are removed from cache when reporting 'areTilesLoaded' status
private _tileCache: Tile[] = [];
// Tile error cache tracks tile request errors per layer
// Error cache is cleared when map center tile changes
private _tileErrorCache: Record<string, TileError[]> = {};
// Layer cache tracks layers that have requested one or more tiles
// Layer cache is used so that only a layer that has requested one or more tiles reports 'areTilesLoaded' status
// layer cache is never cleared
private _layerCache: Map<string, boolean> = new Map<string, boolean>();
private _prevCenterTileKey?: string;
private readonly _mbMap: MapboxMap;
private readonly _updateTileStatus: (
layer: ILayer,
areTilesLoaded: boolean,
errorMessage?: string
) => void;
private readonly _getCurrentLayerList: () => ILayer[];
private readonly _onSourceDataLoading = (e: MapSourceDataEvent) => {
componentDidMount() {
this._isMounted = true;
this.props.mbMap.on('sourcedataloading', this._onSourceDataLoading);
this.props.mbMap.on('error', this._onError);
this.props.mbMap.on('sourcedata', this._onSourceData);
this.props.mbMap.on('move', this._onMove);
}
componentWillUnmount() {
this._isMounted = false;
this.props.mbMap.off('error', this._onError);
this.props.mbMap.off('sourcedata', this._onSourceData);
this.props.mbMap.off('sourcedataloading', this._onSourceDataLoading);
this.props.mbMap.off('move', this._onMove);
this._tileCache.length = 0;
}
_onSourceDataLoading = (e: MapSourceDataEvent) => {
if (
e.sourceId &&
e.sourceId !== SPATIAL_FILTERS_LAYER_ID &&
@ -61,7 +82,7 @@ export class TileStatusTracker {
e.tile &&
(e.source.type === 'vector' || e.source.type === 'raster')
) {
const targetLayer = this._getCurrentLayerList().find((layer) => {
const targetLayer = this.props.layerList.find((layer) => {
return layer.ownsMbSourceId(e.sourceId);
});
const layerId = targetLayer ? targetLayer.getId() : undefined;
@ -86,14 +107,14 @@ export class TileStatusTracker {
}
};
private readonly _onError = (e: MapSourceDataEvent & { error: Error & { status: number } }) => {
_onError = (e: MapSourceDataEvent & { error: Error & { status: number } }) => {
if (
e.sourceId &&
e.sourceId !== SPATIAL_FILTERS_LAYER_ID &&
e.tile &&
(e.source.type === 'vector' || e.source.type === 'raster')
) {
const targetLayer = this._getCurrentLayerList().find((layer) => {
const targetLayer = this.props.layerList.find((layer) => {
return layer.ownsMbSourceId(e.sourceId);
});
const layerId = targetLayer ? targetLayer.getId() : undefined;
@ -109,7 +130,7 @@ export class TileStatusTracker {
}
};
private readonly _onSourceData = (e: MapSourceDataEvent) => {
_onSourceData = (e: MapSourceDataEvent) => {
if (
e.sourceId &&
e.sourceId !== SPATIAL_FILTERS_LAYER_ID &&
@ -125,44 +146,33 @@ export class TileStatusTracker {
* Clear errors when center tile changes.
* Tracking center tile provides the cleanest way to know when a new data fetching cycle is beginning
*/
private readonly _onMove = () => {
const center = this._mbMap.getCenter();
_onMove = () => {
const center = this.props.mbMap.getCenter();
// Maplibre rounds zoom when 'source.roundZoom' is true and floors zoom when 'source.roundZoom' is false
// 'source.roundZoom' is true for raster and video layers
// 'source.roundZoom' is false for vector layers
// Always floor zoom to keep logic as simple as possible and not have to track center tile by source.
// We are mainly concerned with showing errors from Elasticsearch vector tile requests (which are vector sources)
const centerTileKey = getTileKey(center.lat, center.lng, Math.floor(this._mbMap.getZoom()));
const centerTileKey = getTileKey(
center.lat,
center.lng,
Math.floor(this.props.mbMap.getZoom())
);
if (this._prevCenterTileKey !== centerTileKey) {
this._prevCenterTileKey = centerTileKey;
this._tileErrorCache = {};
}
};
constructor({
mbMap,
updateTileStatus,
getCurrentLayerList,
}: {
mbMap: MapboxMap;
updateTileStatus: (layer: ILayer, areTilesLoaded: boolean, errorMessage?: string) => void;
getCurrentLayerList: () => ILayer[];
}) {
this._updateTileStatus = updateTileStatus;
this._getCurrentLayerList = getCurrentLayerList;
this._mbMap = mbMap;
this._mbMap.on('sourcedataloading', this._onSourceDataLoading);
this._mbMap.on('error', this._onError);
this._mbMap.on('sourcedata', this._onSourceData);
this._mbMap.on('move', this._onMove);
}
_updateTileStatusForAllLayers = _.debounce(() => {
if (!this._isMounted) {
return;
}
this._tileCache = this._tileCache.filter((tile) => {
return typeof tile.mbTile.aborted === 'boolean' ? !tile.mbTile.aborted : true;
});
const layerList = this._getCurrentLayerList();
const layerList = this.props.layerList;
for (let i = 0; i < layerList.length; i++) {
const layer: ILayer = layerList[i];
@ -191,7 +201,7 @@ export class TileStatusTracker {
});
})
: [];
this._updateTileStatus(
this._updateTileStatusForLayer(
layer,
!atLeastOnePendingTile,
tileErrorMessages.length
@ -207,6 +217,47 @@ export class TileStatusTracker {
}
}, 100);
_updateTileStatusForLayer = (layer: ILayer, areTilesLoaded: boolean, errorMessage?: string) => {
this.props.setAreTilesLoaded(layer.getId(), areTilesLoaded);
if (errorMessage) {
this.props.setTileLoadError(layer.getId(), errorMessage);
} else {
this.props.clearTileLoadError(layer.getId());
}
const source = layer.getSource();
if (
layer.isVisible() &&
source.isESSource() &&
typeof (source as IVectorSource).isMvt === 'function' &&
(source as IVectorSource).isMvt()
) {
// querySourceFeatures can return duplicated features when features cross tile boundaries.
// Tile meta will never have duplicated features since by their nature, tile meta is a feature contained within a single tile
const mbFeatures = this.props.mbMap.querySourceFeatures(layer.getMbSourceId(), {
sourceLayer: ES_MVT_META_LAYER_NAME,
filter: [],
});
const features = mbFeatures
.map((mbFeature) => {
try {
return {
type: 'Feature',
id: mbFeature?.id,
geometry: mbFeature?.geometry, // this getter might throw with non-conforming geometries
properties: mbFeature?.properties,
} as TileMetaFeature;
} catch (e) {
return null;
}
})
.filter((mbFeature: TileMetaFeature | null) => mbFeature !== null) as TileMetaFeature[];
this.props.updateMetaFromTiles(layer.getId(), features);
}
};
_removeTileFromCache = (mbSourceId: string, mbKey: string) => {
const trackedIndex = this._tileCache.findIndex((tile) => {
return tile.mbKey === (mbKey as unknown as string) && tile.mbSourceId === mbSourceId;
@ -218,11 +269,7 @@ export class TileStatusTracker {
}
};
destroy() {
this._mbMap.off('error', this._onError);
this._mbMap.off('sourcedata', this._onSourceData);
this._mbMap.off('sourcedataloading', this._onSourceDataLoading);
this._mbMap.off('move', this._onMove);
this._tileCache.length = 0;
render() {
return null;
}
}

View file

@ -6,10 +6,8 @@
*/
import type { Map as MbMap } from '@kbn/mapbox-gl';
import { TileMetaFeature } from '../../../common/descriptor_types';
import { isGlDrawLayer } from './sort_layers';
import { ILayer } from '../../classes/layers/layer';
import { ES_MVT_META_LAYER_NAME } from '../../classes/util/tile_meta_feature_utils';
export function removeOrphanedSourcesAndLayers(
mbMap: MbMap,
@ -60,27 +58,3 @@ export function removeOrphanedSourcesAndLayers(
}
mbSourcesToRemove.forEach((mbSourceId) => mbMap.removeSource(mbSourceId));
}
export function getTileMetaFeatures(mbMap: MbMap, mbSourceId: string): TileMetaFeature[] {
// querySourceFeatures can return duplicated features when features cross tile boundaries.
// Tile meta will never have duplicated features since by there nature, tile meta is a feature contained within a single tile
const mbFeatures = mbMap.querySourceFeatures(mbSourceId, {
sourceLayer: ES_MVT_META_LAYER_NAME,
filter: [],
});
return mbFeatures
.map((mbFeature) => {
try {
return {
type: 'Feature',
id: mbFeature?.id,
geometry: mbFeature?.geometry, // this getter might throw with non-conforming geometries
properties: mbFeature?.properties,
} as TileMetaFeature;
} catch (e) {
return null;
}
})
.filter((mbFeature) => mbFeature !== null) as TileMetaFeature[];
}