[Maps] Track tile loading status (#91585) (#93659)

This commit is contained in:
Thomas Neirynck 2021-03-04 15:37:59 -05:00 committed by GitHub
parent 2e4c9a169d
commit af60da210a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 352 additions and 21 deletions

View file

@ -27,6 +27,7 @@ export type LayerDescriptor = {
__isPreviewLayer?: boolean;
__errorMessage?: string;
__trackedLayerDescriptor?: LayerDescriptor;
__areTilesLoaded?: boolean;
alpha?: number;
id: string;
joins?: JoinDescriptor[];

View file

@ -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,
};
}

View file

@ -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() {

View file

@ -16,5 +16,6 @@ interface ITileLayerArguments {
export class TileLayer extends AbstractLayer {
static type: string;
constructor(args: ITileLayerArguments);
}

View file

@ -117,8 +117,4 @@ export class TileLayer extends AbstractLayer {
getLayerTypeIconName() {
return 'grid';
}
isLayerLoading() {
return false;
}
}

View file

@ -18,6 +18,7 @@ import {
clearGoto,
setMapInitError,
MapExtentState,
setAreTilesLoaded,
} from '../../actions';
import {
getLayerList,
@ -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));
},
};
}

View file

@ -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
@ -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 {
@ -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,
@ -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;
@ -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();

View file

@ -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);
});
});

View file

@ -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;
}
}

View file

@ -109,7 +109,23 @@ exports[`LayerControl isLayerTOCOpen Should render expand button with error icon
</EuiToolTip>
`;
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`] = `
<EuiToolTip
content="Expand layers panel"
delay="long"
position="left"
>
<EuiButtonIcon
aria-label="Expand layers panel"
className="mapLayerControl__openLayerTOCButton"
color="text"
iconType="menuLeft"
onClick={[Function]}
/>
</EuiToolTip>
`;
exports[`LayerControl isLayerTOCOpen spinner icon Should render expand button with loading icon when layer is loading 1`] = `
<EuiToolTip
content="Expand layers panel"
delay="long"

View file

@ -83,13 +83,13 @@ export class TOCEntryButton extends Component<Props, State> {
/>
);
tooltipContent = this.props.layer.getErrors();
} else if (this.props.layer.isLayerLoading()) {
icon = <EuiLoadingSpinner size="m" />;
} else if (!this.props.layer.isVisible()) {
icon = <EuiIcon size="m" type="eyeClosed" />;
tooltipContent = i18n.translate('xpack.maps.layer.layerHiddenTooltip', {
defaultMessage: `Layer is hidden.`,
});
} else if (this.props.layer.isLayerLoading()) {
icon = <EuiLoadingSpinner size="m" />;
} else if (!this.props.layer.showAtZoomLevel(this.props.zoom)) {
const minZoom = this.props.layer.getMinZoom();
const maxZoom = this.props.layer.getMaxZoom();

View file

@ -65,7 +65,7 @@ export function LayerControl({
return layer.hasErrors();
});
const isLoading = layerList.some((layer) => {
return layer.isLayerLoading();
return layer.isLayerLoading() && layer.isVisible();
});
return (

View file

@ -47,28 +47,44 @@ describe('LayerControl', () => {
describe('isLayerTOCOpen', () => {
test('Should render expand button', () => {
const component = shallow(<LayerControl {...defaultProps} isLayerTOCOpen={false} />);
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(
<LayerControl
{...defaultProps}
isLayerTOCOpen={false}
layerList={[mockLayerThatIsLoading]}
/>
);
expect(component).toMatchSnapshot();
test('Should render expand button with loading icon when layer is loading', () => {
const component = shallow(
<LayerControl
{...defaultProps}
isLayerTOCOpen={false}
layerList={[mockLayerThatIsLoading]}
/>
);
expect(component).toMatchSnapshot();
});
test('Should not render expand button with loading icon when layer is invisible', () => {
isVisible = false;
const component = shallow(
<LayerControl
{...defaultProps}
isLayerTOCOpen={false}
layerList={[mockLayerThatIsLoading]}
/>
);
expect(component).toMatchSnapshot();
});
});
test('Should render expand button with error icon when layer has error', () => {