[Maps] show vector tile labels on top (#69444)

* [Maps] show vector tile labels on top

* experiment with new sort algorithm

* clean up

* remove old sort method

* add unit test for sort layer

* tslint

* clean up

* make labelsOnTop configurable

* tslint

* more tslint

* add another test case for single layer move

* clarify should messages

* fix assert not null operators

* review feedback

* update snapshot

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2020-07-02 18:50:50 -06:00 committed by GitHub
parent 6c62c686cf
commit ebcec3a904
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 577 additions and 295 deletions

View file

@ -169,6 +169,7 @@ export type LayerDescriptor = {
alpha?: number;
id: string;
label?: string | null;
areLabelsOnTop?: boolean;
minZoom?: number;
maxZoom?: number;
sourceDescriptor: SourceDescriptor | null;

View file

@ -319,6 +319,15 @@ export function updateLayerAlpha(id: string, alpha: number) {
};
}
export function updateLabelsOnTop(id: string, areLabelsOnTop: boolean) {
return {
type: UPDATE_LAYER_PROP,
id,
propName: 'areLabelsOnTop',
newValue: areLabelsOnTop,
};
}
export function setLayerQuery(id: string, query: Query) {
return (dispatch: Dispatch) => {
dispatch({

View file

@ -76,6 +76,8 @@ export interface ILayer {
getPrevRequestToken(dataId: string): symbol | undefined;
destroy: () => void;
isPreviewLayer: () => boolean;
areLabelsOnTop: () => boolean;
supportsLabelsOnTop: () => boolean;
}
export type Footnote = {
icon: ReactElement<any>;
@ -483,4 +485,12 @@ export class AbstractLayer implements ILayer {
getType(): string | undefined {
return this._descriptor.type;
}
areLabelsOnTop(): boolean {
return false;
}
supportsLabelsOnTop(): boolean {
return false;
}
}

View file

@ -277,4 +277,12 @@ export class VectorTileLayer extends TileLayer {
this._setOpacityForType(mbMap, mbLayer, mbLayerId);
});
}
areLabelsOnTop() {
return !!this._descriptor.areLabelsOnTop;
}
supportsLabelsOnTop() {
return true;
}
}

View file

@ -89,7 +89,19 @@ exports[`LayerPanel is rendered 1`] = `
className="mapLayerPanel__bodyOverflow"
>
<LayerErrors />
<LayerSettings />
<LayerSettings
layer={
Object {
"getDisplayName": [Function],
"getId": [Function],
"getImmutableSourceProperties": [Function],
"getLayerTypeIconName": [Function],
"isJoinable": [Function],
"renderSourceSettingsEditor": [Function],
"supportsElasticsearchFilters": [Function],
}
}
/>
<div>
mockSourceSettings
</div>

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { LayerSettings } from './layer_settings';
import { getSelectedLayer } from '../../../selectors/map_selectors';
import {
updateLayerLabel,
updateLayerMaxZoom,
updateLayerMinZoom,
updateLayerAlpha,
} from '../../../actions';
import { MAX_ZOOM } from '../../../../common/constants';
function mapStateToProps(state = {}) {
const selectedLayer = getSelectedLayer(state);
return {
minVisibilityZoom: selectedLayer.getMinSourceZoom(),
maxVisibilityZoom: MAX_ZOOM,
alpha: selectedLayer.getAlpha(),
label: selectedLayer.getLabel(),
layerId: selectedLayer.getId(),
maxZoom: selectedLayer.getMaxZoom(),
minZoom: selectedLayer.getMinZoom(),
};
}
function mapDispatchToProps(dispatch) {
return {
updateLabel: (id, label) => dispatch(updateLayerLabel(id, label)),
updateMinZoom: (id, minZoom) => dispatch(updateLayerMinZoom(id, minZoom)),
updateMaxZoom: (id, maxZoom) => dispatch(updateLayerMaxZoom(id, maxZoom)),
updateAlpha: (id, alpha) => dispatch(updateLayerAlpha(id, alpha)),
};
}
const connectedLayerSettings = connect(mapStateToProps, mapDispatchToProps)(LayerSettings);
export { connectedLayerSettings as LayerSettings };

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AnyAction, Dispatch } from 'redux';
import { connect } from 'react-redux';
import { LayerSettings } from './layer_settings';
import {
updateLayerLabel,
updateLayerMaxZoom,
updateLayerMinZoom,
updateLayerAlpha,
updateLabelsOnTop,
} from '../../../actions';
function mapDispatchToProps(dispatch: Dispatch<AnyAction>) {
return {
updateLabel: (id: string, label: string) => dispatch(updateLayerLabel(id, label)),
updateMinZoom: (id: string, minZoom: number) => dispatch(updateLayerMinZoom(id, minZoom)),
updateMaxZoom: (id: string, maxZoom: number) => dispatch(updateLayerMaxZoom(id, maxZoom)),
updateAlpha: (id: string, alpha: number) => dispatch(updateLayerAlpha(id, alpha)),
updateLabelsOnTop: (id: string, areLabelsOnTop: boolean) =>
dispatch(updateLabelsOnTop(id, areLabelsOnTop)),
};
}
const connectedLayerSettings = connect(null, mapDispatchToProps)(LayerSettings);
export { connectedLayerSettings as LayerSettings };

View file

@ -1,87 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { EuiTitle, EuiPanel, EuiFormRow, EuiFieldText, EuiSpacer } from '@elastic/eui';
import { AlphaSlider } from '../../../components/alpha_slider';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { ValidatedDualRange } from '../../../../../../../src/plugins/kibana_react/public';
export function LayerSettings(props) {
const onLabelChange = (event) => {
const label = event.target.value;
props.updateLabel(props.layerId, label);
};
const onZoomChange = ([min, max]) => {
props.updateMinZoom(props.layerId, Math.max(props.minVisibilityZoom, parseInt(min, 10)));
props.updateMaxZoom(props.layerId, Math.min(props.maxVisibilityZoom, parseInt(max, 10)));
};
const onAlphaChange = (alpha) => {
props.updateAlpha(props.layerId, alpha);
};
const renderZoomSliders = () => {
return (
<ValidatedDualRange
label={i18n.translate('xpack.maps.layerPanel.settingsPanel.visibleZoomLabel', {
defaultMessage: 'Visibility',
})}
formRowDisplay="columnCompressed"
min={props.minVisibilityZoom}
max={props.maxVisibilityZoom}
value={[props.minZoom, props.maxZoom]}
showInput="inputWithPopover"
showRange
showLabels
onChange={onZoomChange}
allowEmptyRange={false}
compressed
prepend={i18n.translate('xpack.maps.layerPanel.settingsPanel.visibleZoom', {
defaultMessage: 'Zoom levels',
})}
/>
);
};
const renderLabel = () => {
return (
<EuiFormRow
label={i18n.translate('xpack.maps.layerPanel.settingsPanel.layerNameLabel', {
defaultMessage: 'Name',
})}
display="columnCompressed"
>
<EuiFieldText value={props.label} onChange={onLabelChange} compressed />
</EuiFormRow>
);
};
return (
<Fragment>
<EuiPanel>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.maps.layerPanel.layerSettingsTitle"
defaultMessage="Layer settings"
/>
</h5>
</EuiTitle>
<EuiSpacer size="m" />
{renderLabel()}
{renderZoomSliders()}
<AlphaSlider alpha={props.alpha} onChange={onAlphaChange} />
</EuiPanel>
<EuiSpacer size="s" />
</Fragment>
);
}

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { ChangeEvent, Fragment } from 'react';
import {
EuiTitle,
EuiPanel,
EuiFormRow,
EuiFieldText,
EuiSpacer,
EuiSwitch,
EuiSwitchEvent,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { MAX_ZOOM } from '../../../../common/constants';
import { AlphaSlider } from '../../../components/alpha_slider';
import { ValidatedDualRange } from '../../../../../../../src/plugins/kibana_react/public';
import { ILayer } from '../../../classes/layers/layer';
interface Props {
layer: ILayer;
updateLabel: (layerId: string, label: string) => void;
updateMinZoom: (layerId: string, minZoom: number) => void;
updateMaxZoom: (layerId: string, maxZoom: number) => void;
updateAlpha: (layerId: string, alpha: number) => void;
updateLabelsOnTop: (layerId: string, areLabelsOnTop: boolean) => void;
}
export function LayerSettings(props: Props) {
const minVisibilityZoom = props.layer.getMinSourceZoom();
const maxVisibilityZoom = MAX_ZOOM;
const layerId = props.layer.getId();
const onLabelChange = (event: ChangeEvent<HTMLInputElement>) => {
const label = event.target.value;
props.updateLabel(layerId, label);
};
const onZoomChange = (value: [string, string]) => {
props.updateMinZoom(layerId, Math.max(minVisibilityZoom, parseInt(value[0], 10)));
props.updateMaxZoom(layerId, Math.min(maxVisibilityZoom, parseInt(value[1], 10)));
};
const onAlphaChange = (alpha: number) => {
props.updateAlpha(layerId, alpha);
};
const onLabelsOnTopChange = (event: EuiSwitchEvent) => {
props.updateLabelsOnTop(layerId, event.target.checked);
};
const renderZoomSliders = () => {
return (
<ValidatedDualRange
label={i18n.translate('xpack.maps.layerPanel.settingsPanel.visibleZoomLabel', {
defaultMessage: 'Visibility',
})}
formRowDisplay="columnCompressed"
min={minVisibilityZoom}
max={maxVisibilityZoom}
value={[props.layer.getMinZoom(), props.layer.getMaxZoom()]}
showInput="inputWithPopover"
showRange
showLabels
onChange={onZoomChange}
allowEmptyRange={false}
compressed
prepend={i18n.translate('xpack.maps.layerPanel.settingsPanel.visibleZoom', {
defaultMessage: 'Zoom levels',
})}
/>
);
};
const renderLabel = () => {
return (
<EuiFormRow
label={i18n.translate('xpack.maps.layerPanel.settingsPanel.layerNameLabel', {
defaultMessage: 'Name',
})}
display="columnCompressed"
>
<EuiFieldText value={props.layer.getLabel()} onChange={onLabelChange} compressed />
</EuiFormRow>
);
};
const renderShowLabelsOnTop = () => {
if (!props.layer.supportsLabelsOnTop()) {
return null;
}
return (
<EuiFormRow display="columnCompressedSwitch">
<EuiSwitch
label={i18n.translate('xpack.maps.layerPanel.settingsPanel.labelsOnTop', {
defaultMessage: `Show labels on top`,
})}
checked={props.layer.areLabelsOnTop()}
onChange={onLabelsOnTopChange}
data-test-subj="mapLayerPanelApplyGlobalQueryCheckbox"
compressed
/>
</EuiFormRow>
);
};
return (
<Fragment>
<EuiPanel>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.maps.layerPanel.layerSettingsTitle"
defaultMessage="Layer settings"
/>
</h5>
</EuiTitle>
<EuiSpacer size="m" />
{renderLabel()}
{renderZoomSliders()}
<AlphaSlider alpha={props.layer.getAlpha()} onChange={onAlphaChange} />
{renderShowLabelsOnTop()}
</EuiPanel>
<EuiSpacer size="s" />
</Fragment>
);
}

View file

@ -205,7 +205,7 @@ export class LayerPanel extends React.Component {
<div className="mapLayerPanel__bodyOverflow">
<LayerErrors />
<LayerSettings />
<LayerSettings layer={selectedLayer} />
{this.props.selectedLayer.renderSourceSettingsEditor({
onChange: this._onSourceChange,

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { removeOrphanedSourcesAndLayers, syncLayerOrderForSingleLayer } from './utils';
import { removeOrphanedSourcesAndLayers } from './utils';
import { SPATIAL_FILTERS_LAYER_ID } from '../../../../common/constants';
import _ from 'lodash';
@ -186,80 +186,3 @@ describe('removeOrphanedSourcesAndLayers', () => {
expect(mockMbMap.getStyle()).toEqual(styleWithSpatialFilters);
});
});
describe('syncLayerOrderForSingleLayer', () => {
test('should move bar layer in front of foo layer', async () => {
const fooLayer = makeSingleSourceMockLayer('foo');
const barLayer = makeSingleSourceMockLayer('bar');
const currentLayerOrder = [fooLayer, barLayer];
const nextLayerListOrder = [barLayer, fooLayer];
const currentStyle = getMockStyle(currentLayerOrder);
const mockMbMap = new MockMbMap(currentStyle);
syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder);
const orderedStyle = mockMbMap.getStyle();
const nextStyle = getMockStyle(nextLayerListOrder);
expect(orderedStyle).toEqual(nextStyle);
});
test('should fail at moving multiple layers (this tests a limitation of the sync)', async () => {
//This is a known limitation of the layer order syncing.
//It assumes only a single layer will have moved.
//In practice, the Maps app will likely not cause multiple layers to move at once:
// - the UX only allows dragging a single layer
// - redux triggers a updates frequently enough
//But this is conceptually "wrong", as the sync does not actually operate in the same way as all the other mb-syncing methods
const fooLayer = makeSingleSourceMockLayer('foo');
const barLayer = makeSingleSourceMockLayer('bar');
const foozLayer = makeSingleSourceMockLayer('foo');
const bazLayer = makeSingleSourceMockLayer('baz');
const currentLayerOrder = [fooLayer, barLayer, foozLayer, bazLayer];
const nextLayerListOrder = [bazLayer, barLayer, foozLayer, fooLayer];
const currentStyle = getMockStyle(currentLayerOrder);
const mockMbMap = new MockMbMap(currentStyle);
syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder);
const orderedStyle = mockMbMap.getStyle();
const nextStyle = getMockStyle(nextLayerListOrder);
const isSyncSuccesful = _.isEqual(orderedStyle, nextStyle);
expect(isSyncSuccesful).toEqual(false);
});
test('should move bar layer in front of foo layer (multi source)', async () => {
const fooLayer = makeSingleSourceMockLayer('foo');
const barLayer = makeMultiSourceMockLayer('bar');
const currentLayerOrder = [fooLayer, barLayer];
const nextLayerListOrder = [barLayer, fooLayer];
const currentStyle = getMockStyle(currentLayerOrder);
const mockMbMap = new MockMbMap(currentStyle);
syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder);
const orderedStyle = mockMbMap.getStyle();
const nextStyle = getMockStyle(nextLayerListOrder);
expect(orderedStyle).toEqual(nextStyle);
});
test('should move bar layer in front of foo layer, but after baz layer', async () => {
const bazLayer = makeSingleSourceMockLayer('baz');
const fooLayer = makeSingleSourceMockLayer('foo');
const barLayer = makeSingleSourceMockLayer('bar');
const currentLayerOrder = [bazLayer, fooLayer, barLayer];
const nextLayerListOrder = [bazLayer, barLayer, fooLayer];
const currentStyle = getMockStyle(currentLayerOrder);
const mockMbMap = new MockMbMap(currentStyle);
syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder);
const orderedStyle = mockMbMap.getStyle();
const nextStyle = getMockStyle(nextLayerListOrder);
expect(orderedStyle).toEqual(nextStyle);
});
});

View file

@ -0,0 +1,225 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable max-classes-per-file */
import _ from 'lodash';
import { Map as MbMap, Layer as MbLayer, Style as MbStyle } from 'mapbox-gl';
import { getIsTextLayer, syncLayerOrder } from './sort_layers';
import { SPATIAL_FILTERS_LAYER_ID } from '../../../../common/constants';
import { ILayer } from '../../../classes/layers/layer';
let moveCounter = 0;
class MockMbMap {
private _style: MbStyle;
constructor(style: MbStyle) {
this._style = _.cloneDeep(style);
}
getStyle() {
return _.cloneDeep(this._style);
}
moveLayer(id: string, beforeId?: string) {
moveCounter++;
if (!this._style.layers) {
throw new Error(`Can not move layer, mapbox style does not contain layers`);
}
const layerIndex = this._style.layers.findIndex((layer) => {
return layer.id === id;
});
if (layerIndex === -1) {
throw new Error(`Can not move layer, layer with id: ${id} does not exist`);
}
const moveMbLayer = this._style.layers[layerIndex];
if (beforeId) {
const beforeLayerIndex = this._style.layers.findIndex((mbLayer) => {
return mbLayer.id === beforeId;
});
if (beforeLayerIndex === -1) {
throw new Error(`Can not move layer, before layer with id: ${id} does not exist`);
}
this._style.layers.splice(beforeLayerIndex, 0, moveMbLayer);
} else {
const topIndex = this._style.layers.length;
this._style.layers.splice(topIndex, 0, moveMbLayer);
}
// Remove layer from previous location
this._style.layers.splice(layerIndex, 1);
return this;
}
}
class MockMapLayer {
private readonly _id: string;
private readonly _areLabelsOnTop: boolean;
constructor(id: string, areLabelsOnTop: boolean) {
this._id = id;
this._areLabelsOnTop = areLabelsOnTop;
}
ownsMbLayerId(mbLayerId: string) {
return mbLayerId.startsWith(this._id);
}
areLabelsOnTop() {
return this._areLabelsOnTop;
}
getId() {
return this._id;
}
}
test('getIsTextLayer', () => {
const paintLabelMbLayer = {
id: `mylayer_text`,
type: 'symbol',
paint: { 'text-color': 'red' },
} as MbLayer;
expect(getIsTextLayer(paintLabelMbLayer)).toBe(true);
const layoutLabelMbLayer = {
id: `mylayer_text`,
type: 'symbol',
layout: { 'text-size': 'red' },
} as MbLayer;
expect(getIsTextLayer(layoutLabelMbLayer)).toBe(true);
const iconMbLayer = {
id: `mylayer_text`,
type: 'symbol',
paint: { 'icon-color': 'house' },
} as MbLayer;
expect(getIsTextLayer(iconMbLayer)).toBe(false);
const circleMbLayer = { id: `mylayer_text`, type: 'circle' } as MbLayer;
expect(getIsTextLayer(circleMbLayer)).toBe(false);
});
describe('sortLayer', () => {
const ALPHA_LAYER_ID = 'alpha';
const BRAVO_LAYER_ID = 'bravo';
const CHARLIE_LAYER_ID = 'charlie';
const spatialFilterLayer = (new MockMapLayer(
SPATIAL_FILTERS_LAYER_ID,
false
) as unknown) as ILayer;
const mapLayers = [
(new MockMapLayer(CHARLIE_LAYER_ID, true) as unknown) as ILayer,
(new MockMapLayer(BRAVO_LAYER_ID, false) as unknown) as ILayer,
(new MockMapLayer(ALPHA_LAYER_ID, false) as unknown) as ILayer,
];
beforeEach(() => {
moveCounter = 0;
});
// Initial order that styles are added to mapbox is non-deterministic and depends on the order of data fetches.
test('Should sort initial layer load order to expected order', () => {
const initialMbStyle = {
version: 0,
layers: [
{ id: `${BRAVO_LAYER_ID}_text`, type: 'symbol' } as MbLayer,
{ id: `${BRAVO_LAYER_ID}_circle`, type: 'circle' } as MbLayer,
{ id: `${SPATIAL_FILTERS_LAYER_ID}_fill`, type: 'fill' } as MbLayer,
{ id: `${SPATIAL_FILTERS_LAYER_ID}_circle`, type: 'circle' } as MbLayer,
{
id: `${CHARLIE_LAYER_ID}_text`,
type: 'symbol',
paint: { 'text-color': 'red' },
} as MbLayer,
{ id: `${CHARLIE_LAYER_ID}_fill`, type: 'fill' } as MbLayer,
{ id: `${ALPHA_LAYER_ID}_text`, type: 'symbol' } as MbLayer,
{ id: `${ALPHA_LAYER_ID}_circle`, type: 'circle' } as MbLayer,
],
};
const mbMap = new MockMbMap(initialMbStyle);
syncLayerOrder((mbMap as unknown) as MbMap, spatialFilterLayer, mapLayers);
const sortedMbStyle = mbMap.getStyle();
const sortedMbLayerIds = sortedMbStyle.layers!.map((mbLayer) => {
return mbLayer.id;
});
expect(sortedMbLayerIds).toEqual([
'charlie_fill',
'bravo_text',
'bravo_circle',
'alpha_text',
'alpha_circle',
'charlie_text',
'SPATIAL_FILTERS_LAYER_ID_fill',
'SPATIAL_FILTERS_LAYER_ID_circle',
]);
});
// Test case testing when layer is moved in Table of Contents
test('Should sort single layer single move to expected order', () => {
const initialMbStyle = {
version: 0,
layers: [
{ id: `${CHARLIE_LAYER_ID}_fill`, type: 'fill' } as MbLayer,
{ id: `${ALPHA_LAYER_ID}_text`, type: 'symbol' } as MbLayer,
{ id: `${ALPHA_LAYER_ID}_circle`, type: 'circle' } as MbLayer,
{ id: `${BRAVO_LAYER_ID}_text`, type: 'symbol' } as MbLayer,
{ id: `${BRAVO_LAYER_ID}_circle`, type: 'circle' } as MbLayer,
{
id: `${CHARLIE_LAYER_ID}_text`,
type: 'symbol',
paint: { 'text-color': 'red' },
} as MbLayer,
{ id: `${SPATIAL_FILTERS_LAYER_ID}_fill`, type: 'fill' } as MbLayer,
{ id: `${SPATIAL_FILTERS_LAYER_ID}_circle`, type: 'circle' } as MbLayer,
],
};
const mbMap = new MockMbMap(initialMbStyle);
syncLayerOrder((mbMap as unknown) as MbMap, spatialFilterLayer, mapLayers);
const sortedMbStyle = mbMap.getStyle();
const sortedMbLayerIds = sortedMbStyle.layers!.map((mbLayer) => {
return mbLayer.id;
});
expect(sortedMbLayerIds).toEqual([
'charlie_fill',
'bravo_text',
'bravo_circle',
'alpha_text',
'alpha_circle',
'charlie_text',
'SPATIAL_FILTERS_LAYER_ID_fill',
'SPATIAL_FILTERS_LAYER_ID_circle',
]);
});
test('Should not call move layers when layers are in expected order', () => {
const initialMbStyle = {
version: 0,
layers: [
{ id: `${CHARLIE_LAYER_ID}_fill`, type: 'fill' } as MbLayer,
{ id: `${BRAVO_LAYER_ID}_text`, type: 'symbol' } as MbLayer,
{ id: `${BRAVO_LAYER_ID}_circle`, type: 'circle' } as MbLayer,
{ id: `${ALPHA_LAYER_ID}_text`, type: 'symbol' } as MbLayer,
{ id: `${ALPHA_LAYER_ID}_circle`, type: 'circle' } as MbLayer,
{
id: `${CHARLIE_LAYER_ID}_text`,
type: 'symbol',
paint: { 'text-color': 'red' },
} as MbLayer,
{ id: `${SPATIAL_FILTERS_LAYER_ID}_fill`, type: 'fill' } as MbLayer,
{ id: `${SPATIAL_FILTERS_LAYER_ID}_circle`, type: 'circle' } as MbLayer,
],
};
const mbMap = new MockMbMap(initialMbStyle);
syncLayerOrder((mbMap as unknown) as MbMap, spatialFilterLayer, mapLayers);
expect(moveCounter).toBe(0);
});
});

View file

@ -0,0 +1,142 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Map as MbMap, Layer as MbLayer } from 'mapbox-gl';
import { ILayer } from '../../../classes/layers/layer';
// "Layer" is overloaded and can mean the following
// 1) Map layer (ILayer): A single map layer consists of one to many mapbox layers.
// 2) Mapbox layer (MbLayer): Individual unit of rendering such as text, circles, polygons, or lines.
export function getIsTextLayer(mbLayer: MbLayer) {
if (mbLayer.type !== 'symbol') {
return false;
}
const styleNames = [];
if (mbLayer.paint) {
styleNames.push(...Object.keys(mbLayer.paint));
}
if (mbLayer.layout) {
styleNames.push(...Object.keys(mbLayer.layout));
}
return styleNames.some((styleName) => {
return styleName.startsWith('text-');
});
}
function doesMbLayerBelongToMapLayerAndClass(
mapLayer: ILayer,
mbLayer: MbLayer,
layerClass: LAYER_CLASS
) {
if (!mapLayer.ownsMbLayerId(mbLayer.id)) {
return false;
}
// mb layer belongs to mapLayer, now filter by layer class
if (layerClass === LAYER_CLASS.ANY) {
return true;
}
const isTextLayer = getIsTextLayer(mbLayer);
return layerClass === LAYER_CLASS.LABEL ? isTextLayer : !isTextLayer;
}
enum LAYER_CLASS {
ANY = 'ANY',
LABEL = 'LABEL',
NON_LABEL = 'NON_LABEL',
}
function moveMapLayer(
mbMap: MbMap,
mbLayers: MbLayer[],
mapLayer: ILayer,
layerClass: LAYER_CLASS,
beneathMbLayerId?: string
) {
mbLayers
.filter((mbLayer) => {
return doesMbLayerBelongToMapLayerAndClass(mapLayer, mbLayer, layerClass);
})
.forEach((mbLayer) => {
mbMap.moveLayer(mbLayer.id, beneathMbLayerId);
});
}
function getBottomMbLayerId(mbLayers: MbLayer[], mapLayer: ILayer, layerClass: LAYER_CLASS) {
const bottomMbLayer = mbLayers.find((mbLayer) => {
return doesMbLayerBelongToMapLayerAndClass(mapLayer, mbLayer, layerClass);
});
return bottomMbLayer ? bottomMbLayer.id : undefined;
}
function isLayerInOrder(
mbMap: MbMap,
mapLayer: ILayer,
layerClass: LAYER_CLASS,
beneathMbLayerId?: string
) {
const mbLayers = mbMap.getStyle().layers!; // check ordering against mapbox to account for any upstream moves.
if (!beneathMbLayerId) {
// Check that map layer is top layer
return doesMbLayerBelongToMapLayerAndClass(mapLayer, mbLayers[mbLayers.length - 1], layerClass);
}
let inMapLayerBlock = false;
let nextMbLayerId = null;
for (let i = 0; i < mbLayers.length; i++) {
if (!inMapLayerBlock) {
if (doesMbLayerBelongToMapLayerAndClass(mapLayer, mbLayers[i], layerClass)) {
inMapLayerBlock = true;
}
} else {
// Next mbLayer not belonging to this map layer is the bottom mb layer for the next map layer
if (!doesMbLayerBelongToMapLayerAndClass(mapLayer, mbLayers[i], layerClass)) {
nextMbLayerId = mbLayers[i].id;
break;
}
}
}
return nextMbLayerId === beneathMbLayerId;
}
export function syncLayerOrder(mbMap: MbMap, spatialFiltersLayer: ILayer, layerList: ILayer[]) {
const mbLayers = mbMap.getStyle().layers;
if (!mbLayers || mbLayers.length === 0) {
return;
}
// Ensure spatial filters layer is the top layer.
if (!isLayerInOrder(mbMap, spatialFiltersLayer, LAYER_CLASS.ANY)) {
moveMapLayer(mbMap, mbLayers, spatialFiltersLayer, LAYER_CLASS.ANY);
}
let beneathMbLayerId = getBottomMbLayerId(mbLayers, spatialFiltersLayer, LAYER_CLASS.ANY);
// Sort map layer labels
[...layerList]
.reverse()
.filter((mapLayer) => {
return mapLayer.areLabelsOnTop();
})
.forEach((mapLayer: ILayer) => {
if (!isLayerInOrder(mbMap, mapLayer, LAYER_CLASS.LABEL, beneathMbLayerId)) {
moveMapLayer(mbMap, mbLayers, mapLayer, LAYER_CLASS.LABEL, beneathMbLayerId);
}
beneathMbLayerId = getBottomMbLayerId(mbLayers, mapLayer, LAYER_CLASS.LABEL);
});
// Sort map layers
[...layerList].reverse().forEach((mapLayer: ILayer) => {
const layerClass = mapLayer.areLabelsOnTop() ? LAYER_CLASS.NON_LABEL : LAYER_CLASS.ANY;
if (!isLayerInOrder(mbMap, mapLayer, layerClass, beneathMbLayerId)) {
moveMapLayer(mbMap, mbLayers, mapLayer, layerClass, beneathMbLayerId);
}
beneathMbLayerId = getBottomMbLayerId(mbLayers, mapLayer, layerClass);
});
}

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import { RGBAImage } from './image_utils';
export function removeOrphanedSourcesAndLayers(mbMap, layerList, spatialFilterLayer) {
@ -45,84 +44,6 @@ export function removeOrphanedSourcesAndLayers(mbMap, layerList, spatialFilterLa
mbSourcesToRemove.forEach((mbSourceId) => mbMap.removeSource(mbSourceId));
}
export function moveLayerToTop(mbMap, layer) {
const mbStyle = mbMap.getStyle();
if (!mbStyle.layers || mbStyle.layers.length === 0) {
return;
}
layer.getMbLayerIds().forEach((mbLayerId) => {
const mbLayer = mbMap.getLayer(mbLayerId);
if (mbLayer) {
mbMap.moveLayer(mbLayerId);
}
});
}
/**
* This is function assumes only a single layer moved in the layerList, compared to mbMap
* It is optimized to minimize the amount of mbMap.moveLayer calls.
* @param mbMap
* @param layerList
*/
export function syncLayerOrderForSingleLayer(mbMap, layerList) {
if (!layerList || layerList.length === 0) {
return;
}
const mbLayers = mbMap.getStyle().layers.slice();
const layerIds = [];
mbLayers.forEach((mbLayer) => {
const layer = layerList.find((layer) => layer.ownsMbLayerId(mbLayer.id));
if (layer) {
layerIds.push(layer.getId());
}
});
const currentLayerOrderLayerIds = _.uniq(layerIds);
const newLayerOrderLayerIdsUnfiltered = layerList.map((l) => l.getId());
const newLayerOrderLayerIds = newLayerOrderLayerIdsUnfiltered.filter((layerId) =>
currentLayerOrderLayerIds.includes(layerId)
);
let netPos = 0;
let netNeg = 0;
const movementArr = currentLayerOrderLayerIds.reduce((accu, id, idx) => {
const movement = newLayerOrderLayerIds.findIndex((newOId) => newOId === id) - idx;
movement > 0 ? netPos++ : movement < 0 && netNeg++;
accu.push({ id, movement });
return accu;
}, []);
if (netPos === 0 && netNeg === 0) {
return;
}
const movedLayerId =
(netPos >= netNeg && movementArr.find((l) => l.movement < 0).id) ||
(netPos < netNeg && movementArr.find((l) => l.movement > 0).id);
const nextLayerIdx = newLayerOrderLayerIds.findIndex((layerId) => layerId === movedLayerId) + 1;
let nextMbLayerId;
if (nextLayerIdx === newLayerOrderLayerIds.length) {
nextMbLayerId = null;
} else {
const foundLayer = mbLayers.find(({ id: mbLayerId }) => {
const layerId = newLayerOrderLayerIds[nextLayerIdx];
const layer = layerList.find((layer) => layer.getId() === layerId);
return layer.ownsMbLayerId(mbLayerId);
});
nextMbLayerId = foundLayer.id;
}
const movedLayer = layerList.find((layer) => layer.getId() === movedLayerId);
mbLayers.forEach(({ id: mbLayerId }) => {
if (movedLayer.ownsMbLayerId(mbLayerId)) {
mbMap.moveLayer(mbLayerId, nextMbLayerId);
}
});
}
export async function addSpritesheetToMap(json, imgUrl, mbMap) {
const imgData = await loadSpriteSheetImageData(imgUrl);
addSpriteSheetToMapFromImageData(json, imgData, mbMap);

View file

@ -7,12 +7,8 @@
import _ from 'lodash';
import React from 'react';
import { ResizeChecker } from '../../../../../../../src/plugins/kibana_utils/public';
import {
syncLayerOrderForSingleLayer,
removeOrphanedSourcesAndLayers,
addSpritesheetToMap,
moveLayerToTop,
} from './utils';
import { removeOrphanedSourcesAndLayers, addSpritesheetToMap } from './utils';
import { syncLayerOrder } from './sort_layers';
import { getGlyphUrl, isRetina } from '../../../meta';
import { DECIMAL_DEGREES_PRECISION, ZOOM_PRECISION } from '../../../../common/constants';
import mapboxgl from 'mapbox-gl/dist/mapbox-gl-csp';
@ -265,8 +261,7 @@ export class MBMapContainer extends React.Component {
this.props.spatialFiltersLayer
);
this.props.layerList.forEach((layer) => layer.syncLayerWithMB(this.state.mbMap));
syncLayerOrderForSingleLayer(this.state.mbMap, this.props.layerList);
moveLayerToTop(this.state.mbMap, this.props.spatialFiltersLayer);
syncLayerOrder(this.state.mbMap, this.props.spatialFiltersLayer, this.props.layerList);
};
_syncMbMapWithInspector = () => {