[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:
parent
6c62c686cf
commit
ebcec3a904
15 changed files with 577 additions and 295 deletions
|
@ -169,6 +169,7 @@ export type LayerDescriptor = {
|
|||
alpha?: number;
|
||||
id: string;
|
||||
label?: string | null;
|
||||
areLabelsOnTop?: boolean;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
sourceDescriptor: SourceDescriptor | null;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -277,4 +277,12 @@ export class VectorTileLayer extends TileLayer {
|
|||
this._setOpacityForType(mbMap, mbLayer, mbLayerId);
|
||||
});
|
||||
}
|
||||
|
||||
areLabelsOnTop() {
|
||||
return !!this._descriptor.areLabelsOnTop;
|
||||
}
|
||||
|
||||
supportsLabelsOnTop() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
Loading…
Reference in a new issue