[Maps] auto-fit to data bounds (#72129)

* [Maps] auto-fit to data bounds

* update jest snapshot

* add buffer to fit to bounds

* sync join layers prior to fitting to bounds

* clean-up comment

* better names

* fix tslint errors

* update functional test expect

* add functional tests

* clean-up

* change test run location

* fix test expect

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2020-07-20 15:24:32 -06:00 committed by GitHub
parent b9413cf3c8
commit 9947c671ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 249 additions and 22 deletions

View file

@ -37,8 +37,12 @@ import {
UPDATE_SOURCE_DATA_REQUEST,
} from './map_action_constants';
import { ILayer } from '../classes/layers/layer';
import { IVectorLayer } from '../classes/layers/vector_layer/vector_layer';
import { DataMeta, MapExtent, MapFilters } from '../../common/descriptor_types';
import { DataRequestAbortError } from '../classes/util/data_request';
import { scaleBounds } from '../elasticsearch_geo_utils';
const FIT_TO_BOUNDS_SCALE_FACTOR = 0.1;
export type DataRequestContext = {
startLoading(dataId: string, requestToken: symbol, meta: DataMeta): void;
@ -122,13 +126,26 @@ function getDataRequestContext(
export function syncDataForAllLayers() {
return async (dispatch: Dispatch, getState: () => MapStoreState) => {
const syncPromises = getLayerList(getState()).map(async (layer) => {
const syncPromises = getLayerList(getState()).map((layer) => {
return dispatch<any>(syncDataForLayer(layer));
});
await Promise.all(syncPromises);
};
}
export function syncDataForAllJoinLayers() {
return async (dispatch: Dispatch, getState: () => MapStoreState) => {
const syncPromises = getLayerList(getState())
.filter((layer) => {
return 'hasJoins' in layer ? (layer as IVectorLayer).hasJoins() : false;
})
.map((layer) => {
return dispatch<any>(syncDataForLayer(layer));
});
await Promise.all(syncPromises);
};
}
export function syncDataForLayer(layer: ILayer) {
return async (dispatch: Dispatch, getState: () => MapStoreState) => {
const dataRequestContext = getDataRequestContext(dispatch, getState, layer.getId());
@ -284,7 +301,7 @@ export function fitToLayerExtent(layerId: string) {
getDataRequestContext(dispatch, getState, layerId)
);
if (bounds) {
await dispatch(setGotoWithBounds(bounds));
await dispatch(setGotoWithBounds(scaleBounds(bounds, FIT_TO_BOUNDS_SCALE_FACTOR)));
}
} catch (error) {
if (!(error instanceof DataRequestAbortError)) {
@ -359,7 +376,7 @@ export function fitToDataBounds() {
maxLat: turfUnionBbox[3],
};
dispatch(setGotoWithBounds(dataBounds));
dispatch(setGotoWithBounds(scaleBounds(dataBounds, FIT_TO_BOUNDS_SCALE_FACTOR)));
};
}

View file

@ -8,11 +8,13 @@
import { Dispatch } from 'redux';
// @ts-ignore
import turf from 'turf';
import uuid from 'uuid/v4';
import turfBooleanContains from '@turf/boolean-contains';
import { Filter, Query, TimeRange } from 'src/plugins/data/public';
import { MapStoreState } from '../reducers/store';
import {
getDataFilters,
getMapSettings,
getWaitingForMapReadyLayerListRaw,
getQuery,
} from '../selectors/map_selectors';
@ -42,7 +44,11 @@ import {
UPDATE_DRAW_STATE,
UPDATE_MAP_SETTING,
} from './map_action_constants';
import { syncDataForAllLayers } from './data_request_actions';
import {
fitToDataBounds,
syncDataForAllJoinLayers,
syncDataForAllLayers,
} from './data_request_actions';
import { addLayer } from './layer_actions';
import { MapSettings } from '../reducers/map';
import {
@ -51,6 +57,7 @@ import {
MapExtent,
MapRefreshConfig,
} from '../../common/descriptor_types';
import { scaleBounds } from '../elasticsearch_geo_utils';
export function setMapInitError(errorMessage: string) {
return {
@ -134,15 +141,7 @@ export function mapExtentChanged(newMapConstants: { zoom: number; extent: MapExt
}
if (!doesBufferContainExtent || currentZoom !== newZoom) {
const scaleFactor = 0.5; // TODO put scale factor in store and fetch with selector
const width = extent.maxLon - extent.minLon;
const height = extent.maxLat - extent.minLat;
dataFilters.buffer = {
minLon: extent.minLon - width * scaleFactor,
minLat: extent.minLat - height * scaleFactor,
maxLon: extent.maxLon + width * scaleFactor,
maxLat: extent.maxLat + height * scaleFactor,
};
dataFilters.buffer = scaleBounds(extent, 0.5);
}
}
@ -197,6 +196,7 @@ function generateQueryTimestamp() {
return new Date().toISOString();
}
let lastSetQueryCallId: string = '';
export function setQuery({
query,
timeFilters,
@ -226,7 +226,22 @@ export function setQuery({
filters,
});
await dispatch<any>(syncDataForAllLayers());
if (getMapSettings(getState()).autoFitToDataBounds) {
// Joins are performed on the client.
// As a result, bounds for join layers must also be performed on the client.
// Therefore join layers need to fetch data prior to auto fitting bounds.
const localSetQueryCallId = uuid();
lastSetQueryCallId = localSetQueryCallId;
await dispatch<any>(syncDataForAllJoinLayers());
// setQuery can be triggered before async data fetching completes
// Only continue execution path if setQuery has not been re-triggered.
if (localSetQueryCallId === lastSetQueryCallId) {
dispatch<any>(fitToDataBounds());
}
} else {
await dispatch<any>(syncDataForAllLayers());
}
};
}

View file

@ -237,6 +237,10 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer {
return [];
}
hasJoins() {
return false;
}
getSource() {
return this._isClustered ? this._clusterSource : this._documentSource;
}

View file

@ -35,6 +35,7 @@ export interface IVectorLayer extends ILayer {
getStyle(): IVectorStyle;
getFeatureById(id: string | number): Feature | null;
getPropertiesForTooltip(properties: GeoJsonProperties): Promise<ITooltipProperty[]>;
hasJoins(): boolean;
}
export class VectorLayer extends AbstractLayer implements IVectorLayer {
@ -81,4 +82,5 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer {
getStyle(): IVectorStyle;
getFeatureById(id: string | number): Feature | null;
getPropertiesForTooltip(properties: GeoJsonProperties): Promise<ITooltipProperty[]>;
hasJoins(): boolean;
}

View file

@ -85,7 +85,7 @@ export class VectorLayer extends AbstractLayer {
});
}
_hasJoins() {
hasJoins() {
return this.getValidJoins().length > 0;
}
@ -159,7 +159,7 @@ export class VectorLayer extends AbstractLayer {
async getBounds({ startLoading, stopLoading, registerCancelCallback, dataFilters }) {
const isStaticLayer = !this.getSource().isBoundsAware();
if (isStaticLayer) {
return getFeatureCollectionBounds(this._getSourceFeatureCollection(), this._hasJoins());
return getFeatureCollectionBounds(this._getSourceFeatureCollection(), this.hasJoins());
}
const requestToken = Symbol(`${SOURCE_BOUNDS_DATA_REQUEST_ID}-${this.getId()}`);
@ -193,6 +193,11 @@ export class VectorLayer extends AbstractLayer {
return bounds;
}
isLoadingBounds() {
const boundsDataRequest = this.getDataRequest(SOURCE_BOUNDS_DATA_REQUEST_ID);
return !!boundsDataRequest && boundsDataRequest.isLoading();
}
async getLeftJoinFields() {
return await this.getSource().getLeftJoinFields();
}
@ -583,7 +588,7 @@ export class VectorLayer extends AbstractLayer {
}
async syncData(syncContext) {
this._syncData(syncContext, this.getSource(), this.getCurrentStyle());
await this._syncData(syncContext, this.getSource(), this.getCurrentStyle());
}
// TLDR: Do not call getSource or getCurrentStyle in syncData flow. Use 'source' and 'style' arguments instead.
@ -597,13 +602,16 @@ export class VectorLayer extends AbstractLayer {
// Given 2 above, which source/style to use can not be pulled from data request state.
// Therefore, source and style are provided as arugments and must be used instead of calling getSource or getCurrentStyle.
async _syncData(syncContext, source, style) {
if (this.isLoadingBounds()) {
return;
}
await this._syncSourceStyleMeta(syncContext, source, style);
await this._syncSourceFormatters(syncContext, source, style);
const sourceResult = await this._syncSource(syncContext, source, style);
if (
!sourceResult.featureCollection ||
!sourceResult.featureCollection.features.length ||
!this._hasJoins()
!this.hasJoins()
) {
return;
}
@ -711,7 +719,7 @@ export class VectorLayer extends AbstractLayer {
mbMap.addLayer(mbLayer);
}
const filterExpr = getPointFilterExpression(this._hasJoins());
const filterExpr = getPointFilterExpression(this.hasJoins());
if (filterExpr !== mbMap.getFilter(pointLayerId)) {
mbMap.setFilter(pointLayerId, filterExpr);
mbMap.setFilter(textLayerId, filterExpr);
@ -747,7 +755,7 @@ export class VectorLayer extends AbstractLayer {
mbMap.addLayer(mbLayer);
}
const filterExpr = getPointFilterExpression(this._hasJoins());
const filterExpr = getPointFilterExpression(this.hasJoins());
if (filterExpr !== mbMap.getFilter(symbolLayerId)) {
mbMap.setFilter(symbolLayerId, filterExpr);
}
@ -769,7 +777,7 @@ export class VectorLayer extends AbstractLayer {
const sourceId = this.getId();
const fillLayerId = this._getMbPolygonLayerId();
const lineLayerId = this._getMbLineLayerId();
const hasJoins = this._hasJoins();
const hasJoins = this.hasJoins();
if (!mbMap.getLayer(fillLayerId)) {
const mbLayer = {
id: fillLayerId,

View file

@ -16,6 +16,25 @@ exports[`should render 1`] = `
<EuiSpacer
size="m"
/>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
labelType="label"
>
<EuiSwitch
checked={false}
compressed={true}
data-test-subj="autoFitToDataBoundsSwitch"
label="Auto fit map to data bounds"
onChange={[Function]}
/>
</EuiFormRow>
<EuiSpacer
size="m"
/>
<ValidatedDualRange
allowEmptyRange={false}
compressed={true}
@ -35,6 +54,9 @@ exports[`should render 1`] = `
]
}
/>
<EuiSpacer
size="m"
/>
<EuiFormRow
describedByIds={Array []}
display="row"
@ -84,6 +106,25 @@ exports[`should render browser location form when initialLocation is BROWSER_LOC
<EuiSpacer
size="m"
/>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
labelType="label"
>
<EuiSwitch
checked={false}
compressed={true}
data-test-subj="autoFitToDataBoundsSwitch"
label="Auto fit map to data bounds"
onChange={[Function]}
/>
</EuiFormRow>
<EuiSpacer
size="m"
/>
<ValidatedDualRange
allowEmptyRange={false}
compressed={true}
@ -103,6 +144,9 @@ exports[`should render browser location form when initialLocation is BROWSER_LOC
]
}
/>
<EuiSpacer
size="m"
/>
<EuiFormRow
describedByIds={Array []}
display="row"
@ -172,6 +216,25 @@ exports[`should render fixed location form when initialLocation is FIXED_LOCATIO
<EuiSpacer
size="m"
/>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
labelType="label"
>
<EuiSwitch
checked={false}
compressed={true}
data-test-subj="autoFitToDataBoundsSwitch"
label="Auto fit map to data bounds"
onChange={[Function]}
/>
</EuiFormRow>
<EuiSpacer
size="m"
/>
<ValidatedDualRange
allowEmptyRange={false}
compressed={true}
@ -191,6 +254,9 @@ exports[`should render fixed location form when initialLocation is FIXED_LOCATIO
]
}
/>
<EuiSpacer
size="m"
/>
<EuiFormRow
describedByIds={Array []}
display="row"

View file

@ -96,6 +96,7 @@ export function MapSettingsPanel({
iconType="check"
onClick={keepChanges}
fill
data-test-subj="mapSettingSubmitButton"
>
<FormattedMessage
id="xpack.maps.mapSettingsPanel.keepChangesButtonLabel"

View file

@ -14,6 +14,8 @@ import {
EuiPanel,
EuiRadioGroup,
EuiSpacer,
EuiSwitch,
EuiSwitchEvent,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -54,6 +56,10 @@ const initialLocationOptions = [
];
export function NavigationPanel({ center, settings, updateMapSetting, zoom }: Props) {
const onAutoFitToDataBoundsChange = (event: EuiSwitchEvent) => {
updateMapSetting('autoFitToDataBounds', event.target.checked);
};
const onZoomChange = (value: Value) => {
const minZoom = Math.max(MIN_ZOOM, parseInt(value[0] as string, 10));
const maxZoom = Math.min(MAX_ZOOM, parseInt(value[1] as string, 10));
@ -207,6 +213,19 @@ export function NavigationPanel({ center, settings, updateMapSetting, zoom }: Pr
</h5>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow>
<EuiSwitch
label={i18n.translate('xpack.maps.mapSettingsPanel.autoFitToDataBoundsLabel', {
defaultMessage: 'Auto fit map to data bounds',
})}
checked={settings.autoFitToDataBounds}
onChange={onAutoFitToDataBoundsChange}
compressed
data-test-subj="autoFitToDataBoundsSwitch"
/>
</EuiFormRow>
<EuiSpacer size="m" />
<ValidatedDualRange
label={i18n.translate('xpack.maps.mapSettingsPanel.zoomRangeLabel', {
@ -224,6 +243,7 @@ export function NavigationPanel({ center, settings, updateMapSetting, zoom }: Pr
compressed
/>
<EuiSpacer size="m" />
<EuiFormRow
label={i18n.translate('xpack.maps.source.mapSettingsPanel.initialLocationLabel', {
defaultMessage: 'Initial map location',

View file

@ -0,0 +1,9 @@
/*
* 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 { MapExtent } from '../common/descriptor_types';
export function scaleBounds(bounds: MapExtent, scaleFactor: number): MapExtent;

View file

@ -469,3 +469,14 @@ export function extractFeaturesFromFilters(filters) {
return features;
}
export function scaleBounds(bounds, scaleFactor) {
const width = bounds.maxLon - bounds.minLon;
const height = bounds.maxLat - bounds.minLat;
return {
minLon: bounds.minLon - width * scaleFactor,
minLat: bounds.minLat - height * scaleFactor,
maxLon: bounds.maxLon + width * scaleFactor,
maxLat: bounds.maxLat + height * scaleFactor,
};
}

View file

@ -20,6 +20,7 @@ import {
roundCoordinates,
extractFeaturesFromFilters,
makeESBbox,
scaleBounds,
} from './elasticsearch_geo_utils';
import { indexPatterns } from '../../../../src/plugins/data/public';
@ -687,3 +688,20 @@ describe('makeESBbox', () => {
expect(bbox).toEqual({ bottom_right: [-170, -89], top_left: [-175, 89] });
});
});
describe('scaleBounds', () => {
it('Should scale bounds', () => {
const bounds = {
maxLat: 10,
maxLon: 100,
minLat: 5,
minLon: 95,
};
expect(scaleBounds(bounds, 0.5)).toEqual({
maxLat: 12.5,
maxLon: 102.5,
minLat: 2.5,
minLon: 92.5,
});
});
});

View file

@ -9,6 +9,7 @@ import { MapSettings } from './map';
export function getDefaultMapSettings(): MapSettings {
return {
autoFitToDataBounds: false,
initialLocation: INITIAL_LOCATION.LAST_SAVED_LOCATION,
fixedLocation: { lat: 0, lon: 0, zoom: 2 },
browserLocation: { zoom: 2 },

View file

@ -42,6 +42,7 @@ export type MapContext = {
};
export type MapSettings = {
autoFitToDataBounds: boolean;
initialLocation: INITIAL_LOCATION;
fixedLocation: {
lat: number;

View file

@ -0,0 +1,35 @@
/*
* 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 expect from '@kbn/expect';
export default function ({ getPageObjects }) {
const PageObjects = getPageObjects(['maps']);
describe('auto fit map to bounds', () => {
describe('without joins', () => {
before(async () => {
await PageObjects.maps.loadSavedMap('document example');
await PageObjects.maps.enableAutoFitToBounds();
});
it('should automatically fit to bounds when query is applied', async () => {
// Set view to other side of world so no matching results
await PageObjects.maps.setView(-15, -100, 6);
// Setting query should trigger fit to bounds and move map
const origView = await PageObjects.maps.getView();
await PageObjects.maps.setAndSubmitQuery('machine.os.raw : "ios"');
await PageObjects.maps.waitForMapPanAndZoom(origView);
const { lat, lon, zoom } = await PageObjects.maps.getView();
expect(Math.round(lat)).to.equal(43);
expect(Math.round(lon)).to.equal(-102);
expect(Math.round(zoom)).to.equal(5);
});
});
});
}

View file

@ -103,7 +103,7 @@ export default function ({ getPageObjects, getService }) {
await PageObjects.maps.setView(-15, -100, 6);
await PageObjects.maps.clickFitToBounds('logstash');
const { lat, lon, zoom } = await PageObjects.maps.getView();
expect(Math.round(lat)).to.equal(42);
expect(Math.round(lat)).to.equal(43);
expect(Math.round(lon)).to.equal(-102);
expect(Math.round(zoom)).to.equal(5);
});

View file

@ -34,6 +34,7 @@ export default function ({ loadTestFile, getService }) {
loadTestFile(require.resolve('./vector_styling'));
loadTestFile(require.resolve('./saved_object_management'));
loadTestFile(require.resolve('./sample_data'));
loadTestFile(require.resolve('./auto_fit_to_bounds'));
loadTestFile(require.resolve('./feature_controls/maps_security'));
loadTestFile(require.resolve('./feature_controls/maps_spaces'));
loadTestFile(require.resolve('./full_screen_mode'));

View file

@ -656,6 +656,24 @@ export function GisPageProvider({ getService, getPageObjects }) {
async getCategorySuggestions() {
return await comboBox.getOptionsList(`colorStopInput1`);
}
async enableAutoFitToBounds() {
await testSubjects.click('openSettingsButton');
const isEnabled = await testSubjects.getAttribute('autoFitToDataBoundsSwitch', 'checked');
if (!isEnabled) {
await retry.try(async () => {
await testSubjects.click('autoFitToDataBoundsSwitch');
const ensureEnabled = await testSubjects.getAttribute(
'autoFitToDataBoundsSwitch',
'checked'
);
if (!ensureEnabled) {
throw new Error('autoFitToDataBoundsSwitch is not enabled');
}
});
}
await testSubjects.click('mapSettingSubmitButton');
}
}
return new GisPage();
}