Skip to content

Commit db3db0c

Browse files
[Maps] Track tile loading status (#91585)
1 parent 88f4156 commit db3db0c

File tree

13 files changed

+352
-21
lines changed

13 files changed

+352
-21
lines changed

x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type LayerDescriptor = {
2727
__isPreviewLayer?: boolean;
2828
__errorMessage?: string;
2929
__trackedLayerDescriptor?: LayerDescriptor;
30+
__areTilesLoaded?: boolean;
3031
alpha?: number;
3132
id: string;
3233
joins?: JoinDescriptor[];

x-pack/plugins/maps/public/actions/layer_actions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,3 +539,12 @@ export function setHiddenLayers(hiddenLayerIds: string[]) {
539539
}
540540
};
541541
}
542+
543+
export function setAreTilesLoaded(layerId: string, areTilesLoaded: boolean) {
544+
return {
545+
type: UPDATE_LAYER_PROP,
546+
id: layerId,
547+
propName: '__areTilesLoaded',
548+
newValue: areTilesLoaded,
549+
};
550+
}

x-pack/plugins/maps/public/classes/layers/layer.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,11 @@ export class AbstractLayer implements ILayer {
400400
}
401401

402402
isLayerLoading(): boolean {
403-
return this._dataRequests.some((dataRequest) => dataRequest.isLoading());
403+
const areTilesLoading =
404+
typeof this._descriptor.__areTilesLoaded !== 'undefined'
405+
? !this._descriptor.__areTilesLoaded
406+
: false;
407+
return areTilesLoading || this._dataRequests.some((dataRequest) => dataRequest.isLoading());
404408
}
405409

