From af60da210af2ba25856f31d63e731dcb3ab50563 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Thu, 4 Mar 2021 15:37:59 -0500 Subject: [PATCH] [Maps] Track tile loading status (#91585) (#93659) --- .../layer_descriptor_types.ts | 1 + .../maps/public/actions/layer_actions.ts | 9 ++ .../maps/public/classes/layers/layer.tsx | 6 +- .../classes/layers/tile_layer/tile_layer.d.ts | 1 + .../classes/layers/tile_layer/tile_layer.js | 4 - .../connected_components/mb_map/index.ts | 4 + .../connected_components/mb_map/mb_map.tsx | 12 ++ .../mb_map/tile_status_tracker.test.ts | 140 ++++++++++++++++++ .../mb_map/tile_status_tracker.ts | 132 +++++++++++++++++ .../__snapshots__/view.test.js.snap | 18 ++- .../toc_entry_button/toc_entry_button.tsx | 4 +- .../widget_overlay/layer_control/view.js | 2 +- .../widget_overlay/layer_control/view.test.js | 40 +++-- 13 files changed, 352 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/maps/public/connected_components/mb_map/tile_status_tracker.test.ts create mode 100644 x-pack/plugins/maps/public/connected_components/mb_map/tile_status_tracker.ts diff --git a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts index 7a21599605b5..7c4746fd2ccb 100644 --- a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts @@ -27,6 +27,7 @@ export type LayerDescriptor = { __isPreviewLayer?: boolean; __errorMessage?: string; __trackedLayerDescriptor?: LayerDescriptor; + __areTilesLoaded?: boolean; alpha?: number; id: string; joins?: JoinDescriptor[]; diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index d68e4744975f..fe62e9fe9da5 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -539,3 +539,12 @@ export function setHiddenLayers(hiddenLayerIds: string[]) { } }; } + +export function setAreTilesLoaded(layerId: string, areTilesLoaded: boolean) { + return { + type: UPDATE_LAYER_PROP, + id: layerId, + propName: '__areTilesLoaded', + newValue: areTilesLoaded, + }; +} diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index e3d5150c9cd0..a73449b0fa71 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -400,7 +400,11 @@ export class AbstractLayer implements ILayer { } isLayerLoading(): boolean { - return this._dataRequests.some((dataRequest) => dataRequest.isLoading()); + const areTilesLoading = + typeof this._descriptor.__areTilesLoaded !== 'undefined' + ? !this._descriptor.__areTilesLoaded + : false; + return areTilesLoading || this._dataRequests.some((dataRequest) => dataRequest.isLoading()); } isLoadingBounds() { diff --git a/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.d.ts b/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.d.ts index 3ef8f0918927..e83eff53c57c 100644 --- a/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.d.ts +++ b/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.d.ts @@ -16,5 +16,6 @@ interface ITileLayerArguments { export class TileLayer extends AbstractLayer { static type: string; + constructor(args: ITileLayerArguments); } diff --git a/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.js b/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.js index d3aaefcd3419..0995d117aaa4 100644 --- a/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.js +++ b/x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.js @@ -117,8 +117,4 @@ export class TileLayer extends AbstractLayer { getLayerTypeIconName() { return 'grid'; } - - isLayerLoading() { - return false; - } } diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/index.ts b/x-pack/plugins/maps/public/connected_components/mb_map/index.ts index 352e9ae8382d..2c92f67bd741 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/index.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/index.ts @@ -18,6 +18,7 @@ import { clearGoto, setMapInitError, MapExtentState, + setAreTilesLoaded, } from '../../actions'; import { getLayerList, @@ -69,6 +70,9 @@ function mapDispatchToProps(dispatch: ThunkDispatch void; geoFields: GeoFieldWithIndex[]; renderTooltipContent?: RenderToolTipContent; + setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void; } interface State { @@ -86,6 +88,7 @@ export class MBMap extends Component { private _containerRef: HTMLDivElement | null = null; private _prevDisableInteractive?: boolean; private _navigationControl = new mapboxgl.NavigationControl({ showCompass: false }); + private _tileStatusTracker?: TileStatusTracker; state: State = { prevLayerList: undefined, @@ -123,6 +126,9 @@ export class MBMap extends Component { if (this._checker) { this._checker.destroy(); } + if (this._tileStatusTracker) { + this._tileStatusTracker.destroy(); + } if (this.state.mbMap) { this.state.mbMap.remove(); this.state.mbMap = undefined; @@ -199,6 +205,12 @@ export class MBMap extends Component { mbMap.dragRotate.disable(); mbMap.touchZoomRotate.disableRotation(); + this._tileStatusTracker = new TileStatusTracker({ + mbMap, + getCurrentLayerList: () => this.props.layerList, + setAreTilesLoaded: this.props.setAreTilesLoaded, + }); + const tooManyFeaturesImageSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA7DgAAOw4BzLahgwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAARLSURBVHic7ZnPbxRVAMe/7735sWO3293ZlUItJsivCxEE0oTYRgu1FqTQoFSwKTYx8SAH/wHjj4vRozGGi56sMcW2UfqTEuOhppE0KJc2GIuKQFDY7qzdtrudX88D3YTUdFuQN8+k87ltZt7uZz958/bNLAGwBWsYKltANmEA2QKyCQPIFpBNGEC2gGzCALIFZBMGkC0gmzCAbAHZhAFkC8gmDCBbQDZhANkCslnzARQZH6oDpNs0D5UDSUIInePcOpPLfdfnODNBuwQWIAWwNOABwHZN0x8npE6hNLJ4DPWRyFSf40wE5VOEQPBjcR0g3YlE4ybGmtK+/1NzJtOZA/xSYwZMs3nG962T2ez3It2AANaA/kSidYuivOQBs5WM1fUnk6f0u+GXJUqIuUtVXx00zRbRfkIDfBqL7a1WlIYbjvNtTTr99jXXHVpH6dMjK0R4cXq6c9rzxjcx9sKX8XitSEdhAToMI7VP10/97fsTh7PZrgWAN1lW72KE2vOm2b5chDTgtWQyn93x/bEEIetEOQIC14CxVOr1CkKefH929t0v8vn0vcdGEoljGxXl4C3PGz2YyXy+AHARDqtByAxoUdWKBKV70r4/vvTLA0CjZfX+5nkDGxirKzUTgkBIgNaysh3gnF627R+XO+dQJvP1ddcdrmSsbtA020pF+CAW21qrqmUiXIUEqGRsIwD0FQq/lzqv0bJ6rrvucBVjzwyb5ivLRTiiaW+8VV7eIEBVTAANiIIQd9RxZlc6t9Gyem647vn1jD07ZJonl4sQASoevqmgABzwwHnJzc69PGdZ3X+47sgGxuqHTPPE0ggeVtg5/QeEBMhxPg1Aa1DV2GrHPG9ZXy1G2D+wNALn9jyQEeHKAJgP+033Kgrdqij7AFwZtu3bqx3XWShMHtV1o1pRGo4YxiNd+fyEB2DKdX/4aG5u0hbwcylkBryTy/3scT6zW9Nq7ndso2Wdvea6Q1WUHuiPx1/WAXLBcWZXun94UMRcAoD/p+ddTFK6u8MwUvc7vsmyem+67oVqVT0wkEgcF+FYRNhW+L25uX6f84XThtHxIBudE5bVY/t++jFVrU/dvVSFICzAqG3PX/S8rihj2/61qK1AOUB7ksl2jdLUL7Z9rvgcQQRCFsEi5wqFmw26XnhCUQ63GcZmCly95Lrzpca0G0byk3j8tEnpU1c975tmyxoU5QcE8EAEAM5WVOzfoarHAeC2749dcpzxMwsLv07Ztg0AOzVNf03Ttu/S9T2PMlbjc25fdpyutmx2TLRbIAEA4M1otKo1EjmaoHQn4ZwBgA/kAVAK6MXXdzxv/ONcrq/HcbJBeAUWoEizqsaORaPbKglZrxMSZZyrM76f/ovzWx/m85PFWREUgQf4v7Hm/xcIA8gWkE0YQLaAbMIAsgVkEwaQLSCbMIBsAdmEAWQLyCYMIFtANmEA2QKyCQPIFpDNmg/wD3OFdEybUvJjAAAAAElFTkSuQmCC'; const tooManyFeaturesImage = new Image(); diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tile_status_tracker.test.ts b/x-pack/plugins/maps/public/connected_components/mb_map/tile_status_tracker.test.ts new file mode 100644 index 000000000000..223efae65760 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tile_status_tracker.test.ts @@ -0,0 +1,140 @@ +/* + * 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. + */ + +// eslint-disable-next-line max-classes-per-file +import { TileStatusTracker } from './tile_status_tracker'; +import { Map as MbMap } from 'mapbox-gl'; +import { ILayer } from '../../classes/layers/layer'; + +class MockMbMap { + public listeners: Array<{ type: string; callback: (e: unknown) => void }> = []; + + on(type: string, callback: (e: unknown) => void) { + this.listeners.push({ + type, + callback, + }); + } + + emit(type: string, e: unknown) { + this.listeners.forEach((listener) => { + if (listener.type === type) { + listener.callback(e); + } + }); + } + + off(type: string, callback: (e: unknown) => void) { + this.listeners = this.listeners.filter((listener) => { + return !(listener.type === type && listener.callback === callback); + }); + } +} + +class MockLayer { + readonly _id: string; + readonly _mbSourceId: string; + constructor(id: string, mbSourceId: string) { + this._id = id; + this._mbSourceId = mbSourceId; + } + getId() { + return this._id; + } + + ownsMbSourceId(mbSourceId: string) { + return this._mbSourceId === mbSourceId; + } +} + +function createMockLayer(id: string, mbSourceId: string): ILayer { + return (new MockLayer(id, mbSourceId) as unknown) as ILayer; +} + +function createMockMbDataEvent(mbSourceId: string, tileKey: string): unknown { + return { + sourceId: mbSourceId, + dataType: 'source', + tile: { + tileID: { + key: tileKey, + }, + }, + source: { + type: 'vector', + }, + }; +} + +async function sleep(timeout: number) { + return await new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }, timeout); + }); +} + +describe('TileStatusTracker', () => { + test('should add and remove tiles', async () => { + const mockMbMap = new MockMbMap(); + const loadedMap: Map = new Map(); + new TileStatusTracker({ + mbMap: (mockMbMap as unknown) as MbMap, + setAreTilesLoaded: (layerId, areTilesLoaded) => { + loadedMap.set(layerId, areTilesLoaded); + }, + getCurrentLayerList: () => { + return [ + createMockLayer('foo', 'foosource'), + createMockLayer('bar', 'barsource'), + createMockLayer('foobar', 'foobarsource'), + ]; + }, + }); + + mockMbMap.emit('sourcedataloading', createMockMbDataEvent('foosource', 'aa11')); + + const aa11BarTile = createMockMbDataEvent('barsource', 'aa11'); + mockMbMap.emit('sourcedataloading', aa11BarTile); + + mockMbMap.emit('sourcedata', createMockMbDataEvent('foosource', 'aa11')); + + // simulate delay. Cache-checking is debounced. + await sleep(300); + + expect(loadedMap.get('foo')).toBe(true); + expect(loadedMap.get('bar')).toBe(false); // still outstanding tile requests + expect(loadedMap.has('foobar')).toBe(true); // never received tile requests + + (aa11BarTile as { tile: { aborted: boolean } })!.tile.aborted = true; // abort tile + mockMbMap.emit('sourcedataloading', createMockMbDataEvent('barsource', 'af1d')); + mockMbMap.emit('sourcedataloading', createMockMbDataEvent('foosource', 'af1d')); + mockMbMap.emit('error', createMockMbDataEvent('barsource', 'af1d')); + + // simulate delay. Cache-checking is debounced. + await sleep(300); + + 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(true); // never received tile requests + }); + + test('should cleanup listeners on destroy', async () => { + const mockMbMap = new MockMbMap(); + const tileStatusTracker = new TileStatusTracker({ + mbMap: (mockMbMap as unknown) as MbMap, + setAreTilesLoaded: () => {}, + getCurrentLayerList: () => { + return []; + }, + }); + + expect(mockMbMap.listeners.length).toBe(3); + tileStatusTracker.destroy(); + expect(mockMbMap.listeners.length).toBe(0); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tile_status_tracker.ts b/x-pack/plugins/maps/public/connected_components/mb_map/tile_status_tracker.ts new file mode 100644 index 000000000000..be946a12fe22 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tile_status_tracker.ts @@ -0,0 +1,132 @@ +/* + * 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 { Map as MapboxMap, MapSourceDataEvent } from 'mapbox-gl'; +import _ from 'lodash'; +import { ILayer } from '../../classes/layers/layer'; +import { SPATIAL_FILTERS_LAYER_ID } from '../../../common/constants'; + +interface MbTile { + // references internal object from mapbox + aborted?: boolean; +} + +interface Tile { + mbKey: string; + mbSourceId: string; + mbTile: MbTile; +} + +export class TileStatusTracker { + private _tileCache: Tile[]; + private readonly _mbMap: MapboxMap; + private readonly _setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void; + private readonly _getCurrentLayerList: () => ILayer[]; + private readonly _onSourceDataLoading = (e: MapSourceDataEvent) => { + if ( + e.sourceId && + e.sourceId !== SPATIAL_FILTERS_LAYER_ID && + e.dataType === 'source' && + e.tile && + (e.source.type === 'vector' || e.source.type === 'raster') + ) { + const tracked = this._tileCache.find((tile) => { + return ( + tile.mbKey === ((e.tile.tileID.key as unknown) as string) && + tile.mbSourceId === e.sourceId + ); + }); + + if (!tracked) { + this._tileCache.push({ + mbKey: (e.tile.tileID.key as unknown) as string, + mbSourceId: e.sourceId, + mbTile: e.tile, + }); + this._updateTileStatus(); + } + } + }; + + private readonly _onError = (e: MapSourceDataEvent) => { + if ( + e.sourceId && + e.sourceId !== SPATIAL_FILTERS_LAYER_ID && + e.tile && + (e.source.type === 'vector' || e.source.type === 'raster') + ) { + this._removeTileFromCache(e.sourceId, (e.tile.tileID.key as unknown) as string); + } + }; + private readonly _onSourceData = (e: MapSourceDataEvent) => { + if ( + e.sourceId && + e.sourceId !== SPATIAL_FILTERS_LAYER_ID && + e.dataType === 'source' && + e.tile && + (e.source.type === 'vector' || e.source.type === 'raster') + ) { + this._removeTileFromCache(e.sourceId, (e.tile.tileID.key as unknown) as string); + } + }; + + constructor({ + mbMap, + setAreTilesLoaded, + getCurrentLayerList, + }: { + mbMap: MapboxMap; + setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void; + getCurrentLayerList: () => ILayer[]; + }) { + this._tileCache = []; + this._setAreTilesLoaded = setAreTilesLoaded; + this._getCurrentLayerList = getCurrentLayerList; + + this._mbMap = mbMap; + this._mbMap.on('sourcedataloading', this._onSourceDataLoading); + this._mbMap.on('error', this._onError); + this._mbMap.on('sourcedata', this._onSourceData); + } + + _updateTileStatus = _.debounce(() => { + this._tileCache = this._tileCache.filter((tile) => { + return typeof tile.mbTile.aborted === 'boolean' ? !tile.mbTile.aborted : true; + }); + const layerList = this._getCurrentLayerList(); + for (let i = 0; i < layerList.length; i++) { + const layer: ILayer = layerList[i]; + let atLeastOnePendingTile = false; + for (let j = 0; j < this._tileCache.length; j++) { + const tile = this._tileCache[j]; + if (layer.ownsMbSourceId(tile.mbSourceId)) { + atLeastOnePendingTile = true; + break; + } + } + this._setAreTilesLoaded(layer.getId(), !atLeastOnePendingTile); + } + }, 100); + + _removeTileFromCache = (mbSourceId: string, mbKey: string) => { + const trackedIndex = this._tileCache.findIndex((tile) => { + return tile.mbKey === ((mbKey as unknown) as string) && tile.mbSourceId === mbSourceId; + }); + + if (trackedIndex >= 0) { + this._tileCache.splice(trackedIndex, 1); + this._updateTileStatus(); + } + }; + + destroy() { + this._mbMap.off('error', this._onError); + this._mbMap.off('sourcedata', this._onSourceData); + this._mbMap.off('sourcedataloading', this._onSourceDataLoading); + this._tileCache.length = 0; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/view.test.js.snap index 0af4eb0793f0..05c2ad69af77 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/view.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/view.test.js.snap @@ -109,7 +109,23 @@ exports[`LayerControl isLayerTOCOpen Should render expand button with error icon `; -exports[`LayerControl isLayerTOCOpen Should render expand button with loading icon when layer is loading 1`] = ` +exports[`LayerControl isLayerTOCOpen spinner icon Should not render expand button with loading icon when layer is invisible 1`] = ` + + + +`; + +exports[`LayerControl isLayerTOCOpen spinner icon Should render expand button with loading icon when layer is loading 1`] = ` { /> ); tooltipContent = this.props.layer.getErrors(); - } else if (this.props.layer.isLayerLoading()) { - icon = ; } else if (!this.props.layer.isVisible()) { icon = ; tooltipContent = i18n.translate('xpack.maps.layer.layerHiddenTooltip', { defaultMessage: `Layer is hidden.`, }); + } else if (this.props.layer.isLayerLoading()) { + icon = ; } else if (!this.props.layer.showAtZoomLevel(this.props.zoom)) { const minZoom = this.props.layer.getMinZoom(); const maxZoom = this.props.layer.getMaxZoom(); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.js index b24dc515c861..6a859befa18c 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.js +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.js @@ -65,7 +65,7 @@ export function LayerControl({ return layer.hasErrors(); }); const isLoading = layerList.some((layer) => { - return layer.isLayerLoading(); + return layer.isLayerLoading() && layer.isVisible(); }); return ( diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.test.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.test.js index 25baabe74e0b..e4af1ad4f46c 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.test.js +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.test.js @@ -47,28 +47,44 @@ describe('LayerControl', () => { describe('isLayerTOCOpen', () => { test('Should render expand button', () => { const component = shallow(); - expect(component).toMatchSnapshot(); }); - test('Should render expand button with loading icon when layer is loading', () => { + describe('spinner icon', () => { + const isLayerLoading = true; + let isVisible = true; const mockLayerThatIsLoading = { hasErrors: () => { return false; }, isLayerLoading: () => { - return true; + return isLayerLoading; + }, + isVisible: () => { + return isVisible; }, }; - const component = shallow( - - ); - - expect(component).toMatchSnapshot(); + test('Should render expand button with loading icon when layer is loading', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + test('Should not render expand button with loading icon when layer is invisible', () => { + isVisible = false; + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); }); test('Should render expand button with error icon when layer has error', () => {