Skip to content

Commit

Permalink
[Maps] Track tile loading status (elastic#91585)
Browse files Browse the repository at this point in the history
  • Loading branch information
thomasneirynck committed Mar 4, 2021
1 parent 4fd56e3 commit b926e41
Show file tree
Hide file tree
Showing 13 changed files with 352 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type LayerDescriptor = {
__isPreviewLayer?: boolean;
__errorMessage?: string;
__trackedLayerDescriptor?: LayerDescriptor;
__areTilesLoaded?: boolean;
alpha?: number;
id: string;
joins?: JoinDescriptor[];
Expand Down
9 changes: 9 additions & 0 deletions x-pack/plugins/maps/public/actions/layer_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
6 changes: 5 additions & 1 deletion x-pack/plugins/maps/public/classes/layers/layer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ interface ITileLayerArguments {

export class TileLayer extends AbstractLayer {
static type: string;

constructor(args: ITileLayerArguments);
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,4 @@ export class TileLayer extends AbstractLayer {
getLayerTypeIconName() {
return 'grid';
}

isLayerLoading() {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
clearGoto,
setMapInitError,
MapExtentState,
setAreTilesLoaded,
} from '../../actions';
import {
getLayerList,
Expand Down Expand Up @@ -69,6 +70,9 @@ function mapDispatchToProps(dispatch: ThunkDispatch<MapStoreState, void, AnyActi
setMapInitError(errorMessage: string) {
dispatch(setMapInitError(errorMessage));
},
setAreTilesLoaded(layerId: string, areTilesLoaded: boolean) {
dispatch(setAreTilesLoaded(layerId, areTilesLoaded));
},
};
}

Expand Down
12 changes: 12 additions & 0 deletions x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public
import { GeoFieldWithIndex } from '../../components/geo_field_with_index';
import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property';
import { MapExtentState } from '../../actions';
import { TileStatusTracker } from './tile_status_tracker';
// @ts-expect-error
import mbRtlPlugin from '!!file-loader!@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js';
// @ts-expect-error
Expand Down Expand Up @@ -72,6 +73,7 @@ export interface Props {
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
geoFields: GeoFieldWithIndex[];
renderTooltipContent?: RenderToolTipContent;
setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void;
}

interface State {
Expand All @@ -86,6 +88,7 @@ export class MBMap extends Component<Props, State> {
private _containerRef: HTMLDivElement | null = null;
private _prevDisableInteractive?: boolean;
private _navigationControl = new mapboxgl.NavigationControl({ showCompass: false });
private _tileStatusTracker?: TileStatusTracker;

state: State = {
prevLayerList: undefined,
Expand Down Expand Up @@ -123,6 +126,9 @@ 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;
Expand Down Expand Up @@ -199,6 +205,12 @@ export class MBMap extends Component<Props, State> {
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, boolean> = new Map<string, boolean>();
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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit b926e41

Please sign in to comment.