406410
isLoadingBounds() {

x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ interface ITileLayerArguments {
1616

1717
export class TileLayer extends AbstractLayer {
1818
static type: string;
19+
1920
constructor(args: ITileLayerArguments);
2021
}

x-pack/plugins/maps/public/classes/layers/tile_layer/tile_layer.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,4 @@ export class TileLayer extends AbstractLayer {
117117
getLayerTypeIconName() {
118118
return 'grid';
119119
}
120-
121-
isLayerLoading() {
122-
return false;
123-
}
124120
}

x-pack/plugins/maps/public/connected_components/mb_map/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
clearGoto,
1919
setMapInitError,
2020
MapExtentState,
21+
setAreTilesLoaded,
2122
} from '../../actions';
2223
import {
2324
getLayerList,
@@ -69,6 +70,9 @@ function mapDispatchToProps(dispatch: ThunkDispatch<MapStoreState, void, AnyActi
6970
setMapInitError(errorMessage: string) {
7071
dispatch(setMapInitError(errorMessage));
7172
},
73+
setAreTilesLoaded(layerId: string, areTilesLoaded: boolean) {
74+
dispatch(setAreTilesLoaded(layerId, areTilesLoaded));
75+
},
7276
};
7377
}
7478

x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public
4242
import { GeoFieldWithIndex } from '../../components/geo_field_with_index';
4343
import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property';
4444
import { MapExtentState } from '../../actions';
45+
import { TileStatusTracker } from './tile_status_tracker';
4546
// @ts-expect-error
4647
import mbRtlPlugin from '!!file-loader!@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js';
4748
// @ts-expect-error
@@ -72,6 +73,7 @@ export interface Props {
7273
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
7374
geoFields: GeoFieldWithIndex[];
7475
renderTooltipContent?: RenderToolTipContent;
76+
setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void;
7577
}
7678

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

9093
state: State = {
9194
prevLayerList: undefined,
@@ -123,6 +126,9 @@ export class MBMap extends Component<Props, State> {
123126
if (this._checker) {
124127
this._checker.destroy();
125128
}
129+
if (this._tileStatusTracker) {
130+
this._tileStatusTracker.destroy();
131+
}
126132
if (this.state.mbMap) {
127133
this.state.mbMap.remove();
128134
this.state.mbMap = undefined;
@@ -199,6 +205,12 @@ export class MBMap extends Component<Props, State> {
199205
mbMap.dragRotate.disable();
200206
mbMap.touchZoomRotate.disableRotation();
201207

208+
this._tileStatusTracker = new TileStatusTracker({
209+
mbMap,
210+
getCurrentLayerList: () => this.props.layerList,
211+
setAreTilesLoaded: this.props.setAreTilesLoaded,
212+
});
213+
202214
const tooManyFeaturesImageSrc =
203215
'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';
204216
const tooManyFeaturesImage = new Image();
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
// eslint-disable-next-line max-classes-per-file
9+
import { TileStatusTracker } from './tile_status_tracker';
10+
import { Map as MbMap } from 'mapbox-gl';
11+
import { ILayer } from '../../classes/layers/layer';
12+
13+
class MockMbMap {
14+
public listeners: Array<{ type: string; callback: (e: unknown) => void }> = [];
15+
16+
on(type: string, callback: (e: unknown) => void) {
17+
this.listeners.push({
18+
type,
19+
callback,
20+
});
21+
}
22+
23+
emit(type: string, e: unknown) {
24+
this.listeners.forEach((listener) => {
25+
if (listener.type === type) {
26+
listener.callback(e);
27+
}
28+
});
29+
}
30+
31+
off(type: string, callback: (e: unknown) => void) {
32+
this.listeners = this.listeners.filter((listener) => {
33+
return !(listener.type === type && listener.callback === callback);
34+
});
35+
}
36+
}
37+
38+
class MockLayer {
39+
readonly _id: string;
40+
readonly _mbSourceId: string;
41+
constructor(id: string, mbSourceId: string) {
42+
this._id = id;
43+
this._mbSourceId = mbSourceId;
44+
}
45+
getId() {
46+
return this._id;
47+
}
48+
49+
ownsMbSourceId(mbSourceId: string) {
50+
return this._mbSourceId === mbSourceId;
51+
}
52+
}
53+
54+
function createMockLayer(id: string, mbSourceId: string): ILayer {
55+
return (new MockLayer(id, mbSourceId) as unknown) as ILayer;
56+
}
57+
58+
function createMockMbDataEvent(mbSourceId: string, tileKey: string): unknown {
59+
return {
60+
sourceId: mbSourceId,
61+
dataType: 'source',
62+
tile: {
63+
tileID: {
64+
key: tileKey,
65+
},
66+
},
67+
source: {
68+
type: 'vector',
69+
},
70+
};
71+
}
72+
73+
async function sleep(timeout: number) {
74+
return await new Promise((resolve) => {
75+
setTimeout(() => {
76+
resolve(true);
77+
}, timeout);
78+
});
79+
}
80+
81+
describe('TileStatusTracker', () => {
82+
test('should add and remove tiles', async () => {
83+
const mockMbMap = new MockMbMap();
84+
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
85+
new TileStatusTracker({
86+
mbMap: (mockMbMap as unknown) as MbMap,
87+
setAreTilesLoaded: (layerId, areTilesLoaded) => {
88+
loadedMap.set(layerId, areTilesLoaded);
89+
},
90+
getCurrentLayerList: () => {
91+
return [
92+
createMockLayer('foo', 'foosource'),
93+
createMockLayer('bar', 'barsource'),
94+
createMockLayer('foobar', 'foobarsource'),
95+
];
96+
},
97+
});
98+
99+
mockMbMap.emit('sourcedataloading', createMockMbDataEvent('foosource', 'aa11'));
100+
101+
const aa11BarTile = createMockMbDataEvent('barsource', 'aa11');
102+
mockMbMap.emit('sourcedataloading', aa11BarTile);
103+
104+
mockMbMap.emit('sourcedata', createMockMbDataEvent('foosource', 'aa11'));
105+
106+
// simulate delay. Cache-checking is debounced.
107+
await sleep(300);
108+
109+
expect(loadedMap.get('foo')).toBe(true);
110+
expect(loadedMap.get('bar')).toBe(false); // still outstanding tile requests
111+
expect(loadedMap.has('foobar')).toBe(true); // never received tile requests
112+
113+
(aa11BarTile as { tile: { aborted: boolean } })!.tile.aborted = true; // abort tile
114+
mockMbMap.emit('sourcedataloading', createMockMbDataEvent('barsource', 'af1d'));
115+
mockMbMap.emit('sourcedataloading', createMockMbDataEvent('foosource', 'af1d'));
116+
mockMbMap.emit('error', createMockMbDataEvent('barsource', 'af1d'));
117+
118+
// simulate delay. Cache-checking is debounced.
119+
await sleep(300);
120+
121+
expect(loadedMap.get('foo')).toBe(false); // still outstanding tile requests
122+
expect(loadedMap.get('bar')).toBe(true); // tiles were aborted or errored
123+
expect(loadedMap.has('foobar')).toBe(true); // never received tile requests
124+
});
125+
126+
test('should cleanup listeners on destroy', async () => {
127+
const mockMbMap = new MockMbMap();
128+
const tileStatusTracker = new TileStatusTracker({
129+
mbMap: (mockMbMap as unknown) as MbMap,
130+
setAreTilesLoaded: () => {},
131+
getCurrentLayerList: () => {
132+
return [];
133+
},
134+
});
135+
136+
expect(mockMbMap.listeners.length).toBe(3);
137+
tileStatusTracker.destroy();
138+
expect(mockMbMap.listeners.length).toBe(0);
139+
});
140+
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { Map as MapboxMap, MapSourceDataEvent } from 'mapbox-gl';
9+
import _ from 'lodash';
10+
import { ILayer } from '../../classes/layers/layer';
11+
import { SPATIAL_FILTERS_LAYER_ID } from '../../../common/constants';
12+
13+
interface MbTile {
14+
// references internal object from mapbox
15+
aborted?: boolean;
16+
}
17+
18+
interface Tile {
19+
mbKey: string;
20+
mbSourceId: string;
21+
mbTile: MbTile;
22+
}
23+
24+
export class TileStatusTracker {
25+
private _tileCache: Tile[];
26+
private readonly _mbMap: MapboxMap;
27+
private readonly _setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void;
28+
private readonly _getCurrentLayerList: () => ILayer[];
29+
private readonly _onSourceDataLoading = (e: MapSourceDataEvent) => {
30+
if (
31+
e.sourceId &&
32+
e.sourceId !== SPATIAL_FILTERS_LAYER_ID &&
33+
e.dataType === 'source' &&
34+
e.tile &&
35+
(e.source.type === 'vector' || e.source.type === 'raster')
36+
) {
37+
const tracked = this._tileCache.find((tile) => {
38+
return (
39+
tile.mbKey === ((e.tile.tileID.key as unknown) as string) &&
40+
tile.mbSourceId === e.sourceId
41+
);
42+
});
43+
44+
if (!tracked) {
45+
this._tileCache.push({
46+
mbKey: (e.tile.tileID.key as unknown) as string,
47+
mbSourceId: e.sourceId,
48+
mbTile: e.tile,
49+
});
50+
this._updateTileStatus();
51+
}
52+
}
53+
};
54+
55+
private readonly _onError = (e: MapSourceDataEvent) => {
56+
if (
57+
e.sourceId &&
58+
e.sourceId !== SPATIAL_FILTERS_LAYER_ID &&
59+
e.tile &&
60+
(e.source.type === 'vector' || e.source.type === 'raster')
61+
) {
62+
this._removeTileFromCache(e.sourceId, (e.tile.tileID.key as unknown) as string);
63+
}
64+
};
65+
private readonly _onSourceData = (e: MapSourceDataEvent) => {
66+
if (
67+
e.sourceId &&
68+
e.sourceId !== SPATIAL_FILTERS_LAYER_ID &&
69+
e.dataType === 'source' &&
70+
e.tile &&
71+
(e.source.type === 'vector' || e.source.type === 'raster')
72+
) {
73+
this._removeTileFromCache(e.sourceId, (e.tile.tileID.key as unknown) as string);
74+
}
75+
};
76+
77+
constructor({
78+
mbMap,
79+
setAreTilesLoaded,
80+
getCurrentLayerList,
81+
}: {
82+
mbMap: MapboxMap;
83+
setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void;
84+
getCurrentLayerList: () => ILayer[];
85+
}) {
86+
this._tileCache = [];
87+
this._setAreTilesLoaded = setAreTilesLoaded;
88+
this._getCurrentLayerList = getCurrentLayerList;
89+
90+
this._mbMap = mbMap;
91+
this._mbMap.on('sourcedataloading', this._onSourceDataLoading);
92+
this._mbMap.on('error', this._onError);
93+
this._mbMap.on('sourcedata', this._onSourceData);
94+
}
95+
96+
_updateTileStatus = _.debounce(() => {
97+
this._tileCache = this._tileCache.filter((tile) => {
98+
return typeof tile.mbTile.aborted === 'boolean' ? !tile.mbTile.aborted : true;
99+
});
100+
const layerList = this._getCurrentLayerList();
101+
for (let i = 0; i < layerList.length; i++) {
102+
const layer: ILayer = layerList[i];
103+
let atLeastOnePendingTile = false;
104+
for (let j = 0; j < this._tileCache.length; j++) {
105+
const tile = this._tileCache[j];
106+
if (layer.ownsMbSourceId(tile.mbSourceId)) {
107+
atLeastOnePendingTile = true;
108+
break;
109+
}
110+
}
111+
this._setAreTilesLoaded(layer.getId(), !atLeastOnePendingTile);
112+
}
113+
}, 100);
114+
115+
_removeTileFromCache = (mbSourceId: string, mbKey: string) => {
116+
const trackedIndex = this._tileCache.findIndex((tile) => {
117+
return tile.mbKey === ((mbKey as unknown) as string) && tile.mbSourceId === mbSourceId;
118+
});
119+
120+
if (trackedIndex >= 0) {
121+
this._tileCache.splice(trackedIndex, 1);
122+
this._updateTileStatus();
123+
}
124+
};
125+
126+
destroy() {
127+
this._mbMap.off('error', this._onError);
128+
this._mbMap.off('sourcedata', this._onSourceData);
129+
this._mbMap.off('sourcedataloading', this._onSourceDataLoading);
130+
this._tileCache.length = 0;
131+
}
132+
}

0 commit comments

Comments
 (0)