diff --git a/x-pack/plugins/maps/public/actions/data_request_actions.ts b/x-pack/plugins/maps/public/actions/data_request_actions.ts index a0c484a82e530..5919feadfcc2a 100644 --- a/x-pack/plugins/maps/public/actions/data_request_actions.ts +++ b/x-pack/plugins/maps/public/actions/data_request_actions.ts @@ -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(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(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))); }; } diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 75df8689a670e..ef0cfdf0b4742 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -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(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(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(fitToDataBounds()); + } + } else { + await dispatch(syncDataForAllLayers()); + } }; } diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index aefa2beede7d1..c0b9c4553d01e 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -237,6 +237,10 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { return []; } + hasJoins() { + return false; + } + getSource() { return this._isClustered ? this._clusterSource : this._documentSource; } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts index 77daf9c9af570..e6cb212daddae 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.d.ts @@ -35,6 +35,7 @@ export interface IVectorLayer extends ILayer { getStyle(): IVectorStyle; getFeatureById(id: string | number): Feature | null; getPropertiesForTooltip(properties: GeoJsonProperties): Promise; + 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; + hasJoins(): boolean; } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js index 0a4fcfc23060c..23889bdca2dd7 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js @@ -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, diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap index 641dd20a1a44a..18e30d9446e05 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap @@ -16,6 +16,25 @@ exports[`should render 1`] = ` + + + + + + + + + + + + + + + { + 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 + + + + + + { 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, + }); + }); +}); diff --git a/x-pack/plugins/maps/public/reducers/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/default_map_settings.ts index 9c9b814ae6add..896ac11e36782 100644 --- a/x-pack/plugins/maps/public/reducers/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/default_map_settings.ts @@ -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 }, diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index 33794fcf8657d..aca75334032d9 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -42,6 +42,7 @@ export type MapContext = { }; export type MapSettings = { + autoFitToDataBounds: boolean; initialLocation: INITIAL_LOCATION; fixedLocation: { lat: number; diff --git a/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js b/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js new file mode 100644 index 0000000000000..64c07273c9ccf --- /dev/null +++ b/x-pack/test/functional/apps/maps/auto_fit_to_bounds.js @@ -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); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/documents_source/search_hits.js b/x-pack/test/functional/apps/maps/documents_source/search_hits.js index 5d75679432c97..cc0f3a7df32de 100644 --- a/x-pack/test/functional/apps/maps/documents_source/search_hits.js +++ b/x-pack/test/functional/apps/maps/documents_source/search_hits.js @@ -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); }); diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index 15928170972d9..d0735aecda78b 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -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')); diff --git a/x-pack/test/functional/page_objects/gis_page.js b/x-pack/test/functional/page_objects/gis_page.js index 8c2af84845035..d3d7aca2c2452 100644 --- a/x-pack/test/functional/page_objects/gis_page.js +++ b/x-pack/test/functional/page_objects/gis_page.js @@ -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(); }