From 54eee3e230242c42d3a7fcf1862ec7496bf7fbb3 Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Thu, 16 Jan 2020 16:28:41 -0700 Subject: [PATCH 1/4] Register maps embeddable in NP plugin setup --- .../maps/public/embeddable/map_embeddable_factory.js | 3 --- x-pack/legacy/plugins/maps/public/plugin.ts | 8 +++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.js b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.js index ec3a588d3627f..9cbaabaaf6946 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.js +++ b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.js @@ -12,7 +12,6 @@ import { EmbeddableFactory, ErrorEmbeddable, } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { setup } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; import { MapEmbeddable } from './map_embeddable'; import { indexPatternService } from '../kibana_services'; @@ -146,5 +145,3 @@ export class MapEmbeddableFactory extends EmbeddableFactory { ); } } - -setup.registerEmbeddableFactory(MAP_SAVED_OBJECT_TYPE, new MapEmbeddableFactory()); diff --git a/x-pack/legacy/plugins/maps/public/plugin.ts b/x-pack/legacy/plugins/maps/public/plugin.ts index 0df7109852486..9d9ce79b0a43d 100644 --- a/x-pack/legacy/plugins/maps/public/plugin.ts +++ b/x-pack/legacy/plugins/maps/public/plugin.ts @@ -11,6 +11,10 @@ import { wrapInI18nContext } from 'ui/i18n'; import { MapListing } from './components/map_listing'; // @ts-ignore import { setLicenseId } from './kibana_services'; +// @ts-ignore +import { MapEmbeddableFactory } from './embeddable/map_embeddable_factory.js'; +// @ts-ignore +import { MAP_SAVED_OBJECT_TYPE } from '../common/constants'; /** * These are the interfaces with your public contracts. You should export these @@ -25,9 +29,11 @@ export class MapsPlugin implements Plugin { public setup(core: any, plugins: any) { const { __LEGACY: { uiModules }, - np: { licensing }, + np: { licensing, embeddable }, } = plugins; + embeddable.registerEmbeddableFactory(MAP_SAVED_OBJECT_TYPE, new MapEmbeddableFactory()); + uiModules .get('app/maps', ['ngRoute', 'react']) .directive('mapListing', function(reactDirective: any) { From 2bcc4134962990a7111cc7fc6fb9f355f9397d64 Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Mon, 3 Feb 2020 14:30:17 -0700 Subject: [PATCH 2/4] Embeddable init moved over to NP. Some dependencies converted --- x-pack/plugins/maps/common/constants.js | 160 +++++++++++ x-pack/plugins/maps/kibana.json | 8 + .../plugins/maps/public/embeddable/README.md | 182 ++++++++++++ .../maps/public/embeddable/map_embeddable.js | 260 ++++++++++++++++++ .../embeddable/map_embeddable_factory.js | 145 ++++++++++ .../embeddable/merge_input_with_saved_map.js | 42 +++ x-pack/plugins/maps/public/index.ts | 18 ++ x-pack/plugins/maps/public/kibana_services.js | 11 + x-pack/plugins/maps/public/plugin.ts | 61 ++++ 9 files changed, 887 insertions(+) create mode 100644 x-pack/plugins/maps/common/constants.js create mode 100644 x-pack/plugins/maps/kibana.json create mode 100644 x-pack/plugins/maps/public/embeddable/README.md create mode 100644 x-pack/plugins/maps/public/embeddable/map_embeddable.js create mode 100644 x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js create mode 100644 x-pack/plugins/maps/public/embeddable/merge_input_with_saved_map.js create mode 100644 x-pack/plugins/maps/public/index.ts create mode 100644 x-pack/plugins/maps/public/kibana_services.js create mode 100644 x-pack/plugins/maps/public/plugin.ts diff --git a/x-pack/plugins/maps/common/constants.js b/x-pack/plugins/maps/common/constants.js new file mode 100644 index 0000000000000..2570341aa5756 --- /dev/null +++ b/x-pack/plugins/maps/common/constants.js @@ -0,0 +1,160 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const EMS_CATALOGUE_PATH = 'ems/catalogue'; + +export const EMS_FILES_CATALOGUE_PATH = 'ems/files'; +export const EMS_FILES_API_PATH = 'ems/files'; +export const EMS_FILES_DEFAULT_JSON_PATH = 'file'; +export const EMS_GLYPHS_PATH = 'fonts'; +export const EMS_SPRITES_PATH = 'sprites'; + +export const EMS_TILES_CATALOGUE_PATH = 'ems/tiles'; +export const EMS_TILES_API_PATH = 'ems/tiles'; +export const EMS_TILES_RASTER_STYLE_PATH = 'raster/style'; +export const EMS_TILES_RASTER_TILE_PATH = 'raster/tile'; + +export const EMS_TILES_VECTOR_STYLE_PATH = 'vector/style'; +export const EMS_TILES_VECTOR_SOURCE_PATH = 'vector/source'; +export const EMS_TILES_VECTOR_TILE_PATH = 'vector/tile'; + +export const MAP_SAVED_OBJECT_TYPE = 'map'; +export const APP_ID = 'maps'; +export const APP_ICON = 'gisApp'; +export const TELEMETRY_TYPE = 'maps-telemetry'; + +export const MAP_APP_PATH = `app/${APP_ID}`; +export const GIS_API_PATH = `api/${APP_ID}`; +export const INDEX_SETTINGS_API_PATH = `${GIS_API_PATH}/indexSettings`; + +export const MAP_BASE_URL = `/${MAP_APP_PATH}#/${MAP_SAVED_OBJECT_TYPE}`; + +export function createMapPath(id) { + return `${MAP_BASE_URL}/${id}`; +} + +export const LAYER_TYPE = { + TILE: 'TILE', + VECTOR: 'VECTOR', + VECTOR_TILE: 'VECTOR_TILE', + HEATMAP: 'HEATMAP', +}; + +export const SORT_ORDER = { + ASC: 'asc', + DESC: 'desc', +}; + +export const EMS_TMS = 'EMS_TMS'; +export const EMS_FILE = 'EMS_FILE'; +export const ES_GEO_GRID = 'ES_GEO_GRID'; +export const ES_SEARCH = 'ES_SEARCH'; +export const ES_PEW_PEW = 'ES_PEW_PEW'; + +export const FIELD_ORIGIN = { + SOURCE: 'source', + JOIN: 'join', +}; + +export const SOURCE_DATA_ID_ORIGIN = 'source'; +export const META_ID_ORIGIN_SUFFIX = 'meta'; +export const SOURCE_META_ID_ORIGIN = `${SOURCE_DATA_ID_ORIGIN}_${META_ID_ORIGIN_SUFFIX}`; +export const FORMATTERS_ID_ORIGIN_SUFFIX = 'formatters'; +export const SOURCE_FORMATTERS_ID_ORIGIN = `${SOURCE_DATA_ID_ORIGIN}_${FORMATTERS_ID_ORIGIN_SUFFIX}`; + +export const GEOJSON_FILE = 'GEOJSON_FILE'; + +export const MIN_ZOOM = 0; +export const MAX_ZOOM = 24; + +export const DECIMAL_DEGREES_PRECISION = 5; // meters precision +export const ZOOM_PRECISION = 2; +export const DEFAULT_MAX_RESULT_WINDOW = 10000; +export const DEFAULT_MAX_INNER_RESULT_WINDOW = 100; +export const DEFAULT_MAX_BUCKETS_LIMIT = 10000; + +export const FEATURE_ID_PROPERTY_NAME = '__kbn__feature_id__'; +export const FEATURE_VISIBLE_PROPERTY_NAME = '__kbn_isvisibleduetojoin__'; + +export const MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER = '_'; + +export const ES_GEO_FIELD_TYPE = { + GEO_POINT: 'geo_point', + GEO_SHAPE: 'geo_shape', +}; + +export const ES_SPATIAL_RELATIONS = { + INTERSECTS: 'INTERSECTS', + DISJOINT: 'DISJOINT', + WITHIN: 'WITHIN', +}; + +export const GEO_JSON_TYPE = { + POINT: 'Point', + MULTI_POINT: 'MultiPoint', + LINE_STRING: 'LineString', + MULTI_LINE_STRING: 'MultiLineString', + POLYGON: 'Polygon', + MULTI_POLYGON: 'MultiPolygon', + GEOMETRY_COLLECTION: 'GeometryCollection', +}; + +export const POLYGON_COORDINATES_EXTERIOR_INDEX = 0; +export const LON_INDEX = 0; +export const LAT_INDEX = 1; + +export const EMPTY_FEATURE_COLLECTION = { + type: 'FeatureCollection', + features: [], +}; + +export const DRAW_TYPE = { + BOUNDS: 'BOUNDS', + POLYGON: 'POLYGON', +}; + +export const METRIC_TYPE = { + AVG: 'avg', + COUNT: 'count', + MAX: 'max', + MIN: 'min', + SUM: 'sum', + UNIQUE_COUNT: 'cardinality', +}; + +export const COUNT_AGG_TYPE = METRIC_TYPE.COUNT; +export const COUNT_PROP_LABEL = i18n.translate('xpack.maps.aggs.defaultCountLabel', { + defaultMessage: 'count', +}); + +export const COUNT_PROP_NAME = 'doc_count'; + +export const STYLE_TYPE = { + STATIC: 'STATIC', + DYNAMIC: 'DYNAMIC', +}; + +export const LAYER_STYLE_TYPE = { + VECTOR: 'VECTOR', + HEATMAP: 'HEATMAP', +}; + +export const COLOR_MAP_TYPE = { + CATEGORICAL: 'CATEGORICAL', + ORDINAL: 'ORDINAL', +}; + +export const COLOR_PALETTE_MAX_SIZE = 10; + +export const CATEGORICAL_DATA_TYPES = ['string', 'ip', 'boolean']; + +export const SYMBOLIZE_AS_TYPES = { + CIRCLE: 'circle', + ICON: 'icon', +}; + +export const DEFAULT_ICON = 'airfield'; diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json new file mode 100644 index 0000000000000..43bf06261064e --- /dev/null +++ b/x-pack/plugins/maps/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "maps", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "maps"], + "requiredPlugins": ["embeddable", "data"], + "ui": true +} diff --git a/x-pack/plugins/maps/public/embeddable/README.md b/x-pack/plugins/maps/public/embeddable/README.md new file mode 100644 index 0000000000000..1de327702fb87 --- /dev/null +++ b/x-pack/plugins/maps/public/embeddable/README.md @@ -0,0 +1,182 @@ + +### Map specific `input` parameters +- **hideFilterActions:** (Boolean) Set to true to hide all filtering controls. +- **isLayerTOCOpen:** (Boolean) Set to false to render map with legend in collapsed state. +- **openTOCDetails:** (Array of Strings) Array of layer ids. Add layer id to show layer details on initial render. +- **mapCenter:** ({lat, lon, zoom }) Provide mapCenter to customize initial map location. +- **disableInteractive:** (Boolean) Will disable map interactions, panning, zooming in the map. +- **disableTooltipControl:** (Boolean) Will disable tooltip which shows relevant information on hover, like Continent name etc +- **hideToolbarOverlay:** (Boolean) Will disable toolbar, which can be used to navigate to coordinate by entering lat/long and zoom values. +- **hideLayerControl:** (Boolean) Will hide useful layer control, which can be used to hide/show a layer to get a refined view of the map. +- **hideViewControl:** (Boolean) Will hide view control at bottom right of the map, which shows lat/lon values based on mouse hover in the map, this is useful to get coordinate value from a particular point in map. +- **hiddenLayers:** (Array of Strings) Array of layer ids that should be hidden. Any other layers will be set to visible regardless of their value in the layerList used to initialize the embeddable + +### Creating a Map embeddable from saved object +``` + const factory = new MapEmbeddableFactory(); + const input = { + hideFilterActions: true, + isLayerTOCOpen: false, + openTOCDetails: ['tfi3f', 'edh66'], + mapCenter: { lat: 0.0, lon: 0.0, zoom: 7 } + } + const mapEmbeddable = await factory.createFromSavedObject( + 'de71f4f0-1902-11e9-919b-ffe5949a18d2', + input, + parent + ); +``` + +### Creating a Map embeddable from state +``` +const factory = new MapEmbeddableFactory(); +const state = { + layerList: [], // where layerList is same as saved object layerListJSON property (unstringified) + title: 'my map', +} +const input = { + hideFilterActions: true, + isLayerTOCOpen: false, + openTOCDetails: ['tfi3f', 'edh66'], + mapCenter: { lat: 0.0, lon: 0.0, zoom: 7 } +} +const mapEmbeddable = await factory.createFromState(state, input, parent); +``` + +#### Customize tooltip +``` +/** + * Render custom tooltip content + * + * @param {function} addFilters + * @param {function} closeTooltip + * @param {Array} features - Vector features at tooltip location. + * @param {boolean} isLocked + * @param {function} getLayerName - Get layer name. Call with (layerId). Returns Promise. + * @param {function} loadFeatureProperties - Loads feature properties. Call with ({ layerId, featureId }). Returns Promise. + * @param {function} loadFeatureGeometry - Loads feature geometry. Call with ({ layerId, featureId }). Returns geojson geometry object { type, coordinates }. + * + * @return {Component} A React Component. + */ +const renderTooltipContent = ({ addFilters, closeTooltip, features, isLocked, loadFeatureProperties}) => { + return
Custom tooltip content
; +} + +const mapEmbeddable = await factory.createFromState(state, input, parent, renderTooltipContent); +``` + + +#### Event handlers +``` +const eventHandlers = { + onDataLoad: (layerId: string, dataId: string) => { + // take action on data load + }, + onDataLoadEnd: (layerId: string, dataId: string, resultMeta: object) => { + // take action on data load end + }, + onDataLoadError: (layerId: string, dataId: string, errorMessage: string) => { + // take action on data load error + }, +} + +const mapEmbeddable = await factory.createFromState(state, input, parent, renderTooltipContent, eventHandlers); +``` + + +#### Passing in geospatial data +You can pass geospatial data into the Map embeddable by configuring the layerList parameter with a layer with `GEOJSON_FILE` source. +Geojson sources will not update unless you modify `__featureCollection` property by calling the `setLayerList` method. + +``` +const factory = new MapEmbeddableFactory(); +const state = { + layerList: [ + { + 'id': 'gaxya', + 'label': 'My geospatial data', + 'minZoom': 0, + 'maxZoom': 24, + 'alpha': 1, + 'sourceDescriptor': { + 'id': 'b7486', + 'type': 'GEOJSON_FILE', + '__featureCollection': { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [0, 0], [10, 10], [10, 0], [0, 0] + ] + ] + }, + "properties": { + "name": "null island", + "another_prop": "something else interesting" + } + } + ] + } + }, + 'visible': true, + 'style': { + 'type': 'VECTOR', + 'properties': {} + }, + 'type': 'VECTOR' + } + ], + title: 'my map', +} +const input = { + hideFilterActions: true, + isLayerTOCOpen: false, + openTOCDetails: ['tfi3f', 'edh66'], + mapCenter: { lat: 0.0, lon: 0.0, zoom: 7 } +} +const mapEmbeddable = await factory.createFromState(state, input, parent); + +mapEmbeddable.setLayerList([ + { + 'id': 'gaxya', + 'label': 'My geospatial data', + 'minZoom': 0, + 'maxZoom': 24, + 'alpha': 1, + 'sourceDescriptor': { + 'id': 'b7486', + 'type': 'GEOJSON_FILE', + '__featureCollection': { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [35, 35], [45, 45], [45, 35], [35, 35] + ] + ] + }, + "properties": { + "name": "null island", + "another_prop": "something else interesting" + } + } + ] + } + }, + 'visible': true, + 'style': { + 'type': 'VECTOR', + 'properties': {} + }, + 'type': 'VECTOR' + } +]); +``` diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.js b/x-pack/plugins/maps/public/embeddable/map_embeddable.js new file mode 100644 index 0000000000000..c723e996ee679 --- /dev/null +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.js @@ -0,0 +1,260 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { render, unmountComponentAtNode } from 'react-dom'; +import 'mapbox-gl/dist/mapbox-gl.css'; + +import { + Embeddable, + APPLY_FILTER_TRIGGER, +} from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { onlyDisabledFiltersChanged } from '../../../../../../src/plugins/data/public'; + +import { I18nContext } from 'ui/i18n'; + +import { GisMap } from '../connected_components/gis_map'; +import { createMapStore } from '../reducers/store'; +import { npStart } from 'ui/new_platform'; +import { + setGotoWithCenter, + replaceLayerList, + setQuery, + setRefreshConfig, + disableScrollZoom, + disableInteractive, + disableTooltipControl, + hideToolbarOverlay, + hideLayerControl, + hideViewControl, + setHiddenLayers, +} from '../actions/map_actions'; +import { setReadOnly, setIsLayerTOCOpen, setOpenTOCDetails } from '../actions/ui_actions'; +import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors'; +import { getInspectorAdapters, setEventHandlers } from '../reducers/non_serializable_instances'; +import { getMapCenter, getMapZoom, getHiddenLayerIds } from '../selectors/map_selectors'; +import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; + +export class MapEmbeddable extends Embeddable { + type = MAP_SAVED_OBJECT_TYPE; + + constructor(config, initialInput, parent, renderTooltipContent, eventHandlers) { + super( + initialInput, + { + editUrl: config.editUrl, + indexPatterns: config.indexPatterns, + editable: config.editable, + defaultTitle: config.title, + }, + parent + ); + + this._renderTooltipContent = renderTooltipContent; + this._eventHandlers = eventHandlers; + this._layerList = config.layerList; + this._store = createMapStore(); + + this._subscription = this.getInput$().subscribe(input => this.onContainerStateChanged(input)); + } + + getInspectorAdapters() { + return getInspectorAdapters(this._store.getState()); + } + + onContainerStateChanged(containerState) { + if ( + !_.isEqual(containerState.timeRange, this._prevTimeRange) || + !_.isEqual(containerState.query, this._prevQuery) || + !onlyDisabledFiltersChanged(containerState.filters, this._prevFilters) + ) { + this._dispatchSetQuery(containerState); + } + + if (!_.isEqual(containerState.refreshConfig, this._prevRefreshConfig)) { + this._dispatchSetRefreshConfig(containerState); + } + } + + _dispatchSetQuery({ query, timeRange, filters, refresh }) { + this._prevTimeRange = timeRange; + this._prevQuery = query; + this._prevFilters = filters; + this._store.dispatch( + setQuery({ + filters: filters.filter(filter => !filter.meta.disabled), + query, + timeFilters: timeRange, + refresh, + }) + ); + } + + _dispatchSetRefreshConfig({ refreshConfig }) { + this._prevRefreshConfig = refreshConfig; + this._store.dispatch( + setRefreshConfig({ + isPaused: refreshConfig.pause, + interval: refreshConfig.value, + }) + ); + } + + /** + * + * @param {HTMLElement} domNode + * @param {ContainerState} containerState + */ + render(domNode) { + this._store.dispatch(setEventHandlers(this._eventHandlers)); + this._store.dispatch(setReadOnly(true)); + this._store.dispatch(disableScrollZoom()); + + if (_.has(this.input, 'isLayerTOCOpen')) { + this._store.dispatch(setIsLayerTOCOpen(this.input.isLayerTOCOpen)); + } + + if (_.has(this.input, 'openTOCDetails')) { + this._store.dispatch(setOpenTOCDetails(this.input.openTOCDetails)); + } + + if (_.has(this.input, 'disableInteractive') && this.input.disableInteractive) { + this._store.dispatch(disableInteractive(this.input.disableInteractive)); + } + + if (_.has(this.input, 'disableTooltipControl') && this.input.disableTooltipControl) { + this._store.dispatch(disableTooltipControl(this.input.disableTooltipControl)); + } + + if (_.has(this.input, 'hideToolbarOverlay') && this.input.hideToolbarOverlay) { + this._store.dispatch(hideToolbarOverlay(this.input.hideToolbarOverlay)); + } + + if (_.has(this.input, 'hideLayerControl') && this.input.hideLayerControl) { + this._store.dispatch(hideLayerControl(this.input.hideLayerControl)); + } + + if (_.has(this.input, 'hideViewControl') && this.input.hideViewControl) { + this._store.dispatch(hideViewControl(this.input.hideViewControl)); + } + + if (this.input.mapCenter) { + this._store.dispatch( + setGotoWithCenter({ + lat: this.input.mapCenter.lat, + lon: this.input.mapCenter.lon, + zoom: this.input.mapCenter.zoom, + }) + ); + } + + this._store.dispatch(replaceLayerList(this._layerList)); + if (this.input.hiddenLayers) { + this._store.dispatch(setHiddenLayers(this.input.hiddenLayers)); + } + this._dispatchSetQuery(this.input); + this._dispatchSetRefreshConfig(this.input); + + this._domNode = domNode; + + render( + + + + + , + this._domNode + ); + + this._unsubscribeFromStore = this._store.subscribe(() => { + this._handleStoreChanges(); + }); + } + + async setLayerList(layerList) { + this._layerList = layerList; + return await this._store.dispatch(replaceLayerList(this._layerList)); + } + + addFilters = filters => { + npStart.plugins.uiActions.executeTriggerActions(APPLY_FILTER_TRIGGER, { + embeddable: this, + filters, + }); + }; + + destroy() { + super.destroy(); + if (this._unsubscribeFromStore) { + this._unsubscribeFromStore(); + } + + if (this._domNode) { + unmountComponentAtNode(this._domNode); + } + + if (this._subscription) { + this._subscription.unsubscribe(); + } + } + + reload() { + this._dispatchSetQuery({ + query: this._prevQuery, + timeRange: this._prevTimeRange, + filters: this._prevFilters, + refresh: true, + }); + } + + _handleStoreChanges() { + const center = getMapCenter(this._store.getState()); + const zoom = getMapZoom(this._store.getState()); + + const mapCenter = this.input.mapCenter || {}; + if ( + !mapCenter || + mapCenter.lat !== center.lat || + mapCenter.lon !== center.lon || + mapCenter.zoom !== zoom + ) { + this.updateInput({ + mapCenter: { + lat: center.lat, + lon: center.lon, + zoom: zoom, + }, + }); + } + + const isLayerTOCOpen = getIsLayerTOCOpen(this._store.getState()); + if (this.input.isLayerTOCOpen !== isLayerTOCOpen) { + this.updateInput({ + isLayerTOCOpen, + }); + } + + const openTOCDetails = getOpenTOCDetails(this._store.getState()); + if (!_.isEqual(this.input.openTOCDetails, openTOCDetails)) { + this.updateInput({ + openTOCDetails, + }); + } + + const hiddenLayerIds = getHiddenLayerIds(this._store.getState()); + + if (!_.isEqual(this.input.hiddenLayers, hiddenLayerIds)) { + this.updateInput({ + hiddenLayers: hiddenLayerIds, + }); + } + } +} diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js new file mode 100644 index 0000000000000..91f5a6ee75965 --- /dev/null +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js @@ -0,0 +1,145 @@ +/* + * 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 _ from 'lodash'; +import chrome from 'ui/chrome'; +import { i18n } from '@kbn/i18n'; +import { + EmbeddableFactory, + ErrorEmbeddable, +} from '../../../../../src/plugins/embeddable/public'; +import { MapEmbeddable } from './map_embeddable'; +import { indexPatternService } from '../kibana_services'; + +import { createMapPath, MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants'; +import { createMapStore } from '../reducers/store'; +import { addLayerWithoutDataSync } from '../actions/map_actions'; +import { getQueryableUniqueIndexPatternIds } from '../selectors/map_selectors'; +import { getInitialLayers } from '../angular/get_initial_layers'; +import { mergeInputWithSavedMap } from './merge_input_with_saved_map'; +import '../angular/services/gis_map_saved_object_loader'; + +export class MapEmbeddableFactory extends EmbeddableFactory { + type = MAP_SAVED_OBJECT_TYPE; + isEditable; + + constructor(isEditable) { + super({ + savedObjectMetaData: { + name: i18n.translate('xpack.maps.mapSavedObjectLabel', { + defaultMessage: 'Map', + }), + type: MAP_SAVED_OBJECT_TYPE, + getIconForSavedObject: () => APP_ICON, + }, + }); + this.isEditable = isEditable; + } + + // Not supported yet for maps types. + canCreateNew() { + return false; + } + + getDisplayName() { + return i18n.translate('xpack.maps.embeddableDisplayName', { + defaultMessage: 'map', + }); + } + + async _getIndexPatterns(layerList) { + // Need to extract layerList from store to get queryable index pattern ids + const store = createMapStore(); + let queryableIndexPatternIds; + try { + layerList.forEach(layerDescriptor => { + store.dispatch(addLayerWithoutDataSync(layerDescriptor)); + }); + queryableIndexPatternIds = getQueryableUniqueIndexPatternIds(store.getState()); + } catch (error) { + throw new Error( + i18n.translate('xpack.maps.mapEmbeddableFactory.invalidLayerList', { + defaultMessage: 'Unable to load map, malformed layer list', + }) + ); + } + + const promises = queryableIndexPatternIds.map(async indexPatternId => { + try { + return await indexPatternService.get(indexPatternId); + } catch (error) { + // Unable to load index pattern, better to not throw error so map embeddable can render + // Error will be surfaced by map embeddable since it too will be unable to locate the index pattern + return null; + } + }); + const indexPatterns = await Promise.all(promises); + return _.compact(indexPatterns); + } + + async _fetchSavedMap(savedObjectId) { + const $injector = await chrome.dangerouslyGetActiveInjector(); + const savedObjectLoader = $injector.get('gisMapSavedObjectLoader'); + return await savedObjectLoader.get(savedObjectId); + } + + async createFromSavedObject(savedObjectId, input, parent) { + const savedMap = await this._fetchSavedMap(savedObjectId); + const layerList = getInitialLayers(savedMap.layerListJSON); + const indexPatterns = await this._getIndexPatterns(layerList); + + const embeddable = new MapEmbeddable( + { + layerList, + title: savedMap.title, + editUrl: chrome.addBasePath(createMapPath(savedObjectId)), + indexPatterns, + editable: this.isEditable(), + }, + input, + parent + ); + + try { + embeddable.updateInput(mergeInputWithSavedMap(input, savedMap)); + } catch (error) { + throw new Error( + i18n.translate('xpack.maps.mapEmbeddableFactory.invalidSavedObject', { + defaultMessage: 'Unable to load map, malformed saved object', + }) + ); + } + + return embeddable; + } + + async createFromState(state, input, parent, renderTooltipContent, eventHandlers) { + const layerList = state && state.layerList ? state.layerList : getInitialLayers(); + const indexPatterns = await this._getIndexPatterns(layerList); + + return new MapEmbeddable( + { + layerList, + title: state && state.title ? state.title : '', + editUrl: null, + indexPatterns, + editable: false, + }, + input, + parent, + renderTooltipContent, + eventHandlers + ); + } + + async create(input) { + window.location.href = chrome.addBasePath(createMapPath('')); + return new ErrorEmbeddable( + 'Maps can only be created with createFromSavedObject or createFromState', + input + ); + } +} diff --git a/x-pack/plugins/maps/public/embeddable/merge_input_with_saved_map.js b/x-pack/plugins/maps/public/embeddable/merge_input_with_saved_map.js new file mode 100644 index 0000000000000..935747da93687 --- /dev/null +++ b/x-pack/plugins/maps/public/embeddable/merge_input_with_saved_map.js @@ -0,0 +1,42 @@ +/* + * 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 _ from 'lodash'; +import { DEFAULT_IS_LAYER_TOC_OPEN } from '../reducers/ui'; + +const MAP_EMBEDDABLE_INPUT_KEYS = [ + 'hideFilterActions', + 'isLayerTOCOpen', + 'openTOCDetails', + 'mapCenter', +]; + +export function mergeInputWithSavedMap(input, savedMap) { + const mergedInput = _.pick(input, MAP_EMBEDDABLE_INPUT_KEYS); + + if (!_.has(input, 'isLayerTOCOpen') && savedMap.uiStateJSON) { + const uiState = JSON.parse(savedMap.uiStateJSON); + mergedInput.isLayerTOCOpen = _.get(uiState, 'isLayerTOCOpen', DEFAULT_IS_LAYER_TOC_OPEN); + } + + if (!_.has(input, 'openTOCDetails') && savedMap.uiStateJSON) { + const uiState = JSON.parse(savedMap.uiStateJSON); + if (_.has(uiState, 'openTOCDetails')) { + mergedInput.openTOCDetails = _.get(uiState, 'openTOCDetails', []); + } + } + + if (!input.mapCenter && savedMap.mapStateJSON) { + const mapState = JSON.parse(savedMap.mapStateJSON); + mergedInput.mapCenter = { + lat: mapState.center.lat, + lon: mapState.center.lon, + zoom: mapState.zoom, + }; + } + + return mergedInput; +} diff --git a/x-pack/plugins/maps/public/index.ts b/x-pack/plugins/maps/public/index.ts new file mode 100644 index 0000000000000..ff79c001c7d6e --- /dev/null +++ b/x-pack/plugins/maps/public/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { + MapsPlugin, + MapsPluginSetup, + MapsPluginStart, +} from './plugin'; +import {PluginInitializer, PluginInitializerContext} from 'kibana/public'; + +export const plugin: PluginInitializer = ( + context: PluginInitializerContext +) => { + return new MapsPlugin(context); +}; diff --git a/x-pack/plugins/maps/public/kibana_services.js b/x-pack/plugins/maps/public/kibana_services.js new file mode 100644 index 0000000000000..61825755e257f --- /dev/null +++ b/x-pack/plugins/maps/public/kibana_services.js @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export let indexPatternService; + +export const initKibanaServices = ({ data }) => { + indexPatternService = data.indexPatterns; +}; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts new file mode 100644 index 0000000000000..caff0098522d8 --- /dev/null +++ b/x-pack/plugins/maps/public/plugin.ts @@ -0,0 +1,61 @@ +/* + * 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 { Plugin, CoreSetup, CoreStart, PluginInitializerContext } from 'src/core/public'; +import { IEmbeddableSetup } from '../../../../src/plugins/embeddable/public'; +// @ts-ignore +import { MapEmbeddableFactory } from './embeddable/map_embeddable_factory.js'; +// @ts-ignore +import { MAP_SAVED_OBJECT_TYPE } from '../common/constants'; +import { initKibanaServices } from './kibana_services'; +import {auto} from "angular"; + +export interface MapsPluginSetupDependencies { + embeddable: IEmbeddableSetup; +} +// eslint-disable-line @typescript-eslint/no-empty-interface +export interface MapsPluginStartDependencies {} + +/** + * These are the interfaces with your public contracts. You should export these + * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. + * @public + */ +export type MapsPluginSetup = ReturnType; +export type MapsPluginStart = ReturnType; + +/** @internal */ +export class MapsPlugin implements + Plugin< + MapsPluginSetup, + MapsPluginStart, + MapsPluginSetupDependencies, + MapsPluginStartDependencies + > { + private getEmbeddableInjector: (() => Promise) | null = null; + constructor(context: PluginInitializerContext) {} + + public setup(core: CoreSetup, plugins: MapsPluginSetupDependencies) { + initKibanaServices(plugins); + + // Set up embeddables + const isEditable = () => core.application.capabilities.get().maps.save as boolean; + if (!this.getEmbeddableInjector) { + throw Error('Maps plugin method getEmbeddableInjector is undefined'); + } + const factory = new MapEmbeddableFactory( + plugins.uiActions.executeTriggerActions, + this.getEmbeddableInjector, + isEditable + ); + plugins.embeddable.registerEmbeddableFactory(factory.type, factory); + plugins.embeddable.registerEmbeddableFactory(MAP_SAVED_OBJECT_TYPE, new MapEmbeddableFactory()); + } + + public start(core: CoreStart, plugins: any) { + // setInspector(plugins.np.inspector); + } +} From 24a2af4c7540f3150f1bf9e4d3b4cf3821c6770c Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Mon, 3 Feb 2020 15:21:46 -0700 Subject: [PATCH 3/4] Remove chrome dependencies --- .../embeddable/map_embeddable_factory.js | 30 +++++++++++-------- x-pack/plugins/maps/public/index.ts | 8 ++--- x-pack/plugins/maps/public/plugin.ts | 20 +++++++------ 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js index 91f5a6ee75965..f34ee29cd1d75 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js @@ -5,28 +5,28 @@ */ import _ from 'lodash'; -import chrome from 'ui/chrome'; import { i18n } from '@kbn/i18n'; -import { - EmbeddableFactory, - ErrorEmbeddable, -} from '../../../../../src/plugins/embeddable/public'; +import { EmbeddableFactory, ErrorEmbeddable } from '../../../../../src/plugins/embeddable/public'; import { MapEmbeddable } from './map_embeddable'; import { indexPatternService } from '../kibana_services'; - import { createMapPath, MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants'; +import { mergeInputWithSavedMap } from './merge_input_with_saved_map'; +import '../angular/services/gis_map_saved_object_loader'; + +// Legacy maps dependencies import { createMapStore } from '../reducers/store'; import { addLayerWithoutDataSync } from '../actions/map_actions'; import { getQueryableUniqueIndexPatternIds } from '../selectors/map_selectors'; import { getInitialLayers } from '../angular/get_initial_layers'; -import { mergeInputWithSavedMap } from './merge_input_with_saved_map'; -import '../angular/services/gis_map_saved_object_loader'; export class MapEmbeddableFactory extends EmbeddableFactory { type = MAP_SAVED_OBJECT_TYPE; isEditable; + $injector = null; + getInjector = () => null; + addBasePath = () => null; - constructor(isEditable) { + constructor(isEditable, getInjector, addBasePath) { super({ savedObjectMetaData: { name: i18n.translate('xpack.maps.mapSavedObjectLabel', { @@ -36,7 +36,10 @@ export class MapEmbeddableFactory extends EmbeddableFactory { getIconForSavedObject: () => APP_ICON, }, }); + this.$injector = null; + this.getInjector = getInjector; this.isEditable = isEditable; + this.addBasePath = addBasePath; } // Not supported yet for maps types. @@ -81,7 +84,10 @@ export class MapEmbeddableFactory extends EmbeddableFactory { } async _fetchSavedMap(savedObjectId) { - const $injector = await chrome.dangerouslyGetActiveInjector(); + if (!this.$injector) { + this.$injector = await this.getInjector(); + } + const $injector = this.$injector; const savedObjectLoader = $injector.get('gisMapSavedObjectLoader'); return await savedObjectLoader.get(savedObjectId); } @@ -95,7 +101,7 @@ export class MapEmbeddableFactory extends EmbeddableFactory { { layerList, title: savedMap.title, - editUrl: chrome.addBasePath(createMapPath(savedObjectId)), + editUrl: this.addBasePath(createMapPath(savedObjectId)), indexPatterns, editable: this.isEditable(), }, @@ -136,7 +142,7 @@ export class MapEmbeddableFactory extends EmbeddableFactory { } async create(input) { - window.location.href = chrome.addBasePath(createMapPath('')); + window.location.href = this.addBasePath(createMapPath('')); return new ErrorEmbeddable( 'Maps can only be created with createFromSavedObject or createFromState', input diff --git a/x-pack/plugins/maps/public/index.ts b/x-pack/plugins/maps/public/index.ts index ff79c001c7d6e..985da137eff35 100644 --- a/x-pack/plugins/maps/public/index.ts +++ b/x-pack/plugins/maps/public/index.ts @@ -4,12 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - MapsPlugin, - MapsPluginSetup, - MapsPluginStart, -} from './plugin'; -import {PluginInitializer, PluginInitializerContext} from 'kibana/public'; +import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; +import { MapsPlugin, MapsPluginSetup, MapsPluginStart } from './plugin'; export const plugin: PluginInitializer = ( context: PluginInitializerContext diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index caff0098522d8..a9738cf5ed331 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -5,13 +5,13 @@ */ import { Plugin, CoreSetup, CoreStart, PluginInitializerContext } from 'src/core/public'; +import { auto } from 'angular'; import { IEmbeddableSetup } from '../../../../src/plugins/embeddable/public'; // @ts-ignore import { MapEmbeddableFactory } from './embeddable/map_embeddable_factory.js'; // @ts-ignore import { MAP_SAVED_OBJECT_TYPE } from '../common/constants'; import { initKibanaServices } from './kibana_services'; -import {auto} from "angular"; export interface MapsPluginSetupDependencies { embeddable: IEmbeddableSetup; @@ -28,12 +28,13 @@ export type MapsPluginSetup = ReturnType; export type MapsPluginStart = ReturnType; /** @internal */ -export class MapsPlugin implements - Plugin< - MapsPluginSetup, - MapsPluginStart, - MapsPluginSetupDependencies, - MapsPluginStartDependencies +export class MapsPlugin + implements + Plugin< + MapsPluginSetup, + MapsPluginStart, + MapsPluginSetupDependencies, + MapsPluginStartDependencies > { private getEmbeddableInjector: (() => Promise) | null = null; constructor(context: PluginInitializerContext) {} @@ -46,10 +47,11 @@ export class MapsPlugin implements if (!this.getEmbeddableInjector) { throw Error('Maps plugin method getEmbeddableInjector is undefined'); } + const addBasePath = core.http.basePath.prepend; const factory = new MapEmbeddableFactory( - plugins.uiActions.executeTriggerActions, this.getEmbeddableInjector, - isEditable + isEditable, + addBasePath ); plugins.embeddable.registerEmbeddableFactory(factory.type, factory); plugins.embeddable.registerEmbeddableFactory(MAP_SAVED_OBJECT_TYPE, new MapEmbeddableFactory()); From 264947259da7c0c14e130373b14e1bb6917cbeb3 Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Mon, 10 Feb 2020 14:15:51 -0700 Subject: [PATCH 4/4] Files moved and relative paths updated --- x-pack/plugins/maps/common/i18n_getters.js | 50 + .../plugins/maps/common/parse_xml_string.js | 22 + .../maps/common/parse_xml_string.test.js | 16 + .../maps/public/actions/map_actions.js | 871 +++++++++++++++++ .../maps/public/actions/map_actions.test.js | 233 +++++ .../plugins/maps/public/actions/ui_actions.js | 85 ++ .../add_tooltip_field_popover.test.js.snap | 149 +++ .../geometry_filter_form.test.js.snap | 374 ++++++++ .../layer_toc_actions.test.js.snap | 343 +++++++ .../tooltip_selector.test.js.snap | 49 + .../validated_range.test.js.snap | 72 ++ .../public/components/_geometry_filter.scss | 11 + .../maps/public/components/_index.scss | 3 + .../public/components/_metric_editors.scss | 23 + .../public/components/_tooltip_selector.scss | 59 ++ .../components/add_tooltip_field_popover.js | 170 ++++ .../add_tooltip_field_popover.test.js | 47 + .../public/components/geometry_filter_form.js | 216 +++++ .../components/geometry_filter_form.test.js | 90 ++ .../components/global_filter_checkbox.js | 26 + .../public/components/layer_toc_actions.js | 197 ++++ .../components/layer_toc_actions.test.js | 80 ++ .../maps/public/components/map_listing.js | 436 +++++++++ .../maps/public/components/metric_editor.js | 139 +++ .../maps/public/components/metric_select.js | 89 ++ .../maps/public/components/metrics_editor.js | 105 +++ .../components/no_index_pattern_callout.js | 52 ++ .../public/components/single_field_select.js | 65 ++ .../public/components/tooltip_selector.js | 229 +++++ .../components/tooltip_selector.test.js | 58 ++ .../maps/public/components/validated_range.js | 94 ++ .../public/components/validated_range.test.js | 36 + .../public/connected_components/_index.scss | 6 + .../gis_map/_gis_map.scss | 19 + .../connected_components/gis_map/index.js | 45 + .../connected_components/gis_map/view.js | 222 +++++ .../layer_addpanel/flyout_footer/index.js | 27 + .../layer_addpanel/flyout_footer/view.js | 55 ++ .../layer_addpanel/import_editor/index.js | 29 + .../layer_addpanel/import_editor/view.js | 52 ++ .../layer_addpanel/index.js | 58 ++ .../layer_addpanel/source_editor/index.js | 18 + .../layer_addpanel/source_editor/view.js | 46 + .../layer_addpanel/source_select/_index.scss | 1 + .../source_select/_source_select.scss | 12 + .../source_select/source_select.js | 50 + .../layer_addpanel/view.js | 181 ++++ .../__snapshots__/view.test.js.snap | 121 +++ .../layer_panel/_index.scss | 3 + .../layer_panel/_layer_panel.scss | 39 + .../filter_editor/_filter_editor.scss | 4 + .../filter_editor/filter_editor.js | 232 +++++ .../layer_panel/filter_editor/index.js | 28 + .../layer_panel/flyout_footer/index.js | 42 + .../layer_panel/flyout_footer/view.js | 65 ++ .../connected_components/layer_panel/index.js | 28 + .../layer_panel/join_editor/index.js | 31 + .../metrics_expression.test.js.snap | 111 +++ .../join_editor/resources/_join.scss | 37 + .../layer_panel/join_editor/resources/join.js | 268 ++++++ .../join_editor/resources/join_expression.js | 250 +++++ .../resources/metrics_expression.js | 134 +++ .../resources/metrics_expression.test.js | 34 + .../join_editor/resources/where_expression.js | 101 ++ .../layer_panel/join_editor/view.js | 101 ++ .../__snapshots__/layer_errors.test.js.snap | 21 + .../layer_panel/layer_errors/index.js | 18 + .../layer_panel/layer_errors/layer_errors.js | 29 + .../layer_errors/layer_errors.test.js | 35 + .../layer_panel/layer_settings/index.js | 38 + .../layer_settings/layer_settings.js | 118 +++ .../layer_panel/style_settings/index.js | 27 + .../style_settings/style_settings.js | 46 + .../connected_components/layer_panel/view.js | 231 +++++ .../layer_panel/view.test.js | 100 ++ .../feature_properties.test.js.snap | 110 +++ .../__snapshots__/tooltip_header.test.js.snap | 238 +++++ .../map/features_tooltip/_index.scss | 20 + .../feature_geometry_filter_form.js | 149 +++ .../features_tooltip/feature_properties.js | 178 ++++ .../feature_properties.test.js | 102 ++ .../map/features_tooltip/features_tooltip.js | 144 +++ .../map/features_tooltip/tooltip_header.js | 220 +++++ .../features_tooltip/tooltip_header.test.js | 153 +++ .../map/mb/draw_control/draw_control.js | 126 +++ .../map/mb/draw_control/draw_tooltip.js | 91 ++ .../map/mb/draw_control/index.js | 28 + .../map/mb/image_utils.js | 169 ++++ .../connected_components/map/mb/index.js | 76 ++ .../map/mb/mb.utils.test.js | 289 ++++++ .../tooltip_control.test.js.snap | 117 +++ .../map/mb/tooltip_control/index.js | 36 + .../map/mb/tooltip_control/tooltip_control.js | 353 +++++++ .../tooltip_control/tooltip_control.test.js | 350 +++++++ .../connected_components/map/mb/utils.js | 144 +++ .../connected_components/map/mb/view.js | 316 +++++++ .../toolbar_overlay/_index.scss | 21 + .../toolbar_overlay/index.js | 15 + .../toolbar_overlay/set_view_control/index.js | 38 + .../set_view_control/set_view_control.js | 197 ++++ .../toolbar_overlay/toolbar_overlay.js | 43 + .../__snapshots__/tools_control.test.js.snap | 196 ++++ .../toolbar_overlay/tools_control/_index.scss | 3 + .../toolbar_overlay/tools_control/index.js | 30 + .../tools_control/tools_control.js | 175 ++++ .../tools_control/tools_control.test.js | 34 + .../widget_overlay/_index.scss | 6 + .../widget_overlay/_mixins.scss | 12 + .../widget_overlay/_widget_overlay.scss | 47 + .../__snapshots__/view.test.js.snap | 25 + .../_attribution_control.scss | 6 + .../attribution_control/index.js | 22 + .../attribution_control/view.js | 98 ++ .../attribution_control/view.test.js | 33 + .../widget_overlay/index.js | 20 + .../__snapshots__/view.test.js.snap | 189 ++++ .../widget_overlay/layer_control/_index.scss | 2 + .../layer_control/_layer_control.scss | 30 + .../widget_overlay/layer_control/index.js | 44 + .../layer_toc/__snapshots__/view.test.js.snap | 41 + .../layer_control/layer_toc/index.js | 27 + .../toc_entry/__snapshots__/view.test.js.snap | 194 ++++ .../layer_toc/toc_entry/_toc_entry.scss | 129 +++ .../layer_toc/toc_entry/index.js | 68 ++ .../layer_control/layer_toc/toc_entry/view.js | 282 ++++++ .../layer_toc/toc_entry/view.test.js | 87 ++ .../layer_control/layer_toc/view.js | 89 ++ .../layer_control/layer_toc/view.test.js | 45 + .../widget_overlay/layer_control/view.js | 162 ++++ .../widget_overlay/layer_control/view.test.js | 86 ++ .../view_control/_view_control.scss | 5 + .../widget_overlay/view_control/index.js | 19 + .../view_control/view_control.js | 38 + .../widget_overlay/widget_overlay.js | 31 + .../maps/public/elasticsearch_geo_utils.js | 434 +++++++++ .../public/elasticsearch_geo_utils.test.js | 488 ++++++++++ .../maps/public/embeddable/map_embeddable.js | 12 +- .../embeddable/map_embeddable_factory.js | 11 +- .../plugins/maps/public/index_pattern_util.js | 52 ++ .../maps/public/index_pattern_util.test.js | 29 + .../public/inspector/adapters/map_adapter.js | 24 + .../public/inspector/views/map_details.js | 128 +++ .../maps/public/inspector/views/map_view.js | 66 ++ .../public/inspector/views/register_views.ts | 12 + x-pack/plugins/maps/public/layers/_index.scss | 1 + .../public/layers/fields/ems_file_field.js | 25 + .../maps/public/layers/fields/es_agg_field.js | 88 ++ .../public/layers/fields/es_agg_field.test.js | 28 + .../maps/public/layers/fields/es_doc_field.js | 82 ++ .../maps/public/layers/fields/field.js | 55 ++ .../layers/fields/kibana_region_field.js | 23 + .../maps/public/layers/grid_resolution.js | 11 + .../maps/public/layers/heatmap_layer.js | 109 +++ .../maps/public/layers/joins/inner_join.js | 109 +++ .../public/layers/joins/inner_join.test.js | 163 ++++ x-pack/plugins/maps/public/layers/layer.js | 358 +++++++ .../maps/public/layers/sources/all_sources.js | 29 + .../create_client_file_source_editor.js | 31 + .../client_file_source/geojson_file_source.js | 164 ++++ .../sources/client_file_source/index.js | 7 + .../ems_file_source/create_source_editor.js | 88 ++ .../ems_file_source/ems_file_source.js | 169 ++++ .../ems_file_source/ems_file_source.test.js | 60 ++ .../layers/sources/ems_file_source/index.js | 7 + .../ems_file_source/update_source_editor.js | 82 ++ .../sources/ems_tms_source/ems_tms_source.js | 161 ++++ .../ems_tms_source/ems_tms_source.test.js | 60 ++ .../layers/sources/ems_tms_source/index.js | 7 + .../ems_tms_source/tile_service_select.js | 94 ++ .../ems_tms_source/update_source_editor.js | 38 + .../layers/sources/ems_unavailable_message.js | 23 + .../public/layers/sources/es_agg_source.js | 127 +++ .../es_geo_grid_source/convert_to_geojson.js | 102 ++ .../create_source_editor.js | 251 +++++ .../es_geo_grid_source/es_geo_grid_source.js | 329 +++++++ .../es_geo_grid_source/geo_tile_utils.js | 114 +++ .../es_geo_grid_source/geo_tile_utils.test.js | 52 ++ .../sources/es_geo_grid_source/index.js | 7 + .../sources/es_geo_grid_source/render_as.js | 11 + .../es_geo_grid_source/resolution_editor.js | 49 + .../update_source_editor.js | 119 +++ .../es_pew_pew_source/convert_to_lines.js | 57 ++ .../es_pew_pew_source/create_source_editor.js | 213 +++++ .../es_pew_pew_source/es_pew_pew_source.js | 253 +++++ .../es_pew_pew_source/update_source_editor.js | 83 ++ .../update_source_editor.test.js.snap | 374 ++++++++ .../sources/es_search_source/constants.js | 7 + .../es_search_source/create_source_editor.js | 284 ++++++ .../es_search_source/es_search_source.js | 613 ++++++++++++ .../layers/sources/es_search_source/index.js | 7 + .../es_search_source/load_index_settings.js | 58 ++ .../es_search_source/update_source_editor.js | 306 ++++++ .../update_source_editor.test.js | 47 + .../maps/public/layers/sources/es_source.js | 347 +++++++ .../public/layers/sources/es_term_source.js | 196 ++++ .../layers/sources/es_term_source.test.js | 195 ++++ .../create_source_editor.js | 52 ++ .../sources/kibana_regionmap_source/index.js | 7 + .../kibana_regionmap_source.js | 110 +++ .../create_source_editor.js | 42 + .../sources/kibana_tilemap_source/index.js | 7 + .../kibana_tilemap_source.js | 96 ++ .../maps/public/layers/sources/source.js | 142 +++ .../maps/public/layers/sources/tms_source.js | 26 + .../layers/sources/vector_feature_types.js | 11 + .../public/layers/sources/vector_source.js | 170 ++++ .../public/layers/sources/wms_source/index.js | 7 + .../layers/sources/wms_source/wms_client.js | 206 ++++ .../sources/wms_source/wms_client.test.js | 258 ++++++ .../wms_source/wms_create_source_editor.js | 309 ++++++ .../layers/sources/wms_source/wms_source.js | 106 +++ .../public/layers/sources/xyz_tms_source.js | 176 ++++ .../maps/public/layers/styles/_index.scss | 4 + .../public/layers/styles/abstract_style.js | 29 + .../maps/public/layers/styles/color_utils.js | 128 +++ .../public/layers/styles/color_utils.test.js | 101 ++ .../styles/components/_color_gradient.scss | 13 + .../styles/components/color_gradient.js | 20 + .../components/ranged_style_legend_row.js | 48 + .../heatmap_style_editor.test.js.snap | 78 ++ .../heatmap/components/heatmap_constants.js | 22 + .../components/heatmap_style_editor.js | 45 + .../components/heatmap_style_editor.test.js | 22 + .../components/legend/heatmap_legend.js | 66 ++ .../layers/styles/heatmap/heatmap_style.js | 113 +++ .../vector/components/_style_prop_editor.scss | 3 + .../vector/components/color/_color_stops.scss | 39 + .../components/color/color_map_select.js | 111 +++ .../vector/components/color/color_stops.js | 141 +++ .../color/color_stops_categorical.js | 117 +++ .../components/color/color_stops_ordinal.js | 94 ++ .../components/color/color_stops_utils.js | 86 ++ .../components/color/dynamic_color_form.js | 108 +++ .../components/color/static_color_form.js | 33 + .../color/vector_style_color_editor.js | 34 + .../styles/vector/components/field_select.js | 113 +++ .../components/get_vector_style_label.js | 67 ++ .../components/label/dynamic_label_form.js | 36 + .../components/label/static_label_form.js | 34 + .../vector_style_label_border_size_editor.js | 84 ++ .../label/vector_style_label_editor.js | 21 + .../__snapshots__/vector_icon.test.js.snap | 45 + .../vector/components/legend/category.js | 43 + .../vector/components/legend/circle_icon.js | 44 + .../extract_color_from_style_property.js | 58 ++ .../vector/components/legend/line_icon.js | 13 + .../vector/components/legend/polygon_icon.js | 13 + .../vector/components/legend/symbol_icon.js | 104 +++ .../vector/components/legend/vector_icon.js | 54 ++ .../components/legend/vector_icon.test.js | 58 ++ .../components/legend/vector_style_legend.js | 21 + .../ordinal_field_meta_options_popover.js | 130 +++ .../orientation/dynamic_orientation_form.js | 39 + .../orientation/orientation_editor.js | 21 + .../orientation/static_orientation_form.js | 33 + .../components/size/dynamic_size_form.js | 62 ++ .../components/size/size_range_selector.js | 44 + .../components/size/static_size_form.js | 37 + .../size/vector_style_size_editor.js | 21 + .../vector/components/style_map_select.js | 92 ++ .../vector/components/style_option_shapes.js | 37 + .../vector/components/style_prop_editor.js | 125 +++ .../__snapshots__/icon_select.test.js.snap | 81 ++ .../components/symbol/_icon_select.scss | 3 + .../components/symbol/dynamic_icon_form.js | 70 ++ .../components/symbol/icon_map_select.js | 54 ++ .../vector/components/symbol/icon_select.js | 138 +++ .../components/symbol/icon_select.test.js | 28 + .../vector/components/symbol/icon_stops.js | 132 +++ .../components/symbol/static_icon_form.js | 35 + .../symbol/vector_style_icon_editor.js | 31 + .../vector_style_symbolize_as_editor.js | 81 ++ .../vector/components/vector_style_editor.js | 514 ++++++++++ .../dynamic_color_property.test.js.snap | 151 +++ .../components/categorical_legend.js | 48 + .../properties/components/ordinal_legend.js | 78 ++ .../properties/dynamic_color_property.js | 317 +++++++ .../properties/dynamic_color_property.test.js | 224 +++++ .../properties/dynamic_icon_property.js | 159 ++++ .../dynamic_orientation_property.js | 32 + .../properties/dynamic_size_property.js | 180 ++++ .../properties/dynamic_style_property.js | 295 ++++++ .../properties/dynamic_text_property.js | 35 + .../properties/label_border_size_property.js | 50 + .../properties/static_color_property.js | 46 + .../vector/properties/static_icon_property.js | 16 + .../properties/static_orientation_property.js | 21 + .../vector/properties/static_size_property.js | 53 ++ .../properties/static_style_property.js | 12 + .../vector/properties/static_text_property.js | 21 + .../vector/properties/style_property.js | 55 ++ .../properties/symbolize_as_property.js | 18 + .../public/layers/styles/vector/style_util.js | 72 ++ .../layers/styles/vector/style_util.test.js | 128 +++ .../layers/styles/vector/symbol_utils.js | 138 +++ .../layers/styles/vector/symbol_utils.test.js | 52 ++ .../layers/styles/vector/vector_style.js | 691 ++++++++++++++ .../layers/styles/vector/vector_style.test.js | 290 ++++++ .../styles/vector/vector_style_defaults.js | 252 +++++ .../plugins/maps/public/layers/tile_layer.js | 106 +++ .../tooltips/es_aggmetric_tooltip_property.js | 42 + .../layers/tooltips/es_tooltip_property.js | 49 + .../layers/tooltips/join_tooltip_property.js | 55 ++ .../layers/tooltips/tooltip_property.js | 39 + .../public/layers/util/assign_feature_ids.js | 56 ++ .../layers/util/assign_feature_ids.test.js | 82 ++ .../maps/public/layers/util/can_skip_fetch.js | 172 ++++ .../public/layers/util/can_skip_fetch.test.js | 308 ++++++ .../maps/public/layers/util/data_request.js | 50 + .../public/layers/util/is_metric_countable.js | 11 + .../layers/util/is_refresh_only_query.js | 18 + .../layers/util/mb_filter_expressions.js | 47 + .../maps/public/layers/vector_layer.js | 877 ++++++++++++++++++ .../maps/public/layers/vector_tile_layer.js | 295 ++++++ x-pack/plugins/maps/public/meta.js | 99 ++ x-pack/plugins/maps/public/meta.test.js | 51 + x-pack/plugins/maps/public/reducers/map.js | 543 +++++++++++ .../plugins/maps/public/reducers/map.test.js | 56 ++ .../reducers/non_serializable_instances.js | 107 +++ x-pack/plugins/maps/public/reducers/store.js | 24 + x-pack/plugins/maps/public/reducers/ui.js | 81 ++ x-pack/plugins/maps/public/reducers/util.js | 21 + .../plugins/maps/public/reducers/util.test.js | 47 + .../maps/public/selectors/map_selectors.js | 239 +++++ .../public/selectors/map_selectors.test.js | 54 ++ .../maps/public/selectors/ui_selectors.js | 13 + 326 files changed, 33891 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/maps/common/i18n_getters.js create mode 100644 x-pack/plugins/maps/common/parse_xml_string.js create mode 100644 x-pack/plugins/maps/common/parse_xml_string.test.js create mode 100644 x-pack/plugins/maps/public/actions/map_actions.js create mode 100644 x-pack/plugins/maps/public/actions/map_actions.test.js create mode 100644 x-pack/plugins/maps/public/actions/ui_actions.js create mode 100644 x-pack/plugins/maps/public/components/__snapshots__/add_tooltip_field_popover.test.js.snap create mode 100644 x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap create mode 100644 x-pack/plugins/maps/public/components/__snapshots__/layer_toc_actions.test.js.snap create mode 100644 x-pack/plugins/maps/public/components/__snapshots__/tooltip_selector.test.js.snap create mode 100644 x-pack/plugins/maps/public/components/__snapshots__/validated_range.test.js.snap create mode 100644 x-pack/plugins/maps/public/components/_geometry_filter.scss create mode 100644 x-pack/plugins/maps/public/components/_index.scss create mode 100644 x-pack/plugins/maps/public/components/_metric_editors.scss create mode 100644 x-pack/plugins/maps/public/components/_tooltip_selector.scss create mode 100644 x-pack/plugins/maps/public/components/add_tooltip_field_popover.js create mode 100644 x-pack/plugins/maps/public/components/add_tooltip_field_popover.test.js create mode 100644 x-pack/plugins/maps/public/components/geometry_filter_form.js create mode 100644 x-pack/plugins/maps/public/components/geometry_filter_form.test.js create mode 100644 x-pack/plugins/maps/public/components/global_filter_checkbox.js create mode 100644 x-pack/plugins/maps/public/components/layer_toc_actions.js create mode 100644 x-pack/plugins/maps/public/components/layer_toc_actions.test.js create mode 100644 x-pack/plugins/maps/public/components/map_listing.js create mode 100644 x-pack/plugins/maps/public/components/metric_editor.js create mode 100644 x-pack/plugins/maps/public/components/metric_select.js create mode 100644 x-pack/plugins/maps/public/components/metrics_editor.js create mode 100644 x-pack/plugins/maps/public/components/no_index_pattern_callout.js create mode 100644 x-pack/plugins/maps/public/components/single_field_select.js create mode 100644 x-pack/plugins/maps/public/components/tooltip_selector.js create mode 100644 x-pack/plugins/maps/public/components/tooltip_selector.test.js create mode 100644 x-pack/plugins/maps/public/components/validated_range.js create mode 100644 x-pack/plugins/maps/public/components/validated_range.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/_index.scss create mode 100644 x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss create mode 100644 x-pack/plugins/maps/public/connected_components/gis_map/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/gis_map/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_index.scss create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_source_select.scss create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/_index.scss create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/_layer_panel.scss create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/_filter_editor.scss create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/flyout_footer/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/flyout_footer/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/_join.scss create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join_expression.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/__snapshots__/layer_errors.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/layer_errors.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/layer_errors.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/style_settings/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/style_settings/style_settings.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/tooltip_header.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss create mode 100644 x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_tooltip.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/draw_control/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/image_utils.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/mb.utils.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/utils.js create mode 100644 x-pack/plugins/maps/public/connected_components/map/mb/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.js create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/_index.scss create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js create mode 100644 x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/_index.scss create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/_mixins.scss create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/_widget_overlay.scss create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/__snapshots__/view.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/_attribution_control.scss create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/view.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_index.scss create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_layer_control.scss create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/__snapshots__/view.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/view.test.js.snap create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/_toc_entry.scss create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/view.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.test.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/_view_control.scss create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/view_control.js create mode 100644 x-pack/plugins/maps/public/connected_components/widget_overlay/widget_overlay.js create mode 100644 x-pack/plugins/maps/public/elasticsearch_geo_utils.js create mode 100644 x-pack/plugins/maps/public/elasticsearch_geo_utils.test.js create mode 100644 x-pack/plugins/maps/public/index_pattern_util.js create mode 100644 x-pack/plugins/maps/public/index_pattern_util.test.js create mode 100644 x-pack/plugins/maps/public/inspector/adapters/map_adapter.js create mode 100644 x-pack/plugins/maps/public/inspector/views/map_details.js create mode 100644 x-pack/plugins/maps/public/inspector/views/map_view.js create mode 100644 x-pack/plugins/maps/public/inspector/views/register_views.ts create mode 100644 x-pack/plugins/maps/public/layers/_index.scss create mode 100644 x-pack/plugins/maps/public/layers/fields/ems_file_field.js create mode 100644 x-pack/plugins/maps/public/layers/fields/es_agg_field.js create mode 100644 x-pack/plugins/maps/public/layers/fields/es_agg_field.test.js create mode 100644 x-pack/plugins/maps/public/layers/fields/es_doc_field.js create mode 100644 x-pack/plugins/maps/public/layers/fields/field.js create mode 100644 x-pack/plugins/maps/public/layers/fields/kibana_region_field.js create mode 100644 x-pack/plugins/maps/public/layers/grid_resolution.js create mode 100644 x-pack/plugins/maps/public/layers/heatmap_layer.js create mode 100644 x-pack/plugins/maps/public/layers/joins/inner_join.js create mode 100644 x-pack/plugins/maps/public/layers/joins/inner_join.test.js create mode 100644 x-pack/plugins/maps/public/layers/layer.js create mode 100644 x-pack/plugins/maps/public/layers/sources/all_sources.js create mode 100644 x-pack/plugins/maps/public/layers/sources/client_file_source/create_client_file_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/client_file_source/index.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_file_source/create_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.test.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_file_source/index.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_file_source/update_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.test.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_tms_source/index.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_tms_source/tile_service_select.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_tms_source/update_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/ems_unavailable_message.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_agg_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/create_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/geo_tile_utils.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/geo_tile_utils.test.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/index.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/render_as.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/resolution_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/update_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/create_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/update_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap create mode 100644 x-pack/plugins/maps/public/layers/sources/es_search_source/constants.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_search_source/create_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_search_source/index.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_search_source/load_index_settings.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_term_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/es_term_source.test.js create mode 100644 x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/create_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/index.js create mode 100644 x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/create_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/index.js create mode 100644 x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/tms_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/vector_feature_types.js create mode 100644 x-pack/plugins/maps/public/layers/sources/vector_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/wms_source/index.js create mode 100644 x-pack/plugins/maps/public/layers/sources/wms_source/wms_client.js create mode 100644 x-pack/plugins/maps/public/layers/sources/wms_source/wms_client.test.js create mode 100644 x-pack/plugins/maps/public/layers/sources/wms_source/wms_create_source_editor.js create mode 100644 x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js create mode 100644 x-pack/plugins/maps/public/layers/sources/xyz_tms_source.js create mode 100644 x-pack/plugins/maps/public/layers/styles/_index.scss create mode 100644 x-pack/plugins/maps/public/layers/styles/abstract_style.js create mode 100644 x-pack/plugins/maps/public/layers/styles/color_utils.js create mode 100644 x-pack/plugins/maps/public/layers/styles/color_utils.test.js create mode 100644 x-pack/plugins/maps/public/layers/styles/components/_color_gradient.scss create mode 100644 x-pack/plugins/maps/public/layers/styles/components/color_gradient.js create mode 100644 x-pack/plugins/maps/public/layers/styles/components/ranged_style_legend_row.js create mode 100644 x-pack/plugins/maps/public/layers/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.js.snap create mode 100644 x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_constants.js create mode 100644 x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_style_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_style_editor.test.js create mode 100644 x-pack/plugins/maps/public/layers/styles/heatmap/components/legend/heatmap_legend.js create mode 100644 x-pack/plugins/maps/public/layers/styles/heatmap/heatmap_style.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/_style_prop_editor.scss create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/color/color_map_select.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_categorical.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_ordinal.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_utils.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/color/static_color_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/color/vector_style_color_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/field_select.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/label/static_label_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/category.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/circle_icon.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/extract_color_from_style_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/line_icon.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/polygon_icon.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/symbol_icon.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/ordinal_field_meta_options_popover.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/orientation/orientation_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/size/size_range_selector.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/size/static_size_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/size/vector_style_size_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/style_map_select.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/style_option_shapes.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/_icon_select.scss create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/dynamic_icon_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_map_select.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_select.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_select.test.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/static_icon_form.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/vector_style_icon_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/symbol/vector_style_symbolize_as_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/components/categorical_legend.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/components/ordinal_legend.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/static_color_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/static_icon_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/static_orientation_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/static_size_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/static_style_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/static_text_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/style_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/properties/symbolize_as_property.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/style_util.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/style_util.test.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/symbol_utils.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/symbol_utils.test.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/vector_style.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/vector_style.test.js create mode 100644 x-pack/plugins/maps/public/layers/styles/vector/vector_style_defaults.js create mode 100644 x-pack/plugins/maps/public/layers/tile_layer.js create mode 100644 x-pack/plugins/maps/public/layers/tooltips/es_aggmetric_tooltip_property.js create mode 100644 x-pack/plugins/maps/public/layers/tooltips/es_tooltip_property.js create mode 100644 x-pack/plugins/maps/public/layers/tooltips/join_tooltip_property.js create mode 100644 x-pack/plugins/maps/public/layers/tooltips/tooltip_property.js create mode 100644 x-pack/plugins/maps/public/layers/util/assign_feature_ids.js create mode 100644 x-pack/plugins/maps/public/layers/util/assign_feature_ids.test.js create mode 100644 x-pack/plugins/maps/public/layers/util/can_skip_fetch.js create mode 100644 x-pack/plugins/maps/public/layers/util/can_skip_fetch.test.js create mode 100644 x-pack/plugins/maps/public/layers/util/data_request.js create mode 100644 x-pack/plugins/maps/public/layers/util/is_metric_countable.js create mode 100644 x-pack/plugins/maps/public/layers/util/is_refresh_only_query.js create mode 100644 x-pack/plugins/maps/public/layers/util/mb_filter_expressions.js create mode 100644 x-pack/plugins/maps/public/layers/vector_layer.js create mode 100644 x-pack/plugins/maps/public/layers/vector_tile_layer.js create mode 100644 x-pack/plugins/maps/public/meta.js create mode 100644 x-pack/plugins/maps/public/meta.test.js create mode 100644 x-pack/plugins/maps/public/reducers/map.js create mode 100644 x-pack/plugins/maps/public/reducers/map.test.js create mode 100644 x-pack/plugins/maps/public/reducers/non_serializable_instances.js create mode 100644 x-pack/plugins/maps/public/reducers/store.js create mode 100644 x-pack/plugins/maps/public/reducers/ui.js create mode 100644 x-pack/plugins/maps/public/reducers/util.js create mode 100644 x-pack/plugins/maps/public/reducers/util.test.js create mode 100644 x-pack/plugins/maps/public/selectors/map_selectors.js create mode 100644 x-pack/plugins/maps/public/selectors/map_selectors.test.js create mode 100644 x-pack/plugins/maps/public/selectors/ui_selectors.js diff --git a/x-pack/plugins/maps/common/i18n_getters.js b/x-pack/plugins/maps/common/i18n_getters.js new file mode 100644 index 0000000000000..578d0cd4780e9 --- /dev/null +++ b/x-pack/plugins/maps/common/i18n_getters.js @@ -0,0 +1,50 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +import { ES_SPATIAL_RELATIONS } from './constants'; + +export function getAppTitle() { + return i18n.translate('xpack.maps.appTitle', { + defaultMessage: 'Maps', + }); +} + +export function getDataSourceLabel() { + return i18n.translate('xpack.maps.source.dataSourceLabel', { + defaultMessage: 'Data source', + }); +} + +export function getUrlLabel() { + return i18n.translate('xpack.maps.source.urlLabel', { + defaultMessage: 'Url', + }); +} + +export function getEsSpatialRelationLabel(spatialRelation) { + switch (spatialRelation) { + case ES_SPATIAL_RELATIONS.INTERSECTS: + return i18n.translate('xpack.maps.common.esSpatialRelation.intersectsLabel', { + defaultMessage: 'intersects', + }); + case ES_SPATIAL_RELATIONS.DISJOINT: + return i18n.translate('xpack.maps.common.esSpatialRelation.disjointLabel', { + defaultMessage: 'disjoint', + }); + case ES_SPATIAL_RELATIONS.WITHIN: + return i18n.translate('xpack.maps.common.esSpatialRelation.withinLabel', { + defaultMessage: 'within', + }); + case ES_SPATIAL_RELATIONS.CONTAINS: + return i18n.translate('xpack.maps.common.esSpatialRelation.containsLabel', { + defaultMessage: 'contains', + }); + default: + return spatialRelation; + } +} diff --git a/x-pack/plugins/maps/common/parse_xml_string.js b/x-pack/plugins/maps/common/parse_xml_string.js new file mode 100644 index 0000000000000..9d95e0e78280d --- /dev/null +++ b/x-pack/plugins/maps/common/parse_xml_string.js @@ -0,0 +1,22 @@ +/* + * 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 { parseString } from 'xml2js'; + +// promise based wrapper around parseString +export async function parseXmlString(xmlString) { + const parsePromise = new Promise((resolve, reject) => { + parseString(xmlString, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + + return await parsePromise; +} diff --git a/x-pack/plugins/maps/common/parse_xml_string.test.js b/x-pack/plugins/maps/common/parse_xml_string.test.js new file mode 100644 index 0000000000000..cfd6235667aae --- /dev/null +++ b/x-pack/plugins/maps/common/parse_xml_string.test.js @@ -0,0 +1,16 @@ +/* + * 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 { parseXmlString } from './parse_xml_string'; + +describe('parseXmlString', () => { + it('Should parse xml string into JS object', async () => { + const xmlAsObject = await parseXmlString('bar'); + expect(xmlAsObject).toEqual({ + foo: 'bar', + }); + }); +}); diff --git a/x-pack/plugins/maps/public/actions/map_actions.js b/x-pack/plugins/maps/public/actions/map_actions.js new file mode 100644 index 0000000000000..59b54c2434d17 --- /dev/null +++ b/x-pack/plugins/maps/public/actions/map_actions.js @@ -0,0 +1,871 @@ +/* + * 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 turf from 'turf'; +import turfBooleanContains from '@turf/boolean-contains'; +import { + getLayerList, + getLayerListRaw, + getDataFilters, + getSelectedLayerId, + getMapReady, + getWaitingForMapReadyLayerListRaw, + getTransientLayerId, + getTooltipState, + getQuery, +} from '../selectors/map_selectors'; +import { FLYOUT_STATE } from '../reducers/ui'; +import { + cancelRequest, + registerCancelCallback, + unregisterCancelCallback, + getEventHandlers, +} from '../reducers/non_serializable_instances'; +import { updateFlyout } from '../actions/ui_actions'; +import { + FEATURE_ID_PROPERTY_NAME, + LAYER_TYPE, + SOURCE_DATA_ID_ORIGIN, +} from '../../common/constants'; + +export const SET_SELECTED_LAYER = 'SET_SELECTED_LAYER'; +export const SET_TRANSIENT_LAYER = 'SET_TRANSIENT_LAYER'; +export const UPDATE_LAYER_ORDER = 'UPDATE_LAYER_ORDER'; +export const ADD_LAYER = 'ADD_LAYER'; +export const SET_LAYER_ERROR_STATUS = 'SET_LAYER_ERROR_STATUS'; +export const ADD_WAITING_FOR_MAP_READY_LAYER = 'ADD_WAITING_FOR_MAP_READY_LAYER'; +export const CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST = 'CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST'; +export const REMOVE_LAYER = 'REMOVE_LAYER'; +export const SET_LAYER_VISIBILITY = 'SET_LAYER_VISIBILITY'; +export const MAP_EXTENT_CHANGED = 'MAP_EXTENT_CHANGED'; +export const MAP_READY = 'MAP_READY'; +export const MAP_DESTROYED = 'MAP_DESTROYED'; +export const LAYER_DATA_LOAD_STARTED = 'LAYER_DATA_LOAD_STARTED'; +export const LAYER_DATA_LOAD_ENDED = 'LAYER_DATA_LOAD_ENDED'; +export const LAYER_DATA_LOAD_ERROR = 'LAYER_DATA_LOAD_ERROR'; +export const UPDATE_SOURCE_DATA_REQUEST = 'UPDATE_SOURCE_DATA_REQUEST'; +export const SET_JOINS = 'SET_JOINS'; +export const SET_QUERY = 'SET_QUERY'; +export const TRIGGER_REFRESH_TIMER = 'TRIGGER_REFRESH_TIMER'; +export const UPDATE_LAYER_PROP = 'UPDATE_LAYER_PROP'; +export const UPDATE_LAYER_STYLE = 'UPDATE_LAYER_STYLE'; +export const SET_LAYER_STYLE_META = 'SET_LAYER_STYLE_META'; +export const TOUCH_LAYER = 'TOUCH_LAYER'; +export const UPDATE_SOURCE_PROP = 'UPDATE_SOURCE_PROP'; +export const SET_REFRESH_CONFIG = 'SET_REFRESH_CONFIG'; +export const SET_MOUSE_COORDINATES = 'SET_MOUSE_COORDINATES'; +export const CLEAR_MOUSE_COORDINATES = 'CLEAR_MOUSE_COORDINATES'; +export const SET_GOTO = 'SET_GOTO'; +export const CLEAR_GOTO = 'CLEAR_GOTO'; +export const TRACK_CURRENT_LAYER_STATE = 'TRACK_CURRENT_LAYER_STATE'; +export const ROLLBACK_TO_TRACKED_LAYER_STATE = 'ROLLBACK_TO_TRACKED_LAYER_STATE'; +export const REMOVE_TRACKED_LAYER_STATE = 'REMOVE_TRACKED_LAYER_STATE'; +export const SET_TOOLTIP_STATE = 'SET_TOOLTIP_STATE'; +export const UPDATE_DRAW_STATE = 'UPDATE_DRAW_STATE'; +export const SET_SCROLL_ZOOM = 'SET_SCROLL_ZOOM'; +export const SET_MAP_INIT_ERROR = 'SET_MAP_INIT_ERROR'; +export const SET_INTERACTIVE = 'SET_INTERACTIVE'; +export const DISABLE_TOOLTIP_CONTROL = 'DISABLE_TOOLTIP_CONTROL'; +export const HIDE_TOOLBAR_OVERLAY = 'HIDE_TOOLBAR_OVERLAY'; +export const HIDE_LAYER_CONTROL = 'HIDE_LAYER_CONTROL'; +export const HIDE_VIEW_CONTROL = 'HIDE_VIEW_CONTROL'; +export const SET_WAITING_FOR_READY_HIDDEN_LAYERS = 'SET_WAITING_FOR_READY_HIDDEN_LAYERS'; + +function getLayerLoadingCallbacks(dispatch, layerId) { + return { + startLoading: (dataId, requestToken, meta) => + dispatch(startDataLoad(layerId, dataId, requestToken, meta)), + stopLoading: (dataId, requestToken, data, meta) => + dispatch(endDataLoad(layerId, dataId, requestToken, data, meta)), + onLoadError: (dataId, requestToken, errorMessage) => + dispatch(onDataLoadError(layerId, dataId, requestToken, errorMessage)), + updateSourceData: newData => { + dispatch(updateSourceDataRequest(layerId, newData)); + }, + registerCancelCallback: (requestToken, callback) => + dispatch(registerCancelCallback(requestToken, callback)), + }; +} + +function getLayerById(layerId, state) { + return getLayerList(state).find(layer => { + return layerId === layer.getId(); + }); +} + +async function syncDataForAllLayers(getState, dispatch, dataFilters) { + const state = getState(); + const layerList = getLayerList(state); + const syncs = layerList.map(layer => { + const loadingFunctions = getLayerLoadingCallbacks(dispatch, layer.getId()); + return layer.syncData({ ...loadingFunctions, dataFilters }); + }); + await Promise.all(syncs); +} + +export function cancelAllInFlightRequests() { + return (dispatch, getState) => { + getLayerList(getState()).forEach(layer => { + layer.getInFlightRequestTokens().forEach(requestToken => { + dispatch(cancelRequest(requestToken)); + }); + }); + }; +} + +export function setMapInitError(errorMessage) { + return { + type: SET_MAP_INIT_ERROR, + errorMessage, + }; +} + +export function trackCurrentLayerState(layerId) { + return { + type: TRACK_CURRENT_LAYER_STATE, + layerId: layerId, + }; +} + +export function rollbackToTrackedLayerStateForSelectedLayer() { + return async (dispatch, getState) => { + const layerId = getSelectedLayerId(getState()); + await dispatch({ + type: ROLLBACK_TO_TRACKED_LAYER_STATE, + layerId: layerId, + }); + + // Ensure updateStyleMeta is triggered + // syncDataForLayer may not trigger endDataLoad if no re-fetch is required + dispatch(updateStyleMeta(layerId)); + + dispatch(syncDataForLayer(layerId)); + }; +} + +export function removeTrackedLayerStateForSelectedLayer() { + return (dispatch, getState) => { + const layerId = getSelectedLayerId(getState()); + dispatch({ + type: REMOVE_TRACKED_LAYER_STATE, + layerId: layerId, + }); + }; +} + +export function replaceLayerList(newLayerList) { + return (dispatch, getState) => { + getLayerListRaw(getState()).forEach(({ id }) => { + dispatch(removeLayerFromLayerList(id)); + }); + + newLayerList.forEach(layerDescriptor => { + dispatch(addLayer(layerDescriptor)); + }); + }; +} + +export function cloneLayer(layerId) { + return async (dispatch, getState) => { + const layer = getLayerById(layerId, getState()); + if (!layer) { + return; + } + + const clonedDescriptor = await layer.cloneDescriptor(); + dispatch(addLayer(clonedDescriptor)); + }; +} + +export function addLayer(layerDescriptor) { + return (dispatch, getState) => { + const isMapReady = getMapReady(getState()); + if (!isMapReady) { + dispatch({ + type: ADD_WAITING_FOR_MAP_READY_LAYER, + layer: layerDescriptor, + }); + return; + } + + dispatch({ + type: ADD_LAYER, + layer: layerDescriptor, + }); + dispatch(syncDataForLayer(layerDescriptor.id)); + }; +} + +// Do not use when rendering a map. Method exists to enable selectors for getLayerList when +// rendering is not needed. +export function addLayerWithoutDataSync(layerDescriptor) { + return { + type: ADD_LAYER, + layer: layerDescriptor, + }; +} + +function setLayerDataLoadErrorStatus(layerId, errorMessage) { + return dispatch => { + dispatch({ + type: SET_LAYER_ERROR_STATUS, + isInErrorState: errorMessage !== null, + layerId, + errorMessage, + }); + }; +} + +export function cleanTooltipStateForLayer(layerId, layerFeatures = []) { + return (dispatch, getState) => { + const tooltipState = getTooltipState(getState()); + + if (!tooltipState) { + return; + } + + const nextTooltipFeatures = tooltipState.features.filter(tooltipFeature => { + if (tooltipFeature.layerId !== layerId) { + // feature from another layer, keep it + return true; + } + + // Keep feature if it is still in layer + return layerFeatures.some(layerFeature => { + return layerFeature.properties[FEATURE_ID_PROPERTY_NAME] === tooltipFeature.id; + }); + }); + + if (tooltipState.features.length === nextTooltipFeatures.length) { + // no features got removed, nothing to update + return; + } + + if (nextTooltipFeatures.length === 0) { + // all features removed from tooltip, close tooltip + dispatch(setTooltipState(null)); + } else { + dispatch(setTooltipState({ ...tooltipState, features: nextTooltipFeatures })); + } + }; +} + +export function setLayerVisibility(layerId, makeVisible) { + return async (dispatch, getState) => { + //if the current-state is invisible, we also want to sync data + //e.g. if a layer was invisible at start-up, it won't have any data loaded + const layer = getLayerById(layerId, getState()); + + // If the layer visibility is already what we want it to be, do nothing + if (!layer || layer.isVisible() === makeVisible) { + return; + } + + if (!makeVisible) { + dispatch(cleanTooltipStateForLayer(layerId)); + } + + await dispatch({ + type: SET_LAYER_VISIBILITY, + layerId, + visibility: makeVisible, + }); + if (makeVisible) { + dispatch(syncDataForLayer(layerId)); + } + }; +} + +export function toggleLayerVisible(layerId) { + return async (dispatch, getState) => { + const layer = getLayerById(layerId, getState()); + if (!layer) { + return; + } + const makeVisible = !layer.isVisible(); + + dispatch(setLayerVisibility(layerId, makeVisible)); + }; +} + +export function setSelectedLayer(layerId) { + return async (dispatch, getState) => { + const oldSelectedLayer = getSelectedLayerId(getState()); + if (oldSelectedLayer) { + await dispatch(rollbackToTrackedLayerStateForSelectedLayer()); + } + if (layerId) { + dispatch(trackCurrentLayerState(layerId)); + } + dispatch({ + type: SET_SELECTED_LAYER, + selectedLayerId: layerId, + }); + }; +} + +export function removeTransientLayer() { + return async (dispatch, getState) => { + const transientLayerId = getTransientLayerId(getState()); + if (transientLayerId) { + await dispatch(removeLayerFromLayerList(transientLayerId)); + await dispatch(setTransientLayer(null)); + } + }; +} + +export function setTransientLayer(layerId) { + return { + type: SET_TRANSIENT_LAYER, + transientLayerId: layerId, + }; +} + +export function clearTransientLayerStateAndCloseFlyout() { + return async dispatch => { + await dispatch(updateFlyout(FLYOUT_STATE.NONE)); + await dispatch(setSelectedLayer(null)); + await dispatch(removeTransientLayer()); + }; +} + +export function updateLayerOrder(newLayerOrder) { + return { + type: UPDATE_LAYER_ORDER, + newLayerOrder, + }; +} + +export function mapReady() { + return (dispatch, getState) => { + dispatch({ + type: MAP_READY, + }); + + getWaitingForMapReadyLayerListRaw(getState()).forEach(layerDescriptor => { + dispatch(addLayer(layerDescriptor)); + }); + + dispatch({ + type: CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST, + }); + }; +} + +export function mapDestroyed() { + return { + type: MAP_DESTROYED, + }; +} + +export function mapExtentChanged(newMapConstants) { + return async (dispatch, getState) => { + const state = getState(); + const dataFilters = getDataFilters(state); + const { extent, zoom: newZoom } = newMapConstants; + const { buffer, zoom: currentZoom } = dataFilters; + + if (extent) { + let doesBufferContainExtent = false; + if (buffer) { + const bufferGeometry = turf.bboxPolygon([ + buffer.minLon, + buffer.minLat, + buffer.maxLon, + buffer.maxLat, + ]); + const extentGeometry = turf.bboxPolygon([ + extent.minLon, + extent.minLat, + extent.maxLon, + extent.maxLat, + ]); + + doesBufferContainExtent = turfBooleanContains(bufferGeometry, extentGeometry); + } + + 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, + }; + } + } + + dispatch({ + type: MAP_EXTENT_CHANGED, + mapState: { + ...dataFilters, + ...newMapConstants, + }, + }); + const newDataFilters = { ...dataFilters, ...newMapConstants }; + await syncDataForAllLayers(getState, dispatch, newDataFilters); + }; +} + +export function setTooltipState(tooltipState) { + return { + type: 'SET_TOOLTIP_STATE', + tooltipState: tooltipState, + }; +} + +export function setMouseCoordinates({ lat, lon }) { + let safeLon = lon; + if (lon > 180) { + const overlapWestOfDateLine = lon - 180; + safeLon = -180 + overlapWestOfDateLine; + } else if (lon < -180) { + const overlapEastOfDateLine = Math.abs(lon) - 180; + safeLon = 180 - overlapEastOfDateLine; + } + + return { + type: SET_MOUSE_COORDINATES, + lat, + lon: safeLon, + }; +} + +export function clearMouseCoordinates() { + return { type: CLEAR_MOUSE_COORDINATES }; +} + +export function disableScrollZoom() { + return { type: SET_SCROLL_ZOOM, scrollZoom: false }; +} + +export function fitToLayerExtent(layerId) { + return async function(dispatch, getState) { + const targetLayer = getLayerById(layerId, getState()); + + if (targetLayer) { + const dataFilters = getDataFilters(getState()); + const bounds = await targetLayer.getBounds(dataFilters); + if (bounds) { + await dispatch(setGotoWithBounds(bounds)); + } + } + }; +} + +export function setGotoWithBounds(bounds) { + return { + type: SET_GOTO, + bounds: bounds, + }; +} + +export function setGotoWithCenter({ lat, lon, zoom }) { + return { + type: SET_GOTO, + center: { lat, lon, zoom }, + }; +} + +export function clearGoto() { + return { type: CLEAR_GOTO }; +} + +export function startDataLoad(layerId, dataId, requestToken, meta = {}) { + return (dispatch, getState) => { + const layer = getLayerById(layerId, getState()); + if (layer) { + dispatch(cancelRequest(layer.getPrevRequestToken(dataId))); + } + + const eventHandlers = getEventHandlers(getState()); + if (eventHandlers && eventHandlers.onDataLoad) { + eventHandlers.onDataLoad({ + layerId, + dataId, + }); + } + + dispatch({ + meta, + type: LAYER_DATA_LOAD_STARTED, + layerId, + dataId, + requestToken, + }); + }; +} + +export function updateSourceDataRequest(layerId, newData) { + return dispatch => { + dispatch({ + type: UPDATE_SOURCE_DATA_REQUEST, + dataId: SOURCE_DATA_ID_ORIGIN, + layerId, + newData, + }); + + dispatch(updateStyleMeta(layerId)); + }; +} + +export function endDataLoad(layerId, dataId, requestToken, data, meta) { + return async (dispatch, getState) => { + dispatch(unregisterCancelCallback(requestToken)); + + const features = data && data.features ? data.features : []; + + const eventHandlers = getEventHandlers(getState()); + if (eventHandlers && eventHandlers.onDataLoadEnd) { + const layer = getLayerById(layerId, getState()); + const resultMeta = {}; + if (layer && layer.getType() === LAYER_TYPE.VECTOR) { + resultMeta.featuresCount = features.length; + } + + eventHandlers.onDataLoadEnd({ + layerId, + dataId, + resultMeta, + }); + } + + dispatch(cleanTooltipStateForLayer(layerId, features)); + dispatch({ + type: LAYER_DATA_LOAD_ENDED, + layerId, + dataId, + data, + meta, + requestToken, + }); + + //Clear any data-load errors when there is a succesful data return. + //Co this on end-data-load iso at start-data-load to avoid blipping the error status between true/false. + //This avoids jitter in the warning icon of the TOC when the requests continues to return errors. + dispatch(setLayerDataLoadErrorStatus(layerId, null)); + + dispatch(updateStyleMeta(layerId)); + }; +} + +export function onDataLoadError(layerId, dataId, requestToken, errorMessage) { + return async (dispatch, getState) => { + dispatch(unregisterCancelCallback(requestToken)); + + const eventHandlers = getEventHandlers(getState()); + if (eventHandlers && eventHandlers.onDataLoadError) { + eventHandlers.onDataLoadError({ + layerId, + dataId, + errorMessage, + }); + } + + dispatch(cleanTooltipStateForLayer(layerId)); + dispatch({ + type: LAYER_DATA_LOAD_ERROR, + data: null, + layerId, + dataId, + requestToken, + }); + + dispatch(setLayerDataLoadErrorStatus(layerId, errorMessage)); + }; +} + +export function updateSourceProp(layerId, propName, value) { + return async dispatch => { + dispatch({ + type: UPDATE_SOURCE_PROP, + layerId, + propName, + value, + }); + await dispatch(clearMissingStyleProperties(layerId)); + dispatch(syncDataForLayer(layerId)); + }; +} + +export function syncDataForLayer(layerId) { + return async (dispatch, getState) => { + const targetLayer = getLayerById(layerId, getState()); + if (targetLayer) { + const dataFilters = getDataFilters(getState()); + const loadingFunctions = getLayerLoadingCallbacks(dispatch, layerId); + await targetLayer.syncData({ + ...loadingFunctions, + dataFilters, + }); + } + }; +} + +export function updateLayerLabel(id, newLabel) { + return { + type: UPDATE_LAYER_PROP, + id, + propName: 'label', + newValue: newLabel, + }; +} + +export function updateLayerMinZoom(id, minZoom) { + return { + type: UPDATE_LAYER_PROP, + id, + propName: 'minZoom', + newValue: minZoom, + }; +} + +export function updateLayerMaxZoom(id, maxZoom) { + return { + type: UPDATE_LAYER_PROP, + id, + propName: 'maxZoom', + newValue: maxZoom, + }; +} + +export function updateLayerAlpha(id, alpha) { + return { + type: UPDATE_LAYER_PROP, + id, + propName: 'alpha', + newValue: alpha, + }; +} + +export function setLayerQuery(id, query) { + return dispatch => { + dispatch({ + type: UPDATE_LAYER_PROP, + id, + propName: 'query', + newValue: query, + }); + + dispatch(syncDataForLayer(id)); + }; +} + +export function removeSelectedLayer() { + return (dispatch, getState) => { + const state = getState(); + const layerId = getSelectedLayerId(state); + dispatch(removeLayer(layerId)); + }; +} + +export function removeLayer(layerId) { + return async (dispatch, getState) => { + const state = getState(); + const selectedLayerId = getSelectedLayerId(state); + if (layerId === selectedLayerId) { + dispatch(updateFlyout(FLYOUT_STATE.NONE)); + await dispatch(setSelectedLayer(null)); + } + dispatch(removeLayerFromLayerList(layerId)); + }; +} + +function removeLayerFromLayerList(layerId) { + return (dispatch, getState) => { + const layerGettingRemoved = getLayerById(layerId, getState()); + if (!layerGettingRemoved) { + return; + } + + layerGettingRemoved.getInFlightRequestTokens().forEach(requestToken => { + dispatch(cancelRequest(requestToken)); + }); + dispatch(cleanTooltipStateForLayer(layerId)); + layerGettingRemoved.destroy(); + dispatch({ + type: REMOVE_LAYER, + id: layerId, + }); + }; +} + +export function setQuery({ query, timeFilters, filters = [], refresh = false }) { + function generateQueryTimestamp() { + return new Date().toISOString(); + } + return async (dispatch, getState) => { + const prevQuery = getQuery(getState()); + const prevTriggeredAt = + prevQuery && prevQuery.queryLastTriggeredAt + ? prevQuery.queryLastTriggeredAt + : generateQueryTimestamp(); + + dispatch({ + type: SET_QUERY, + timeFilters, + query: { + ...query, + // ensure query changes to trigger re-fetch when "Refresh" clicked + queryLastTriggeredAt: refresh ? generateQueryTimestamp() : prevTriggeredAt, + }, + filters, + }); + + const dataFilters = getDataFilters(getState()); + await syncDataForAllLayers(getState, dispatch, dataFilters); + }; +} + +export function setRefreshConfig({ isPaused, interval }) { + return { + type: SET_REFRESH_CONFIG, + isPaused, + interval, + }; +} + +export function triggerRefreshTimer() { + return async (dispatch, getState) => { + dispatch({ + type: TRIGGER_REFRESH_TIMER, + }); + + const dataFilters = getDataFilters(getState()); + await syncDataForAllLayers(getState, dispatch, dataFilters); + }; +} + +export function clearMissingStyleProperties(layerId) { + return async (dispatch, getState) => { + const targetLayer = getLayerById(layerId, getState()); + if (!targetLayer) { + return; + } + + const style = targetLayer.getCurrentStyle(); + if (!style) { + return; + } + + const nextFields = await targetLayer.getFields(); //take into account all fields, since labels can be driven by any field (source or join) + const { hasChanges, nextStyleDescriptor } = style.getDescriptorWithMissingStylePropsRemoved( + nextFields + ); + if (hasChanges) { + dispatch(updateLayerStyle(layerId, nextStyleDescriptor)); + } + }; +} + +export function updateLayerStyle(layerId, styleDescriptor) { + return dispatch => { + dispatch({ + type: UPDATE_LAYER_STYLE, + layerId, + style: { + ...styleDescriptor, + }, + }); + + // Ensure updateStyleMeta is triggered + // syncDataForLayer may not trigger endDataLoad if no re-fetch is required + dispatch(updateStyleMeta(layerId)); + + // Style update may require re-fetch, for example ES search may need to retrieve field used for dynamic styling + dispatch(syncDataForLayer(layerId)); + }; +} + +export function updateStyleMeta(layerId) { + return async (dispatch, getState) => { + const layer = getLayerById(layerId, getState()); + if (!layer) { + return; + } + const sourceDataRequest = layer.getSourceDataRequest(); + const style = layer.getCurrentStyle(); + if (!style || !sourceDataRequest) { + return; + } + const styleMeta = await style.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); + dispatch({ + type: SET_LAYER_STYLE_META, + layerId, + styleMeta, + }); + }; +} + +export function updateLayerStyleForSelectedLayer(styleDescriptor) { + return (dispatch, getState) => { + const selectedLayerId = getSelectedLayerId(getState()); + if (!selectedLayerId) { + return; + } + dispatch(updateLayerStyle(selectedLayerId, styleDescriptor)); + }; +} + +export function setJoinsForLayer(layer, joins) { + return async dispatch => { + await dispatch({ + type: SET_JOINS, + layer: layer, + joins: joins, + }); + + await dispatch(clearMissingStyleProperties(layer.getId())); + dispatch(syncDataForLayer(layer.getId())); + }; +} + +export function updateDrawState(drawState) { + return async dispatch => { + if (drawState !== null) { + await dispatch(setTooltipState(null)); //tooltips just get in the way + } + dispatch({ + type: UPDATE_DRAW_STATE, + drawState: drawState, + }); + }; +} + +export function disableInteractive() { + return { type: SET_INTERACTIVE, disableInteractive: true }; +} + +export function disableTooltipControl() { + return { type: DISABLE_TOOLTIP_CONTROL, disableTooltipControl: true }; +} + +export function hideToolbarOverlay() { + return { type: HIDE_TOOLBAR_OVERLAY, hideToolbarOverlay: true }; +} + +export function hideLayerControl() { + return { type: HIDE_LAYER_CONTROL, hideLayerControl: true }; +} +export function hideViewControl() { + return { type: HIDE_VIEW_CONTROL, hideViewControl: true }; +} + +export function setHiddenLayers(hiddenLayerIds) { + return (dispatch, getState) => { + const isMapReady = getMapReady(getState()); + + if (!isMapReady) { + dispatch({ type: SET_WAITING_FOR_READY_HIDDEN_LAYERS, hiddenLayerIds }); + } else { + getLayerListRaw(getState()).forEach(layer => + dispatch(setLayerVisibility(layer.id, !hiddenLayerIds.includes(layer.id))) + ); + } + }; +} diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.js b/x-pack/plugins/maps/public/actions/map_actions.test.js new file mode 100644 index 0000000000000..c280b8af7ab80 --- /dev/null +++ b/x-pack/plugins/maps/public/actions/map_actions.test.js @@ -0,0 +1,233 @@ +/* + * 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. + */ + +jest.mock('../selectors/map_selectors', () => ({})); +jest.mock('../kibana_services', () => ({})); + +import { mapExtentChanged, setMouseCoordinates } from './map_actions'; + +const getStoreMock = jest.fn(); +const dispatchMock = jest.fn(); + +describe('map_actions', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('mapExtentChanged', () => { + beforeEach(() => { + // getLayerList mocked to return emtpy array because + // syncDataForAllLayers is triggered by selector and internally calls getLayerList + require('../selectors/map_selectors').getLayerList = () => { + return []; + }; + }); + + describe('store mapState is empty', () => { + beforeEach(() => { + require('../selectors/map_selectors').getDataFilters = () => { + return {}; + }; + }); + + it('should add newMapConstants to dispatch action mapState', async () => { + const action = mapExtentChanged({ zoom: 5 }); + await action(dispatchMock, getStoreMock); + + expect(dispatchMock).toHaveBeenCalledWith({ + mapState: { + zoom: 5, + }, + type: 'MAP_EXTENT_CHANGED', + }); + }); + + it('should add buffer to dispatch action mapState', async () => { + const action = mapExtentChanged({ + extent: { + maxLat: 10, + maxLon: 100, + minLat: 5, + minLon: 95, + }, + }); + await action(dispatchMock, getStoreMock); + + expect(dispatchMock).toHaveBeenCalledWith({ + mapState: { + extent: { + maxLat: 10, + maxLon: 100, + minLat: 5, + minLon: 95, + }, + buffer: { + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }, + }, + type: 'MAP_EXTENT_CHANGED', + }); + }); + }); + + describe('store mapState is populated', () => { + const initialZoom = 10; + beforeEach(() => { + require('../selectors/map_selectors').getDataFilters = () => { + return { + zoom: initialZoom, + buffer: { + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }, + }; + }; + }); + + it('should not update buffer if extent is contained in existing buffer', async () => { + const action = mapExtentChanged({ + zoom: initialZoom, + extent: { + maxLat: 11, + maxLon: 101, + minLat: 6, + minLon: 96, + }, + }); + await action(dispatchMock, getStoreMock); + + expect(dispatchMock).toHaveBeenCalledWith({ + mapState: { + zoom: 10, + extent: { + maxLat: 11, + maxLon: 101, + minLat: 6, + minLon: 96, + }, + buffer: { + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }, + }, + type: 'MAP_EXTENT_CHANGED', + }); + }); + + it('should update buffer if extent is outside of existing buffer', async () => { + const action = mapExtentChanged({ + zoom: initialZoom, + extent: { + maxLat: 5, + maxLon: 90, + minLat: 0, + minLon: 85, + }, + }); + await action(dispatchMock, getStoreMock); + + expect(dispatchMock).toHaveBeenCalledWith({ + mapState: { + zoom: 10, + extent: { + maxLat: 5, + maxLon: 90, + minLat: 0, + minLon: 85, + }, + buffer: { + maxLat: 7.5, + maxLon: 92.5, + minLat: -2.5, + minLon: 82.5, + }, + }, + type: 'MAP_EXTENT_CHANGED', + }); + }); + + it('should update buffer when zoom changes', async () => { + const action = mapExtentChanged({ + zoom: initialZoom + 1, + extent: { + maxLat: 11, + maxLon: 101, + minLat: 6, + minLon: 96, + }, + }); + await action(dispatchMock, getStoreMock); + + expect(dispatchMock).toHaveBeenCalledWith({ + mapState: { + zoom: 11, + extent: { + maxLat: 11, + maxLon: 101, + minLat: 6, + minLon: 96, + }, + buffer: { + maxLat: 13.5, + maxLon: 103.5, + minLat: 3.5, + minLon: 93.5, + }, + }, + type: 'MAP_EXTENT_CHANGED', + }); + }); + }); + }); + + describe('setMouseCoordinates', () => { + it('should create SET_MOUSE_COORDINATES action', () => { + const action = setMouseCoordinates({ + lat: 10, + lon: 100, + }); + + expect(action).toEqual({ + type: 'SET_MOUSE_COORDINATES', + lat: 10, + lon: 100, + }); + }); + + it('should handle longitudes that wrap east to west', () => { + const action = setMouseCoordinates({ + lat: 10, + lon: 190, + }); + + expect(action).toEqual({ + type: 'SET_MOUSE_COORDINATES', + lat: 10, + lon: -170, + }); + }); + + it('should handle longitudes that wrap west to east', () => { + const action = setMouseCoordinates({ + lat: 10, + lon: -190, + }); + + expect(action).toEqual({ + type: 'SET_MOUSE_COORDINATES', + lat: 10, + lon: 170, + }); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/actions/ui_actions.js b/x-pack/plugins/maps/public/actions/ui_actions.js new file mode 100644 index 0000000000000..2b687516f3e5a --- /dev/null +++ b/x-pack/plugins/maps/public/actions/ui_actions.js @@ -0,0 +1,85 @@ +/* + * 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. + */ + +export const UPDATE_FLYOUT = 'UPDATE_FLYOUT'; +export const CLOSE_SET_VIEW = 'CLOSE_SET_VIEW'; +export const OPEN_SET_VIEW = 'OPEN_SET_VIEW'; +export const SET_IS_LAYER_TOC_OPEN = 'SET_IS_LAYER_TOC_OPEN'; +export const SET_FULL_SCREEN = 'SET_FULL_SCREEN'; +export const SET_READ_ONLY = 'SET_READ_ONLY'; +export const SET_OPEN_TOC_DETAILS = 'SET_OPEN_TOC_DETAILS'; +export const SHOW_TOC_DETAILS = 'SHOW_TOC_DETAILS'; +export const HIDE_TOC_DETAILS = 'HIDE_TOC_DETAILS'; +export const UPDATE_INDEXING_STAGE = 'UPDATE_INDEXING_STAGE'; + +export function updateFlyout(display) { + return { + type: UPDATE_FLYOUT, + display, + }; +} +export function closeSetView() { + return { + type: CLOSE_SET_VIEW, + }; +} +export function openSetView() { + return { + type: OPEN_SET_VIEW, + }; +} +export function setIsLayerTOCOpen(isLayerTOCOpen) { + return { + type: SET_IS_LAYER_TOC_OPEN, + isLayerTOCOpen, + }; +} +export function exitFullScreen() { + return { + type: SET_FULL_SCREEN, + isFullScreen: false, + }; +} +export function enableFullScreen() { + return { + type: SET_FULL_SCREEN, + isFullScreen: true, + }; +} +export function setReadOnly(isReadOnly) { + return { + type: SET_READ_ONLY, + isReadOnly, + }; +} + +export function setOpenTOCDetails(layerIds) { + return { + type: SET_OPEN_TOC_DETAILS, + layerIds, + }; +} + +export function showTOCDetails(layerId) { + return { + type: SHOW_TOC_DETAILS, + layerId, + }; +} + +export function hideTOCDetails(layerId) { + return { + type: HIDE_TOC_DETAILS, + layerId, + }; +} + +export function updateIndexingStage(stage) { + return { + type: UPDATE_INDEXING_STAGE, + stage, + }; +} diff --git a/x-pack/plugins/maps/public/components/__snapshots__/add_tooltip_field_popover.test.js.snap b/x-pack/plugins/maps/public/components/__snapshots__/add_tooltip_field_popover.test.js.snap new file mode 100644 index 0000000000000..f37dfdd879c5b --- /dev/null +++ b/x-pack/plugins/maps/public/components/__snapshots__/add_tooltip_field_popover.test.js.snap @@ -0,0 +1,149 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should remove selected fields from selectable 1`] = ` + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="addTooltipFieldPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" +> + , + "value": "@timestamp", + }, + ] + } + searchable={true} + singleSelection={false} + > + + + + + + + Add + + + + +`; + +exports[`Should render 1`] = ` + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="addTooltipFieldPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" +> + , + "value": "@timestamp", + }, + Object { + "label": "custom label for prop1", + "prepend": , + "value": "prop1", + }, + Object { + "label": "prop2", + "prepend": , + "value": "prop2", + }, + ] + } + searchable={true} + singleSelection={false} + > + + + + + + + Add + + + + +`; diff --git a/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap b/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap new file mode 100644 index 0000000000000..c62b07a89e7a3 --- /dev/null +++ b/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap @@ -0,0 +1,374 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should not render relation select when geo field is geo_point 1`] = ` + + + + + + + + + My index + + +
+ my geo field + , + "value": "My index/my geo field", + }, + ] + } + valueOfSelected="My index/my geo field" + /> +
+ + + + Create filter + + +
+`; + +exports[`should not show "within" relation when filter geometry is not closed 1`] = ` + + + + + + + + + My index + + +
+ my geo field + , + "value": "My index/my geo field", + }, + ] + } + valueOfSelected="My index/my geo field" + /> +
+ + + + + + + Create filter + + +
+`; + +exports[`should render error message 1`] = ` + + + + + + + + + My index + + +
+ my geo field + , + "value": "My index/my geo field", + }, + ] + } + valueOfSelected="My index/my geo field" + /> +
+ + + Simulated error + + + + Create filter + + +
+`; + +exports[`should render relation select when geo field is geo_shape 1`] = ` + + + + + + + + + My index + + +
+ my geo field + , + "value": "My index/my geo field", + }, + ] + } + valueOfSelected="My index/my geo field" + /> +
+ + + + + + + Create filter + + +
+`; diff --git a/x-pack/plugins/maps/public/components/__snapshots__/layer_toc_actions.test.js.snap b/x-pack/plugins/maps/public/components/__snapshots__/layer_toc_actions.test.js.snap new file mode 100644 index 0000000000000..af836ceffa4b7 --- /dev/null +++ b/x-pack/plugins/maps/public/components/__snapshots__/layer_toc_actions.test.js.snap @@ -0,0 +1,343 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LayerTocActions is rendered 1`] = ` + + simulated tooltip content at zoom: 0 +
+ + mockFootnoteIcon + + + simulated footnote at isUsingSearch: true +
+ + } + delay="regular" + position="top" + title="layer 1" + > + + + + mockIcon + + + layer 1 + + + + + mockFootnoteIcon + + + + + } + className="mapLayTocActions" + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + }, + Object { + "data-test-subj": "editLayerButton", + "icon": , + "name": "Edit layer", + "onClick": [Function], + }, + Object { + "data-test-subj": "cloneLayerButton", + "icon": , + "name": "Clone layer", + "onClick": [Function], + }, + Object { + "data-test-subj": "removeLayerButton", + "icon": , + "name": "Remove layer", + "onClick": [Function], + }, + ], + "title": "Layer actions", + }, + ] + } + /> +
+`; + +exports[`LayerTocActions should disable fit to data when supportsFitToBounds is false 1`] = ` + + simulated tooltip content at zoom: 0 +
+ + mockFootnoteIcon + + + simulated footnote at isUsingSearch: true +
+ + } + delay="regular" + position="top" + title="layer 1" + > + + + + mockIcon + + + layer 1 + + + + + mockFootnoteIcon + + + + + } + className="mapLayTocActions" + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": "Layer does not support fit to data", + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + }, + Object { + "data-test-subj": "editLayerButton", + "icon": , + "name": "Edit layer", + "onClick": [Function], + }, + Object { + "data-test-subj": "cloneLayerButton", + "icon": , + "name": "Clone layer", + "onClick": [Function], + }, + Object { + "data-test-subj": "removeLayerButton", + "icon": , + "name": "Remove layer", + "onClick": [Function], + }, + ], + "title": "Layer actions", + }, + ] + } + /> +
+`; + +exports[`LayerTocActions should not show edit actions in read only mode 1`] = ` + + simulated tooltip content at zoom: 0 +
+ + mockFootnoteIcon + + + simulated footnote at isUsingSearch: true +
+ + } + delay="regular" + position="top" + title="layer 1" + > + + + + mockIcon + + + layer 1 + + + + + mockFootnoteIcon + + + + + } + className="mapLayTocActions" + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + }, + ], + "title": "Layer actions", + }, + ] + } + /> +
+`; diff --git a/x-pack/plugins/maps/public/components/__snapshots__/tooltip_selector.test.js.snap b/x-pack/plugins/maps/public/components/__snapshots__/tooltip_selector.test.js.snap new file mode 100644 index 0000000000000..e21f034161a87 --- /dev/null +++ b/x-pack/plugins/maps/public/components/__snapshots__/tooltip_selector.test.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TooltipSelector should render component 1`] = ` +
+ + + + + + + + + +
+`; diff --git a/x-pack/plugins/maps/public/components/__snapshots__/validated_range.test.js.snap b/x-pack/plugins/maps/public/components/__snapshots__/validated_range.test.js.snap new file mode 100644 index 0000000000000..1faf4f0c1105c --- /dev/null +++ b/x-pack/plugins/maps/public/components/__snapshots__/validated_range.test.js.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should display error message when value is outside of range 1`] = ` +
+ + + + +
+`; + +exports[`Should pass slider props to slider 1`] = ` + +`; + +exports[`Should render slider 1`] = ` + +`; diff --git a/x-pack/plugins/maps/public/components/_geometry_filter.scss b/x-pack/plugins/maps/public/components/_geometry_filter.scss new file mode 100644 index 0000000000000..5425828173e03 --- /dev/null +++ b/x-pack/plugins/maps/public/components/_geometry_filter.scss @@ -0,0 +1,11 @@ +.mapGeometryFilter__geoFieldSuperSelect { + height: $euiSizeL * 2; +} + +.mapGeometryFilter__geoFieldSuperSelectWrapper { + height: $euiSizeL * 3; +} + +.mapGeometryFilter__geoFieldItem { + padding: $euiSizeXS; +} diff --git a/x-pack/plugins/maps/public/components/_index.scss b/x-pack/plugins/maps/public/components/_index.scss new file mode 100644 index 0000000000000..0b32719442424 --- /dev/null +++ b/x-pack/plugins/maps/public/components/_index.scss @@ -0,0 +1,3 @@ +@import './metric_editors'; +@import './geometry_filter'; +@import './tooltip_selector'; diff --git a/x-pack/plugins/maps/public/components/_metric_editors.scss b/x-pack/plugins/maps/public/components/_metric_editors.scss new file mode 100644 index 0000000000000..674117b146b28 --- /dev/null +++ b/x-pack/plugins/maps/public/components/_metric_editors.scss @@ -0,0 +1,23 @@ +.mapMetricEditorPanel { + margin-bottom: $euiSizeS; +} + +.mapMetricEditorPanel__metricEditor { + padding: $euiSizeM 0; + border-top: $euiBorderThin; + + &:first-child { + padding-top: 0; + border-top: none; + } + + &:last-child { + margin-bottom: $euiSizeM; + border-bottom: 1px solid $euiColorLightShade; + } +} + +.mapMetricEditorPanel__metricRemoveButton { + padding-top: $euiSizeM; + text-align: right; +} diff --git a/x-pack/plugins/maps/public/components/_tooltip_selector.scss b/x-pack/plugins/maps/public/components/_tooltip_selector.scss new file mode 100644 index 0000000000000..edce5104cd633 --- /dev/null +++ b/x-pack/plugins/maps/public/components/_tooltip_selector.scss @@ -0,0 +1,59 @@ +.mapTooltipSelector__propertyRow { + display: flex; + + padding: $euiSizeXS 0; + border-bottom: 1px solid $euiColorLightShade; + background-color: $euiColorEmptyShade; + + &:hover, + &:focus, + &:focus-within { + .mapTooltipSelector__propertyIcons { + display: block; + animation: mapPropertyIconsBecomeVisible $euiAnimSpeedFast $euiAnimSlightResistance; + } + } + + .mapTooltipSelector__propertyIcons { + &:hover, + &:focus { + display: block; + animation: mapPropertyIconsBecomeVisible $euiAnimSpeedFast $euiAnimSlightResistance; + } + } +} + +.mapTooltipSelector__propertyRow-isDragging { + @include euiBottomShadowMedium; +} + +.mapTooltipSelector__propertyRow-isDraggingOver { + // Don't allow interaction events while layer is being re-ordered + // sass-lint:disable-block no-important + pointer-events: none !important; +} + +.mapTooltipSelector__propertyContent { + overflow: hidden; + flex-grow: 1; + min-height: $euiSizeL; +} + +.mapTooltipSelector__propertyIcons { + flex-shrink: 0; + display: none; +} + +.mapTooltipSelector__grab:hover { + cursor: grab; +} + +@keyframes mapPropertyIconsBecomeVisible { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/x-pack/plugins/maps/public/components/add_tooltip_field_popover.js b/x-pack/plugins/maps/public/components/add_tooltip_field_popover.js new file mode 100644 index 0000000000000..ba378679e1aa9 --- /dev/null +++ b/x-pack/plugins/maps/public/components/add_tooltip_field_popover.js @@ -0,0 +1,170 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiButtonEmpty, + EuiSelectable, + EuiButton, + EuiSpacer, + EuiTextAlign, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FieldIcon } from '../../../../../src/plugins/kibana_react/public'; + +const sortByLabel = (a, b) => { + return a.label.localeCompare(b.label); +}; + +function getOptions(fields, selectedFields) { + if (!fields) { + return []; + } + + return fields + .filter(field => { + // remove selected fields + const isFieldSelected = !!selectedFields.find(selectedField => { + return field.name === selectedField.name; + }); + return !isFieldSelected; + }) + .map(field => { + return { + value: field.name, + prepend: 'type' in field ? : null, + label: 'label' in field ? field.label : field.name, + }; + }) + .sort(sortByLabel); +} + +export class AddTooltipFieldPopover extends Component { + state = { + isPopoverOpen: false, + checkedFields: [], + }; + + static getDerivedStateFromProps(nextProps, prevState) { + if ( + nextProps.fields !== prevState.prevFields || + nextProps.selectedFields !== prevState.prevSelectedFields + ) { + return { + options: getOptions(nextProps.fields, nextProps.selectedFields), + checkedFields: [], + prevFields: nextProps.fields, + prevSelectedFields: nextProps.selectedFields, + }; + } + + return null; + } + + _togglePopover = () => { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + }; + + _closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + _onSelect = options => { + const checkedFields = options + .filter(option => { + return option.checked === 'on'; + }) + .map(option => { + return option.value; + }); + + this.setState({ + checkedFields, + options, + }); + }; + + _onAdd = () => { + this.props.onAdd(this.state.checkedFields); + this.setState({ checkedFields: [] }); + this._closePopover(); + }; + + _renderAddButton() { + return ( + + + + ); + } + + _renderContent() { + const addLabel = + this.state.checkedFields.length === 0 + ? i18n.translate('xpack.maps.tooltipSelector.addLabelWithoutCount', { + defaultMessage: 'Add', + }) + : i18n.translate('xpack.maps.tooltipSelector.addLabelWithCount', { + defaultMessage: 'Add {count}', + values: { count: this.state.checkedFields.length }, + }); + + return ( + + + {(list, search) => ( +
+ {search} + {list} +
+ )} +
+ + + + + + {addLabel} + + + +
+ ); + } + + render() { + return ( + + {this._renderContent()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/components/add_tooltip_field_popover.test.js b/x-pack/plugins/maps/public/components/add_tooltip_field_popover.test.js new file mode 100644 index 0000000000000..fa9663cf1ed8c --- /dev/null +++ b/x-pack/plugins/maps/public/components/add_tooltip_field_popover.test.js @@ -0,0 +1,47 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; + +import { AddTooltipFieldPopover } from './add_tooltip_field_popover'; + +const defaultProps = { + fields: [ + { + name: 'prop1', + label: 'custom label for prop1', + type: 'string', + }, + { + name: 'prop2', + type: 'string', + }, + { + name: '@timestamp', + type: 'date', + }, + ], + selectedFields: [], + onSelect: () => {}, +}; + +test('Should render', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('Should remove selected fields from selectable', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/components/geometry_filter_form.js b/x-pack/plugins/maps/public/components/geometry_filter_form.js new file mode 100644 index 0000000000000..3308155caa3e4 --- /dev/null +++ b/x-pack/plugins/maps/public/components/geometry_filter_form.js @@ -0,0 +1,216 @@ +/* + * 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, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiForm, + EuiFormRow, + EuiSuperSelect, + EuiTextColor, + EuiText, + EuiFieldText, + EuiButton, + EuiSelect, + EuiSpacer, + EuiTextAlign, + EuiFormErrorText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS } from '../../common/constants'; +import { getEsSpatialRelationLabel } from '../../common/i18n_getters'; + +const GEO_FIELD_VALUE_DELIMITER = '/'; // `/` is not allowed in index pattern name so should not have collisions + +function createIndexGeoFieldName({ indexPatternTitle, geoFieldName }) { + return `${indexPatternTitle}${GEO_FIELD_VALUE_DELIMITER}${geoFieldName}`; +} + +function splitIndexGeoFieldName(value) { + const split = value.split(GEO_FIELD_VALUE_DELIMITER); + return { + indexPatternTitle: split[0], + geoFieldName: split[1], + }; +} + +export class GeometryFilterForm extends Component { + static propTypes = { + buttonLabel: PropTypes.string.isRequired, + geoFields: PropTypes.array.isRequired, + intitialGeometryLabel: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + isFilterGeometryClosed: PropTypes.bool, + errorMsg: PropTypes.string, + }; + + static defaultProps = { + isFilterGeometryClosed: true, + }; + + state = { + geoFieldTag: this.props.geoFields.length + ? createIndexGeoFieldName(this.props.geoFields[0]) + : '', + geometryLabel: this.props.intitialGeometryLabel, + relation: ES_SPATIAL_RELATIONS.INTERSECTS, + }; + + _getSelectedGeoField = () => { + if (!this.state.geoFieldTag) { + return null; + } + + const { indexPatternTitle, geoFieldName } = splitIndexGeoFieldName(this.state.geoFieldTag); + + return this.props.geoFields.find(option => { + return option.indexPatternTitle === indexPatternTitle && option.geoFieldName === geoFieldName; + }); + }; + + _onGeoFieldChange = selectedValue => { + this.setState({ geoFieldTag: selectedValue }); + }; + + _onGeometryLabelChange = e => { + this.setState({ + geometryLabel: e.target.value, + }); + }; + + _onRelationChange = e => { + this.setState({ + relation: e.target.value, + }); + }; + + _onSubmit = () => { + const geoField = this._getSelectedGeoField(); + this.props.onSubmit({ + geometryLabel: this.state.geometryLabel, + indexPatternId: geoField.indexPatternId, + geoFieldName: geoField.geoFieldName, + geoFieldType: geoField.geoFieldType, + relation: this.state.relation, + }); + }; + + _renderRelationInput() { + if (!this.state.geoFieldTag) { + return null; + } + + const { geoFieldType } = this._getSelectedGeoField(); + + // relationship only used when filtering geo_shape fields + if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT) { + return null; + } + + const spatialRelations = this.props.isFilterGeometryClosed + ? Object.values(ES_SPATIAL_RELATIONS) + : Object.values(ES_SPATIAL_RELATIONS).filter(relation => { + // can not filter by within relation when filtering geometry is not closed + return relation !== ES_SPATIAL_RELATIONS.WITHIN; + }); + const options = spatialRelations.map(relation => { + return { + value: relation, + text: getEsSpatialRelationLabel(relation), + }; + }); + + return ( + + + + ); + } + + render() { + const options = this.props.geoFields.map(({ indexPatternTitle, geoFieldName }) => { + return { + inputDisplay: ( + + + {indexPatternTitle} + +
+ {geoFieldName} +
+ ), + value: createIndexGeoFieldName({ indexPatternTitle, geoFieldName }), + }; + }); + let error; + if (this.props.errorMsg) { + error = {this.props.errorMsg}; + } + return ( + + + + + + + + + + {this._renderRelationInput()} + + + + {error} + + + + {this.props.buttonLabel} + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/components/geometry_filter_form.test.js b/x-pack/plugins/maps/public/components/geometry_filter_form.test.js new file mode 100644 index 0000000000000..d2d55f9ff36da --- /dev/null +++ b/x-pack/plugins/maps/public/components/geometry_filter_form.test.js @@ -0,0 +1,90 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; + +import { GeometryFilterForm } from './geometry_filter_form'; + +const defaultProps = { + buttonLabel: 'Create filter', + intitialGeometryLabel: 'My shape', + onSubmit: () => {}, +}; + +test('should not render relation select when geo field is geo_point', async () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); + +test('should render relation select when geo field is geo_shape', async () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); + +test('should not show "within" relation when filter geometry is not closed', async () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); + +test('should render error message', async () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/components/global_filter_checkbox.js b/x-pack/plugins/maps/public/components/global_filter_checkbox.js new file mode 100644 index 0000000000000..a8c2908e75424 --- /dev/null +++ b/x-pack/plugins/maps/public/components/global_filter_checkbox.js @@ -0,0 +1,26 @@ +/* + * 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 from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; + +export function GlobalFilterCheckbox({ applyGlobalQuery, label, setApplyGlobalQuery }) { + const onApplyGlobalQueryChange = event => { + setApplyGlobalQuery(event.target.checked); + }; + + return ( + + + + ); +} diff --git a/x-pack/plugins/maps/public/components/layer_toc_actions.js b/x-pack/plugins/maps/public/components/layer_toc_actions.js new file mode 100644 index 0000000000000..d79eda16037cb --- /dev/null +++ b/x-pack/plugins/maps/public/components/layer_toc_actions.js @@ -0,0 +1,197 @@ +/* + * 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, { Component, Fragment } from 'react'; + +import { EuiButtonEmpty, EuiPopover, EuiContextMenu, EuiIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export class LayerTocActions extends Component { + state = { + isPopoverOpen: false, + supportsFitToBounds: false, + }; + + componentDidMount() { + this._isMounted = true; + this._loadSupportsFitToBounds(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadSupportsFitToBounds() { + const supportsFitToBounds = await this.props.layer.supportsFitToBounds(); + if (this._isMounted) { + this.setState({ supportsFitToBounds }); + } + } + + _togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + _closePopover = () => { + this.setState(() => ({ + isPopoverOpen: false, + })); + }; + + _renderPopoverToggleButton() { + const { icon, tooltipContent, footnotes } = this.props.layer.getIconAndTooltipContent( + this.props.zoom, + this.props.isUsingSearch + ); + + const footnoteIcons = footnotes.map((footnote, index) => { + return ( + + {''} + {footnote.icon} + + ); + }); + const footnoteTooltipContent = footnotes.map((footnote, index) => { + return ( +
+ {footnote.icon} {footnote.message} +
+ ); + }); + + return ( + + {tooltipContent} + {footnoteTooltipContent} + + } + > + + {icon} + {this.props.displayName} {footnoteIcons} + + + ); + } + + _getActionsPanel() { + const actionItems = [ + { + name: i18n.translate('xpack.maps.layerTocActions.fitToDataTitle', { + defaultMessage: 'Fit to data', + }), + icon: , + 'data-test-subj': 'fitToBoundsButton', + toolTipContent: this.state.supportsFitToBounds + ? null + : i18n.translate('xpack.maps.layerTocActions.noFitSupportTooltip', { + defaultMessage: 'Layer does not support fit to data', + }), + disabled: !this.state.supportsFitToBounds, + onClick: () => { + this._closePopover(); + this.props.fitToBounds(); + }, + }, + { + name: this.props.layer.isVisible() + ? i18n.translate('xpack.maps.layerTocActions.hideLayerTitle', { + defaultMessage: 'Hide layer', + }) + : i18n.translate('xpack.maps.layerTocActions.showLayerTitle', { + defaultMessage: 'Show layer', + }), + icon: , + 'data-test-subj': 'layerVisibilityToggleButton', + onClick: () => { + this._closePopover(); + this.props.toggleVisible(); + }, + }, + ]; + + if (!this.props.isReadOnly) { + actionItems.push({ + name: i18n.translate('xpack.maps.layerTocActions.editLayerTitle', { + defaultMessage: 'Edit layer', + }), + icon: , + 'data-test-subj': 'editLayerButton', + onClick: () => { + this._closePopover(); + this.props.editLayer(); + }, + }); + actionItems.push({ + name: i18n.translate('xpack.maps.layerTocActions.cloneLayerTitle', { + defaultMessage: 'Clone layer', + }), + icon: , + 'data-test-subj': 'cloneLayerButton', + onClick: () => { + this._closePopover(); + this.props.cloneLayer(); + }, + }); + actionItems.push({ + name: i18n.translate('xpack.maps.layerTocActions.removeLayerTitle', { + defaultMessage: 'Remove layer', + }), + icon: , + 'data-test-subj': 'removeLayerButton', + onClick: () => { + this._closePopover(); + this.props.removeLayer(); + }, + }); + } + + return { + id: 0, + title: i18n.translate('xpack.maps.layerTocActions.layerActionsTitle', { + defaultMessage: 'Layer actions', + }), + items: actionItems, + }; + } + + render() { + return ( + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/components/layer_toc_actions.test.js b/x-pack/plugins/maps/public/components/layer_toc_actions.test.js new file mode 100644 index 0000000000000..c3a8f59c4c736 --- /dev/null +++ b/x-pack/plugins/maps/public/components/layer_toc_actions.test.js @@ -0,0 +1,80 @@ +/* + * 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 from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; + +import { LayerTocActions } from './layer_toc_actions'; + +let supportsFitToBounds; +const layerMock = { + supportsFitToBounds: () => { + return supportsFitToBounds; + }, + isVisible: () => { + return true; + }, + getIconAndTooltipContent: (zoom, isUsingSearch) => { + return { + icon: mockIcon, + tooltipContent: `simulated tooltip content at zoom: ${zoom}`, + footnotes: [ + { + icon: mockFootnoteIcon, + message: `simulated footnote at isUsingSearch: ${isUsingSearch}`, + }, + ], + }; + }, +}; + +const defaultProps = { + displayName: 'layer 1', + escapedDisplayName: 'layer1', + zoom: 0, + layer: layerMock, + isUsingSearch: true, +}; + +describe('LayerTocActions', () => { + beforeEach(() => { + supportsFitToBounds = true; + }); + + test('is rendered', async () => { + const component = shallowWithIntl(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should not show edit actions in read only mode', async () => { + const component = shallowWithIntl(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should disable fit to data when supportsFitToBounds is false', async () => { + supportsFitToBounds = false; + const component = shallowWithIntl(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/components/map_listing.js b/x-pack/plugins/maps/public/components/map_listing.js new file mode 100644 index 0000000000000..6fb5930e81a20 --- /dev/null +++ b/x-pack/plugins/maps/public/components/map_listing.js @@ -0,0 +1,436 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import { toastNotifications } from 'ui/notify'; +import { + EuiTitle, + EuiFieldSearch, + EuiBasicTable, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiSpacer, + EuiOverlayMask, + EuiConfirmModal, + EuiCallOut, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { addHelpMenuToAppChrome } from '../help_menu_util'; +import chrome from 'ui/chrome'; + +export const EMPTY_FILTER = ''; + +export class MapListing extends React.Component { + state = { + hasInitialFetchReturned: false, + isFetchingItems: false, + showDeleteModal: false, + showLimitError: false, + filter: EMPTY_FILTER, + items: [], + selectedIds: [], + page: 0, + perPage: 20, + }; + + UNSAFE_componentWillMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + this.debouncedFetch.cancel(); + } + + componentDidMount() { + this.fetchItems(); + addHelpMenuToAppChrome(chrome); + } + + debouncedFetch = _.debounce(async filter => { + const response = await this.props.find(filter); + + if (!this._isMounted) { + return; + } + + // We need this check to handle the case where search results come back in a different + // order than they were sent out. Only load results for the most recent search. + if (filter === this.state.filter) { + this.setState({ + hasInitialFetchReturned: true, + isFetchingItems: false, + items: response.hits, + totalItems: response.total, + showLimitError: response.total > this.props.listingLimit, + }); + } + }, 300); + + fetchItems = () => { + this.setState( + { + isFetchingItems: true, + }, + this.debouncedFetch.bind(null, this.state.filter) + ); + }; + + deleteSelectedItems = async () => { + try { + await this.props.delete(this.state.selectedIds); + } catch (error) { + toastNotifications.addDanger({ + title: i18n.translate('xpack.maps.mapListing.unableToDeleteToastTitle', { + defaultMessage: `Unable to delete map(s)`, + }), + text: `${error}`, + }); + } + this.fetchItems(); + this.setState({ + selectedIds: [], + }); + this.closeDeleteModal(); + }; + + closeDeleteModal = () => { + this.setState({ showDeleteModal: false }); + }; + + openDeleteModal = () => { + this.setState({ showDeleteModal: true }); + }; + + onTableChange = ({ page, sort = {} }) => { + const { index: pageIndex, size: pageSize } = page; + + let { field: sortField, direction: sortDirection } = sort; + + // 3rd sorting state that is not captured by sort - native order (no sort) + // when switching from desc to asc for the same field - use native order + if ( + this.state.sortField === sortField && + this.state.sortDirection === 'desc' && + sortDirection === 'asc' + ) { + sortField = null; + sortDirection = null; + } + + this.setState({ + page: pageIndex, + perPage: pageSize, + sortField, + sortDirection, + }); + }; + + getPageOfItems = () => { + // do not sort original list to preserve elasticsearch ranking order + const itemsCopy = this.state.items.slice(); + + if (this.state.sortField) { + itemsCopy.sort((a, b) => { + const fieldA = _.get(a, this.state.sortField, ''); + const fieldB = _.get(b, this.state.sortField, ''); + let order = 1; + if (this.state.sortDirection === 'desc') { + order = -1; + } + return order * fieldA.toLowerCase().localeCompare(fieldB.toLowerCase()); + }); + } + + // If begin is greater than the length of the sequence, an empty array is returned. + const startIndex = this.state.page * this.state.perPage; + // If end is greater than the length of the sequence, slice extracts through to the end of the sequence (arr.length). + const lastIndex = startIndex + this.state.perPage; + return itemsCopy.slice(startIndex, lastIndex); + }; + + hasNoItems() { + if (!this.state.isFetchingItems && this.state.items.length === 0 && !this.state.filter) { + return true; + } + + return false; + } + + renderConfirmDeleteModal() { + return ( + + +

+ +

+
+
+ ); + } + + renderListingLimitWarning() { + if (this.state.showLimitError) { + return ( + + +

+ + + + + . +

+
+ +
+ ); + } + } + + renderNoResultsMessage() { + if (this.state.isFetchingItems) { + return ''; + } + + if (this.hasNoItems()) { + return i18n.translate('xpack.maps.mapListing.noItemsDescription', { + defaultMessage: `Looks like you don't have any maps. Click the create button to create one.`, + }); + } + + return i18n.translate('xpack.maps.mapListing.noMatchDescription', { + defaultMessage: 'No items matched your search.', + }); + } + + renderSearchBar() { + let deleteBtn; + if (this.state.selectedIds.length > 0) { + deleteBtn = ( + + + + + + ); + } + + return ( + + {deleteBtn} + + { + this.setState( + { + filter: e.target.value, + }, + this.fetchItems + ); + }} + data-test-subj="searchFilter" + /> + + + ); + } + + renderTable() { + const tableColumns = [ + { + field: 'title', + name: i18n.translate('xpack.maps.mapListing.titleFieldTitle', { + defaultMessage: 'Title', + }), + sortable: true, + render: (field, record) => ( + + {field} + + ), + }, + { + field: 'description', + name: i18n.translate('xpack.maps.mapListing.descriptionFieldTitle', { + defaultMessage: 'Description', + }), + dataType: 'string', + sortable: true, + }, + ]; + const pagination = { + pageIndex: this.state.page, + pageSize: this.state.perPage, + totalItemCount: this.state.items.length, + pageSizeOptions: [10, 20, 50], + }; + + let selection = false; + if (!this.props.readOnly) { + selection = { + onSelectionChange: selection => { + this.setState({ + selectedIds: selection.map(item => { + return item.id; + }), + }); + }, + }; + } + + const sorting = {}; + if (this.state.sortField) { + sorting.sort = { + field: this.state.sortField, + direction: this.state.sortDirection, + }; + } + const items = this.state.items.length === 0 ? [] : this.getPageOfItems(); + + return ( + + ); + } + + renderListing() { + let createButton; + if (!this.props.readOnly) { + createButton = ( + + + + ); + } + return ( + + {this.state.showDeleteModal && this.renderConfirmDeleteModal()} + + + + +

+ +

+
+
+ + {createButton} +
+ + + + {this.renderListingLimitWarning()} + + {this.renderSearchBar()} + + + + {this.renderTable()} +
+ ); + } + + renderPageContent() { + if (!this.state.hasInitialFetchReturned) { + return; + } + + return {this.renderListing()}; + } + + render() { + return ( + + {this.renderPageContent()} + + ); + } +} + +MapListing.propTypes = { + readOnly: PropTypes.bool.isRequired, + find: PropTypes.func.isRequired, + delete: PropTypes.func.isRequired, + listingLimit: PropTypes.number.isRequired, +}; diff --git a/x-pack/plugins/maps/public/components/metric_editor.js b/x-pack/plugins/maps/public/components/metric_editor.js new file mode 100644 index 0000000000000..e60c2ac0dd7ab --- /dev/null +++ b/x-pack/plugins/maps/public/components/metric_editor.js @@ -0,0 +1,139 @@ +/* + * 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 PropTypes from 'prop-types'; +import { i18n } from '@kbn/i18n'; + +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; + +import { MetricSelect, METRIC_AGGREGATION_VALUES } from './metric_select'; +import { SingleFieldSelect } from './single_field_select'; +import { METRIC_TYPE } from '../../common/constants'; + +function filterFieldsForAgg(fields, aggType) { + if (!fields) { + return []; + } + + if (aggType === METRIC_TYPE.UNIQUE_COUNT) { + return fields.filter(field => { + return field.aggregatable; + }); + } + + return fields.filter(field => { + return field.aggregatable && field.type === 'number'; + }); +} + +export function MetricEditor({ fields, metricsFilter, metric, onChange, removeButton }) { + const onAggChange = metricAggregationType => { + const newMetricProps = { + ...metric, + type: metricAggregationType, + }; + + // unset field when new agg type does not support currently selected field. + if (metric.field && metricAggregationType !== METRIC_TYPE.COUNT) { + const fieldsForNewAggType = filterFieldsForAgg(fields, metricAggregationType); + const found = fieldsForNewAggType.find(field => { + return field.name === metric.field; + }); + if (!found) { + newMetricProps.field = undefined; + } + } + + onChange(newMetricProps); + }; + const onFieldChange = fieldName => { + onChange({ + ...metric, + field: fieldName, + }); + }; + const onLabelChange = e => { + onChange({ + ...metric, + label: e.target.value, + }); + }; + + let fieldSelect; + if (metric.type && metric.type !== METRIC_TYPE.COUNT) { + fieldSelect = ( + + + + ); + } + + let labelInput; + if (metric.type) { + labelInput = ( + + + + ); + } + + return ( + + + + + + {fieldSelect} + {labelInput} + {removeButton} + + ); +} + +MetricEditor.propTypes = { + metric: PropTypes.shape({ + type: PropTypes.oneOf(METRIC_AGGREGATION_VALUES), + field: PropTypes.string, + label: PropTypes.string, + }), + fields: PropTypes.array, + onChange: PropTypes.func.isRequired, + metricsFilter: PropTypes.func, +}; diff --git a/x-pack/plugins/maps/public/components/metric_select.js b/x-pack/plugins/maps/public/components/metric_select.js new file mode 100644 index 0000000000000..d11ecdf94cccc --- /dev/null +++ b/x-pack/plugins/maps/public/components/metric_select.js @@ -0,0 +1,89 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox } from '@elastic/eui'; +import { METRIC_TYPE } from '../../common/constants'; + +const AGG_OPTIONS = [ + { + label: i18n.translate('xpack.maps.metricSelect.averageDropDownOptionLabel', { + defaultMessage: 'Average', + }), + value: METRIC_TYPE.AVG, + }, + { + label: i18n.translate('xpack.maps.metricSelect.countDropDownOptionLabel', { + defaultMessage: 'Count', + }), + value: METRIC_TYPE.COUNT, + }, + { + label: i18n.translate('xpack.maps.metricSelect.maxDropDownOptionLabel', { + defaultMessage: 'Max', + }), + value: METRIC_TYPE.MAX, + }, + { + label: i18n.translate('xpack.maps.metricSelect.minDropDownOptionLabel', { + defaultMessage: 'Min', + }), + value: METRIC_TYPE.MIN, + }, + { + label: i18n.translate('xpack.maps.metricSelect.sumDropDownOptionLabel', { + defaultMessage: 'Sum', + }), + value: METRIC_TYPE.SUM, + }, + { + label: i18n.translate('xpack.maps.metricSelect.cardinalityDropDownOptionLabel', { + defaultMessage: 'Unique count', + }), + value: METRIC_TYPE.UNIQUE_COUNT, + }, +]; + +export const METRIC_AGGREGATION_VALUES = AGG_OPTIONS.map(({ value }) => { + return value; +}); + +export function MetricSelect({ value, onChange, metricsFilter, ...rest }) { + function onAggChange(selectedOptions) { + if (selectedOptions.length === 0) { + return; + } + + const aggType = selectedOptions[0].value; + onChange(aggType); + } + + const options = metricsFilter ? AGG_OPTIONS.filter(metricsFilter) : AGG_OPTIONS; + + return ( + { + return value === option.value; + })} + onChange={onAggChange} + {...rest} + /> + ); +} + +MetricSelect.propTypes = { + metricsFilter: PropTypes.func, + value: PropTypes.oneOf(METRIC_AGGREGATION_VALUES), + onChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/maps/public/components/metrics_editor.js b/x-pack/plugins/maps/public/components/metrics_editor.js new file mode 100644 index 0000000000000..15ba43a9ae6ba --- /dev/null +++ b/x-pack/plugins/maps/public/components/metrics_editor.js @@ -0,0 +1,105 @@ +/* + * 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 PropTypes from 'prop-types'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButtonEmpty, EuiSpacer, EuiTextAlign } from '@elastic/eui'; +import { MetricEditor } from './metric_editor'; +import { METRIC_TYPE } from '../../common/constants'; + +export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics, metricsFilter }) { + function renderMetrics() { + return metrics.map((metric, index) => { + const onMetricChange = metric => { + onChange([...metrics.slice(0, index), metric, ...metrics.slice(index + 1)]); + }; + + const onRemove = () => { + onChange([...metrics.slice(0, index), ...metrics.slice(index + 1)]); + }; + + let removeButton; + if (index > 0) { + removeButton = ( +
+ + + +
+ ); + } + return ( +
+ +
+ ); + }); + } + + function addMetric() { + onChange([...metrics, {}]); + } + + function renderAddMetricButton() { + if (!allowMultipleMetrics) { + return null; + } + + return ( + <> + + + + + + + + ); + } + + return ( + +
{renderMetrics()}
+ + {renderAddMetricButton()} +
+ ); +} + +MetricsEditor.propTypes = { + metrics: PropTypes.array, + fields: PropTypes.array, + onChange: PropTypes.func.isRequired, + allowMultipleMetrics: PropTypes.bool, + metricsFilter: PropTypes.func, +}; + +MetricsEditor.defaultProps = { + metrics: [{ type: METRIC_TYPE.COUNT }], + allowMultipleMetrics: true, +}; diff --git a/x-pack/plugins/maps/public/components/no_index_pattern_callout.js b/x-pack/plugins/maps/public/components/no_index_pattern_callout.js new file mode 100644 index 0000000000000..3266f13155ca7 --- /dev/null +++ b/x-pack/plugins/maps/public/components/no_index_pattern_callout.js @@ -0,0 +1,52 @@ +/* + * 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 chrome from 'ui/chrome'; + +import React from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export function NoIndexPatternCallout() { + return ( + +

+ + + + + +

+

+ + + + +

+
+ ); +} diff --git a/x-pack/plugins/maps/public/components/single_field_select.js b/x-pack/plugins/maps/public/components/single_field_select.js new file mode 100644 index 0000000000000..43b99f474f60e --- /dev/null +++ b/x-pack/plugins/maps/public/components/single_field_select.js @@ -0,0 +1,65 @@ +/* + * 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 _ from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import { EuiComboBox, EuiHighlight } from '@elastic/eui'; +import { FieldIcon } from '../../../../../src/plugins/kibana_react/public'; + +function fieldsToOptions(fields) { + if (!fields) { + return []; + } + + return fields + .map(field => { + return { + value: field, + label: 'label' in field ? field.label : field.name, + }; + }) + .sort((a, b) => { + return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); + }); +} + +function renderOption(option, searchValue, contentClassName) { + return ( + + +   + {option.label} + + ); +} + +export function SingleFieldSelect({ fields, onChange, value, placeholder, ...rest }) { + const onSelection = selectedOptions => { + onChange(_.get(selectedOptions, '0.value.name')); + }; + + return ( + + ); +} + +SingleFieldSelect.propTypes = { + placeholder: PropTypes.string, + fields: PropTypes.array, + onChange: PropTypes.func.isRequired, + value: PropTypes.string, // fieldName +}; diff --git a/x-pack/plugins/maps/public/components/tooltip_selector.js b/x-pack/plugins/maps/public/components/tooltip_selector.js new file mode 100644 index 0000000000000..953b711cef6c7 --- /dev/null +++ b/x-pack/plugins/maps/public/components/tooltip_selector.js @@ -0,0 +1,229 @@ +/* + * 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, { Component } from 'react'; +import classNames from 'classnames'; +import { + EuiButtonIcon, + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + EuiText, + EuiTextAlign, + EuiSpacer, +} from '@elastic/eui'; +import { AddTooltipFieldPopover } from './add_tooltip_field_popover'; +import { i18n } from '@kbn/i18n'; + +// TODO import reorder from EUI once its exposed as service +// https://github.com/elastic/eui/issues/2372 +const reorder = (list, startIndex, endIndex) => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +}; + +const getProps = async field => { + return new Promise(async (resolve, reject) => { + try { + const label = await field.getLabel(); + const type = await field.getDataType(); + resolve({ + label: label, + type: type, + name: field.getName(), + }); + } catch (e) { + reject(e); + } + }); +}; + +export class TooltipSelector extends Component { + state = { + fieldProps: [], + selectedFieldProps: [], + }; + + constructor() { + super(); + this._isMounted = false; + this._previousFields = null; + this._previousSelectedTooltips = null; + } + + componentDidMount() { + this._isMounted = true; + this._loadFieldProps(); + this._loadTooltipFieldProps(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidUpdate() { + this._loadTooltipFieldProps(); + this._loadFieldProps(); + } + + async _loadTooltipFieldProps() { + if (!this.props.tooltipFields || this.props.tooltipFields === this._previousSelectedTooltips) { + return; + } + + this._previousSelectedTooltips = this.props.tooltipFields; + const selectedProps = this.props.tooltipFields.map(getProps); + const selectedFieldProps = await Promise.all(selectedProps); + if (this._isMounted) { + this.setState({ selectedFieldProps }); + } + } + + async _loadFieldProps() { + if (!this.props.fields || this.props.fields === this._previousFields) { + return; + } + + this._previousFields = this.props.fields; + const props = this.props.fields.map(getProps); + const fieldProps = await Promise.all(props); + if (this._isMounted) { + this.setState({ fieldProps }); + } + } + + _getPropertyLabel = propertyName => { + if (!this.state.fieldProps.length) { + return propertyName; + } + const prop = this.state.fieldProps.find(field => { + return field.name === propertyName; + }); + return prop.label ? prop.label : propertyName; + }; + + _getTooltipProperties() { + return this.props.tooltipFields.map(field => field.getName()); + } + + _onAdd = properties => { + if (!this.props.tooltipFields) { + this.props.onChange([...properties]); + } else { + const existingProperties = this._getTooltipProperties(); + this.props.onChange([...existingProperties, ...properties]); + } + }; + + _removeProperty = index => { + if (!this.props.tooltipFields) { + this.props.onChange([]); + } else { + const tooltipProperties = this._getTooltipProperties(); + tooltipProperties.splice(index, 1); + this.props.onChange(tooltipProperties); + } + }; + + _onDragEnd = ({ source, destination }) => { + // Dragging item out of EuiDroppable results in destination of null + if (!destination) { + return; + } + + this.props.onChange(reorder(this._getTooltipProperties(), source.index, destination.index)); + }; + + _renderProperties() { + if (!this.state.selectedFieldProps.length) { + return null; + } + + return ( + + + {(provided, snapshot) => + this.state.selectedFieldProps.map((field, idx) => ( + + {(provided, state) => ( +
+ + {this._getPropertyLabel(field.name)} + +
+ + +
+
+ )} +
+ )) + } +
+
+ ); + } + + render() { + return ( +
+ {this._renderProperties()} + + + + + + +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/components/tooltip_selector.test.js b/x-pack/plugins/maps/public/components/tooltip_selector.test.js new file mode 100644 index 0000000000000..1a83f4a98bb6f --- /dev/null +++ b/x-pack/plugins/maps/public/components/tooltip_selector.test.js @@ -0,0 +1,58 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; + +import { TooltipSelector } from './tooltip_selector'; + +class MockField { + constructor({ name, label, type }) { + this._name = name; + this._label = label; + this._type = type; + } + + getName() { + return this._name; + } + + async getLabel() { + return this._label || 'foobar_label'; + } + + async getDataType() { + return this._type || 'foobar_type'; + } +} + +const defaultProps = { + tooltipFields: [new MockField({ name: 'iso2' })], + onChange: () => {}, + fields: [ + new MockField({ + name: 'iso2', + label: 'ISO 3166-1 alpha-2 code', + type: 'string', + }), + new MockField({ + name: 'iso3', + type: 'string', + }), + ], +}; + +describe('TooltipSelector', () => { + test('should render component', async () => { + const component = shallow(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/components/validated_range.js b/x-pack/plugins/maps/public/components/validated_range.js new file mode 100644 index 0000000000000..f21dae7feb9b2 --- /dev/null +++ b/x-pack/plugins/maps/public/components/validated_range.js @@ -0,0 +1,94 @@ +/* + * 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 from 'react'; + +import { EuiRange, EuiFormErrorText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +function isWithinRange(min, max, value) { + if (value >= min && value <= max) { + return true; + } + + return false; +} + +// TODO move to EUI +// Wrapper around EuiRange that ensures onChange callback is only called when value is number and within min/max +export class ValidatedRange extends React.Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.value !== prevState.prevValue) { + return { + value: nextProps.value, + prevValue: nextProps.value, + isValid: isWithinRange(nextProps.min, nextProps.max, nextProps.value), + }; + } + + return null; + } + + _onRangeChange = e => { + const sanitizedValue = parseFloat(e.target.value, 10); + let newValue = isNaN(sanitizedValue) ? '' : sanitizedValue; + // work around for https://github.com/elastic/eui/issues/1458 + // TODO remove once above EUI issue is resolved + newValue = Number(newValue); + + const isValid = isWithinRange(this.props.min, this.props.max, newValue) ? true : false; + + this.setState({ + value: newValue, + isValid, + }); + + if (isValid) { + this.props.onChange(newValue); + } + }; + + render() { + const { + max, + min, + value, // eslint-disable-line no-unused-vars + onChange, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + + const rangeInput = ( + + ); + + if (!this.state.isValid) { + // Wrap in div so single child is returned. + // common pattern is to put ValidateRange as a child to EuiFormRow and EuiFormRow expects a single child + return ( +
+ {rangeInput} + + + +
+ ); + } + + return rangeInput; + } +} diff --git a/x-pack/plugins/maps/public/components/validated_range.test.js b/x-pack/plugins/maps/public/components/validated_range.test.js new file mode 100644 index 0000000000000..92889681dbc50 --- /dev/null +++ b/x-pack/plugins/maps/public/components/validated_range.test.js @@ -0,0 +1,36 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; + +import { ValidatedRange } from './validated_range'; + +const MAX = 10; +const defaultProps = { + max: MAX, + min: 0, + value: 3, + onChange: () => {}, +}; + +test('Should render slider', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('Should pass slider props to slider', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('Should display error message when value is outside of range', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/_index.scss b/x-pack/plugins/maps/public/connected_components/_index.scss new file mode 100644 index 0000000000000..99a2e222ea6c1 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/_index.scss @@ -0,0 +1,6 @@ +@import './gis_map/gis_map'; +@import './layer_addpanel/source_select/index'; +@import './layer_panel/index'; +@import './widget_overlay/index'; +@import './toolbar_overlay/index'; +@import './map/features_tooltip/index'; diff --git a/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss b/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss new file mode 100644 index 0000000000000..85168d970c6de --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss @@ -0,0 +1,19 @@ +.mapMapWrapper { + background-color: $euiColorEmptyShade; + position: relative; +} + +.mapMapLayerPanel { + background-color: $euiPageBackgroundColor; + width: 0; + overflow: hidden; + + > * { + width: $euiSizeXXL * 11; + } + + &-isVisible { + width: $euiSizeXXL * 11; + transition: width $euiAnimSpeedNormal $euiAnimSlightResistance; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/gis_map/index.js b/x-pack/plugins/maps/public/connected_components/gis_map/index.js new file mode 100644 index 0000000000000..ceb0a6ea9f922 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/gis_map/index.js @@ -0,0 +1,45 @@ +/* + * 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 { GisMap } from './view'; +import { FLYOUT_STATE } from '../../reducers/ui'; +import { exitFullScreen } from '../../actions/ui_actions'; +import { getFlyoutDisplay, getIsFullScreen } from '../../selectors/ui_selectors'; +import { triggerRefreshTimer, cancelAllInFlightRequests } from '../../actions/map_actions'; +import { + areLayersLoaded, + getRefreshConfig, + getMapInitError, + getQueryableUniqueIndexPatternIds, + isToolbarOverlayHidden, +} from '../../selectors/map_selectors'; + +function mapStateToProps(state = {}) { + const flyoutDisplay = getFlyoutDisplay(state); + return { + areLayersLoaded: areLayersLoaded(state), + layerDetailsVisible: flyoutDisplay === FLYOUT_STATE.LAYER_PANEL, + addLayerVisible: flyoutDisplay === FLYOUT_STATE.ADD_LAYER_WIZARD, + noFlyoutVisible: flyoutDisplay === FLYOUT_STATE.NONE, + isFullScreen: getIsFullScreen(state), + refreshConfig: getRefreshConfig(state), + mapInitError: getMapInitError(state), + indexPatternIds: getQueryableUniqueIndexPatternIds(state), + hideToolbarOverlay: isToolbarOverlayHidden(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + triggerRefreshTimer: () => dispatch(triggerRefreshTimer()), + exitFullScreen: () => dispatch(exitFullScreen()), + cancelAllInFlightRequests: () => dispatch(cancelAllInFlightRequests()), + }; +} + +const connectedGisMap = connect(mapStateToProps, mapDispatchToProps)(GisMap); +export { connectedGisMap as GisMap }; diff --git a/x-pack/plugins/maps/public/connected_components/gis_map/view.js b/x-pack/plugins/maps/public/connected_components/gis_map/view.js new file mode 100644 index 0000000000000..0c16cb9a0ae17 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/gis_map/view.js @@ -0,0 +1,222 @@ +/* + * 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 _ from 'lodash'; +import React, { Component } from 'react'; +import { MBMapContainer } from '../map/mb'; +import { WidgetOverlay } from '../widget_overlay/index'; +import { ToolbarOverlay } from '../toolbar_overlay/index'; +import { LayerPanel } from '../layer_panel/index'; +import { AddLayerPanel } from '../layer_addpanel/index'; +import { EuiFlexGroup, EuiFlexItem, EuiCallOut } from '@elastic/eui'; +import { getIndexPatternsFromIds } from '../../index_pattern_util'; +import { ES_GEO_FIELD_TYPE } from '../../../common/constants'; +import { isNestedField } from '../../../../../../src/plugins/data/public'; +import { i18n } from '@kbn/i18n'; +import uuid from 'uuid/v4'; + +// TODO +import { ExitFullScreenButton } from 'ui/exit_full_screen'; + +const RENDER_COMPLETE_EVENT = 'renderComplete'; + +export class GisMap extends Component { + state = { + isInitialLoadRenderTimeoutComplete: false, + domId: uuid(), + geoFields: [], + }; + + componentDidMount() { + this._isMounted = true; + this._isInitalLoadRenderTimerStarted = false; + this._setRefreshTimer(); + } + + componentDidUpdate() { + this._setRefreshTimer(); + if (this.props.areLayersLoaded && !this._isInitalLoadRenderTimerStarted) { + this._isInitalLoadRenderTimerStarted = true; + this._startInitialLoadRenderTimer(); + } + + if (!!this.props.addFilters) { + this._loadGeoFields(this.props.indexPatternIds); + } + } + + componentWillUnmount() { + this._isMounted = false; + this._clearRefreshTimer(); + this.props.cancelAllInFlightRequests(); + } + + // Reporting uses both a `data-render-complete` attribute and a DOM event listener to determine + // if a visualization is done loading. The process roughly is: + // - See if the `data-render-complete` attribute is "true". If so we're done! + // - If it's not, then reporting injects a listener into the browser for a custom "renderComplete" event. + // - When that event is fired, we snapshot the viz and move on. + // Failure to not have the dom attribute, or custom event, will timeout the job. + // See x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts for more. + _onInitialLoadRenderComplete = () => { + const el = document.querySelector(`[data-dom-id="${this.state.domId}"]`); + + if (el) { + el.dispatchEvent(new CustomEvent(RENDER_COMPLETE_EVENT, { bubbles: true })); + } + }; + + _loadGeoFields = async nextIndexPatternIds => { + if (_.isEqual(nextIndexPatternIds, this._prevIndexPatternIds)) { + // all ready loaded index pattern ids + return; + } + + this._prevIndexPatternIds = nextIndexPatternIds; + + const geoFields = []; + try { + const indexPatterns = await getIndexPatternsFromIds(nextIndexPatternIds); + indexPatterns.forEach(indexPattern => { + indexPattern.fields.forEach(field => { + if ( + !isNestedField(field) && + (field.type === ES_GEO_FIELD_TYPE.GEO_POINT || + field.type === ES_GEO_FIELD_TYPE.GEO_SHAPE) + ) { + geoFields.push({ + geoFieldName: field.name, + geoFieldType: field.type, + indexPatternTitle: indexPattern.title, + indexPatternId: indexPattern.id, + }); + } + }); + }); + } catch (e) { + // swallow errors. + // the Layer-TOC will indicate which layers are disfunctional on a per-layer basis + } + + if (!this._isMounted) { + return; + } + + this.setState({ geoFields }); + }; + + _setRefreshTimer = () => { + const { isPaused, interval } = this.props.refreshConfig; + + if (this.isPaused === isPaused && this.interval === interval) { + // refreshConfig is the same, nothing to do + return; + } + + this.isPaused = isPaused; + this.interval = interval; + + this._clearRefreshTimer(); + + if (!isPaused && interval > 0) { + this.refreshTimerId = setInterval(() => { + this.props.triggerRefreshTimer(); + }, interval); + } + }; + + _clearRefreshTimer = () => { + if (this.refreshTimerId) { + clearInterval(this.refreshTimerId); + } + }; + + // Mapbox does not provide any feedback when rendering is complete. + // Temporary solution is just to wait set period of time after data has loaded. + _startInitialLoadRenderTimer = () => { + setTimeout(() => { + if (this._isMounted) { + this.setState({ isInitialLoadRenderTimeoutComplete: true }); + this._onInitialLoadRenderComplete(); + } + }, 5000); + }; + + render() { + const { + addFilters, + layerDetailsVisible, + addLayerVisible, + noFlyoutVisible, + isFullScreen, + exitFullScreen, + mapInitError, + renderTooltipContent, + } = this.props; + + const { domId } = this.state; + + if (mapInitError) { + return ( +
+ +

{mapInitError}

+
+
+ ); + } + + let currentPanel; + let currentPanelClassName; + if (noFlyoutVisible) { + currentPanel = null; + } else if (addLayerVisible) { + currentPanelClassName = 'mapMapLayerPanel-isVisible'; + currentPanel = ; + } else if (layerDetailsVisible) { + currentPanelClassName = 'mapMapLayerPanel-isVisible'; + currentPanel = ; + } + + let exitFullScreenButton; + if (isFullScreen) { + exitFullScreenButton = ; + } + return ( + + + + {!this.props.hideToolbarOverlay && ( + + )} + + + + + {currentPanel} + + + {exitFullScreenButton} + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/index.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/index.js new file mode 100644 index 0000000000000..757886a9b3a7d --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/index.js @@ -0,0 +1,27 @@ +/* + * 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 { FlyoutFooter } from './view'; +import { getSelectedLayer } from '../../../selectors/map_selectors'; +import { clearTransientLayerStateAndCloseFlyout } from '../../../actions/map_actions'; + +function mapStateToProps(state = {}) { + const selectedLayer = getSelectedLayer(state); + return { + hasLayerSelected: !!selectedLayer, + isLoading: selectedLayer && selectedLayer.isLayerLoading(), + }; +} + +function mapDispatchToProps(dispatch) { + return { + closeFlyout: () => dispatch(clearTransientLayerStateAndCloseFlyout()), + }; +} + +const connectedFlyOut = connect(mapStateToProps, mapDispatchToProps)(FlyoutFooter); +export { connectedFlyOut as FlyoutFooter }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/view.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/view.js new file mode 100644 index 0000000000000..7eb148a36abf1 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/view.js @@ -0,0 +1,55 @@ +/* + * 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 from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutFooter, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const FlyoutFooter = ({ + onClick, + showNextButton, + disableNextButton, + nextButtonText, + closeFlyout, + hasLayerSelected, + isLoading, +}) => { + const nextButton = showNextButton ? ( + + {nextButtonText} + + ) : null; + + return ( + + + + + + + + {nextButton} + + + ); +}; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/index.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/index.js new file mode 100644 index 0000000000000..8d0dd0c266f28 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/index.js @@ -0,0 +1,29 @@ +/* + * 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 { ImportEditor } from './view'; +import { getInspectorAdapters } from '../../../reducers/non_serializable_instances'; +import { INDEXING_STAGE } from '../../../reducers/ui'; +import { updateIndexingStage } from '../../../actions/ui_actions'; +import { getIndexingStage } from '../../../selectors/ui_selectors'; + +function mapStateToProps(state = {}) { + return { + inspectorAdapters: getInspectorAdapters(state), + isIndexingTriggered: getIndexingStage(state) === INDEXING_STAGE.TRIGGERED, + }; +} + +const mapDispatchToProps = { + onIndexReady: indexReady => + indexReady ? updateIndexingStage(INDEXING_STAGE.READY) : updateIndexingStage(null), + importSuccessHandler: () => updateIndexingStage(INDEXING_STAGE.SUCCESS), + importErrorHandler: () => updateIndexingStage(INDEXING_STAGE.ERROR), +}; + +const connectedFlyOut = connect(mapStateToProps, mapDispatchToProps)(ImportEditor); +export { connectedFlyOut as ImportEditor }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/view.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/view.js new file mode 100644 index 0000000000000..e9ef38e17b188 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/import_editor/view.js @@ -0,0 +1,52 @@ +/* + * 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 { GeojsonFileSource } from '../../../layers/sources/client_file_source'; +import { EuiSpacer, EuiPanel, EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const ImportEditor = ({ clearSource, isIndexingTriggered, ...props }) => { + const editorProperties = getEditorProperties({ isIndexingTriggered, ...props }); + const editor = GeojsonFileSource.renderEditor(editorProperties); + return ( + + {isIndexingTriggered ? null : ( + + + + + + + )} + {editor} + + ); +}; + +function getEditorProperties({ + inspectorAdapters, + onRemove, + viewLayer, + isIndexingTriggered, + onIndexReady, + importSuccessHandler, + importErrorHandler, +}) { + return { + onPreviewSource: viewLayer, + inspectorAdapters, + onRemove, + importSuccessHandler, + importErrorHandler, + isIndexingTriggered, + addAndViewSource: viewLayer, + onIndexReady, + }; +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/index.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/index.js new file mode 100644 index 0000000000000..b3f8ee727dff5 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/index.js @@ -0,0 +1,58 @@ +/* + * 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 { AddLayerPanel } from './view'; +import { FLYOUT_STATE, INDEXING_STAGE } from '../../reducers/ui'; +import { updateFlyout, updateIndexingStage } from '../../actions/ui_actions'; +import { getFlyoutDisplay, getIndexingStage } from '../../selectors/ui_selectors'; +import { getMapColors } from '../../selectors/map_selectors'; +import { getInspectorAdapters } from '../../reducers/non_serializable_instances'; +import { + setTransientLayer, + addLayer, + setSelectedLayer, + removeTransientLayer, +} from '../../actions/map_actions'; + +function mapStateToProps(state = {}) { + const indexingStage = getIndexingStage(state); + return { + inspectorAdapters: getInspectorAdapters(state), + flyoutVisible: getFlyoutDisplay(state) !== FLYOUT_STATE.NONE, + mapColors: getMapColors(state), + isIndexingTriggered: indexingStage === INDEXING_STAGE.TRIGGERED, + isIndexingSuccess: indexingStage === INDEXING_STAGE.SUCCESS, + isIndexingReady: indexingStage === INDEXING_STAGE.READY, + }; +} + +function mapDispatchToProps(dispatch) { + return { + viewLayer: async layer => { + await dispatch(setSelectedLayer(null)); + await dispatch(removeTransientLayer()); + dispatch(addLayer(layer.toLayerDescriptor())); + dispatch(setSelectedLayer(layer.getId())); + dispatch(setTransientLayer(layer.getId())); + }, + removeTransientLayer: () => { + dispatch(setSelectedLayer(null)); + dispatch(removeTransientLayer()); + }, + selectLayerAndAdd: () => { + dispatch(setTransientLayer(null)); + dispatch(updateFlyout(FLYOUT_STATE.LAYER_PANEL)); + }, + setIndexingTriggered: () => dispatch(updateIndexingStage(INDEXING_STAGE.TRIGGERED)), + resetIndexing: () => dispatch(updateIndexingStage(null)), + }; +} + +const connectedFlyOut = connect(mapStateToProps, mapDispatchToProps, null, { withRef: true })( + AddLayerPanel +); +export { connectedFlyOut as AddLayerPanel }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/index.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/index.js new file mode 100644 index 0000000000000..51ed19d1c77d1 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/index.js @@ -0,0 +1,18 @@ +/* + * 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 { SourceEditor } from './view'; +import { getInspectorAdapters } from '../../../reducers/non_serializable_instances'; + +function mapStateToProps(state = {}) { + return { + inspectorAdapters: getInspectorAdapters(state), + }; +} + +const connectedFlyOut = connect(mapStateToProps)(SourceEditor); +export { connectedFlyOut as SourceEditor }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/view.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/view.js new file mode 100644 index 0000000000000..45c508e0d5889 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_editor/view.js @@ -0,0 +1,46 @@ +/* + * 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 { ALL_SOURCES } from '../../../layers/sources/all_sources'; +import { EuiSpacer, EuiPanel, EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const SourceEditor = ({ + clearSource, + sourceType, + isIndexingTriggered, + inspectorAdapters, + previewLayer, +}) => { + const editorProperties = { + onPreviewSource: previewLayer, + inspectorAdapters, + }; + const Source = ALL_SOURCES.find(Source => { + return Source.type === sourceType; + }); + if (!Source) { + throw new Error(`Unexpected source type: ${sourceType}`); + } + const editor = Source.renderEditor(editorProperties); + return ( + + {isIndexingTriggered ? null : ( + + + + + + + )} + {editor} + + ); +}; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_index.scss b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_index.scss new file mode 100644 index 0000000000000..7fe1396fcca16 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_index.scss @@ -0,0 +1 @@ +@import './source_select'; diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_source_select.scss b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_source_select.scss new file mode 100644 index 0000000000000..4e60b8d4b7c4b --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/_source_select.scss @@ -0,0 +1,12 @@ +.mapLayerAddpanel__card { + // EUITODO: Fix horizontal layout so it works with any size icon + .euiCard__content { + // sass-lint:disable-block no-important + padding-top: 0 !important; + } + + .euiCard__top + .euiCard__content { + // sass-lint:disable-block no-important + padding-top: 2px !important; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js new file mode 100644 index 0000000000000..574a57b1041a0 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/source_select/source_select.js @@ -0,0 +1,50 @@ +/* + * 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 { ALL_SOURCES } from '../../../layers/sources/all_sources'; +import { EuiTitle, EuiSpacer, EuiCard, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import _ from 'lodash'; + +export function SourceSelect({ updateSourceSelection }) { + const sourceCards = ALL_SOURCES.map(Source => { + const icon = Source.icon ? : null; + + const sourceTitle = Source.title; + + return ( + + + + updateSourceSelection({ type: Source.type, isIndexingSource: Source.isIndexingSource }) + } + description={Source.description} + layout="horizontal" + data-test-subj={_.camelCase(Source.title)} + /> + + ); + }); + + return ( + + +

+ +

+
+ {sourceCards} +
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js b/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js new file mode 100644 index 0000000000000..425cc1cae3649 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_addpanel/view.js @@ -0,0 +1,181 @@ +/* + * 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, { Component } from 'react'; +import { SourceSelect } from './source_select/source_select'; +import { FlyoutFooter } from './flyout_footer'; +import { SourceEditor } from './source_editor'; +import { ImportEditor } from './import_editor'; +import { EuiFlexGroup, EuiTitle, EuiFlyoutHeader } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export class AddLayerPanel extends Component { + state = { + sourceType: null, + layer: null, + importView: false, + layerImportAddReady: false, + }; + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidUpdate() { + if (!this.state.layerImportAddReady && this.props.isIndexingSuccess) { + this.setState({ layerImportAddReady: true }); + } + } + + _getPanelDescription() { + const { sourceType, importView, layerImportAddReady } = this.state; + let panelDescription; + if (!sourceType) { + panelDescription = i18n.translate('xpack.maps.addLayerPanel.selectSource', { + defaultMessage: 'Select source', + }); + } else if (layerImportAddReady || !importView) { + panelDescription = i18n.translate('xpack.maps.addLayerPanel.addLayer', { + defaultMessage: 'Add layer', + }); + } else { + panelDescription = i18n.translate('xpack.maps.addLayerPanel.importFile', { + defaultMessage: 'Import file', + }); + } + return panelDescription; + } + + _viewLayer = async (source, options = {}) => { + if (!this._isMounted) { + return; + } + if (!source) { + this.setState({ layer: null }); + this.props.removeTransientLayer(); + return; + } + + const style = + this.state.layer && this.state.layer.getCurrentStyle() + ? this.state.layer.getCurrentStyle().getDescriptor() + : null; + const layerInitProps = { + ...options, + style: style, + }; + const newLayer = source.createDefaultLayer(layerInitProps, this.props.mapColors); + if (!this._isMounted) { + return; + } + this.setState({ layer: newLayer }, () => this.props.viewLayer(this.state.layer)); + }; + + _clearLayerData = ({ keepSourceType = false }) => { + if (!this._isMounted) { + return; + } + + this.setState({ + layer: null, + ...(!keepSourceType ? { sourceType: null, importView: false } : {}), + }); + this.props.removeTransientLayer(); + }; + + _onSourceSelectionChange = ({ type, isIndexingSource }) => { + this.setState({ sourceType: type, importView: isIndexingSource }); + }; + + _layerAddHandler = () => { + const { + isIndexingTriggered, + setIndexingTriggered, + selectLayerAndAdd, + resetIndexing, + } = this.props; + const layerSource = this.state.layer.getSource(); + const boolIndexLayer = layerSource.shouldBeIndexed(); + this.setState({ layer: null }); + if (boolIndexLayer && !isIndexingTriggered) { + setIndexingTriggered(); + } else { + selectLayerAndAdd(); + if (this.state.importView) { + this.setState({ + layerImportAddReady: false, + }); + resetIndexing(); + } + } + }; + + _renderAddLayerPanel() { + const { sourceType, importView } = this.state; + if (!sourceType) { + return ; + } + if (importView) { + return ( + this._clearLayerData({ keepSourceType: true })} + /> + ); + } + return ( + + ); + } + + _renderFooter(buttonDescription) { + const { importView, layer } = this.state; + const { isIndexingReady, isIndexingSuccess } = this.props; + + const buttonEnabled = importView ? isIndexingReady || isIndexingSuccess : !!layer; + + return ( + + ); + } + + _renderFlyout() { + const panelDescription = this._getPanelDescription(); + + return ( + + + +

{panelDescription}

+
+
+ +
+
{this._renderAddLayerPanel()}
+
+ {this._renderFooter(panelDescription)} +
+ ); + } + + render() { + return this.props.flyoutVisible ? this._renderFlyout() : null; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap new file mode 100644 index 0000000000000..101716d297b81 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LayerPanel is rendered 1`] = ` + + + + + + + + + + + +

+ layer 1 +

+
+
+
+ +
+ + + +

+ + source prop1 + + + + you get one chance to set me + +

+
+
+
+
+
+
+ + +
+ mockSourceSettings +
+ + + + + +
+
+ + + +
+
+`; + +exports[`LayerPanel should render empty panel when selectedLayer is null 1`] = `""`; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/_index.scss b/x-pack/plugins/maps/public/connected_components/layer_panel/_index.scss new file mode 100644 index 0000000000000..b219f59476ce9 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/_index.scss @@ -0,0 +1,3 @@ +@import './layer_panel'; +@import './filter_editor/filter_editor'; +@import './join_editor/resources/join'; \ No newline at end of file diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/_layer_panel.scss b/x-pack/plugins/maps/public/connected_components/layer_panel/_layer_panel.scss new file mode 100644 index 0000000000000..90fada08a0bfe --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/_layer_panel.scss @@ -0,0 +1,39 @@ +/** + * 1. Firefox and IE don't respect bottom padding of overflow scrolling flex items. + * So we instead strip out the bottom padding and add the same amount as a margin + * to the last child element. + */ + +.mapLayerPanel__header, +.mapLayerPanel__footer { + padding: $euiSize; +} + +.mapLayerPanel__body { + @include euiScrollBar; + padding-bottom: 0; /* 1 */ + flex-grow: 1; + flex-basis: 1px; /* Fixes scrolling for Firefox */ + overflow-y: auto; + + .mapLayerPanel__bodyOverflow { + padding: $euiSize; + + > *:last-child { + margin-bottom: $euiSize; /* 1 */ + } + } +} + +.mapLayerPanel__sourceDetails { + margin-left: $euiSizeXL; +} + +.mapLayerPanel__sourceDetail { + // sass-lint:disable-block no-important + margin-bottom: 0 !important; +} + +.mapLayerPanel__footer { + border-top: $euiBorderThin; +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/_filter_editor.scss b/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/_filter_editor.scss new file mode 100644 index 0000000000000..089f9ab08a1f7 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/_filter_editor.scss @@ -0,0 +1,4 @@ +.mapFilterEditor { + width: calc(95vw - #{$euiSizeXXL * 11}); + overflow-y: visible; +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js b/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js new file mode 100644 index 0000000000000..94e855fc6708f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js @@ -0,0 +1,232 @@ +/* + * 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, { Component, Fragment } from 'react'; + +import { + EuiButton, + EuiCodeBlock, + EuiTitle, + EuiPopover, + EuiSpacer, + EuiText, + EuiTextColor, + EuiTextAlign, + EuiButtonEmpty, + EuiFormRow, + EuiSwitch, +} from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { indexPatternService } from '../../../kibana_services'; +import { GlobalFilterCheckbox } from '../../../components/global_filter_checkbox'; + +import { npStart } from 'ui/new_platform'; +const { SearchBar } = npStart.plugins.data.ui; + +export class FilterEditor extends Component { + state = { + isPopoverOpen: false, + indexPatterns: [], + }; + + componentDidMount() { + this._isMounted = true; + this._loadIndexPatterns(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + _loadIndexPatterns = async () => { + // Filter only effects source so only load source indices. + const indexPatternIds = this.props.layer.getSource().getIndexPatternIds(); + const indexPatterns = []; + const getIndexPatternPromises = indexPatternIds.map(async indexPatternId => { + try { + const indexPattern = await indexPatternService.get(indexPatternId); + indexPatterns.push(indexPattern); + } catch (err) { + // unable to fetch index pattern + } + }); + + await Promise.all(getIndexPatternPromises); + + if (!this._isMounted) { + return; + } + + this.setState({ indexPatterns }); + }; + + _toggle = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + _close = () => { + this.setState({ isPopoverOpen: false }); + }; + + _onQueryChange = ({ query }) => { + this.props.setLayerQuery(this.props.layer.getId(), query); + this._close(); + }; + + _onFilterByMapBoundsChange = event => { + this.props.updateSourceProp( + this.props.layer.getId(), + 'filterByMapBounds', + event.target.checked + ); + }; + + _onApplyGlobalQueryChange = applyGlobalQuery => { + this.props.updateSourceProp(this.props.layer.getId(), 'applyGlobalQuery', applyGlobalQuery); + }; + + _renderQueryPopover() { + const layerQuery = this.props.layer.getQuery(); + const { uiSettings } = npStart.core; + + return ( + +
+ + + + } + /> +
+
+ ); + } + + _renderQuery() { + const query = this.props.layer.getQuery(); + if (!query || !query.query) { + return ( + +

+ + + +

+
+ ); + } + + return ( + + {query.query} + + + + ); + } + + _renderOpenButton() { + const query = this.props.layer.getQuery(); + const openButtonLabel = + query && query.query + ? i18n.translate('xpack.maps.layerPanel.filterEditor.editFilterButtonLabel', { + defaultMessage: 'Edit filter', + }) + : i18n.translate('xpack.maps.layerPanel.filterEditor.addFilterButtonLabel', { + defaultMessage: 'Add filter', + }); + const openButtonIcon = query && query.query ? 'pencil' : 'plusInCircleFilled'; + + return ( + + {openButtonLabel} + + ); + } + + render() { + let filterByBoundsSwitch; + if (this.props.layer.getSource().isFilterByMapBoundsConfigurable()) { + filterByBoundsSwitch = ( + + + + ); + } + + return ( + + +
+ +
+
+ + + + {this._renderQuery()} + + {this._renderQueryPopover()} + + + + {filterByBoundsSwitch} + + +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/index.js new file mode 100644 index 0000000000000..127f2ca70ab93 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/index.js @@ -0,0 +1,28 @@ +/* + * 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 { FilterEditor } from './filter_editor'; +import { getSelectedLayer } from '../../../selectors/map_selectors'; +import { setLayerQuery, updateSourceProp } from '../../../actions/map_actions'; + +function mapStateToProps(state = {}) { + return { + layer: getSelectedLayer(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + setLayerQuery: (layerId, query) => { + dispatch(setLayerQuery(layerId, query)); + }, + updateSourceProp: (id, propName, value) => dispatch(updateSourceProp(id, propName, value)), + }; +} + +const connectedFilterEditor = connect(mapStateToProps, mapDispatchToProps)(FilterEditor); +export { connectedFilterEditor as FilterEditor }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/flyout_footer/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/flyout_footer/index.js new file mode 100644 index 0000000000000..76e650cad97eb --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/flyout_footer/index.js @@ -0,0 +1,42 @@ +/* + * 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 { FlyoutFooter } from './view'; +import { FLYOUT_STATE } from '../../../reducers/ui'; +import { updateFlyout } from '../../../actions/ui_actions'; +import { hasDirtyState } from '../../../selectors/map_selectors'; +import { + setSelectedLayer, + removeSelectedLayer, + removeTrackedLayerStateForSelectedLayer, +} from '../../../actions/map_actions'; + +function mapStateToProps(state = {}) { + return { + hasStateChanged: hasDirtyState(state), + }; +} + +const mapDispatchToProps = dispatch => { + return { + cancelLayerPanel: () => { + dispatch(updateFlyout(FLYOUT_STATE.NONE)); + dispatch(setSelectedLayer(null)); + }, + saveLayerEdits: () => { + dispatch(updateFlyout(FLYOUT_STATE.NONE)); + dispatch(removeTrackedLayerStateForSelectedLayer()); + dispatch(setSelectedLayer(null)); + }, + removeLayer: () => { + dispatch(removeSelectedLayer()); + }, + }; +}; + +const connectedFlyoutFooter = connect(mapStateToProps, mapDispatchToProps)(FlyoutFooter); +export { connectedFlyoutFooter as FlyoutFooter }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/flyout_footer/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/flyout_footer/view.js new file mode 100644 index 0000000000000..bb344962eda0f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/flyout_footer/view.js @@ -0,0 +1,65 @@ +/* + * 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 from 'react'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const FlyoutFooter = ({ + cancelLayerPanel, + saveLayerEdits, + removeLayer, + hasStateChanged, +}) => { + const removeBtn = ( + + + + + + ); + + const cancelButtonLabel = hasStateChanged ? ( + + ) : ( + + ); + + return ( + + + + {cancelButtonLabel} + + + + + + {removeBtn} + + + + + + + ); +}; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/index.js new file mode 100644 index 0000000000000..89ab7cf927d5b --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/index.js @@ -0,0 +1,28 @@ +/* + * 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 { LayerPanel } from './view'; +import { getSelectedLayer } from '../../selectors/map_selectors'; +import { fitToLayerExtent, updateSourceProp } from '../../actions/map_actions'; + +function mapStateToProps(state = {}) { + return { + selectedLayer: getSelectedLayer(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + fitToBounds: layerId => { + dispatch(fitToLayerExtent(layerId)); + }, + updateSourceProp: (id, propName, value) => dispatch(updateSourceProp(id, propName, value)), + }; +} + +const connectedLayerPanel = connect(mapStateToProps, mapDispatchToProps)(LayerPanel); +export { connectedLayerPanel as LayerPanel }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js new file mode 100644 index 0000000000000..d9ff48b752c0b --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js @@ -0,0 +1,31 @@ +/* + * 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 { JoinEditor } from './view'; +import { + getSelectedLayer, + getSelectedLayerJoinDescriptors, +} from '../../../selectors/map_selectors'; +import { setJoinsForLayer } from '../../../actions/map_actions'; + +function mapDispatchToProps(dispatch) { + return { + onChange: (layer, joins) => { + dispatch(setJoinsForLayer(layer, joins)); + }, + }; +} + +function mapStateToProps(state = {}) { + return { + joins: getSelectedLayerJoinDescriptors(state), + layer: getSelectedLayer(state), + }; +} + +const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor); +export { connectedJoinEditor as JoinEditor }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap new file mode 100644 index 0000000000000..326dac08fa1e0 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render default props 1`] = ` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="metricsPopover" + initialFocus="body" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + withTitle={true} +> +
+ + + + + + + + + +
+
+`; + +exports[`Should render metrics expression for metrics 1`] = ` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="metricsPopover" + initialFocus="body" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + withTitle={true} +> +
+ + + + + + + + + +
+
+`; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/_join.scss b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/_join.scss new file mode 100644 index 0000000000000..8b54e563c257e --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/_join.scss @@ -0,0 +1,37 @@ +.mapJoinItem { + position: relative; + background: tintOrShade($euiColorLightShade, 85%, 0); + border-radius: $euiBorderRadius; + padding: $euiSizeXS; + + .mapJoinItem__inner { + @include euiScrollBar; + overflow-x: auto; + } + + &:hover, + &:focus { + .mapJoinItem__delete { + visibility: visible; + opacity: 1; + } + } +} + +.mapJoinItem__delete { + @include euiBottomShadowSmall; + position: absolute; + right: 0; + top: 50%; + margin-right: -$euiSizeS; + margin-top: -$euiSizeM; + background: $euiColorEmptyShade; + padding: $euiSizeXS; + visibility: hidden; + opacity: 0; +} + +.mapJoinExpressionHelpText { + padding-top: 0; + padding-bottom: $euiSizeS; +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join.js new file mode 100644 index 0000000000000..59911586bfb66 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join.js @@ -0,0 +1,268 @@ +/* + * 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 _ from 'lodash'; +import React, { Component } from 'react'; +import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { JoinExpression } from './join_expression'; +import { MetricsExpression } from './metrics_expression'; +import { WhereExpression } from './where_expression'; +import { GlobalFilterCheckbox } from '../../../../components/global_filter_checkbox'; + +import { isNestedField } from '../../../../../../../../src/plugins/data/public'; +import { indexPatternService } from '../../../../kibana_services'; + +const getIndexPatternId = props => { + return _.get(props, 'join.right.indexPatternId'); +}; + +export class Join extends Component { + state = { + leftFields: null, + leftSourceName: '', + rightFields: undefined, + indexPattern: undefined, + loadError: undefined, + prevIndexPatternId: getIndexPatternId(this.props), + }; + + componentDidMount() { + this._isMounted = true; + this._loadLeftFields(); + this._loadLeftSourceName(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidUpdate() { + if (!this.state.rightFields && getIndexPatternId(this.props) && !this.state.loadError) { + this._loadRightFields(getIndexPatternId(this.props)); + } + } + + static getDerivedStateFromProps(nextProps, prevState) { + const nextIndexPatternId = getIndexPatternId(nextProps); + if (nextIndexPatternId !== prevState.prevIndexPatternId) { + return { + rightFields: undefined, + loadError: undefined, + prevIndexPatternId: nextIndexPatternId, + }; + } + + return null; + } + + async _loadRightFields(indexPatternId) { + if (!indexPatternId) { + return; + } + + let indexPattern; + try { + indexPattern = await indexPatternService.get(indexPatternId); + } catch (err) { + if (this._isMounted) { + this.setState({ + loadError: i18n.translate('xpack.maps.layerPanel.join.noIndexPatternErrorMessage', { + defaultMessage: `Unable to find Index pattern {indexPatternId}`, + values: { indexPatternId }, + }), + }); + } + return; + } + + if (indexPatternId !== this.state.prevIndexPatternId) { + // ignore out of order responses + return; + } + + if (!this._isMounted) { + return; + } + + this.setState({ + rightFields: indexPattern.fields.filter(field => !isNestedField(field)), + indexPattern, + }); + } + + async _loadLeftSourceName() { + const leftSourceName = await this.props.layer.getSourceName(); + if (!this._isMounted) { + return; + } + this.setState({ leftSourceName }); + } + + async _loadLeftFields() { + let leftFields; + try { + const leftFieldsInstances = await this.props.layer.getLeftJoinFields(); + const leftFieldPromises = leftFieldsInstances.map(async field => { + return { + name: field.getName(), + label: await field.getLabel(), + }; + }); + leftFields = await Promise.all(leftFieldPromises); + } catch (error) { + leftFields = []; + } + if (!this._isMounted) { + return; + } + this.setState({ leftFields }); + } + + _onLeftFieldChange = leftField => { + this.props.onChange({ + leftField: leftField, + right: this.props.join.right, + }); + }; + + _onRightSourceChange = ({ indexPatternId, indexPatternTitle }) => { + this.props.onChange({ + leftField: this.props.join.leftField, + right: { + id: this.props.join.right.id, + indexPatternId, + indexPatternTitle, + }, + }); + }; + + _onRightFieldChange = term => { + this.props.onChange({ + leftField: this.props.join.leftField, + right: { + ...this.props.join.right, + term, + }, + }); + }; + + _onMetricsChange = metrics => { + this.props.onChange({ + leftField: this.props.join.leftField, + right: { + ...this.props.join.right, + metrics, + }, + }); + }; + + _onWhereQueryChange = whereQuery => { + this.props.onChange({ + leftField: this.props.join.leftField, + right: { + ...this.props.join.right, + whereQuery, + }, + }); + }; + + _onApplyGlobalQueryChange = applyGlobalQuery => { + this.props.onChange({ + leftField: this.props.join.leftField, + right: { + ...this.props.join.right, + applyGlobalQuery, + }, + }); + }; + + render() { + const { join, onRemove } = this.props; + const { leftSourceName, leftFields, rightFields, indexPattern } = this.state; + const right = _.get(join, 'right', {}); + const rightSourceName = right.indexPatternTitle + ? right.indexPatternTitle + : right.indexPatternId; + const isJoinConfigComplete = join.leftField && right.indexPatternId && right.term; + + let metricsExpression; + let globalFilterCheckbox; + if (isJoinConfigComplete) { + metricsExpression = ( + + + + ); + globalFilterCheckbox = ( + + ); + } + + let whereExpression; + if (indexPattern && isJoinConfigComplete) { + whereExpression = ( + + + + ); + } + + return ( +
+ + + + + + {metricsExpression} + + {whereExpression} + + {globalFilterCheckbox} + + + +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join_expression.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join_expression.js new file mode 100644 index 0000000000000..777c8ae0923fe --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join_expression.js @@ -0,0 +1,250 @@ +/* + * 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 _ from 'lodash'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiPopover, + EuiPopoverTitle, + EuiExpression, + EuiFormRow, + EuiComboBox, + EuiFormHelpText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SingleFieldSelect } from '../../../../components/single_field_select'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { getTermsFields } from '../../../../index_pattern_util'; + +import { indexPatternService } from '../../../../kibana_services'; + +import { npStart } from 'ui/new_platform'; +const { IndexPatternSelect } = npStart.plugins.data.ui; + +export class JoinExpression extends Component { + state = { + isPopoverOpen: false, + }; + + _togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + _closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + _onRightSourceChange = async indexPatternId => { + try { + const indexPattern = await indexPatternService.get(indexPatternId); + this.props.onRightSourceChange({ + indexPatternId, + indexPatternTitle: indexPattern.title, + }); + } catch (err) { + // do not call onChange with when unable to get indexPatternId + } + }; + + _onLeftFieldChange = selectedFields => { + this.props.onLeftFieldChange(_.get(selectedFields, '[0].value.name', null)); + }; + + _renderLeftFieldSelect() { + const { leftValue, leftFields } = this.props; + + if (!leftFields) { + return null; + } + + const options = leftFields.map(field => { + return { + value: field, + label: field.label, + }; + }); + + let leftFieldOption; + if (leftValue) { + leftFieldOption = options.find(option => { + const field = option.value; + return field.name === leftValue; + }); + } + const selectedOptions = leftFieldOption ? [leftFieldOption] : []; + + return ( + + + + ); + } + + _renderRightSourceSelect() { + if (!this.props.leftValue) { + return null; + } + + return ( + + + + ); + } + + _renderRightFieldSelect() { + if (!this.props.rightFields || !this.props.leftValue) { + return null; + } + + return ( + + + + ); + } + + _getExpressionValue() { + const { leftSourceName, leftValue, rightSourceName, rightValue } = this.props; + if (leftSourceName && leftValue && rightSourceName && rightValue) { + return `${leftSourceName}:${leftValue} with ${rightSourceName}:${rightValue}`; + } + + return i18n.translate('xpack.maps.layerPanel.joinExpression.selectPlaceholder', { + defaultMessage: '-- select --', + }); + } + + render() { + const { leftSourceName } = this.props; + return ( + + } + > +
+ + + + + + + + + + {this._renderLeftFieldSelect()} + + {this._renderRightSourceSelect()} + + {this._renderRightFieldSelect()} +
+
+ ); + } +} + +JoinExpression.propTypes = { + // Left source props (static - can not change) + leftSourceName: PropTypes.string, + + // Left field props + leftValue: PropTypes.string, + leftFields: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }) + ), + onLeftFieldChange: PropTypes.func.isRequired, + + // Right source props + rightSourceIndexPatternId: PropTypes.string, + rightSourceName: PropTypes.string, + onRightSourceChange: PropTypes.func.isRequired, + + // Right field props + rightValue: PropTypes.string, + rightFields: PropTypes.array, + onRightFieldChange: PropTypes.func.isRequired, +}; + +function getSelectFieldPlaceholder() { + return i18n.translate('xpack.maps.layerPanel.joinExpression.selectFieldPlaceholder', { + defaultMessage: 'Select field', + }); +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.js new file mode 100644 index 0000000000000..47ed69f86ba18 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.js @@ -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, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { i18n } from '@kbn/i18n'; +import { + EuiPopover, + EuiPopoverTitle, + EuiExpression, + EuiFormErrorText, + EuiFormHelpText, +} from '@elastic/eui'; +import { MetricsEditor } from '../../../../components/metrics_editor'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { METRIC_TYPE } from '../../../../../common/constants'; + +export class MetricsExpression extends Component { + state = { + isPopoverOpen: false, + }; + + _togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + _closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + _renderMetricsEditor = () => { + if (!this.props.rightFields) { + return ( + + + + ); + } + + return ( + + ); + }; + + render() { + const metricExpressions = this.props.metrics + .filter(({ type, field }) => { + if (type === METRIC_TYPE.COUNT) { + return true; + } + + if (field) { + return true; + } + return false; + }) + .map(({ type, field }) => { + // do not use metric label so field and aggregation are not obscured. + if (type === METRIC_TYPE.COUNT) { + return 'count'; + } + + return `${type} ${field}`; + }); + const useMetricDescription = i18n.translate( + 'xpack.maps.layerPanel.metricsExpression.useMetricsDescription', + { + defaultMessage: '{metricsLength, plural, one {and use metric} other {and use metrics}}', + values: { + metricsLength: metricExpressions.length, + }, + } + ); + return ( + 0 ? metricExpressions.join(', ') : 'count'} + /> + } + > +
+ + + + + + + {this._renderMetricsEditor()} +
+
+ ); + } +} + +MetricsExpression.propTypes = { + metrics: PropTypes.array, + rightFields: PropTypes.array, + onChange: PropTypes.func.isRequired, +}; + +MetricsExpression.defaultProps = { + metrics: [{ type: METRIC_TYPE.COUNT }], +}; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js new file mode 100644 index 0000000000000..e0e1556ecde06 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js @@ -0,0 +1,34 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; +import { MetricsExpression } from './metrics_expression'; + +const defaultProps = { + onChange: () => {}, +}; + +test('Should render default props', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('Should render metrics expression for metrics', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js new file mode 100644 index 0000000000000..d72ccde12a414 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js @@ -0,0 +1,101 @@ +/* + * 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, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiPopover, EuiExpression, EuiFormHelpText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { npStart } from 'ui/new_platform'; +const { SearchBar } = npStart.plugins.data.ui; + +export class WhereExpression extends Component { + state = { + isPopoverOpen: false, + }; + + _togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + _closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + _onQueryChange = ({ query }) => { + this.props.onChange(query); + this._closePopover(); + }; + + render() { + const { whereQuery, indexPattern } = this.props; + const expressionValue = + whereQuery && whereQuery.query + ? whereQuery.query + : i18n.translate('xpack.maps.layerPanel.whereExpression.expressionValuePlaceholder', { + defaultMessage: '-- add filter --', + }); + + const { uiSettings } = npStart.core; + + return ( + + } + > +
+ + + + + + + } + /> +
+
+ ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js new file mode 100644 index 0000000000000..9f3461e45dfd4 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js @@ -0,0 +1,101 @@ +/* + * 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 uuid from 'uuid/v4'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiTitle, + EuiSpacer, + EuiToolTip, +} from '@elastic/eui'; + +import { Join } from './resources/join'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +export function JoinEditor({ joins, layer, onChange }) { + const renderJoins = () => { + return joins.map((joinDescriptor, index) => { + const handleOnChange = updatedDescriptor => { + onChange(layer, [...joins.slice(0, index), updatedDescriptor, ...joins.slice(index + 1)]); + }; + + const handleOnRemove = () => { + onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]); + }; + + return ( + + + + + ); + }); + }; + + const addJoin = () => { + onChange(layer, [ + ...joins, + { + right: { + id: uuid(), + applyGlobalQuery: true, + }, + }, + ]); + }; + + if (!layer.isJoinable()) { + return null; + } + + return ( +
+ + + +
+ + + +
+
+
+ + + +
+ + {renderJoins()} +
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/__snapshots__/layer_errors.test.js.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/__snapshots__/layer_errors.test.js.snap new file mode 100644 index 0000000000000..1fdc3a1bfdf17 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/__snapshots__/layer_errors.test.js.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render errors when layer has errors 1`] = ` + + +

+ simulated layer error +

+
+ +
+`; + +exports[`should render nothing when layer has no errors 1`] = `""`; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/index.js new file mode 100644 index 0000000000000..d7ecf6dcd40ab --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/index.js @@ -0,0 +1,18 @@ +/* + * 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 { LayerErrors } from './layer_errors'; +import { getSelectedLayer } from '../../../selectors/map_selectors'; + +function mapStateToProps(state = {}) { + return { + layer: getSelectedLayer(state), + }; +} + +const connectedLayerErrors = connect(mapStateToProps, null)(LayerErrors); +export { connectedLayerErrors as LayerErrors }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/layer_errors.js b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/layer_errors.js new file mode 100644 index 0000000000000..9b31f1cf4b70e --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/layer_errors.js @@ -0,0 +1,29 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; + +export function LayerErrors({ layer }) { + if (!layer.hasErrors()) { + return null; + } + + return ( + + +

{layer.getErrors()}

+
+ +
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/layer_errors.test.js b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/layer_errors.test.js new file mode 100644 index 0000000000000..cc3dc54d0e530 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_errors/layer_errors.test.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 React from 'react'; +import { shallow } from 'enzyme'; + +import { LayerErrors } from './layer_errors'; + +test('Should render errors when layer has errors', () => { + const mockLayer = { + hasErrors: () => { + return true; + }, + getErrors: () => { + return 'simulated layer error'; + }, + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render nothing when layer has no errors', () => { + const mockLayer = { + hasErrors: () => { + return false; + }, + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/index.js new file mode 100644 index 0000000000000..73c98db8e429d --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/index.js @@ -0,0 +1,38 @@ +/* + * 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/map_actions'; + +function mapStateToProps(state = {}) { + const selectedLayer = getSelectedLayer(state); + return { + 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 }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.js b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.js new file mode 100644 index 0000000000000..ac17915b5f277 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.js @@ -0,0 +1,118 @@ +/* + * 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 { ValidatedRange } from '../../../components/validated_range'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ValidatedDualRange } from 'ui/validated_range'; +import { MAX_ZOOM, MIN_ZOOM } from '../../../../common/constants'; + +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(MIN_ZOOM, parseInt(min, 10))); + props.updateMaxZoom(props.layerId, Math.min(MAX_ZOOM, parseInt(max, 10))); + }; + + const onAlphaChange = alpha => { + const alphaDecimal = alpha / 100; + props.updateAlpha(props.layerId, alphaDecimal); + }; + + const renderZoomSliders = () => { + return ( + + ); + }; + + const renderLabel = () => { + return ( + + + + ); + }; + + const renderAlphaSlider = () => { + const alphaPercent = Math.round(props.alpha * 100); + + return ( + + + + ); + }; + + return ( + + + +
+ +
+
+ + + {renderLabel()} + {renderZoomSliders()} + {renderAlphaSlider()} +
+ + +
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/style_settings/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/style_settings/index.js new file mode 100644 index 0000000000000..882d0131e1837 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/style_settings/index.js @@ -0,0 +1,27 @@ +/* + * 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 { StyleSettings } from './style_settings'; +import { getSelectedLayer } from '../../../selectors/map_selectors'; +import { updateLayerStyleForSelectedLayer } from '../../../actions/map_actions'; + +function mapStateToProps(state = {}) { + return { + layer: getSelectedLayer(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + updateStyleDescriptor: styleDescriptor => { + dispatch(updateLayerStyleForSelectedLayer(styleDescriptor)); + }, + }; +} + +const connectedStyleSettings = connect(mapStateToProps, mapDispatchToProps)(StyleSettings); +export { connectedStyleSettings as StyleSettings }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/style_settings/style_settings.js b/x-pack/plugins/maps/public/connected_components/layer_panel/style_settings/style_settings.js new file mode 100644 index 0000000000000..2857065f04c70 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/style_settings/style_settings.js @@ -0,0 +1,46 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiPanel, EuiSpacer } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +export function StyleSettings({ layer, updateStyleDescriptor }) { + const settingsEditor = layer.renderStyleEditor({ + onStyleDescriptorChange: updateStyleDescriptor, + }); + + if (!settingsEditor) { + return null; + } + + return ( + + + + + +
+ +
+
+
+
+ + + + {settingsEditor} +
+ + +
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js new file mode 100644 index 0000000000000..934736c877e45 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js @@ -0,0 +1,231 @@ +/* + * 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 { FilterEditor } from './filter_editor'; +import { JoinEditor } from './join_editor'; +import { FlyoutFooter } from './flyout_footer'; +import { LayerErrors } from './layer_errors'; +import { LayerSettings } from './layer_settings'; +import { StyleSettings } from './style_settings'; +import { + EuiButtonIcon, + EuiFlexItem, + EuiTitle, + EuiPanel, + EuiFlexGroup, + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiSpacer, + EuiAccordion, + EuiText, + EuiLink, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; + +const localStorage = new Storage(window.localStorage); + +// This import will eventually become a dependency injected by the fully deangularized NP plugin. +import { npStart } from 'ui/new_platform'; + +export class LayerPanel extends React.Component { + static getDerivedStateFromProps(nextProps, prevState) { + const nextId = nextProps.selectedLayer ? nextProps.selectedLayer.getId() : null; + if (nextId !== prevState.prevId) { + return { + displayName: '', + immutableSourceProps: [], + hasLoadedSourcePropsForLayer: false, + prevId: nextId, + }; + } + return null; + } + + state = {}; + + componentDidMount() { + this._isMounted = true; + this.loadDisplayName(); + this.loadImmutableSourceProperties(); + } + + componentDidUpdate() { + this.loadDisplayName(); + this.loadImmutableSourceProperties(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + loadDisplayName = async () => { + if (!this.props.selectedLayer) { + return; + } + + const displayName = await this.props.selectedLayer.getDisplayName(); + if (!this._isMounted || displayName === this.state.displayName) { + return; + } + + this.setState({ displayName }); + }; + + loadImmutableSourceProperties = async () => { + if (this.state.hasLoadedSourcePropsForLayer || !this.props.selectedLayer) { + return; + } + + const immutableSourceProps = await this.props.selectedLayer.getImmutableSourceProperties(); + if (this._isMounted) { + this.setState({ + immutableSourceProps, + hasLoadedSourcePropsForLayer: true, + }); + } + }; + + _onSourceChange = ({ propName, value }) => { + this.props.updateSourceProp(this.props.selectedLayer.getId(), propName, value); + }; + + _renderFilterSection() { + if (!this.props.selectedLayer.supportsElasticsearchFilters()) { + return null; + } + + return ( + + + + + + + ); + } + + _renderJoinSection() { + if (!this.props.selectedLayer.isJoinable()) { + return null; + } + + return ( + + + + + + + ); + } + + _renderSourceProperties() { + return this.state.immutableSourceProps.map(({ label, value, link }) => { + function renderValue() { + if (link) { + return ( + + {value} + + ); + } + return {value}; + } + return ( +

+ {label} {renderValue()} +

+ ); + }); + } + + render() { + const { selectedLayer } = this.props; + + if (!selectedLayer) { + return null; + } + + return ( + + + + + + + + + + + +

{this.state.displayName}

+
+
+
+ +
+ + + + {this._renderSourceProperties()} + + +
+
+ +
+
+ + + + + {this.props.selectedLayer.renderSourceSettingsEditor({ + onChange: this._onSourceChange, + })} + + {this._renderFilterSection()} + + {this._renderJoinSection()} + + +
+
+ + + + +
+
+ ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js new file mode 100644 index 0000000000000..9f882cd467527 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js @@ -0,0 +1,100 @@ +/* + * 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. + */ + +jest.mock('./style_settings', () => ({ + StyleSettings: () => { + return
mockStyleSettings
; + }, +})); + +jest.mock('./join_editor', () => ({ + JoinEditor: () => { + return
mockJoinEditor
; + }, +})); + +jest.mock('./filter_editor', () => ({ + JoinEditor: () => { + return
mockFilterEditor
; + }, +})); + +jest.mock('./flyout_footer', () => ({ + FlyoutFooter: () => { + return
mockFlyoutFooter
; + }, +})); + +jest.mock('./layer_errors', () => ({ + LayerErrors: () => { + return
mockLayerErrors
; + }, +})); + +jest.mock('./layer_settings', () => ({ + LayerSettings: () => { + return
mockLayerSettings
; + }, +})); + +import React from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; + +import { LayerPanel } from './view'; + +const mockLayer = { + getId: () => { + return '1'; + }, + getDisplayName: () => { + return 'layer 1'; + }, + getImmutableSourceProperties: () => { + return [{ label: 'source prop1', value: 'you get one chance to set me' }]; + }, + isJoinable: () => { + return true; + }, + supportsElasticsearchFilters: () => { + return false; + }, + getLayerTypeIconName: () => { + return 'vector'; + }, + renderSourceSettingsEditor: () => { + return
mockSourceSettings
; + }, +}; + +const defaultProps = { + selectedLayer: mockLayer, + fitToBounds: () => {}, + updateSourceProp: () => {}, +}; + +describe('LayerPanel', () => { + test('is rendered', async () => { + const component = shallowWithIntl(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should render empty panel when selectedLayer is null', async () => { + const component = shallowWithIntl(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap new file mode 100644 index 0000000000000..a52c118bca8cd --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/feature_properties.test.js.snap @@ -0,0 +1,110 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeatureProperties should not show filter button 1`] = ` + + + + + + + + + +
+ prop1 + +
+ prop2 + +
+`; + +exports[`FeatureProperties should show error message if unable to load tooltip content 1`] = ` + +

+ Simulated load properties error +

+
+`; + +exports[`FeatureProperties should show only filter button for filterable properties 1`] = ` + + + + + + + + + + +
+ prop1 + + + +
+ prop2 + +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/tooltip_header.test.js.snap b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/tooltip_header.test.js.snap new file mode 100644 index 0000000000000..486a830d21b65 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/__snapshots__/tooltip_header.test.js.snap @@ -0,0 +1,238 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TooltipHeader multiple features, multiple layers: locked should show pagination controls, features count, layer select, and close button 1`] = ` + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`TooltipHeader multiple features, multiple layers: mouseover (unlocked) should only show features count 1`] = ` + + + + + + + + + + +`; + +exports[`TooltipHeader multiple features, single layer: locked should show pagination controls, features count, and close button 1`] = ` + + + + + + + + + + + + + + + + +`; + +exports[`TooltipHeader multiple features, single layer: mouseover (unlocked) should only show features count 1`] = ` + + + + + + + + + + +`; + +exports[`TooltipHeader single feature: locked should show close button when locked 1`] = ` + + + + + + + + + +`; + +exports[`TooltipHeader single feature: mouseover (unlocked) should not render header 1`] = `""`; diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss new file mode 100644 index 0000000000000..c80de7e2f6350 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/_index.scss @@ -0,0 +1,20 @@ +.mapFeatureTooltip_table { + width: 100%; + + td { + padding: $euiSizeXS; + } +} + +.mapFeatureTooltip_actionLinks { + padding: $euiSizeXS; +} + +.mapFeatureTooltip_backButton { + padding-left: 0; +} + +.mapFeatureTooltip__propertyLabel { + max-width: $euiSizeXL * 4; + font-weight: $euiFontWeightSemiBold; +} diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js new file mode 100644 index 0000000000000..416af95581058 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_geometry_filter_form.js @@ -0,0 +1,149 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { createSpatialFilterWithGeometry } from '../../../elasticsearch_geo_utils'; +import { GEO_JSON_TYPE } from '../../../../common/constants'; +import { GeometryFilterForm } from '../../../components/geometry_filter_form'; +import { UrlOverflowService } from 'ui/error_url_overflow'; +import rison from 'rison-node'; + +// over estimated and imprecise value to ensure filter has additional room for any meta keys added when filter is mapped. +const META_OVERHEAD = 100; + +const urlOverflow = new UrlOverflowService(); + +export class FeatureGeometryFilterForm extends Component { + state = { + isLoading: false, + errorMsg: undefined, + }; + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + _loadPreIndexedShape = async () => { + this.setState({ + isLoading: true, + }); + + let preIndexedShape; + try { + preIndexedShape = await this.props.loadPreIndexedShape(); + } catch (err) { + // ignore error, just fall back to using geometry if preIndexedShape can not be fetched + } + + if (this._isMounted) { + this.setState({ isLoading: false }); + } + + return preIndexedShape; + }; + + _createFilter = async ({ + geometryLabel, + indexPatternId, + geoFieldName, + geoFieldType, + relation, + }) => { + this.setState({ errorMsg: undefined }); + const preIndexedShape = await this._loadPreIndexedShape(); + if (!this._isMounted) { + // do not create filter if component is unmounted + return; + } + + const filter = createSpatialFilterWithGeometry({ + preIndexedShape, + geometry: this.props.geometry, + geometryLabel, + indexPatternId, + geoFieldName, + geoFieldType, + relation, + }); + + // Ensure filter will not overflow URL. Filters that contain geometry can be extremely large. + // No elasticsearch support for pre-indexed shapes and geo_point spatial queries. + if ( + window.location.href.length + rison.encode(filter).length + META_OVERHEAD > + urlOverflow.failLength() + ) { + this.setState({ + errorMsg: i18n.translate('xpack.maps.tooltip.geometryFilterForm.filterTooLargeMessage', { + defaultMessage: + 'Cannot create filter. Filters are added to the URL, and this shape has too many vertices to fit in the URL.', + }), + }); + return; + } + + this.props.addFilters([filter]); + this.props.onClose(); + }; + + _renderHeader() { + return ( + + ); + } + + _renderForm() { + return ( + + ); + } + + render() { + return ( + + {this._renderHeader()} + {this._renderForm()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js new file mode 100644 index 0000000000000..0b2b838f9feb7 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.js @@ -0,0 +1,178 @@ +/* + * 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 from 'react'; +import { EuiCallOut, EuiLoadingSpinner, EuiTextAlign, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export class FeatureProperties extends React.Component { + state = { + properties: null, + loadPropertiesErrorMsg: null, + prevWidth: null, + prevHeight: null, + }; + + componentDidMount() { + this._isMounted = true; + this.prevLayerId = undefined; + this.prevFeatureId = undefined; + this._loadProperties(); + } + + componentDidUpdate() { + this._loadProperties(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + _loadProperties = () => { + this._fetchProperties({ + nextFeatureId: this.props.featureId, + nextLayerId: this.props.layerId, + }); + }; + + _fetchProperties = async ({ nextLayerId, nextFeatureId }) => { + if (this.prevLayerId === nextLayerId && this.prevFeatureId === nextFeatureId) { + // do not reload same feature properties + return; + } + + this.prevLayerId = nextLayerId; + this.prevFeatureId = nextFeatureId; + this.setState({ + properties: undefined, + loadPropertiesErrorMsg: undefined, + }); + + // Preserve current properties width/height so they can be used while rendering loading indicator. + if (this.state.properties && this._node) { + this.setState({ + prevWidth: this._node.clientWidth, + prevHeight: this._node.clientHeight, + }); + } + + let properties; + try { + properties = await this.props.loadFeatureProperties({ + layerId: nextLayerId, + featureId: nextFeatureId, + }); + } catch (error) { + if (this._isMounted) { + this.setState({ + properties: [], + loadPropertiesErrorMsg: error.message, + }); + } + return; + } + + if (this.prevLayerId !== nextLayerId && this.prevFeatureId !== nextFeatureId) { + // ignore results for old request + return; + } + + if (this._isMounted) { + this.setState({ + properties, + }); + } + }; + + _renderFilterCell(tooltipProperty) { + if (!this.props.showFilterButtons || !tooltipProperty.isFilterable()) { + return null; + } + + return ( + + { + this.props.onCloseTooltip(); + const filters = await tooltipProperty.getESFilters(); + this.props.addFilters(filters); + }} + aria-label={i18n.translate('xpack.maps.tooltip.filterOnPropertyAriaLabel', { + defaultMessage: 'Filter on property', + })} + data-test-subj="mapTooltipCreateFilterButton" + /> + + ); + } + + render() { + if (this.state.loadPropertiesErrorMsg) { + return ( + +

{this.state.loadPropertiesErrorMsg}

+
+ ); + } + + if (!this.state.properties) { + const loadingMsg = i18n.translate('xpack.maps.tooltip.loadingMsg', { + defaultMessage: 'Loading', + }); + // Use width/height of last viewed properties while displaying loading status + // to avoid resizing component during loading phase and bouncing tooltip container around + const style = {}; + if (this.state.prevWidth && this.state.prevHeight) { + style.width = this.state.prevWidth; + style.height = this.state.prevHeight; + } + return ( + + + {loadingMsg} + + ); + } + + const rows = this.state.properties.map(tooltipProperty => { + const label = tooltipProperty.getPropertyName(); + return ( + + {label} + + {this._renderFilterCell(tooltipProperty)} + + ); + }); + + return ( + (this._node = node)}> + {rows} +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.test.js b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.test.js new file mode 100644 index 0000000000000..10c3711392fb3 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/feature_properties.test.js @@ -0,0 +1,102 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; +import { FeatureProperties } from './feature_properties'; + +class MockTooltipProperty { + constructor(key, value, isFilterable) { + this._key = key; + this._value = value; + this._isFilterable = isFilterable; + } + + isFilterable() { + return this._isFilterable; + } + + getHtmlDisplayValue() { + return this._value; + } + + getPropertyName() { + return this._key; + } +} + +const defaultProps = { + loadFeatureProperties: () => { + return []; + }, + featureId: `feature`, + layerId: `layer`, + onCloseTooltip: () => {}, + showFilterButtons: false, +}; + +const mockTooltipProperties = [ + new MockTooltipProperty('prop1', 'foobar1', true), + new MockTooltipProperty('prop2', 'foobar2', false), +]; + +describe('FeatureProperties', () => { + test('should not show filter button', async () => { + const component = shallow( + { + return mockTooltipProperties; + }} + /> + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should show only filter button for filterable properties', async () => { + const component = shallow( + { + return mockTooltipProperties; + }} + /> + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should show error message if unable to load tooltip content', async () => { + const component = shallow( + { + throw new Error('Simulated load properties error'); + }} + /> + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js new file mode 100644 index 0000000000000..8a1b556d21c1f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/features_tooltip.js @@ -0,0 +1,144 @@ +/* + * 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 { EuiLink } from '@elastic/eui'; +import { FeatureProperties } from './feature_properties'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { GEO_JSON_TYPE, ES_GEO_FIELD_TYPE } from '../../../../common/constants'; +import { FeatureGeometryFilterForm } from './feature_geometry_filter_form'; +import { TooltipHeader } from './tooltip_header'; + +const VIEWS = { + PROPERTIES_VIEW: 'PROPERTIES_VIEW', + GEOMETRY_FILTER_VIEW: 'GEOMETRY_FILTER_VIEW', +}; + +export class FeaturesTooltip extends React.Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.features !== prevState.prevFeatures) { + return { + currentFeature: nextProps.features ? nextProps.features[0] : null, + view: VIEWS.PROPERTIES_VIEW, + prevFeatures: nextProps.features, + }; + } + + return null; + } + + _setCurrentFeature = feature => { + this.setState({ currentFeature: feature }); + }; + + _showGeometryFilterView = () => { + this.setState({ view: VIEWS.GEOMETRY_FILTER_VIEW }); + }; + + _showPropertiesView = () => { + this.setState({ view: VIEWS.PROPERTIES_VIEW }); + }; + + _renderActions(geoFields) { + if (!this.props.isLocked || geoFields.length === 0) { + return null; + } + + return ( + + + + ); + } + + _filterGeoFields(featureGeometry) { + if (!featureGeometry) { + return []; + } + + // line geometry can only create filters for geo_shape fields. + if ( + featureGeometry.type === GEO_JSON_TYPE.LINE_STRING || + featureGeometry.type === GEO_JSON_TYPE.MULTI_LINE_STRING + ) { + return this.props.geoFields.filter(({ geoFieldType }) => { + return geoFieldType === ES_GEO_FIELD_TYPE.GEO_SHAPE; + }); + } + + // TODO support geo distance filters for points + if ( + featureGeometry.type === GEO_JSON_TYPE.POINT || + featureGeometry.type === GEO_JSON_TYPE.MULTI_POINT + ) { + return []; + } + + return this.props.geoFields; + } + + _loadCurrentFeaturePreIndexedShape = () => { + if (!this.state.currentFeature) { + return; + } + + return this.props.loadPreIndexedShape({ + layerId: this.state.currentFeature.layerId, + featureId: this.state.currentFeature.id, + }); + }; + + render() { + if (!this.state.currentFeature) { + return null; + } + + const currentFeatureGeometry = this.props.loadFeatureGeometry({ + layerId: this.state.currentFeature.layerId, + featureId: this.state.currentFeature.id, + }); + const geoFields = this._filterGeoFields(currentFeatureGeometry); + + if (this.state.view === VIEWS.GEOMETRY_FILTER_VIEW && currentFeatureGeometry) { + return ( + + ); + } + + return ( + + + + {this._renderActions(geoFields)} + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.js b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.js new file mode 100644 index 0000000000000..e9186aa93677f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.js @@ -0,0 +1,220 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { + EuiButtonIcon, + EuiPagination, + EuiSelect, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +const ALL_LAYERS = '_ALL_LAYERS_'; +const DEFAULT_PAGE_NUMBER = 0; + +export class TooltipHeader extends Component { + state = { + filteredFeatures: this.props.features, + pageNumber: DEFAULT_PAGE_NUMBER, + selectedLayerId: ALL_LAYERS, + layerOptions: [], + }; + + componentDidMount() { + this._isMounted = true; + this._prevFeatures = null; + this._loadUniqueLayers(); + } + + componentDidUpdate() { + this._loadUniqueLayers(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + _loadUniqueLayers = async () => { + if (this._prevFeatures === this.props.features) { + return; + } + + this._prevFeatures = this.props.features; + + const countByLayerId = new Map(); + for (let i = 0; i < this.props.features.length; i++) { + let count = countByLayerId.get(this.props.features[i].layerId); + if (!count) { + count = 0; + } + count++; + countByLayerId.set(this.props.features[i].layerId, count); + } + + const layers = []; + countByLayerId.forEach((count, layerId) => { + layers.push(this.props.findLayerById(layerId)); + }); + const layerNamePromises = layers.map(layer => { + return layer.getDisplayName(); + }); + const layerNames = await Promise.all(layerNamePromises); + + if (this._isMounted) { + this.setState( + { + filteredFeatures: this.props.features, + selectedLayerId: ALL_LAYERS, + layerOptions: layers.map((layer, index) => { + const displayName = layerNames[index]; + const count = countByLayerId.get(layer.getId()); + return { + value: layer.getId(), + text: `(${count}) ${displayName}`, + }; + }), + }, + () => this._onPageChange(DEFAULT_PAGE_NUMBER) + ); + } + }; + + _onPageChange = pageNumber => { + this.setState({ pageNumber }); + this.props.setCurrentFeature(this.state.filteredFeatures[pageNumber]); + }; + + _onLayerChange = e => { + const newLayerId = e.target.value; + if (this.state.selectedLayerId === newLayerId) { + return; + } + + const filteredFeatures = + newLayerId === ALL_LAYERS + ? this.props.features + : this.props.features.filter(feature => { + return feature.layerId === newLayerId; + }); + + this.setState( + { + filteredFeatures, + selectedLayerId: newLayerId, + }, + () => this._onPageChange(DEFAULT_PAGE_NUMBER) + ); + }; + + render() { + const { isLocked } = this.props; + const { filteredFeatures, pageNumber, selectedLayerId, layerOptions } = this.state; + + const isLayerSelectVisible = isLocked && layerOptions.length > 1; + const headerItems = []; + + // Pagination controls + if (isLocked && filteredFeatures.length > 1) { + headerItems.push( + + + + ); + } + + // Page number readout + if (filteredFeatures.length > 1) { + headerItems.push( + + + + + + ); + } + + // Layer select + if (isLayerSelectVisible) { + headerItems.push( + + + + + + ); + } + + // Close button + if (isLocked) { + // When close button is the only item, add empty FlexItem to push close button to right + if (headerItems.length === 0) { + headerItems.push(); + } + + headerItems.push( + + + + ); + } + + if (headerItems.length === 0) { + return null; + } + + return ( + + + {headerItems} + + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.test.js b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.test.js new file mode 100644 index 0000000000000..329e52b3519cf --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/features_tooltip/tooltip_header.test.js @@ -0,0 +1,153 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; +import { TooltipHeader } from './tooltip_header'; + +class MockLayer { + constructor(id) { + this._id = id; + } + async getDisplayName() { + return `display + ${this._id}`; + } + getId() { + return this._id; + } +} + +const defaultProps = { + onClose: () => {}, + isLocked: false, + findLayerById: id => { + return new MockLayer(id); + }, + setCurrentFeature: () => {}, +}; + +describe('TooltipHeader', () => { + describe('single feature:', () => { + const SINGLE_FEATURE = [ + { + id: 'feature1', + layerId: 'layer1', + }, + ]; + describe('mouseover (unlocked)', () => { + test('should not render header', async () => { + const component = shallow(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + }); + describe('locked', () => { + test('should show close button when locked', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + }); + }); + + describe('multiple features, single layer:', () => { + const MULTI_FEATURES_SINGE_LAYER = [ + { + id: 'feature1', + layerId: 'layer1', + }, + { + id: 'feature2', + layerId: 'layer1', + }, + ]; + describe('mouseover (unlocked)', () => { + test('should only show features count', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + }); + describe('locked', () => { + test('should show pagination controls, features count, and close button', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + }); + }); + + describe('multiple features, multiple layers:', () => { + const MULTI_FEATURES_MULTI_LAYERS = [ + { + id: 'feature1', + layerId: 'layer1', + }, + { + id: 'feature2', + layerId: 'layer1', + }, + { + id: 'feature1', + layerId: 'layer2', + }, + ]; + describe('mouseover (unlocked)', () => { + test('should only show features count', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + }); + describe('locked', () => { + test('should show pagination controls, features count, layer select, and close button', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js b/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js new file mode 100644 index 0000000000000..f1b4fe2aad1f7 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_control.js @@ -0,0 +1,126 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { DRAW_TYPE } from '../../../../../common/constants'; +import MapboxDraw from '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw-unminified'; +import DrawRectangle from 'mapbox-gl-draw-rectangle-mode'; +import { + createSpatialFilterWithBoundingBox, + createSpatialFilterWithGeometry, + getBoundingBoxGeometry, + roundCoordinates, +} from '../../../../elasticsearch_geo_utils'; +import { DrawTooltip } from './draw_tooltip'; + +const mbDrawModes = MapboxDraw.modes; +mbDrawModes.draw_rectangle = DrawRectangle; + +export class DrawControl extends React.Component { + constructor() { + super(); + this._mbDrawControl = new MapboxDraw({ + displayControlsDefault: false, + modes: mbDrawModes, + }); + this._mbDrawControlAdded = false; + } + + componentDidUpdate() { + this._syncDrawControl(); + } + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + this._removeDrawControl(); + } + + _syncDrawControl = _.debounce(() => { + if (!this.props.mbMap) { + return; + } + + if (this.props.isDrawingFilter) { + this._updateDrawControl(); + } else { + this._removeDrawControl(); + } + }, 256); + + _onDraw = e => { + if (!e.features.length) { + return; + } + + const isBoundingBox = this.props.drawState.drawType === DRAW_TYPE.BOUNDS; + const geometry = e.features[0].geometry; + // MapboxDraw returns coordinates with 12 decimals. Round to a more reasonable number + roundCoordinates(geometry.coordinates); + + try { + const options = { + indexPatternId: this.props.drawState.indexPatternId, + geoFieldName: this.props.drawState.geoFieldName, + geoFieldType: this.props.drawState.geoFieldType, + geometryLabel: this.props.drawState.geometryLabel, + relation: this.props.drawState.relation, + }; + const filter = isBoundingBox + ? createSpatialFilterWithBoundingBox({ + ...options, + geometry: getBoundingBoxGeometry(geometry), + }) + : createSpatialFilterWithGeometry({ + ...options, + geometry, + }); + this.props.addFilters([filter]); + } catch (error) { + // TODO notify user why filter was not created + console.error(error); + } finally { + this.props.disableDrawState(); + } + }; + + _removeDrawControl() { + if (!this._mbDrawControlAdded) { + return; + } + + this.props.mbMap.getCanvas().style.cursor = ''; + this.props.mbMap.off('draw.create', this._onDraw); + this.props.mbMap.removeControl(this._mbDrawControl); + this._mbDrawControlAdded = false; + } + + _updateDrawControl() { + if (!this._mbDrawControlAdded) { + this.props.mbMap.addControl(this._mbDrawControl); + this._mbDrawControlAdded = true; + this.props.mbMap.getCanvas().style.cursor = 'crosshair'; + this.props.mbMap.on('draw.create', this._onDraw); + } + const mbDrawMode = + this.props.drawState.drawType === DRAW_TYPE.POLYGON + ? this._mbDrawControl.modes.DRAW_POLYGON + : 'draw_rectangle'; + this._mbDrawControl.changeMode(mbDrawMode); + } + + render() { + if (!this.props.mbMap || !this.props.isDrawingFilter) { + return null; + } + + return ; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_tooltip.js b/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_tooltip.js new file mode 100644 index 0000000000000..463fe52981410 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/draw_tooltip.js @@ -0,0 +1,91 @@ +/* + * 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 _ from 'lodash'; +import React, { Component } from 'react'; +import { EuiPopover, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DRAW_TYPE } from '../../../../../common/constants'; + +const noop = () => {}; + +export class DrawTooltip extends Component { + state = { + x: undefined, + y: undefined, + isOpen: false, + }; + + constructor(props) { + super(props); + this._popoverRef = React.createRef(); + } + + componentDidMount() { + this.props.mbMap.on('mousemove', this._updateTooltipLocation); + this.props.mbMap.on('mouseout', this._hideTooltip); + } + + componentDidUpdate() { + if (this._popoverRef.current) { + this._popoverRef.current.positionPopoverFluid(); + } + } + + componentWillUnmount() { + this.props.mbMap.off('mousemove', this._updateTooltipLocation); + this.props.mbMap.off('mouseout', this._hideTooltip); + this._updateTooltipLocation.cancel(); + } + + render() { + const instructions = + this.props.drawState.drawType === DRAW_TYPE.BOUNDS + ? i18n.translate('xpack.maps.drawTooltip.boundsInstructions', { + defaultMessage: 'Click to start rectangle. Click again to finish.', + }) + : i18n.translate('xpack.maps.drawTooltip.polygonInstructions', { + defaultMessage: 'Click to add vertex. Double click to finish.', + }); + + const tooltipAnchor = ( +
+ ); + + return ( + + + {instructions} + + + ); + } + + _hideTooltip = () => { + this._updateTooltipLocation.cancel(); + this.setState({ isOpen: false }); + }; + + _updateTooltipLocation = _.throttle(({ lngLat }) => { + const mouseLocation = this.props.mbMap.project(lngLat); + this.setState({ + isOpen: true, + x: mouseLocation.x, + y: mouseLocation.y, + }); + }, 100); +} diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/index.js b/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/index.js new file mode 100644 index 0000000000000..01a98b914aecf --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/draw_control/index.js @@ -0,0 +1,28 @@ +/* + * 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 { DrawControl } from './draw_control'; +import { updateDrawState } from '../../../../actions/map_actions'; +import { getDrawState, isDrawingFilter } from '../../../../selectors/map_selectors'; + +function mapStateToProps(state = {}) { + return { + isDrawingFilter: isDrawingFilter(state), + drawState: getDrawState(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + disableDrawState() { + dispatch(updateDrawState(null)); + }, + }; +} + +const connectedDrawControl = connect(mapStateToProps, mapDispatchToProps)(DrawControl); +export { connectedDrawControl as DrawControl }; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/image_utils.js b/x-pack/plugins/maps/public/connected_components/map/mb/image_utils.js new file mode 100644 index 0000000000000..844deaf07dcfa --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/image_utils.js @@ -0,0 +1,169 @@ +/* + * 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. + */ + +/* @notice + * This product includes code that is adapted from mapbox-gl-js, which is + * available under a "BSD-3-Clause" license. + * https://github.com/mapbox/mapbox-gl-js/blob/master/src/util/image.js + * + * Copyright (c) 2016, Mapbox + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * * Neither the name of Mapbox GL JS nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import assert from 'assert'; + +function createImage(image, { width, height }, channels, data) { + if (!data) { + data = new Uint8Array(width * height * channels); + } else if (data instanceof Uint8ClampedArray) { + data = new Uint8Array(data.buffer); + } else if (data.length !== width * height * channels) { + throw new RangeError('mismatched image size'); + } + image.width = width; + image.height = height; + image.data = data; + return image; +} + +function resizeImage(image, { width, height }, channels) { + if (width === image.width && height === image.height) { + return; + } + + const newImage = createImage({}, { width, height }, channels); + + copyImage( + image, + newImage, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { + width: Math.min(image.width, width), + height: Math.min(image.height, height), + }, + channels + ); + + image.width = width; + image.height = height; + image.data = newImage.data; +} + +function copyImage(srcImg, dstImg, srcPt, dstPt, size, channels) { + if (size.width === 0 || size.height === 0) { + return dstImg; + } + + if ( + size.width > srcImg.width || + size.height > srcImg.height || + srcPt.x > srcImg.width - size.width || + srcPt.y > srcImg.height - size.height + ) { + throw new RangeError('out of range source coordinates for image copy'); + } + + if ( + size.width > dstImg.width || + size.height > dstImg.height || + dstPt.x > dstImg.width - size.width || + dstPt.y > dstImg.height - size.height + ) { + throw new RangeError('out of range destination coordinates for image copy'); + } + + const srcData = srcImg.data; + const dstData = dstImg.data; + + assert(srcData !== dstData); + + for (let y = 0; y < size.height; y++) { + const srcOffset = ((srcPt.y + y) * srcImg.width + srcPt.x) * channels; + const dstOffset = ((dstPt.y + y) * dstImg.width + dstPt.x) * channels; + for (let i = 0; i < size.width * channels; i++) { + dstData[dstOffset + i] = srcData[srcOffset + i]; + } + } + + return dstImg; +} + +export class AlphaImage { + constructor(size, data) { + createImage(this, size, 1, data); + } + + resize(size) { + resizeImage(this, size, 1); + } + + clone() { + return new AlphaImage({ width: this.width, height: this.height }, new Uint8Array(this.data)); + } + + static copy(srcImg, dstImg, srcPt, dstPt, size) { + copyImage(srcImg, dstImg, srcPt, dstPt, size, 1); + } +} + +// Not premultiplied, because ImageData is not premultiplied. +// UNPACK_PREMULTIPLY_ALPHA_WEBGL must be used when uploading to a texture. +export class RGBAImage { + // data must be a Uint8Array instead of Uint8ClampedArray because texImage2D does not + // support Uint8ClampedArray in all browsers + + constructor(size, data) { + createImage(this, size, 4, data); + } + + resize(size) { + resizeImage(this, size, 4); + } + + replace(data, copy) { + if (copy) { + this.data.set(data); + } else if (data instanceof Uint8ClampedArray) { + this.data = new Uint8Array(data.buffer); + } else { + this.data = data; + } + } + + clone() { + return new RGBAImage({ width: this.width, height: this.height }, new Uint8Array(this.data)); + } + + static copy(srcImg, dstImg, srcPt, dstPt, size) { + copyImage(srcImg, dstImg, srcPt, dstPt, size, 4); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/index.js b/x-pack/plugins/maps/public/connected_components/map/mb/index.js new file mode 100644 index 0000000000000..0274f849daf3d --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/index.js @@ -0,0 +1,76 @@ +/* + * 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 { MBMapContainer } from './view'; +import { + mapExtentChanged, + mapReady, + mapDestroyed, + setMouseCoordinates, + clearMouseCoordinates, + clearGoto, + setMapInitError, +} from '../../../actions/map_actions'; +import { + getTooltipState, + getLayerList, + getMapReady, + getGoto, + getScrollZoom, + isInteractiveDisabled, + isTooltipControlDisabled, + isViewControlHidden, +} from '../../../selectors/map_selectors'; +import { getInspectorAdapters } from '../../../reducers/non_serializable_instances'; + +function mapStateToProps(state = {}) { + return { + isMapReady: getMapReady(state), + layerList: getLayerList(state), + goto: getGoto(state), + inspectorAdapters: getInspectorAdapters(state), + tooltipState: getTooltipState(state), + scrollZoom: getScrollZoom(state), + disableInteractive: isInteractiveDisabled(state), + disableTooltipControl: isTooltipControlDisabled(state), + disableTooltipControl: isTooltipControlDisabled(state), + hideViewControl: isViewControlHidden(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + extentChanged: e => { + dispatch(mapExtentChanged(e)); + }, + onMapReady: e => { + dispatch(clearGoto()); + dispatch(mapExtentChanged(e)); + dispatch(mapReady()); + }, + onMapDestroyed: () => { + dispatch(mapDestroyed()); + }, + setMouseCoordinates: ({ lat, lon }) => { + dispatch(setMouseCoordinates({ lat, lon })); + }, + clearMouseCoordinates: () => { + dispatch(clearMouseCoordinates()); + }, + clearGoto: () => { + dispatch(clearGoto()); + }, + setMapInitError(errorMessage) { + dispatch(setMapInitError(errorMessage)); + }, + }; +} + +const connectedMBMapContainer = connect(mapStateToProps, mapDispatchToProps, null, { + withRef: true, +})(MBMapContainer); +export { connectedMBMapContainer as MBMapContainer }; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/mb.utils.test.js b/x-pack/plugins/maps/public/connected_components/map/mb/mb.utils.test.js new file mode 100644 index 0000000000000..a8c4f61a00da3 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/mb.utils.test.js @@ -0,0 +1,289 @@ +/* + * 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 { removeOrphanedSourcesAndLayers, syncLayerOrderForSingleLayer } from './utils'; +import _ from 'lodash'; + +class MockMbMap { + constructor(style) { + this._style = _.cloneDeep(style); + } + + getStyle() { + return _.cloneDeep(this._style); + } + + moveLayer(mbLayerId, nextMbLayerId) { + const indexOfLayerToMove = this._style.layers.findIndex(layer => { + return layer.id === mbLayerId; + }); + + const layerToMove = this._style.layers[indexOfLayerToMove]; + this._style.layers.splice(indexOfLayerToMove, 1); + + const indexOfNextLayer = this._style.layers.findIndex(layer => { + return layer.id === nextMbLayerId; + }); + + this._style.layers.splice(indexOfNextLayer, 0, layerToMove); + } + + removeSource(sourceId) { + delete this._style.sources[sourceId]; + } + + removeLayer(layerId) { + const layerToRemove = this._style.layers.findIndex(layer => { + return layer.id === layerId; + }); + this._style.layers.splice(layerToRemove, 1); + } +} + +class MockLayer { + constructor(layerId, mbSourceIds, mbLayerIdsToSource) { + this._mbSourceIds = mbSourceIds; + this._mbLayerIdsToSource = mbLayerIdsToSource; + this._layerId = layerId; + } + getId() { + return this._layerId; + } + getMbSourceIds() { + return this._mbSourceIds; + } + getMbLayersIdsToSource() { + return this._mbLayerIdsToSource; + } + + getMbLayerIds() { + return this._mbLayerIdsToSource.map(({ id }) => id); + } + + ownsMbLayerId(mbLayerId) { + return this._mbLayerIdsToSource.some(mbLayerToSource => { + return mbLayerToSource.id === mbLayerId; + }); + } + + ownsMbSourceId(mbSourceId) { + return this._mbSourceIds.some(id => mbSourceId === id); + } +} + +function getMockStyle(orderedMockLayerList) { + const mockStyle = { + sources: {}, + layers: [], + }; + + orderedMockLayerList.forEach(mockLayer => { + mockLayer.getMbSourceIds().forEach(mbSourceId => { + mockStyle.sources[mbSourceId] = {}; + }); + mockLayer.getMbLayersIdsToSource().forEach(({ id, source }) => { + mockStyle.layers.push({ + id: id, + source: source, + }); + }); + }); + + return mockStyle; +} + +function makeSingleSourceMockLayer(layerId) { + return new MockLayer( + layerId, + [layerId], + [ + { id: layerId + '_fill', source: layerId }, + { id: layerId + '_line', source: layerId }, + ] + ); +} + +function makeMultiSourceMockLayer(layerId) { + const source1 = layerId + '_source1'; + const source2 = layerId + '_source2'; + return new MockLayer( + layerId, + [source1, source2], + [ + { id: source1 + '_fill', source: source1 }, + { id: source2 + '_line', source: source2 }, + { id: source1 + '_line', source: source1 }, + { id: source1 + '_point', source: source1 }, + ] + ); +} + +describe('mb/utils', () => { + test('should remove foo and bar layer', async () => { + const bazLayer = makeSingleSourceMockLayer('baz'); + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeSingleSourceMockLayer('bar'); + + const currentLayerList = [bazLayer, fooLayer, barLayer]; + const nextLayerList = [bazLayer]; + + const currentStyle = getMockStyle(currentLayerList); + const mockMbMap = new MockMbMap(currentStyle); + + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerList); + const removedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerList); + expect(removedStyle).toEqual(nextStyle); + }); + + test('should remove foo and bar layer (multisource)', async () => { + const bazLayer = makeMultiSourceMockLayer('baz'); + const fooLayer = makeMultiSourceMockLayer('foo'); + const barLayer = makeMultiSourceMockLayer('bar'); + + const currentLayerList = [bazLayer, fooLayer, barLayer]; + const nextLayerList = [bazLayer]; + + const currentStyle = getMockStyle(currentLayerList); + const mockMbMap = new MockMbMap(currentStyle); + + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerList); + const removedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerList); + expect(removedStyle).toEqual(nextStyle); + }); + + test('should not remove anything', async () => { + const bazLayer = makeSingleSourceMockLayer('baz'); + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeSingleSourceMockLayer('bar'); + + const currentLayerList = [bazLayer, fooLayer, barLayer]; + const nextLayerList = [bazLayer, fooLayer, barLayer]; + + const currentStyle = getMockStyle(currentLayerList); + const mockMbMap = new MockMbMap(currentStyle); + + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerList); + const removedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerList); + expect(removedStyle).toEqual(nextStyle); + }); + + 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); + }); + + test('should reorder foo and bar and remove baz', async () => { + const bazLayer = makeSingleSourceMockLayer('baz'); + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeSingleSourceMockLayer('bar'); + + const currentLayerOrder = [bazLayer, fooLayer, barLayer]; + const nextLayerListOrder = [barLayer, fooLayer]; + + const currentStyle = getMockStyle(currentLayerOrder); + const mockMbMap = new MockMbMap(currentStyle); + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerListOrder); + syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder); + const orderedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerListOrder); + expect(orderedStyle).toEqual(nextStyle); + }); + + test('should reorder foo and bar and remove baz, when having multi-source multi-layer data', async () => { + const bazLayer = makeMultiSourceMockLayer('baz'); + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeMultiSourceMockLayer('bar'); + + const currentLayerOrder = [bazLayer, fooLayer, barLayer]; + const nextLayerListOrder = [barLayer, fooLayer]; + + const currentStyle = getMockStyle(currentLayerOrder); + const mockMbMap = new MockMbMap(currentStyle); + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerListOrder); + syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder); + const orderedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerListOrder); + expect(orderedStyle).toEqual(nextStyle); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap new file mode 100644 index 0000000000000..7e8feeec01bbd --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap @@ -0,0 +1,117 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TooltipControl render tooltipState is not provided should not render tooltip popover when tooltipState is not provided 1`] = `""`; + +exports[`TooltipControl render tooltipState is provided should render tooltip popover with custom tooltip content when renderTooltipContent provided 1`] = ` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="mapTooltip" + isOpen={true} + ownFocus={false} + panelPaddingSize="m" + style={ + Object { + "pointerEvents": "none", + "transform": "translate(11987px, 2987px)", + } + } +> +
+ Custom tooltip content +
+
+`; + +exports[`TooltipControl render tooltipState is provided should render tooltip popover with features tooltip content 1`] = ` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="mapTooltip" + isOpen={true} + ownFocus={false} + panelPaddingSize="m" + style={ + Object { + "pointerEvents": "none", + "transform": "translate(11987px, 2987px)", + } + } +> + + + + +`; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js new file mode 100644 index 0000000000000..6bc9511c6c580 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js @@ -0,0 +1,36 @@ +/* + * 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 { TooltipControl } from './tooltip_control'; +import { setTooltipState } from '../../../../actions/map_actions'; +import { + getLayerList, + getTooltipState, + isDrawingFilter, +} from '../../../../selectors/map_selectors'; + +function mapStateToProps(state = {}) { + return { + layerList: getLayerList(state), + tooltipState: getTooltipState(state), + isDrawingFilter: isDrawingFilter(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + setTooltipState(tooltipState) { + dispatch(setTooltipState(tooltipState)); + }, + clearTooltipState() { + dispatch(setTooltipState(null)); + }, + }; +} + +const connectedTooltipControl = connect(mapStateToProps, mapDispatchToProps)(TooltipControl); +export { connectedTooltipControl as TooltipControl }; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js new file mode 100644 index 0000000000000..cfb92a8677455 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js @@ -0,0 +1,353 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { FEATURE_ID_PROPERTY_NAME, LAT_INDEX, LON_INDEX } from '../../../../../common/constants'; +import { FeaturesTooltip } from '../../features_tooltip/features_tooltip'; +import { EuiPopover, EuiText } from '@elastic/eui'; + +export const TOOLTIP_TYPE = { + HOVER: 'HOVER', + LOCKED: 'LOCKED', +}; + +const noop = () => {}; + +function justifyAnchorLocation(mbLngLat, targetFeature) { + let popupAnchorLocation = [mbLngLat.lng, mbLngLat.lat]; // default popup location to mouse location + if (targetFeature.geometry.type === 'Point') { + const coordinates = targetFeature.geometry.coordinates.slice(); + + // Ensure that if the map is zoomed out such that multiple + // copies of the feature are visible, the popup appears + // over the copy being pointed to. + while (Math.abs(mbLngLat.lng - coordinates[LON_INDEX]) > 180) { + coordinates[0] += mbLngLat.lng > coordinates[LON_INDEX] ? 360 : -360; + } + + popupAnchorLocation = coordinates; + } + return popupAnchorLocation; +} + +export class TooltipControl extends React.Component { + state = { + x: undefined, + y: undefined, + }; + + constructor(props) { + super(props); + this._popoverRef = React.createRef(); + } + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.tooltipState) { + const nextPoint = nextProps.mbMap.project(nextProps.tooltipState.location); + if (nextPoint.x !== prevState.x || nextPoint.y !== prevState.y) { + return { + x: nextPoint.x, + y: nextPoint.y, + }; + } + } + + return null; + } + + componentDidMount() { + this.props.mbMap.on('mouseout', this._onMouseout); + this.props.mbMap.on('mousemove', this._updateHoverTooltipState); + this.props.mbMap.on('move', this._updatePopoverPosition); + this.props.mbMap.on('click', this._lockTooltip); + } + + componentDidUpdate() { + if (this.props.tooltipState && this._popoverRef.current) { + this._popoverRef.current.positionPopoverFluid(); + } + } + + componentWillUnmount() { + this.props.mbMap.off('mouseout', this._onMouseout); + this.props.mbMap.off('mousemove', this._updateHoverTooltipState); + this.props.mbMap.off('move', this._updatePopoverPosition); + this.props.mbMap.off('click', this._lockTooltip); + } + + _onMouseout = () => { + this._updateHoverTooltipState.cancel(); + if (this.props.tooltipState && this.props.tooltipState.type !== TOOLTIP_TYPE.LOCKED) { + this.props.clearTooltipState(); + } + }; + + _updatePopoverPosition = () => { + if (!this.props.tooltipState) { + return; + } + + const lat = this.props.tooltipState.location[LAT_INDEX]; + const lon = this.props.tooltipState.location[LON_INDEX]; + const bounds = this.props.mbMap.getBounds(); + if ( + lat > bounds.getNorth() || + lat < bounds.getSouth() || + lon < bounds.getWest() || + lon > bounds.getEast() + ) { + this.props.clearTooltipState(); + return; + } + + const nextPoint = this.props.mbMap.project(this.props.tooltipState.location); + this.setState({ + x: nextPoint.x, + y: nextPoint.y, + }); + }; + + _getLayerByMbLayerId(mbLayerId) { + return this.props.layerList.find(layer => { + const mbLayerIds = layer.getMbLayerIds(); + return mbLayerIds.indexOf(mbLayerId) > -1; + }); + } + + _getIdsForFeatures(mbFeatures) { + const uniqueFeatures = []; + //there may be duplicates in the results from mapbox + //this is because mapbox returns the results per tile + //for polygons or lines, it might return multiple features, one for each tile + for (let i = 0; i < mbFeatures.length; i++) { + const mbFeature = mbFeatures[i]; + const layer = this._getLayerByMbLayerId(mbFeature.layer.id); + const featureId = mbFeature.properties[FEATURE_ID_PROPERTY_NAME]; + const layerId = layer.getId(); + let match = false; + for (let j = 0; j < uniqueFeatures.length; j++) { + const uniqueFeature = uniqueFeatures[j]; + if (featureId === uniqueFeature.id && layerId === uniqueFeature.layerId) { + match = true; + break; + } + } + if (!match) { + uniqueFeatures.push({ + id: featureId, + layerId: layerId, + }); + } + } + return uniqueFeatures; + } + + _lockTooltip = e => { + if (this.props.isDrawingFilter) { + //ignore click events when in draw mode + return; + } + + this._updateHoverTooltipState.cancel(); //ignore any possible moves + + const mbFeatures = this._getFeaturesUnderPointer(e.point); + if (!mbFeatures.length) { + this.props.clearTooltipState(); + return; + } + + const targetMbFeataure = mbFeatures[0]; + const popupAnchorLocation = justifyAnchorLocation(e.lngLat, targetMbFeataure); + + const features = this._getIdsForFeatures(mbFeatures); + this.props.setTooltipState({ + type: TOOLTIP_TYPE.LOCKED, + features: features, + location: popupAnchorLocation, + }); + }; + + _updateHoverTooltipState = _.debounce(e => { + if (this.props.isDrawingFilter) { + //ignore hover events when in draw mode + return; + } + + if (this.props.tooltipState && this.props.tooltipState.type === TOOLTIP_TYPE.LOCKED) { + //ignore hover events when tooltip is locked + return; + } + + const mbFeatures = this._getFeaturesUnderPointer(e.point); + if (!mbFeatures.length) { + this.props.clearTooltipState(); + return; + } + + const targetMbFeature = mbFeatures[0]; + if (this.props.tooltipState) { + const firstFeature = this.props.tooltipState.features[0]; + if (targetMbFeature.properties[FEATURE_ID_PROPERTY_NAME] === firstFeature.id) { + return; + } + } + + const popupAnchorLocation = justifyAnchorLocation(e.lngLat, targetMbFeature); + const features = this._getIdsForFeatures(mbFeatures); + this.props.setTooltipState({ + type: TOOLTIP_TYPE.HOVER, + features: features, + location: popupAnchorLocation, + }); + }, 100); + + _getMbLayerIdsForTooltips() { + const mbLayerIds = this.props.layerList.reduce((mbLayerIds, layer) => { + return layer.canShowTooltip() ? mbLayerIds.concat(layer.getMbLayerIds()) : mbLayerIds; + }, []); + + //Ensure that all layers are actually on the map. + //The raw list may contain layer-ids that have not been added to the map yet. + //For example: + //a vector or heatmap layer will not add a source and layer to the mapbox-map, until that data is available. + //during that data-fetch window, the app should not query for layers that do not exist. + return mbLayerIds.filter(mbLayerId => { + return !!this.props.mbMap.getLayer(mbLayerId); + }); + } + + _getFeaturesUnderPointer(mbLngLatPoint) { + if (!this.props.mbMap) { + return []; + } + + const mbLayerIds = this._getMbLayerIdsForTooltips(); + const PADDING = 2; //in pixels + const mbBbox = [ + { + x: mbLngLatPoint.x - PADDING, + y: mbLngLatPoint.y - PADDING, + }, + { + x: mbLngLatPoint.x + PADDING, + y: mbLngLatPoint.y + PADDING, + }, + ]; + return this.props.mbMap.queryRenderedFeatures(mbBbox, { layers: mbLayerIds }); + } + + // Must load original geometry instead of using geometry from mapbox feature. + // Mapbox feature geometry is from vector tile and is not the same as the original geometry. + _loadFeatureGeometry = ({ layerId, featureId }) => { + const tooltipLayer = this._findLayerById(layerId); + if (!tooltipLayer) { + return null; + } + + const targetFeature = tooltipLayer.getFeatureById(featureId); + if (!targetFeature) { + return null; + } + + return targetFeature.geometry; + }; + + _loadFeatureProperties = async ({ layerId, featureId }) => { + const tooltipLayer = this._findLayerById(layerId); + if (!tooltipLayer) { + return []; + } + + const targetFeature = tooltipLayer.getFeatureById(featureId); + if (!targetFeature) { + return []; + } + return await tooltipLayer.getPropertiesForTooltip(targetFeature.properties); + }; + + _loadPreIndexedShape = async ({ layerId, featureId }) => { + const tooltipLayer = this._findLayerById(layerId); + if (!tooltipLayer) { + return null; + } + + const targetFeature = tooltipLayer.getFeatureById(featureId); + if (!targetFeature) { + return null; + } + + return await tooltipLayer.getSource().getPreIndexedShape(targetFeature.properties); + }; + + _findLayerById = layerId => { + return this.props.layerList.find(layer => { + return layer.getId() === layerId; + }); + }; + + _getLayerName = async layerId => { + const layer = this._findLayerById(layerId); + if (!layer) { + return null; + } + + return layer.getDisplayName(); + }; + + _renderTooltipContent = () => { + const publicProps = { + addFilters: this.props.addFilters, + closeTooltip: this.props.clearTooltipState, + features: this.props.tooltipState.features, + isLocked: this.props.tooltipState.type === TOOLTIP_TYPE.LOCKED, + loadFeatureProperties: this._loadFeatureProperties, + loadFeatureGeometry: this._loadFeatureGeometry, + getLayerName: this._getLayerName, + }; + + if (this.props.renderTooltipContent) { + return this.props.renderTooltipContent(publicProps); + } + + return ( + + + + ); + }; + + render() { + if (!this.props.tooltipState) { + return null; + } + + const tooltipAnchor = ( +
+ ); + return ( + + {this._renderTooltipContent()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js new file mode 100644 index 0000000000000..b9dc668cfb016 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js @@ -0,0 +1,350 @@ +/* + * 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. + */ + +jest.mock('../../features_tooltip/features_tooltip', () => ({ + FeaturesTooltip: () => { + return
mockFeaturesTooltip
; + }, +})); + +import sinon from 'sinon'; +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { TooltipControl, TOOLTIP_TYPE } from './tooltip_control'; + +// mutable map state +let featuresAtLocation; +let mapCenter; +let mockMbMapBounds; + +const layerId = 'tfi3f'; +const mbLayerId = 'tfi3f_circle'; +const mockLayer = { + getMbLayerIds: () => { + return [mbLayerId]; + }, + getId: () => { + return layerId; + }, + canShowTooltip: () => { + return true; + }, + getFeatureById: () => { + return { + geometry: { + type: 'Point', + coordinates: [102.0, 0.5], + }, + }; + }, +}; + +const mockMbMapHandlers = {}; +const mockMBMap = { + project: lonLatArray => { + const lonDistanceFromCenter = Math.abs(lonLatArray[0] - mapCenter[0]); + const latDistanceFromCenter = Math.abs(lonLatArray[1] - mapCenter[1]); + return { + x: lonDistanceFromCenter * 100, + y: latDistanceFromCenter * 100, + }; + }, + on: (eventName, callback) => { + mockMbMapHandlers[eventName] = callback; + }, + off: eventName => { + delete mockMbMapHandlers[eventName]; + }, + getBounds: () => { + return { + getNorth: () => { + return mockMbMapBounds.north; + }, + getSouth: () => { + return mockMbMapBounds.south; + }, + getWest: () => { + return mockMbMapBounds.west; + }, + getEast: () => { + return mockMbMapBounds.east; + }, + }; + }, + getLayer: () => {}, + queryRenderedFeatures: () => { + return featuresAtLocation; + }, +}; + +const defaultProps = { + mbMap: mockMBMap, + clearTooltipState: () => {}, + setTooltipState: () => {}, + layerList: [mockLayer], + isDrawingFilter: false, + addFilters: () => {}, + geoFields: [{}], +}; + +const hoverTooltipState = { + type: TOOLTIP_TYPE.HOVER, + location: [-120, 30], + features: [ + { + id: 1, + layerId: layerId, + geometry: {}, + }, + ], +}; + +const lockedTooltipState = { + type: TOOLTIP_TYPE.LOCKED, + location: [-120, 30], + features: [ + { + id: 1, + layerId: layerId, + geometry: {}, + }, + ], +}; + +describe('TooltipControl', () => { + beforeEach(() => { + featuresAtLocation = []; + mapCenter = [0, 0]; + mockMbMapBounds = { + west: -180, + east: 180, + north: 90, + south: -90, + }; + }); + + describe('render', () => { + describe('tooltipState is not provided', () => { + test('should not render tooltip popover when tooltipState is not provided', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + }); + + describe('tooltipState is provided', () => { + test('should render tooltip popover with features tooltip content', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('should render tooltip popover with custom tooltip content when renderTooltipContent provided', () => { + const component = shallow( + { + return
Custom tooltip content
; + }} + /> + ); + + expect(component).toMatchSnapshot(); + }); + }); + }); + + describe('on mouse out', () => { + const clearTooltipStateStub = sinon.stub(); + + beforeEach(() => { + clearTooltipStateStub.reset(); + }); + + test('should clear hover tooltip state', () => { + mount( + + ); + + mockMbMapHandlers.mouseout(); + + sinon.assert.calledOnce(clearTooltipStateStub); + }); + + test('should not clear locked tooltip state', () => { + mount( + + ); + + mockMbMapHandlers.mouseout(); + + sinon.assert.notCalled(clearTooltipStateStub); + }); + }); + + describe('on click', () => { + const mockMapMouseEvent = { + point: { x: 0, y: 0 }, + lngLat: { lng: 0, lat: 0 }, + }; + const setTooltipStateStub = sinon.stub(); + const clearTooltipStateStub = sinon.stub(); + + beforeEach(() => { + setTooltipStateStub.reset(); + clearTooltipStateStub.reset(); + }); + + test('should ignore clicks when map is in drawing mode', () => { + mount( + + ); + + mockMbMapHandlers.click(mockMapMouseEvent); + + sinon.assert.notCalled(clearTooltipStateStub); + sinon.assert.notCalled(setTooltipStateStub); + }); + + test('should clear tooltip state when there are no features at clicked location', () => { + featuresAtLocation = []; + mount( + + ); + + mockMbMapHandlers.click(mockMapMouseEvent); + + sinon.assert.calledOnce(clearTooltipStateStub); + sinon.assert.notCalled(setTooltipStateStub); + }); + + test('should set tooltip state when there are features at clicked location and remove duplicate features', () => { + const feature = { + geometry: { + type: 'Point', + coordinates: [100, 30], + }, + layer: { + id: mbLayerId, + }, + properties: { + __kbn__feature_id__: 1, + }, + }; + featuresAtLocation = [feature, feature]; + mount( + + ); + + mockMbMapHandlers.click(mockMapMouseEvent); + + sinon.assert.notCalled(clearTooltipStateStub); + sinon.assert.calledWith(setTooltipStateStub, { + features: [{ id: 1, layerId: 'tfi3f' }], + location: [100, 30], + type: 'LOCKED', + }); + }); + }); + + describe('on map move', () => { + const clearTooltipStateStub = sinon.stub(); + + beforeEach(() => { + clearTooltipStateStub.reset(); + }); + + test('should safely handle map move when there is no tooltip location', () => { + const component = mount( + + ); + + mockMbMapHandlers.move(); + component.update(); + + sinon.assert.notCalled(clearTooltipStateStub); + }); + + test('should update popover location', () => { + const component = mount( + + ); + + // ensure x and y set from original tooltipState.location + expect(component.state('x')).toBe(12000); + expect(component.state('y')).toBe(3000); + + mapCenter = [25, -15]; + mockMbMapHandlers.move(); + component.update(); + + // ensure x and y updated from new map center with same tooltipState.location + expect(component.state('x')).toBe(14500); + expect(component.state('y')).toBe(4500); + + sinon.assert.notCalled(clearTooltipStateStub); + }); + + test('should clear tooltip state if tooltip location is outside map bounds', () => { + const component = mount( + + ); + + // move map bounds outside of hoverTooltipState.location, which is [-120, 30] + mockMbMapBounds = { + west: -180, + east: -170, + north: 90, + south: 80, + }; + mockMbMapHandlers.move(); + component.update(); + + sinon.assert.calledOnce(clearTooltipStateStub); + }); + }); + + test('should un-register all map callbacks on unmount', () => { + const component = mount(); + + expect(Object.keys(mockMbMapHandlers).length).toBe(4); + + component.unmount(); + expect(Object.keys(mockMbMapHandlers).length).toBe(0); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/utils.js b/x-pack/plugins/maps/public/connected_components/map/mb/utils.js new file mode 100644 index 0000000000000..413d66fce7f70 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/utils.js @@ -0,0 +1,144 @@ +/* + * 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 _ from 'lodash'; +import { RGBAImage } from './image_utils'; + +export function removeOrphanedSourcesAndLayers(mbMap, layerList) { + const mbStyle = mbMap.getStyle(); + + const mbLayerIdsToRemove = []; + mbStyle.layers.forEach(mbLayer => { + const layer = layerList.find(layer => { + return layer.ownsMbLayerId(mbLayer.id); + }); + if (!layer) { + mbLayerIdsToRemove.push(mbLayer.id); + } + }); + mbLayerIdsToRemove.forEach(mbLayerId => mbMap.removeLayer(mbLayerId)); + + const mbSourcesToRemove = []; + for (const mbSourceId in mbStyle.sources) { + if (mbStyle.sources.hasOwnProperty(mbSourceId)) { + const layer = layerList.find(layer => { + return layer.ownsMbSourceId(mbSourceId); + }); + if (!layer) { + mbSourcesToRemove.push(mbSourceId); + } + } + } + mbSourcesToRemove.forEach(mbSourceId => mbMap.removeSource(mbSourceId)); +} + +/** + * 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.map(mbLayer => { + const layer = layerList.find(layer => layer.ownsMbLayerId(mbLayer.id)); + return 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); + } + }); +} + +function getImageData(img) { + const canvas = window.document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('failed to create canvas 2d context'); + } + canvas.width = img.width; + canvas.height = img.height; + context.drawImage(img, 0, 0, img.width, img.height); + return context.getImageData(0, 0, img.width, img.height); +} + +export async function loadSpriteSheetImageData(imgUrl) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'Anonymous'; + image.onload = el => { + const imgData = getImageData(el.currentTarget); + resolve(imgData); + }; + image.onerror = e => { + reject(e); + }; + image.src = imgUrl; + }); +} + +export function addSpriteSheetToMapFromImageData(json, imgData, mbMap) { + for (const imageId in json) { + if (!(json.hasOwnProperty(imageId) && !mbMap.hasImage(imageId))) { + continue; + } + const { width, height, x, y, sdf, pixelRatio } = json[imageId]; + if (typeof width !== 'number' || typeof height !== 'number') { + continue; + } + + const data = new RGBAImage({ width, height }); + RGBAImage.copy(imgData, data, { x, y }, { x: 0, y: 0 }, { width, height }); + mbMap.addImage(imageId, data, { pixelRatio, sdf }); + } +} + +export async function addSpritesheetToMap(json, imgUrl, mbMap) { + const imgData = await loadSpriteSheetImageData(imgUrl); + addSpriteSheetToMapFromImageData(json, imgData, mbMap); +} diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/view.js b/x-pack/plugins/maps/public/connected_components/map/mb/view.js new file mode 100644 index 0000000000000..42beb40e48617 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/view.js @@ -0,0 +1,316 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { ResizeChecker } from '../../../../../../../src/plugins/kibana_utils/public'; +import { + syncLayerOrderForSingleLayer, + removeOrphanedSourcesAndLayers, + addSpritesheetToMap, +} from './utils'; +import { getGlyphUrl, isRetina } from '../../../meta'; +import { DECIMAL_DEGREES_PRECISION, ZOOM_PRECISION } from '../../../../common/constants'; +import mapboxgl from 'mapbox-gl/dist/mapbox-gl-csp'; +import mbWorkerUrl from '!!file-loader!mapbox-gl/dist/mapbox-gl-csp-worker'; +import mbRtlPlugin from '!!file-loader!@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js'; +import { spritesheet } from '@elastic/maki'; +import sprites1 from '@elastic/maki/dist/sprite@1.png'; +import sprites2 from '@elastic/maki/dist/sprite@2.png'; +import { DrawControl } from './draw_control'; +import { TooltipControl } from './tooltip_control'; + +// TODO +import chrome from 'ui/chrome'; + +mapboxgl.workerUrl = mbWorkerUrl; +mapboxgl.setRTLTextPlugin(mbRtlPlugin); + +export class MBMapContainer extends React.Component { + state = { + prevLayerList: undefined, + hasSyncedLayerList: false, + mbMap: undefined, + }; + + static getDerivedStateFromProps(nextProps, prevState) { + const nextLayerList = nextProps.layerList; + if (nextLayerList !== prevState.prevLayerList) { + return { + prevLayerList: nextLayerList, + hasSyncedLayerList: false, + }; + } + + return null; + } + + componentDidMount() { + this._initializeMap(); + this._isMounted = true; + } + + componentDidUpdate() { + if (this.state.mbMap) { + // do not debounce syncing of map-state + this._syncMbMapWithMapState(); + this._debouncedSync(); + } + } + + componentWillUnmount() { + this._isMounted = false; + if (this._checker) { + this._checker.destroy(); + } + if (this.state.mbMap) { + this.state.mbMap.remove(); + this.state.mbMap = null; + } + this.props.onMapDestroyed(); + } + + _debouncedSync = _.debounce(() => { + if (this._isMounted) { + if (!this.state.hasSyncedLayerList) { + this.setState( + { + hasSyncedLayerList: true, + }, + () => { + this._syncMbMapWithLayerList(); + this._syncMbMapWithInspector(); + } + ); + } + } + }, 256); + + _getMapState() { + const zoom = this.state.mbMap.getZoom(); + const mbCenter = this.state.mbMap.getCenter(); + const mbBounds = this.state.mbMap.getBounds(); + return { + zoom: _.round(zoom, ZOOM_PRECISION), + center: { + lon: _.round(mbCenter.lng, DECIMAL_DEGREES_PRECISION), + lat: _.round(mbCenter.lat, DECIMAL_DEGREES_PRECISION), + }, + extent: { + minLon: _.round(mbBounds.getWest(), DECIMAL_DEGREES_PRECISION), + minLat: _.round(mbBounds.getSouth(), DECIMAL_DEGREES_PRECISION), + maxLon: _.round(mbBounds.getEast(), DECIMAL_DEGREES_PRECISION), + maxLat: _.round(mbBounds.getNorth(), DECIMAL_DEGREES_PRECISION), + }, + }; + } + + async _createMbMapInstance() { + return new Promise(resolve => { + const mbStyle = { + version: 8, + sources: {}, + layers: [], + }; + const glyphUrl = getGlyphUrl(); + if (glyphUrl) { + mbStyle.glyphs = glyphUrl; + } + + const options = { + attributionControl: false, + container: this.refs.mapContainer, + style: mbStyle, + scrollZoom: this.props.scrollZoom, + preserveDrawingBuffer: chrome.getInjected('preserveDrawingBuffer', false), + interactive: !this.props.disableInteractive, + }; + const initialView = _.get(this.props.goto, 'center'); + if (initialView) { + options.zoom = initialView.zoom; + options.center = { + lng: initialView.lon, + lat: initialView.lat, + }; + } else { + options.bounds = [-170, -60, 170, 75]; + } + const mbMap = new mapboxgl.Map(options); + mbMap.dragRotate.disable(); + mbMap.touchZoomRotate.disableRotation(); + if (!this.props.disableInteractive) { + mbMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-left'); + } + + let emptyImage; + mbMap.on('styleimagemissing', e => { + if (emptyImage) { + mbMap.addImage(e.id, emptyImage); + } + }); + mbMap.on('load', () => { + emptyImage = new Image(); + + emptyImage.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQYV2NgAAIAAAUAAarVyFEAAAAASUVORK5CYII='; + emptyImage.crossOrigin = 'anonymous'; + resolve(mbMap); + }); + }); + } + + async _initializeMap() { + let mbMap; + try { + mbMap = await this._createMbMapInstance(); + } catch (error) { + this.props.setMapInitError(error.message); + return; + } + + if (!this._isMounted) { + return; + } + + this.setState({ mbMap }, () => { + this._loadMakiSprites(); + this._initResizerChecker(); + this._registerMapEventListeners(); + this.props.onMapReady(this._getMapState()); + }); + } + + _registerMapEventListeners() { + // moveend callback is debounced to avoid updating map extent state while map extent is still changing + // moveend is fired while the map extent is still changing in the following scenarios + // 1) During opening/closing of layer details panel, the EUI animation results in 8 moveend events + // 2) Setting map zoom and center from goto is done in 2 API calls, resulting in 2 moveend events + this.state.mbMap.on( + 'moveend', + _.debounce(() => { + this.props.extentChanged(this._getMapState()); + }, 100) + ); + // Attach event only if view control is visible, which shows lat/lon + if (!this.props.hideViewControl) { + const throttledSetMouseCoordinates = _.throttle(e => { + this.props.setMouseCoordinates({ + lat: e.lngLat.lat, + lon: e.lngLat.lng, + }); + }, 100); + this.state.mbMap.on('mousemove', throttledSetMouseCoordinates); + this.state.mbMap.on('mouseout', () => { + throttledSetMouseCoordinates.cancel(); // cancel any delayed setMouseCoordinates invocations + this.props.clearMouseCoordinates(); + }); + } + } + + _initResizerChecker() { + this._checker = new ResizeChecker(this.refs.mapContainer); + this._checker.on('resize', () => { + this.state.mbMap.resize(); + }); + } + + _loadMakiSprites() { + const sprites = isRetina() ? sprites2 : sprites1; + const json = isRetina() ? spritesheet[2] : spritesheet[1]; + addSpritesheetToMap(json, sprites, this.state.mbMap); + } + + _syncMbMapWithMapState = () => { + const { isMapReady, goto, clearGoto } = this.props; + + if (!isMapReady || !goto) { + return; + } + + clearGoto(); + + if (goto.bounds) { + //clamping ot -89/89 latitudes since Mapboxgl does not seem to handle bounds that contain the poles (logs errors to the console when using -90/90) + const lnLatBounds = new mapboxgl.LngLatBounds( + new mapboxgl.LngLat( + clamp(goto.bounds.min_lon, -180, 180), + clamp(goto.bounds.min_lat, -89, 89) + ), + new mapboxgl.LngLat( + clamp(goto.bounds.max_lon, -180, 180), + clamp(goto.bounds.max_lat, -89, 89) + ) + ); + //maxZoom ensure we're not zooming in too far on single points or small shapes + //the padding is to avoid too tight of a fit around edges + this.state.mbMap.fitBounds(lnLatBounds, { maxZoom: 17, padding: 16 }); + } else if (goto.center) { + this.state.mbMap.setZoom(goto.center.zoom); + this.state.mbMap.setCenter({ + lng: goto.center.lon, + lat: goto.center.lat, + }); + } + }; + + _syncMbMapWithLayerList = () => { + if (!this.props.isMapReady) { + return; + } + + removeOrphanedSourcesAndLayers(this.state.mbMap, this.props.layerList); + this.props.layerList.forEach(layer => layer.syncLayerWithMB(this.state.mbMap)); + syncLayerOrderForSingleLayer(this.state.mbMap, this.props.layerList); + }; + + _syncMbMapWithInspector = () => { + if (!this.props.isMapReady || !this.props.inspectorAdapters.map) { + return; + } + + const stats = { + center: this.state.mbMap.getCenter().toArray(), + zoom: this.state.mbMap.getZoom(), + }; + this.props.inspectorAdapters.map.setMapState({ + stats, + style: this.state.mbMap.getStyle(), + }); + }; + + render() { + let drawControl; + let tooltipControl; + if (this.state.mbMap) { + drawControl = ; + tooltipControl = !this.props.disableTooltipControl ? ( + + ) : null; + } + return ( +
+ {drawControl} + {tooltipControl} +
+ ); + } +} + +function clamp(val, min, max) { + if (val > max) val = max; + else if (val < min) val = min; + return val; +} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss new file mode 100644 index 0000000000000..01aea403b27f0 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss @@ -0,0 +1,21 @@ +@import './tools_control/index'; + +.mapToolbarOverlay { + position: absolute; + top: ($euiSizeM + $euiSizeS) + ($euiSizeXL * 2); // Position and height of mapbox controls plus margin + left: $euiSizeM; + z-index: 2; // Sit on top of mapbox controls shadow +} + +.mapToolbarOverlay__button { + @include size($euiSizeXL); + // sass-lint:disable-block no-important + background-color: $euiColorEmptyShade !important; + pointer-events: all; + + &:enabled, + &:enabled:hover, + &:enabled:focus { + @include euiBottomShadowLarge; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/index.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/index.js new file mode 100644 index 0000000000000..cf287fe14c5d6 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/index.js @@ -0,0 +1,15 @@ +/* + * 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 { ToolbarOverlay } from './toolbar_overlay'; + +function mapStateToProps() { + return {}; +} + +const connectedToolbarOverlay = connect(mapStateToProps, null)(ToolbarOverlay); +export { connectedToolbarOverlay as ToolbarOverlay }; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/index.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/index.js new file mode 100644 index 0000000000000..2b6fae26098be --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/index.js @@ -0,0 +1,38 @@ +/* + * 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 { SetViewControl } from './set_view_control'; +import { setGotoWithCenter } from '../../../actions/map_actions'; +import { getMapZoom, getMapCenter } from '../../../selectors/map_selectors'; +import { closeSetView, openSetView } from '../../../actions/ui_actions'; +import { getIsSetViewOpen } from '../../../selectors/ui_selectors'; + +function mapStateToProps(state = {}) { + return { + isSetViewOpen: getIsSetViewOpen(state), + zoom: getMapZoom(state), + center: getMapCenter(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + onSubmit: ({ lat, lon, zoom }) => { + dispatch(closeSetView()); + dispatch(setGotoWithCenter({ lat, lon, zoom })); + }, + closeSetView: () => { + dispatch(closeSetView()); + }, + openSetView: () => { + dispatch(openSetView()); + }, + }; +} + +const connectedSetViewControl = connect(mapStateToProps, mapDispatchToProps)(SetViewControl); +export { connectedSetViewControl as SetViewControl }; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.js new file mode 100644 index 0000000000000..9c983447bfbf6 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.js @@ -0,0 +1,197 @@ +/* + * 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, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiForm, + EuiFormRow, + EuiButton, + EuiFieldNumber, + EuiButtonIcon, + EuiPopover, + EuiTextAlign, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { MAX_ZOOM, MIN_ZOOM } from '../../../../common/constants'; + +function getViewString(lat, lon, zoom) { + return `${lat},${lon},${zoom}`; +} + +export class SetViewControl extends Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + const nextView = getViewString(nextProps.center.lat, nextProps.center.lon, nextProps.zoom); + if (nextView !== prevState.prevView) { + return { + lat: nextProps.center.lat, + lon: nextProps.center.lon, + zoom: nextProps.zoom, + prevView: nextView, + }; + } + + return null; + } + + _togglePopover = () => { + if (this.props.isSetViewOpen) { + this.props.closeSetView(); + return; + } + + this.props.openSetView(); + }; + + _onLatChange = evt => { + this._onChange('lat', evt); + }; + + _onLonChange = evt => { + this._onChange('lon', evt); + }; + + _onZoomChange = evt => { + this._onChange('zoom', evt); + }; + + _onChange = (name, evt) => { + const sanitizedValue = parseFloat(evt.target.value); + this.setState({ + [name]: isNaN(sanitizedValue) ? '' : sanitizedValue, + }); + }; + + _renderNumberFormRow = ({ value, min, max, onChange, label, dataTestSubj }) => { + const isInvalid = value === '' || value > max || value < min; + const error = isInvalid ? `Must be between ${min} and ${max}` : null; + return { + isInvalid, + component: ( + + + + ), + }; + }; + + _onSubmit = () => { + const { lat, lon, zoom } = this.state; + this.props.onSubmit({ lat, lon, zoom }); + }; + + _renderSetViewForm() { + const { isInvalid: isLatInvalid, component: latFormRow } = this._renderNumberFormRow({ + value: this.state.lat, + min: -90, + max: 90, + onChange: this._onLatChange, + label: i18n.translate('xpack.maps.setViewControl.latitudeLabel', { + defaultMessage: 'Latitude', + }), + dataTestSubj: 'latitudeInput', + }); + + const { isInvalid: isLonInvalid, component: lonFormRow } = this._renderNumberFormRow({ + value: this.state.lon, + min: -180, + max: 180, + onChange: this._onLonChange, + label: i18n.translate('xpack.maps.setViewControl.longitudeLabel', { + defaultMessage: 'Longitude', + }), + dataTestSubj: 'longitudeInput', + }); + + const { isInvalid: isZoomInvalid, component: zoomFormRow } = this._renderNumberFormRow({ + value: this.state.zoom, + min: MIN_ZOOM, + max: MAX_ZOOM, + onChange: this._onZoomChange, + label: i18n.translate('xpack.maps.setViewControl.zoomLabel', { + defaultMessage: 'Zoom', + }), + dataTestSubj: 'zoomInput', + }); + + return ( + + {latFormRow} + + {lonFormRow} + + {zoomFormRow} + + + + + + + + + + ); + } + + render() { + return ( + + } + isOpen={this.props.isSetViewOpen} + closePopover={this.props.closeSetView} + > + {this._renderSetViewForm()} + + ); + } +} + +SetViewControl.propTypes = { + isSetViewOpen: PropTypes.bool.isRequired, + zoom: PropTypes.number.isRequired, + center: PropTypes.shape({ + lat: PropTypes.number.isRequired, + lon: PropTypes.number.isRequired, + }), + onSubmit: PropTypes.func.isRequired, + closeSetView: PropTypes.func.isRequired, + openSetView: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js new file mode 100644 index 0000000000000..32668be8f8f67 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js @@ -0,0 +1,43 @@ +/* + * 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 from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { SetViewControl } from './set_view_control'; +import { ToolsControl } from './tools_control'; + +export class ToolbarOverlay extends React.Component { + _renderToolsControl() { + const { addFilters, geoFields } = this.props; + if (!addFilters || !geoFields.length) { + return null; + } + + return ( + + + + ); + } + + render() { + return ( + + + + + + {this._renderToolsControl()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.js.snap b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.js.snap new file mode 100644 index 0000000000000..681c3f0fbfd61 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.js.snap @@ -0,0 +1,196 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render cancel button when drawing 1`] = ` + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} + > + , + "id": 1, + "title": "Draw shape", + }, + Object { + "content": , + "id": 2, + "title": "Draw bounds", + }, + ] + } + /> + + + + + + + + +`; + +exports[`renders 1`] = ` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "id": 1, + "title": "Draw shape", + }, + Object { + "content": , + "id": 2, + "title": "Draw bounds", + }, + ] + } + /> + +`; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/_index.scss b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/_index.scss new file mode 100644 index 0000000000000..de86299d559e8 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/_index.scss @@ -0,0 +1,3 @@ +.mapDrawControl__geometryFilterForm { + padding: $euiSizeS; +} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.js new file mode 100644 index 0000000000000..89f5748adb283 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.js @@ -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 { connect } from 'react-redux'; +import { ToolsControl } from './tools_control'; +import { isDrawingFilter } from '../../../selectors/map_selectors'; +import { updateDrawState } from '../../../actions/map_actions'; + +function mapStateToProps(state = {}) { + return { + isDrawingFilter: isDrawingFilter(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + initiateDraw: options => { + dispatch(updateDrawState(options)); + }, + cancelDraw: () => { + dispatch(updateDrawState(null)); + }, + }; +} + +const connectedToolsControl = connect(mapStateToProps, mapDispatchToProps)(ToolsControl); +export { connectedToolsControl as ToolsControl }; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js new file mode 100644 index 0000000000000..ea6ffe3ba1435 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js @@ -0,0 +1,175 @@ +/* + * 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, { Component } from 'react'; +import { + EuiButtonIcon, + EuiPopover, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DRAW_TYPE } from '../../../../common/constants'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { GeometryFilterForm } from '../../../components/geometry_filter_form'; + +const DRAW_SHAPE_LABEL = i18n.translate('xpack.maps.toolbarOverlay.drawShapeLabel', { + defaultMessage: 'Draw shape to filter data', +}); + +const DRAW_BOUNDS_LABEL = i18n.translate('xpack.maps.toolbarOverlay.drawBoundsLabel', { + defaultMessage: 'Draw bounds to filter data', +}); + +const DRAW_SHAPE_LABEL_SHORT = i18n.translate('xpack.maps.toolbarOverlay.drawShapeLabelShort', { + defaultMessage: 'Draw shape', +}); + +const DRAW_BOUNDS_LABEL_SHORT = i18n.translate('xpack.maps.toolbarOverlay.drawBoundsLabelShort', { + defaultMessage: 'Draw bounds', +}); + +export class ToolsControl extends Component { + state = { + isPopoverOpen: false, + }; + + _togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + _closePopover = () => { + this.setState({ isPopoverOpen: false }); + }; + + _initiateShapeDraw = options => { + this.props.initiateDraw({ + drawType: DRAW_TYPE.POLYGON, + ...options, + }); + this._closePopover(); + }; + + _initiateBoundsDraw = options => { + this.props.initiateDraw({ + drawType: DRAW_TYPE.BOUNDS, + ...options, + }); + this._closePopover(); + }; + + _getDrawPanels() { + return [ + { + id: 0, + title: i18n.translate('xpack.maps.toolbarOverlay.tools.toolbarTitle', { + defaultMessage: 'Tools', + }), + items: [ + { + name: DRAW_SHAPE_LABEL, + panel: 1, + }, + { + name: DRAW_BOUNDS_LABEL, + panel: 2, + }, + ], + }, + { + id: 1, + title: DRAW_SHAPE_LABEL_SHORT, + content: ( + + ), + }, + { + id: 2, + title: DRAW_BOUNDS_LABEL_SHORT, + content: ( + + ), + }, + ]; + } + + _renderToolsButton() { + return ( + + ); + } + + render() { + const toolsPopoverButton = ( + + + + ); + + if (!this.props.isDrawingFilter) { + return toolsPopoverButton; + } + + return ( + + {toolsPopoverButton} + + + + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.js new file mode 100644 index 0000000000000..f0e6dd43e68a1 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.js @@ -0,0 +1,34 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; +import { ToolsControl } from './tools_control'; + +const defaultProps = { + initiateDraw: () => {}, + cancelDraw: () => {}, + geoFields: [ + { + geoFieldName: 'location', + geoFieldType: 'geo_point', + indexPatternTitle: 'my_index', + indexPatternId: '1', + }, + ], +}; + +test('renders', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('Should render cancel button when drawing', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/_index.scss b/x-pack/plugins/maps/public/connected_components/widget_overlay/_index.scss new file mode 100644 index 0000000000000..cc1ab35039dac --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/_index.scss @@ -0,0 +1,6 @@ +@import './mixins'; + +@import './widget_overlay'; +@import './attribution_control/attribution_control'; +@import './layer_control/index'; +@import './view_control/view_control'; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/_mixins.scss b/x-pack/plugins/maps/public/connected_components/widget_overlay/_mixins.scss new file mode 100644 index 0000000000000..88ae78fde6ace --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/_mixins.scss @@ -0,0 +1,12 @@ +@mixin mapOverlayIsTextOnly { + text-shadow: + 0 0 2px $euiColorEmptyShade, + // Multiple shadows helps turn it into an outline since + // text shadows have no spread value + 0 0 1px $euiColorEmptyShade, + 0 0 1px $euiColorEmptyShade, + 0 0 1px $euiColorEmptyShade, + 0 0 1px $euiColorEmptyShade, + 0 0 1px $euiColorEmptyShade, + 0 0 1px $euiColorEmptyShade; +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/_widget_overlay.scss b/x-pack/plugins/maps/public/connected_components/widget_overlay/_widget_overlay.scss new file mode 100644 index 0000000000000..e45f0bef3c040 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/_widget_overlay.scss @@ -0,0 +1,47 @@ +/** + * 1. The overlay captures mouse events even if it's empty space. To counter-act this, + * we remove all pointer events from the overlay then add them back on the + * individual widgets. + */ + +.mapWidgetOverlay { + position: absolute; + z-index: $euiZLevel1; + top: $euiSizeM; + right: $euiSizeM; + bottom: $euiSizeM; + left: 0; // Necessary to have a left value for IE + pointer-events: none; /* 1 */ +} + +.mapWidgetOverlay__layerWrapper { + align-items: flex-end; + width: $euiSize * 20; + min-height: 0; // Fixes scroll in Firefox +} + +.mapWidgetControl__headerFlexItem { + flex-shrink: 0; +} + +.mapWidgetControl { + max-height: 100%; + width: 100%; + overflow: hidden; + padding-bottom: $euiSizeS; // ensures the scrollbar doesn't appear unnecessarily because of flex group negative margins + // sass-lint:disable-block no-important + border-color: transparent !important; + flex-direction: column; + display: flex; + pointer-events: all; /* 1 */ + + &.mapWidgetControl-hasShadow { + @include euiBottomShadowLarge; + } +} + +.mapWidgetControl__header { + padding: 0 $euiSize; + flex-shrink: 0; + text-transform: uppercase; +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/__snapshots__/view.test.js.snap new file mode 100644 index 0000000000000..00381b68382d8 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/__snapshots__/view.test.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AttributionControl is rendered 1`] = ` +
+ + + + + attribution with link + + , + attribution with no link + + + +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/_attribution_control.scss b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/_attribution_control.scss new file mode 100644 index 0000000000000..9ebaee57fba4d --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/_attribution_control.scss @@ -0,0 +1,6 @@ +.mapAttributionControl { + @include mapOverlayIsTextOnly; + @include euiTextBreakWord; + pointer-events: all; + padding-left: $euiSizeM; +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/index.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/index.js new file mode 100644 index 0000000000000..e73a51ffa2ced --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/index.js @@ -0,0 +1,22 @@ +/* + * 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 { AttributionControl } from './view'; +import { getLayerList } from '../../../selectors/map_selectors'; + +function mapStateToProps(state = {}) { + return { + layerList: getLayerList(state), + }; +} + +function mapDispatchToProps() { + return {}; +} + +const connectedViewControl = connect(mapStateToProps, mapDispatchToProps)(AttributionControl); +export { connectedViewControl as AttributionControl }; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.js new file mode 100644 index 0000000000000..161b5b81c1255 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.js @@ -0,0 +1,98 @@ +/* + * 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 _ from 'lodash'; +import { EuiText, EuiLink } from '@elastic/eui'; + +export class AttributionControl extends React.Component { + state = { + uniqueAttributions: [], + }; + + componentDidMount() { + this._isMounted = true; + this._loadAttributions(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidUpdate() { + this._loadAttributions(); + } + + _loadAttributions = async () => { + const attributionPromises = this.props.layerList.map(async layer => { + try { + return await layer.getAttributions(); + } catch (error) { + return []; + } + }); + const attributions = await Promise.all(attributionPromises); + if (!this._isMounted) { + return; + } + + const uniqueAttributions = []; + for (let i = 0; i < attributions.length; i++) { + for (let j = 0; j < attributions[i].length; j++) { + const testAttr = attributions[i][j]; + const attr = uniqueAttributions.find(added => { + return added.url === testAttr.url && added.label === testAttr.label; + }); + if (!attr) { + uniqueAttributions.push(testAttr); + } + } + } + // Reflect top-to-bottom layer order as left-to-right in attribs + uniqueAttributions.reverse(); + if (!_.isEqual(this.state.uniqueAttributions, uniqueAttributions)) { + this.setState({ uniqueAttributions }); + } + }; + + _renderAttribution({ url, label }) { + if (!url) { + return label; + } + + return ( + + {label} + + ); + } + + _renderAttributions() { + return this.state.uniqueAttributions.map((attribution, index) => { + return ( + + {this._renderAttribution(attribution)} + {index < this.state.uniqueAttributions.length - 1 && ', '} + + ); + }); + } + + render() { + if (this.state.uniqueAttributions.length === 0) { + return null; + } + return ( +
+ + + {this._renderAttributions()} + + +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.test.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.test.js new file mode 100644 index 0000000000000..7ec64a94e8f47 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/attribution_control/view.test.js @@ -0,0 +1,33 @@ +/* + * 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 from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; + +import { AttributionControl } from './view'; + +describe('AttributionControl', () => { + test('is rendered', async () => { + const mockLayer1 = { + getAttributions: async () => { + return [{ url: '', label: 'attribution with no link' }]; + }, + }; + const mockLayer2 = { + getAttributions: async () => { + return [{ url: 'https://coolmaps.com', label: 'attribution with link' }]; + }, + }; + const component = shallowWithIntl(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/index.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/index.js new file mode 100644 index 0000000000000..ebf9b8a609d2a --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/index.js @@ -0,0 +1,20 @@ +/* + * 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 { WidgetOverlay } from './widget_overlay'; + +import { isLayerControlHidden, isViewControlHidden } from '../../selectors/map_selectors'; + +function mapStateToProps(state = {}) { + return { + hideLayerControl: isLayerControlHidden(state), + hideViewControl: isViewControlHidden(state), + }; +} + +const connectedWidgetOverlay = connect(mapStateToProps, null)(WidgetOverlay); +export { connectedWidgetOverlay as WidgetOverlay }; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/view.test.js.snap new file mode 100644 index 0000000000000..560ebad89c50e --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/__snapshots__/view.test.js.snap @@ -0,0 +1,189 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LayerControl is rendered 1`] = ` + + + + + + +

+ +

+
+
+ + + + + +
+
+ + + +
+ + + + +
+`; + +exports[`LayerControl isLayerTOCOpen Should render expand button 1`] = ` + + + +`; + +exports[`LayerControl isLayerTOCOpen Should render expand button with error icon when layer has error 1`] = ` + + + +`; + +exports[`LayerControl isLayerTOCOpen Should render expand button with loading icon when layer is loading 1`] = ` + + + +`; + +exports[`LayerControl isReadOnly 1`] = ` + + + + + + +

+ +

+
+
+ + + + + +
+
+ + + +
+
+`; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_index.scss b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_index.scss new file mode 100644 index 0000000000000..761ef9d17b4c2 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_index.scss @@ -0,0 +1,2 @@ +@import './layer_control'; +@import './layer_toc/toc_entry/toc_entry'; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_layer_control.scss b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_layer_control.scss new file mode 100644 index 0000000000000..19f70070ef5b2 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/_layer_control.scss @@ -0,0 +1,30 @@ +.mapLayerControl { + @include euiScrollBar; + overflow-y: auto; + // sass-lint:disable-block no-important + flex-basis: auto !important; // Fixes IE and ensures the layer items are visible + padding-bottom: $euiSizeS + 1px; + border-top: 1px solid $euiColorLightestShade; +} + +.mapLayerControl__addLayerButton { + flex-shrink: 0; +} + +.mapLayerControl__addLayerButton, +.mapLayerControl__openLayerTOCButton { + pointer-events: all; + + &:enabled, + &:enabled:hover, + &:enabled:focus { + @include euiBottomShadowLarge; + } +} + +.mapLayerControl__openLayerTOCButton, +.mapLayerControl__closeLayerTOCButton { + @include size($euiSizeXL); + // sass-lint:disable-block no-important + background-color: $euiColorEmptyShade !important; // During all states +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/index.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/index.js new file mode 100644 index 0000000000000..0b090a639edb2 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/index.js @@ -0,0 +1,44 @@ +/* + * 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 { LayerControl } from './view'; +import { FLYOUT_STATE } from '../../../reducers/ui'; +import { updateFlyout, setIsLayerTOCOpen } from '../../../actions/ui_actions'; +import { setSelectedLayer } from '../../../actions/map_actions'; +import { + getIsReadOnly, + getIsLayerTOCOpen, + getFlyoutDisplay, +} from '../../../selectors/ui_selectors'; +import { getLayerList } from '../../../selectors/map_selectors'; + +function mapStateToProps(state = {}) { + return { + isReadOnly: getIsReadOnly(state), + isLayerTOCOpen: getIsLayerTOCOpen(state), + layerList: getLayerList(state), + isAddButtonActive: getFlyoutDisplay(state) === FLYOUT_STATE.NONE, + }; +} + +function mapDispatchToProps(dispatch) { + return { + showAddLayerWizard: async () => { + await dispatch(setSelectedLayer(null)); + dispatch(updateFlyout(FLYOUT_STATE.ADD_LAYER_WIZARD)); + }, + closeLayerTOC: () => { + dispatch(setIsLayerTOCOpen(false)); + }, + openLayerTOC: () => { + dispatch(setIsLayerTOCOpen(true)); + }, + }; +} + +const connectedLayerControl = connect(mapStateToProps, mapDispatchToProps)(LayerControl); +export { connectedLayerControl as LayerControl }; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/__snapshots__/view.test.js.snap new file mode 100644 index 0000000000000..beacaaecbf7f8 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/__snapshots__/view.test.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LayerTOC is rendered 1`] = ` +
+ + + + + +
+`; + +exports[`LayerTOC props isReadOnly 1`] = ` +
+ + +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/index.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/index.js new file mode 100644 index 0000000000000..0ae497b480776 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/index.js @@ -0,0 +1,27 @@ +/* + * 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 { LayerTOC } from './view'; +import { updateLayerOrder } from '../../../../actions/map_actions'; +import { getLayerList } from '../../../../selectors/map_selectors'; +import { getIsReadOnly } from '../../../../selectors/ui_selectors'; + +const mapDispatchToProps = { + updateLayerOrder: newOrder => updateLayerOrder(newOrder), +}; + +function mapStateToProps(state = {}) { + return { + isReadOnly: getIsReadOnly(state), + layerList: getLayerList(state), + }; +} + +const connectedLayerTOC = connect(mapStateToProps, mapDispatchToProps, null, { withRef: true })( + LayerTOC +); +export { connectedLayerTOC as LayerTOC }; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/view.test.js.snap new file mode 100644 index 0000000000000..2ca994647e1da --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/__snapshots__/view.test.js.snap @@ -0,0 +1,194 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TOCEntry is rendered 1`] = ` +
+
+ +
+ + +
+
+ + + +
+`; + +exports[`TOCEntry props isReadOnly 1`] = ` +
+
+ +
+ + + +
+`; + +exports[`TOCEntry props should display layer details when isLegendDetailsOpen is true 1`] = ` +
+
+ +
+ + +
+
+
+
+ TOC details mock +
+
+ + + +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/_toc_entry.scss b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/_toc_entry.scss new file mode 100644 index 0000000000000..9ac9b43607121 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/_toc_entry.scss @@ -0,0 +1,129 @@ +/** + * 1. Truncate the layer name + * 2. For showing the layer details toggle above the following entry + */ + +.mapTocEntry { + position: relative; + padding: $euiSizeS; + border-bottom: 1px solid $euiColorLightestShade; + + &:hover, + &:focus, + &:focus-within { + z-index: 2; /* 2 */ + + .mapTocEntry__layerIcons, + .mapTocEntry__detailsToggle { + display: block; + animation: mapTocEntryBecomeVisible $euiAnimSpeedFast $euiAnimSlightResistance; + } + } + + .mapTocEntry__layerIcons, + .mapTocEntry__detailsToggle { + &:hover, + &:focus { + display: block; + animation: mapTocEntryBecomeVisible $euiAnimSpeedFast $euiAnimSlightResistance; + } + } +} + +.mapTocEntry-isDragging { + @include euiBottomShadowMedium; +} + +.mapTocEntry-isDraggingOver { + background-color: $euiColorEmptyShade; + // Don't allow interaction events while layer is being re-ordered + // sass-lint:disable-block no-important + pointer-events: none !important; +} + +.mapTocEntry-visible, +.mapTocEntry-notVisible { + display: flex; +} + +.mapLayTocActions { + overflow: hidden; /* 1 */ + flex-grow: 1; +} + +.mapLayTocActions__popoverAnchor, +.mapLayTocActions__tooltipAnchor { + max-width: 100%; +} + +.mapTocEntry-notVisible .mapTocEntry__layerName { + opacity: .5; +} + +.mapTocEntry__grab:hover { + cursor: grab; +} + +.mapTocEntry__layerName { + font-weight: $euiFontWeightMedium; +} + +.mapTocEntry__layerNameText { + display: flex; + align-items: center; +} + +.mapTocEntry__layerNameIcon { + flex-shrink: 0; + margin-right: $euiSizeS; + + > * { + vertical-align: sub; + } +} + +.mapTocEntry__layerIcons { + flex-shrink: 0; + display: none; +} + +.mapTocEntry__detailsToggle { + position: absolute; + display: none; + left: 50%; + top: $euiSizeXL; + transform: translateX(-50%); +} + +.mapTocEntry__detailsToggleButton { + background-color: $euiColorEmptyShade; + border: $euiBorderThin; + color: $euiTextColor; + border-radius: $euiBorderRadius / 2; + height: $euiSize; + width: $euiSizeXL; + line-height: $euiSize; + text-align: center; + + &:focus { + @include euiFocusRing; + } +} + +.mapTocEntry__layerDetails { + @include euiOverflowShadow; + background-color: $euiPageBackgroundColor; + padding: $euiSize $euiSizeS $euiSizeS; + margin: $euiSizeS (-$euiSizeS) (-$euiSizeS); +} + +@keyframes mapTocEntryBecomeVisible { + + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/index.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/index.js new file mode 100644 index 0000000000000..e9debdba7b914 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/index.js @@ -0,0 +1,68 @@ +/* + * 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 _ from 'lodash'; +import { connect } from 'react-redux'; +import { TOCEntry } from './view'; +import { FLYOUT_STATE } from '../../../../../reducers/ui'; +import { updateFlyout, hideTOCDetails, showTOCDetails } from '../../../../../actions/ui_actions'; +import { getIsReadOnly, getOpenTOCDetails } from '../../../../../selectors/ui_selectors'; +import { + fitToLayerExtent, + setSelectedLayer, + toggleLayerVisible, + removeTransientLayer, + cloneLayer, + removeLayer, +} from '../../../../../actions/map_actions'; + +import { + hasDirtyState, + getSelectedLayer, + isUsingSearch, +} from '../../../../../selectors/map_selectors'; + +function mapStateToProps(state = {}, ownProps) { + return { + isReadOnly: getIsReadOnly(state), + zoom: _.get(state, 'map.mapState.zoom', 0), + selectedLayer: getSelectedLayer(state), + hasDirtyStateSelector: hasDirtyState(state), + isLegendDetailsOpen: getOpenTOCDetails(state).includes(ownProps.layer.getId()), + isUsingSearch: isUsingSearch(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + openLayerPanel: async layerId => { + await dispatch(removeTransientLayer()); + await dispatch(setSelectedLayer(layerId)); + dispatch(updateFlyout(FLYOUT_STATE.LAYER_PANEL)); + }, + toggleVisible: layerId => { + dispatch(toggleLayerVisible(layerId)); + }, + fitToBounds: layerId => { + dispatch(fitToLayerExtent(layerId)); + }, + cloneLayer: layerId => { + dispatch(cloneLayer(layerId)); + }, + removeLayer: layerId => { + dispatch(removeLayer(layerId)); + }, + hideTOCDetails: layerId => { + dispatch(hideTOCDetails(layerId)); + }, + showTOCDetails: layerId => { + dispatch(showTOCDetails(layerId)); + }, + }; +} + +const connectedTOCEntry = connect(mapStateToProps, mapDispatchToProps)(TOCEntry); +export { connectedTOCEntry as TOCEntry }; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js new file mode 100644 index 0000000000000..c9f115c1ba4cc --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js @@ -0,0 +1,282 @@ +/* + * 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 from 'react'; +import classNames from 'classnames'; + +import { EuiIcon, EuiOverlayMask, EuiButtonIcon, EuiConfirmModal } from '@elastic/eui'; +import { LayerTocActions } from '../../../../../components/layer_toc_actions'; +import { i18n } from '@kbn/i18n'; + +function escapeLayerName(name) { + return name ? name.split(' ').join('_') : ''; +} + +export class TOCEntry extends React.Component { + state = { + displayName: null, + hasLegendDetails: false, + shouldShowModal: false, + }; + + componentDidMount() { + this._isMounted = true; + this._updateDisplayName(); + this._loadHasLegendDetails(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidUpdate() { + this._updateDisplayName(); + this._loadHasLegendDetails(); + } + + _toggleLayerDetailsVisibility = () => { + if (this.props.isLegendDetailsOpen) { + this.props.hideTOCDetails(this.props.layer.getId()); + } else { + this.props.showTOCDetails(this.props.layer.getId()); + } + }; + + async _loadHasLegendDetails() { + const hasLegendDetails = + (await this.props.layer.hasLegendDetails()) && + this.props.layer.isVisible() && + this.props.layer.showAtZoomLevel(this.props.zoom); + if (this._isMounted && hasLegendDetails !== this.state.hasLegendDetails) { + this.setState({ hasLegendDetails }); + } + } + + async _updateDisplayName() { + const label = await this.props.layer.getDisplayName(); + if (this._isMounted) { + if (label !== this.state.displayName) { + this.setState({ + displayName: label, + }); + } + } + } + + _openLayerPanelWithCheck = () => { + const { selectedLayer, hasDirtyStateSelector } = this.props; + if (selectedLayer && selectedLayer.getId() === this.props.layer.getId()) { + return; + } + + if (hasDirtyStateSelector) { + this.setState({ + shouldShowModal: true, + }); + return; + } + + this.props.openLayerPanel(this.props.layer.getId()); + }; + + _renderCancelModal() { + if (!this.state.shouldShowModal) { + return null; + } + + const closeModal = () => { + this.setState({ + shouldShowModal: false, + }); + }; + + const openPanel = () => { + closeModal(); + this.props.openLayerPanel(this.props.layer.getId()); + }; + + return ( + + +

There are unsaved changes to your layer.

+

Are you sure you want to proceed?

+
+
+ ); + } + + _renderLayerIcons() { + if (this.props.isReadOnly) { + return null; + } + + return ( +
+ + + +
+ ); + } + + _renderDetailsToggle() { + if (!this.state.hasLegendDetails) { + return null; + } + + const { isLegendDetailsOpen } = this.props; + return ( + + + + ); + } + + _renderLayerHeader() { + const { + removeLayer, + cloneLayer, + isReadOnly, + layer, + zoom, + toggleVisible, + fitToBounds, + isUsingSearch, + } = this.props; + + return ( +
+ { + fitToBounds(layer.getId()); + }} + zoom={zoom} + toggleVisible={() => { + toggleVisible(layer.getId()); + }} + displayName={this.state.displayName} + escapedDisplayName={escapeLayerName(this.state.displayName)} + cloneLayer={() => { + cloneLayer(layer.getId()); + }} + editLayer={this._openLayerPanelWithCheck} + isReadOnly={isReadOnly} + removeLayer={() => { + removeLayer(layer.getId()); + }} + /> + + {this._renderLayerIcons()} +
+ ); + } + + _renderLegendDetails = () => { + if (!this.props.isLegendDetailsOpen || !this.state.hasLegendDetails) { + return null; + } + + const tocDetails = this.props.layer.renderLegendDetails(); + if (!tocDetails) { + return null; + } + + return ( +
+ {tocDetails} +
+ ); + }; + + render() { + const classes = classNames('mapTocEntry', { + 'mapTocEntry-isDragging': this.props.isDragging, + 'mapTocEntry-isDraggingOver': this.props.isDraggingOver, + }); + + return ( +
+ {this._renderLayerHeader()} + + {this._renderLegendDetails()} + + {this._renderDetailsToggle()} + + {this._renderCancelModal()} +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.test.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.test.js new file mode 100644 index 0000000000000..e865242c6fb06 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.test.js @@ -0,0 +1,87 @@ +/* + * 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 from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; + +import { TOCEntry } from './view'; + +const LAYER_ID = '1'; + +const mockLayer = { + getId: () => { + return LAYER_ID; + }, + hasLegendDetails: async () => { + return true; + }, + renderLegendDetails: () => { + return
TOC details mock
; + }, + getDisplayName: () => { + return 'layer 1'; + }, + isVisible: () => { + return true; + }, + showAtZoomLevel: () => { + return true; + }, + hasErrors: () => { + return false; + }, + hasLegendDetails: () => { + return true; + }, +}; + +const defaultProps = { + layer: mockLayer, + openLayerPanel: () => {}, + toggleVisible: () => {}, + fitToBounds: () => {}, + getSelectedLayerSelector: () => {}, + hasDirtyStateSelector: () => {}, + zoom: 0, + isLegendDetailsOpen: false, +}; + +describe('TOCEntry', () => { + test('is rendered', async () => { + const component = shallowWithIntl(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + describe('props', () => { + test('isReadOnly', async () => { + const component = shallowWithIntl(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('should display layer details when isLegendDetailsOpen is true', async () => { + const component = shallowWithIntl(); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/view.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/view.js new file mode 100644 index 0000000000000..49ccd503793e4 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/view.js @@ -0,0 +1,89 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { EuiDragDropContext, EuiDroppable, EuiDraggable } from '@elastic/eui'; +import { TOCEntry } from './toc_entry'; + +export class LayerTOC extends React.Component { + componentWillUnmount() { + this._updateDebounced.cancel(); + } + + shouldComponentUpdate() { + this._updateDebounced(); + return false; + } + + _updateDebounced = _.debounce(this.forceUpdate, 100); + + _onDragEnd = ({ source, destination }) => { + // Dragging item out of EuiDroppable results in destination of null + if (!destination) { + return; + } + + // Layer list is displayed in reverse order so index needs to reversed to get back to original reference. + const reverseIndex = index => { + return this.props.layerList.length - index - 1; + }; + + const prevIndex = reverseIndex(source.index); + const newIndex = reverseIndex(destination.index); + const newOrder = []; + for (let i = 0; i < this.props.layerList.length; i++) { + newOrder.push(i); + } + newOrder.splice(prevIndex, 1); + newOrder.splice(newIndex, 0, prevIndex); + this.props.updateLayerOrder(newOrder); + }; + + _renderLayers() { + // Reverse layer list so first layer drawn on map is at the bottom and + // last layer drawn on map is at the top. + const reverseLayerList = [...this.props.layerList].reverse(); + + if (this.props.isReadOnly) { + return reverseLayerList.map(layer => { + return ; + }); + } + + return ( + + + {(provided, snapshot) => + reverseLayerList.map((layer, idx) => ( + + {(provided, state) => ( + + )} + + )) + } + + + ); + } + + render() { + return
{this._renderLayers()}
; + } +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/view.test.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/view.test.js new file mode 100644 index 0000000000000..021f78d030124 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/view.test.js @@ -0,0 +1,45 @@ +/* + * 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. + */ + +jest.mock('./toc_entry', () => ({ + TOCEntry: () => { + return
mockTOCEntry
; + }, +})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { LayerTOC } from './view'; + +const mockLayers = [ + { + getId: () => { + return '1'; + }, + }, + { + getId: () => { + return '2'; + }, + }, +]; + +describe('LayerTOC', () => { + test('is rendered', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + + describe('props', () => { + test('isReadOnly', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.js new file mode 100644 index 0000000000000..537a676287042 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.js @@ -0,0 +1,162 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiButton, + EuiTitle, + EuiSpacer, + EuiButtonIcon, + EuiToolTip, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { LayerTOC } from './layer_toc'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +function renderExpandButton({ hasErrors, isLoading, onClick }) { + const expandLabel = i18n.translate('xpack.maps.layerControl.openLayerTOCButtonAriaLabel', { + defaultMessage: 'Expand layers panel', + }); + + if (isLoading) { + // Can not use EuiButtonIcon with spinner because spinner is a class and not an icon + return ( + + ); + } + + return ( + + ); +} + +export function LayerControl({ + isReadOnly, + isLayerTOCOpen, + showAddLayerWizard, + closeLayerTOC, + openLayerTOC, + layerList, + isAddButtonActive, +}) { + if (!isLayerTOCOpen) { + const hasErrors = layerList.some(layer => { + return layer.hasErrors(); + }); + const isLoading = layerList.some(layer => { + return layer.isLayerLoading(); + }); + + return ( + + {renderExpandButton({ hasErrors, isLoading, onClick: openLayerTOC })} + + ); + } + + let addLayer; + if (!isReadOnly) { + addLayer = ( + + + + + + + ); + } + + return ( + + + + + + +

+ +

+
+
+ + + + + +
+
+ + + + +
+ + {addLayer} +
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.test.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.test.js new file mode 100644 index 0000000000000..ee5745efe5180 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/view.test.js @@ -0,0 +1,86 @@ +/* + * 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. + */ + +jest.mock('./layer_toc', () => ({ + LayerTOC: () => { + return
mockLayerTOC
; + }, +})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { LayerControl } from './view'; + +const defaultProps = { + showAddLayerWizard: () => {}, + closeLayerTOC: () => {}, + openLayerTOC: () => {}, + isLayerTOCOpen: true, + layerList: [], +}; + +describe('LayerControl', () => { + test('is rendered', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + + test('isReadOnly', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + + describe('isLayerTOCOpen', () => { + test('Should render expand button', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + + test('Should render expand button with loading icon when layer is loading', () => { + const mockLayerThatIsLoading = { + hasErrors: () => { + return false; + }, + isLayerLoading: () => { + return true; + }, + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('Should render expand button with error icon when layer has error', () => { + const mockLayerThatHasError = { + hasErrors: () => { + return true; + }, + isLayerLoading: () => { + return false; + }, + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/_view_control.scss b/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/_view_control.scss new file mode 100644 index 0000000000000..c5de44e8742f0 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/_view_control.scss @@ -0,0 +1,5 @@ +.mapViewControl__coordinates { + @include mapOverlayIsTextOnly; + justify-content: center; + pointer-events: none; +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/index.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/index.js new file mode 100644 index 0000000000000..4212b2e067dc7 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/index.js @@ -0,0 +1,19 @@ +/* + * 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 { ViewControl } from './view_control'; +import { getMouseCoordinates, getMapZoom } from '../../../selectors/map_selectors'; + +function mapStateToProps(state = {}) { + return { + mouseCoordinates: getMouseCoordinates(state), + zoom: getMapZoom(state), + }; +} + +const connectedViewControl = connect(mapStateToProps, null)(ViewControl); +export { connectedViewControl as ViewControl }; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/view_control.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/view_control.js new file mode 100644 index 0000000000000..445e5be542ffb --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/view_control/view_control.js @@ -0,0 +1,38 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { EuiText } from '@elastic/eui'; +import { DECIMAL_DEGREES_PRECISION } from '../../../../common/constants'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export function ViewControl({ mouseCoordinates, zoom }) { + if (!mouseCoordinates) { + return null; + } + + return ( +
+ + + + + {' '} + {_.round(mouseCoordinates.lat, DECIMAL_DEGREES_PRECISION)},{' '} + + + {' '} + {_.round(mouseCoordinates.lon, DECIMAL_DEGREES_PRECISION)},{' '} + + + {' '} + {zoom} + + +
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/widget_overlay.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/widget_overlay.js new file mode 100644 index 0000000000000..a37f837874f8f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/widget_overlay.js @@ -0,0 +1,31 @@ +/* + * 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 from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { LayerControl } from './layer_control'; +import { ViewControl } from './view_control'; +import { AttributionControl } from './attribution_control'; + +export function WidgetOverlay({ hideLayerControl, hideViewControl }) { + return ( + + + {!hideLayerControl && } + + {!hideViewControl && } + + + + + ); +} diff --git a/x-pack/plugins/maps/public/elasticsearch_geo_utils.js b/x-pack/plugins/maps/public/elasticsearch_geo_utils.js new file mode 100644 index 0000000000000..9aa5947062c83 --- /dev/null +++ b/x-pack/plugins/maps/public/elasticsearch_geo_utils.js @@ -0,0 +1,434 @@ +/* + * 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 _ from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { parse } from 'wellknown'; +import { + DECIMAL_DEGREES_PRECISION, + ES_GEO_FIELD_TYPE, + ES_SPATIAL_RELATIONS, + GEO_JSON_TYPE, + POLYGON_COORDINATES_EXTERIOR_INDEX, + LON_INDEX, + LAT_INDEX, +} from '../common/constants'; +import { getEsSpatialRelationLabel } from '../common/i18n_getters'; +import { SPATIAL_FILTER_TYPE } from './kibana_services'; + +function ensureGeoField(type) { + const expectedTypes = [ES_GEO_FIELD_TYPE.GEO_POINT, ES_GEO_FIELD_TYPE.GEO_SHAPE]; + if (!expectedTypes.includes(type)) { + const errorMessage = i18n.translate( + 'xpack.maps.es_geo_utils.unsupportedFieldTypeErrorMessage', + { + defaultMessage: + 'Unsupported field type, expected: {expectedTypes}, you provided: {fieldType}', + values: { + fieldType: type, + expectedTypes: expectedTypes.join(','), + }, + } + ); + throw new Error(errorMessage); + } +} + +function ensureGeometryType(type, expectedTypes) { + if (!expectedTypes.includes(type)) { + const errorMessage = i18n.translate( + 'xpack.maps.es_geo_utils.unsupportedGeometryTypeErrorMessage', + { + defaultMessage: + 'Unsupported geometry type, expected: {expectedTypes}, you provided: {geometryType}', + values: { + geometryType: type, + expectedTypes: expectedTypes.join(','), + }, + } + ); + throw new Error(errorMessage); + } +} + +/** + * Converts Elasticsearch search results into GeoJson FeatureCollection + * + * @param {array} hits Elasticsearch search response hits array + * @param {function} flattenHit Method to flatten hits._source and hits.fields into properties object. + * Should just be IndexPattern.flattenHit but wanted to avoid coupling this method to IndexPattern. + * @param {string} geoFieldName Geometry field name + * @param {string} geoFieldType Geometry field type ["geo_point", "geo_shape"] + * @returns {number} + */ +export function hitsToGeoJson(hits, flattenHit, geoFieldName, geoFieldType) { + const features = []; + const tmpGeometriesAccumulator = []; + + for (let i = 0; i < hits.length; i++) { + const properties = flattenHit(hits[i]); + + tmpGeometriesAccumulator.length = 0; //truncate accumulator + + ensureGeoField(geoFieldType); + if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT) { + geoPointToGeometry(properties[geoFieldName], tmpGeometriesAccumulator); + } else { + geoShapeToGeometry(properties[geoFieldName], tmpGeometriesAccumulator); + } + + // don't include geometry field value in properties + delete properties[geoFieldName]; + + //create new geojson Feature for every individual geojson geometry. + for (let j = 0; j < tmpGeometriesAccumulator.length; j++) { + features.push({ + type: 'Feature', + geometry: tmpGeometriesAccumulator[j], + // _id is not unique across Kibana index pattern. Multiple ES indices could have _id collisions + // Need to prefix with _index to guarantee uniqueness + id: `${properties._index}:${properties._id}:${j}`, + properties, + }); + } + } + + return { + type: 'FeatureCollection', + features: features, + }; +} + +// Parse geo_point docvalue_field +// Either +// 1) Array of latLon strings +// 2) latLon string +export function geoPointToGeometry(value, accumulator) { + if (!value) { + return; + } + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + geoPointToGeometry(value[i], accumulator); + } + return; + } + + const commaSplit = value.split(','); + const lat = parseFloat(commaSplit[0]); + const lon = parseFloat(commaSplit[1]); + accumulator.push({ + type: GEO_JSON_TYPE.POINT, + coordinates: [lon, lat], + }); +} + +export function convertESShapeToGeojsonGeometry(value) { + const geoJson = { + type: value.type, + coordinates: value.coordinates, + }; + + // https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-shape.html#input-structure + // For some unknown compatibility nightmarish reason, Elasticsearch types are not capitalized the same as geojson types + // For example: 'LineString' geojson type is 'linestring' in elasticsearch + // Convert feature types to geojson spec values + // Sometimes, the type in ES is capitalized correctly. Sometimes it is not. It depends on how the doc was ingested + // The below is the correction in-place. + switch (value.type) { + case 'point': + geoJson.type = GEO_JSON_TYPE.POINT; + break; + case 'linestring': + geoJson.type = GEO_JSON_TYPE.LINE_STRING; + break; + case 'polygon': + geoJson.type = GEO_JSON_TYPE.POLYGON; + break; + case 'multipoint': + geoJson.type = GEO_JSON_TYPE.MULTI_POINT; + break; + case 'multilinestring': + geoJson.type = GEO_JSON_TYPE.MULTI_LINE_STRING; + break; + case 'multipolygon': + geoJson.type = GEO_JSON_TYPE.MULTI_POLYGON; + break; + case 'geometrycollection': + geoJson.type = GEO_JSON_TYPE.GEOMETRY_COLLECTION; + break; + case 'envelope': + case 'circle': + const errorMessage = i18n.translate( + 'xpack.maps.es_geo_utils.convert.unsupportedGeometryTypeErrorMessage', + { + defaultMessage: `Unable to convert {geometryType} geometry to geojson, not supported`, + values: { + geometryType: geoJson.type, + }, + } + ); + throw new Error(errorMessage); + } + return geoJson; +} + +function convertWKTStringToGeojson(value) { + try { + return parse(value); + } catch (e) { + const errorMessage = i18n.translate('xpack.maps.es_geo_utils.wkt.invalidWKTErrorMessage', { + defaultMessage: `Unable to convert {wkt} to geojson. Valid WKT expected.`, + values: { + wkt: value, + }, + }); + throw new Error(errorMessage); + } +} + +export function geoShapeToGeometry(value, accumulator) { + if (!value) { + return; + } + + if (Array.isArray(value)) { + // value expressed as an array of values + for (let i = 0; i < value.length; i++) { + geoShapeToGeometry(value[i], accumulator); + } + return; + } + + let geoJson; + if (typeof value === 'string') { + geoJson = convertWKTStringToGeojson(value); + } else { + geoJson = convertESShapeToGeojsonGeometry(value); + } + + accumulator.push(geoJson); +} + +function createGeoBoundBoxFilter(geometry, geoFieldName, filterProps = {}) { + ensureGeometryType(geometry.type, [GEO_JSON_TYPE.POLYGON]); + + const TOP_LEFT_INDEX = 0; + const BOTTOM_RIGHT_INDEX = 2; + const verticies = geometry.coordinates[POLYGON_COORDINATES_EXTERIOR_INDEX]; + return { + geo_bounding_box: { + [geoFieldName]: { + top_left: verticies[TOP_LEFT_INDEX], + bottom_right: verticies[BOTTOM_RIGHT_INDEX], + }, + }, + ...filterProps, + }; +} + +function createGeoPolygonFilter(polygonCoordinates, geoFieldName, filterProps = {}) { + return { + geo_polygon: { + ignore_unmapped: true, + [geoFieldName]: { + points: polygonCoordinates[POLYGON_COORDINATES_EXTERIOR_INDEX].map(coordinatePair => { + return { + lon: coordinatePair[LON_INDEX], + lat: coordinatePair[LAT_INDEX], + }; + }), + }, + }, + ...filterProps, + }; +} + +export function createExtentFilter(mapExtent, geoFieldName, geoFieldType) { + ensureGeoField(geoFieldType); + + const safePolygon = convertMapExtentToPolygon(mapExtent); + + if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT) { + return createGeoBoundBoxFilter(safePolygon, geoFieldName); + } + + return { + geo_shape: { + [geoFieldName]: { + shape: safePolygon, + relation: ES_SPATIAL_RELATIONS.INTERSECTS, + }, + }, + }; +} + +export function createSpatialFilterWithBoundingBox(options) { + return createGeometryFilterWithMeta({ ...options, isBoundingBox: true }); +} + +export function createSpatialFilterWithGeometry(options) { + return createGeometryFilterWithMeta(options); +} + +function createGeometryFilterWithMeta({ + preIndexedShape, + geometry, + geometryLabel, + indexPatternId, + geoFieldName, + geoFieldType, + relation = ES_SPATIAL_RELATIONS.INTERSECTS, + isBoundingBox = false, +}) { + ensureGeoField(geoFieldType); + + const relationLabel = + geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT + ? i18n.translate('xpack.maps.es_geo_utils.shapeFilter.geoPointRelationLabel', { + defaultMessage: 'in', + }) + : getEsSpatialRelationLabel(relation); + const meta = { + type: SPATIAL_FILTER_TYPE, + negate: false, + index: indexPatternId, + alias: `${geoFieldName} ${relationLabel} ${geometryLabel}`, + }; + + if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_SHAPE) { + const shapeQuery = { + relation, + }; + + if (preIndexedShape) { + shapeQuery.indexed_shape = preIndexedShape; + } else { + shapeQuery.shape = geometry; + } + + return { + meta, + geo_shape: { + ignore_unmapped: true, + [geoFieldName]: shapeQuery, + }, + }; + } + + // geo_points supports limited geometry types + ensureGeometryType(geometry.type, [GEO_JSON_TYPE.POLYGON, GEO_JSON_TYPE.MULTI_POLYGON]); + + if (geometry.type === GEO_JSON_TYPE.MULTI_POLYGON) { + return { + meta, + query: { + bool: { + should: geometry.coordinates.map(polygonCoordinates => { + return createGeoPolygonFilter(polygonCoordinates, geoFieldName); + }), + }, + }, + }; + } + + if (isBoundingBox) { + return createGeoBoundBoxFilter(geometry, geoFieldName, { meta }); + } + + return createGeoPolygonFilter(geometry.coordinates, geoFieldName, { meta }); +} + +export function roundCoordinates(coordinates) { + for (let i = 0; i < coordinates.length; i++) { + const value = coordinates[i]; + if (Array.isArray(value)) { + roundCoordinates(value); + } else if (!isNaN(value)) { + coordinates[i] = _.round(value, DECIMAL_DEGREES_PRECISION); + } + } +} + +/* + * returns Polygon geometry where coordinates define a bounding box that contains the input geometry + */ +export function getBoundingBoxGeometry(geometry) { + ensureGeometryType(geometry.type, [GEO_JSON_TYPE.POLYGON]); + + const exterior = geometry.coordinates[POLYGON_COORDINATES_EXTERIOR_INDEX]; + const extent = { + minLon: exterior[0][LON_INDEX], + minLat: exterior[0][LAT_INDEX], + maxLon: exterior[0][LON_INDEX], + maxLat: exterior[0][LAT_INDEX], + }; + for (let i = 1; i < exterior.length; i++) { + extent.minLon = Math.min(exterior[i][LON_INDEX], extent.minLon); + extent.minLat = Math.min(exterior[i][LAT_INDEX], extent.minLat); + extent.maxLon = Math.max(exterior[i][LON_INDEX], extent.maxLon); + extent.maxLat = Math.max(exterior[i][LAT_INDEX], extent.maxLat); + } + + return convertMapExtentToPolygon(extent); +} + +function formatEnvelopeAsPolygon({ maxLat, maxLon, minLat, minLon }) { + // GeoJSON mandates that the outer polygon must be counterclockwise to avoid ambiguous polygons + // when the shape crosses the dateline + const left = minLon; + const right = maxLon; + const top = maxLat > 90 ? 90 : maxLat; + const bottom = minLat < -90 ? -90 : minLat; + const topLeft = [left, top]; + const bottomLeft = [left, bottom]; + const bottomRight = [right, bottom]; + const topRight = [right, top]; + return { + type: GEO_JSON_TYPE.POLYGON, + coordinates: [[topLeft, bottomLeft, bottomRight, topRight, topLeft]], + }; +} + +/* + * Convert map bounds to polygon + */ +export function convertMapExtentToPolygon({ maxLat, maxLon, minLat, minLon }) { + const lonDelta = maxLon - minLon; + if (lonDelta >= 360) { + return formatEnvelopeAsPolygon({ + maxLat, + maxLon: 180, + minLat, + minLon: -180, + }); + } + + if (maxLon > 180) { + // bounds cross dateline east to west + const overlapWestOfDateLine = maxLon - 180; + return formatEnvelopeAsPolygon({ + maxLat, + maxLon: -180 + overlapWestOfDateLine, + minLat, + minLon, + }); + } + + if (minLon < -180) { + // bounds cross dateline west to east + const overlapEastOfDateLine = Math.abs(minLon) - 180; + return formatEnvelopeAsPolygon({ + maxLat, + maxLon, + minLat, + minLon: 180 - overlapEastOfDateLine, + }); + } + + return formatEnvelopeAsPolygon({ maxLat, maxLon, minLat, minLon }); +} diff --git a/x-pack/plugins/maps/public/elasticsearch_geo_utils.test.js b/x-pack/plugins/maps/public/elasticsearch_geo_utils.test.js new file mode 100644 index 0000000000000..fb4b0a6e29e6c --- /dev/null +++ b/x-pack/plugins/maps/public/elasticsearch_geo_utils.test.js @@ -0,0 +1,488 @@ +/* + * 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. + */ + +jest.mock('ui/new_platform'); + +jest.mock('./kibana_services', () => { + return { + SPATIAL_FILTER_TYPE: 'spatial_filter', + }; +}); + +import { + hitsToGeoJson, + geoPointToGeometry, + geoShapeToGeometry, + createExtentFilter, + convertMapExtentToPolygon, + roundCoordinates, +} from './elasticsearch_geo_utils'; +import { indexPatterns } from '../../../../../src/plugins/data/public'; + +const geoFieldName = 'location'; +const mapExtent = { + maxLat: 39, + maxLon: -83, + minLat: 35, + minLon: -89, +}; + +const flattenHitMock = hit => { + const properties = {}; + for (const fieldName in hit._source) { + if (hit._source.hasOwnProperty(fieldName)) { + properties[fieldName] = hit._source[fieldName]; + } + } + for (const fieldName in hit.fields) { + if (hit.fields.hasOwnProperty(fieldName)) { + properties[fieldName] = hit.fields[fieldName]; + } + } + properties._id = hit._id; + properties._index = hit._index; + + return properties; +}; + +describe('hitsToGeoJson', () => { + it('Should convert elasitcsearch hits to geojson', () => { + const hits = [ + { + _id: 'doc1', + _index: 'index1', + fields: { + [geoFieldName]: '20,100', + }, + }, + { + _id: 'doc2', + _index: 'index1', + _source: { + [geoFieldName]: '30,110', + }, + }, + ]; + const geojson = hitsToGeoJson(hits, flattenHitMock, geoFieldName, 'geo_point'); + expect(geojson.type).toBe('FeatureCollection'); + expect(geojson.features.length).toBe(2); + expect(geojson.features[0]).toEqual({ + geometry: { + coordinates: [100, 20], + type: 'Point', + }, + id: 'index1:doc1:0', + properties: { + _id: 'doc1', + _index: 'index1', + }, + type: 'Feature', + }); + }); + + it('Should handle documents where geoField is not populated', () => { + const hits = [ + { + _source: { + [geoFieldName]: '20,100', + }, + }, + { + _source: {}, + }, + ]; + const geojson = hitsToGeoJson(hits, flattenHitMock, geoFieldName, 'geo_point'); + expect(geojson.type).toBe('FeatureCollection'); + expect(geojson.features.length).toBe(1); + }); + + it('Should populate properties from hit', () => { + const hits = [ + { + _source: { + [geoFieldName]: '20,100', + myField: 8, + }, + fields: { + myScriptedField: 10, + }, + }, + ]; + const geojson = hitsToGeoJson(hits, flattenHitMock, geoFieldName, 'geo_point'); + expect(geojson.features.length).toBe(1); + const feature = geojson.features[0]; + expect(feature.properties.myField).toBe(8); + }); + + it('Should create feature per item when geometry value is an array', () => { + const hits = [ + { + _id: 'doc1', + _index: 'index1', + _source: { + [geoFieldName]: ['20,100', '30,110'], + myField: 8, + }, + }, + ]; + const geojson = hitsToGeoJson(hits, flattenHitMock, geoFieldName, 'geo_point'); + expect(geojson.type).toBe('FeatureCollection'); + expect(geojson.features.length).toBe(2); + expect(geojson.features[0]).toEqual({ + geometry: { + coordinates: [100, 20], + type: 'Point', + }, + id: 'index1:doc1:0', + properties: { + _id: 'doc1', + _index: 'index1', + myField: 8, + }, + type: 'Feature', + }); + expect(geojson.features[1]).toEqual({ + geometry: { + coordinates: [110, 30], + type: 'Point', + }, + id: 'index1:doc1:1', + properties: { + _id: 'doc1', + _index: 'index1', + myField: 8, + }, + type: 'Feature', + }); + }); + + describe('dot in geoFieldName', () => { + const indexPatternMock = { + fields: { + getByName: name => { + const fields = { + ['my.location']: { + type: 'geo_point', + }, + }; + return fields[name]; + }, + }, + }; + const indexPatternFlattenHit = indexPatterns.flattenHitWrapper(indexPatternMock); + + it('Should handle geoField being an object', () => { + const hits = [ + { + _source: { + my: { + location: '20,100', + }, + }, + }, + ]; + const geojson = hitsToGeoJson(hits, indexPatternFlattenHit, 'my.location', 'geo_point'); + expect(geojson.features[0].geometry).toEqual({ + coordinates: [100, 20], + type: 'Point', + }); + }); + + it('Should handle geoField containing dot in the name', () => { + const hits = [ + { + _source: { + ['my.location']: '20,100', + }, + }, + ]; + const geojson = hitsToGeoJson(hits, indexPatternFlattenHit, 'my.location', 'geo_point'); + expect(geojson.features[0].geometry).toEqual({ + coordinates: [100, 20], + type: 'Point', + }); + }); + }); +}); + +describe('geoPointToGeometry', () => { + const lat = 41.12; + const lon = -71.34; + + it('Should convert single docvalue_field', () => { + const value = `${lat},${lon}`; + const points = []; + geoPointToGeometry(value, points); + expect(points.length).toBe(1); + expect(points[0].type).toBe('Point'); + expect(points[0].coordinates).toEqual([lon, lat]); + }); + + it('Should convert multiple docvalue_fields', () => { + const lat2 = 30; + const lon2 = -60; + const value = [`${lat},${lon}`, `${lat2},${lon2}`]; + const points = []; + geoPointToGeometry(value, points); + expect(points.length).toBe(2); + expect(points[0].coordinates).toEqual([lon, lat]); + expect(points[1].coordinates).toEqual([lon2, lat2]); + }); +}); + +describe('geoShapeToGeometry', () => { + it('Should convert value stored as geojson', () => { + const coordinates = [ + [-77.03653, 38.897676], + [-77.009051, 38.889939], + ]; + const value = { + type: 'linestring', + coordinates: coordinates, + }; + const shapes = []; + geoShapeToGeometry(value, shapes); + expect(shapes.length).toBe(1); + expect(shapes[0].type).toBe('LineString'); + expect(shapes[0].coordinates).toEqual(coordinates); + }); + + it('Should convert array of values', () => { + const linestringCoordinates = [ + [-77.03653, 38.897676], + [-77.009051, 38.889939], + ]; + const pointCoordinates = [125.6, 10.1]; + const value = [ + { + type: 'linestring', + coordinates: linestringCoordinates, + }, + { + type: 'point', + coordinates: pointCoordinates, + }, + ]; + const shapes = []; + geoShapeToGeometry(value, shapes); + expect(shapes.length).toBe(2); + expect(shapes[0].type).toBe('LineString'); + expect(shapes[0].coordinates).toEqual(linestringCoordinates); + expect(shapes[1].type).toBe('Point'); + expect(shapes[1].coordinates).toEqual(pointCoordinates); + }); + + it('Should convert wkt shapes to geojson', () => { + const pointWkt = 'POINT (32 40)'; + const linestringWkt = 'LINESTRING (50 60, 70 80)'; + + const shapes = []; + geoShapeToGeometry(pointWkt, shapes); + geoShapeToGeometry(linestringWkt, shapes); + + expect(shapes.length).toBe(2); + expect(shapes[0]).toEqual({ + coordinates: [32, 40], + type: 'Point', + }); + expect(shapes[1]).toEqual({ + coordinates: [ + [50, 60], + [70, 80], + ], + type: 'LineString', + }); + }); +}); + +describe('createExtentFilter', () => { + it('should return elasticsearch geo_bounding_box filter for geo_point field', () => { + const filter = createExtentFilter(mapExtent, geoFieldName, 'geo_point'); + expect(filter).toEqual({ + geo_bounding_box: { + location: { + bottom_right: [-83, 35], + top_left: [-89, 39], + }, + }, + }); + }); + + it('should return elasticsearch geo_shape filter for geo_shape field', () => { + const filter = createExtentFilter(mapExtent, geoFieldName, 'geo_shape'); + expect(filter).toEqual({ + geo_shape: { + location: { + relation: 'INTERSECTS', + shape: { + coordinates: [ + [ + [-89, 39], + [-89, 35], + [-83, 35], + [-83, 39], + [-89, 39], + ], + ], + type: 'Polygon', + }, + }, + }, + }); + }); + + it('should clamp longitudes to -180 to 180', () => { + const mapExtent = { + maxLat: 39, + maxLon: 209, + minLat: 35, + minLon: -191, + }; + const filter = createExtentFilter(mapExtent, geoFieldName, 'geo_shape'); + expect(filter).toEqual({ + geo_shape: { + location: { + relation: 'INTERSECTS', + shape: { + coordinates: [ + [ + [-180, 39], + [-180, 35], + [180, 35], + [180, 39], + [-180, 39], + ], + ], + type: 'Polygon', + }, + }, + }, + }); + }); +}); + +describe('convertMapExtentToPolygon', () => { + it('should convert bounds to envelope', () => { + const bounds = { + maxLat: 10, + maxLon: 100, + minLat: -10, + minLon: 90, + }; + expect(convertMapExtentToPolygon(bounds)).toEqual({ + type: 'Polygon', + coordinates: [ + [ + [90, 10], + [90, -10], + [100, -10], + [100, 10], + [90, 10], + ], + ], + }); + }); + + it('should clamp longitudes to -180 to 180', () => { + const bounds = { + maxLat: 10, + maxLon: 200, + minLat: -10, + minLon: -400, + }; + expect(convertMapExtentToPolygon(bounds)).toEqual({ + type: 'Polygon', + coordinates: [ + [ + [-180, 10], + [-180, -10], + [180, -10], + [180, 10], + [-180, 10], + ], + ], + }); + }); + + it('should clamp longitudes to -180 to 180 when bounds span entire globe (360)', () => { + const bounds = { + maxLat: 10, + maxLon: 170, + minLat: -10, + minLon: -400, + }; + expect(convertMapExtentToPolygon(bounds)).toEqual({ + type: 'Polygon', + coordinates: [ + [ + [-180, 10], + [-180, -10], + [180, -10], + [180, 10], + [-180, 10], + ], + ], + }); + }); + + it('should handle bounds that cross dateline(east to west)', () => { + const bounds = { + maxLat: 10, + maxLon: 190, + minLat: -10, + minLon: 170, + }; + expect(convertMapExtentToPolygon(bounds)).toEqual({ + type: 'Polygon', + coordinates: [ + [ + [170, 10], + [170, -10], + [-170, -10], + [-170, 10], + [170, 10], + ], + ], + }); + }); + + it('should handle bounds that cross dateline(west to east)', () => { + const bounds = { + maxLat: 10, + maxLon: -170, + minLat: -10, + minLon: -190, + }; + expect(convertMapExtentToPolygon(bounds)).toEqual({ + type: 'Polygon', + coordinates: [ + [ + [170, 10], + [170, -10], + [-170, -10], + [-170, 10], + [170, 10], + ], + ], + }); + }); +}); + +describe('roundCoordinates', () => { + it('should set coordinates precision', () => { + const coordinates = [ + [110.21515290475513, 40.23193047044205], + [-105.30620093073654, 40.23193047044205], + [-105.30620093073654, 30.647128842617803], + ]; + roundCoordinates(coordinates); + expect(coordinates).toEqual([ + [110.21515, 40.23193], + [-105.3062, 40.23193], + [-105.3062, 30.64713], + ]); + }); +}); diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.js b/x-pack/plugins/maps/public/embeddable/map_embeddable.js index c723e996ee679..07362afc07395 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.js +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.js @@ -13,14 +13,11 @@ import 'mapbox-gl/dist/mapbox-gl.css'; import { Embeddable, APPLY_FILTER_TRIGGER, -} from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { onlyDisabledFiltersChanged } from '../../../../../../src/plugins/data/public'; - -import { I18nContext } from 'ui/i18n'; +} from '../../../../../src/plugins/embeddable/public'; +import { onlyDisabledFiltersChanged } from '../../../../../src/plugins/data/public'; import { GisMap } from '../connected_components/gis_map'; import { createMapStore } from '../reducers/store'; -import { npStart } from 'ui/new_platform'; import { setGotoWithCenter, replaceLayerList, @@ -40,6 +37,11 @@ import { getInspectorAdapters, setEventHandlers } from '../reducers/non_serializ import { getMapCenter, getMapZoom, getHiddenLayerIds } from '../selectors/map_selectors'; import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; +// TODO +import { I18nContext } from 'ui/i18n'; +import { npStart } from 'ui/new_platform'; + + export class MapEmbeddable extends Embeddable { type = MAP_SAVED_OBJECT_TYPE; diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js index f34ee29cd1d75..3784354654289 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.js @@ -11,13 +11,14 @@ import { MapEmbeddable } from './map_embeddable'; import { indexPatternService } from '../kibana_services'; import { createMapPath, MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants'; import { mergeInputWithSavedMap } from './merge_input_with_saved_map'; -import '../angular/services/gis_map_saved_object_loader'; -// Legacy maps dependencies import { createMapStore } from '../reducers/store'; import { addLayerWithoutDataSync } from '../actions/map_actions'; import { getQueryableUniqueIndexPatternIds } from '../selectors/map_selectors'; -import { getInitialLayers } from '../angular/get_initial_layers'; + +// TODO +// import '../angular/services/gis_map_saved_object_loader'; +// import { getInitialLayers } from '../angular/get_initial_layers'; export class MapEmbeddableFactory extends EmbeddableFactory { type = MAP_SAVED_OBJECT_TYPE; @@ -94,12 +95,12 @@ export class MapEmbeddableFactory extends EmbeddableFactory { async createFromSavedObject(savedObjectId, input, parent) { const savedMap = await this._fetchSavedMap(savedObjectId); - const layerList = getInitialLayers(savedMap.layerListJSON); + // const layerList = getInitialLayers(savedMap.layerListJSON); const indexPatterns = await this._getIndexPatterns(layerList); const embeddable = new MapEmbeddable( { - layerList, + // layerList, title: savedMap.title, editUrl: this.addBasePath(createMapPath(savedObjectId)), indexPatterns, diff --git a/x-pack/plugins/maps/public/index_pattern_util.js b/x-pack/plugins/maps/public/index_pattern_util.js new file mode 100644 index 0000000000000..48251b858cc9d --- /dev/null +++ b/x-pack/plugins/maps/public/index_pattern_util.js @@ -0,0 +1,52 @@ +/* + * 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 { indexPatternService } from './kibana_services'; +import { isNestedField } from '../../../../src/plugins/data/public'; +import { ES_GEO_FIELD_TYPE } from '../common/constants'; + +export async function getIndexPatternsFromIds(indexPatternIds = []) { + const promises = []; + indexPatternIds.forEach(id => { + const indexPatternPromise = indexPatternService.get(id); + if (indexPatternPromise) { + promises.push(indexPatternPromise); + } + }); + + return await Promise.all(promises); +} + +export function getTermsFields(fields) { + return fields.filter(field => { + return ( + field.aggregatable && + !isNestedField(field) && + ['number', 'boolean', 'date', 'ip', 'string'].includes(field.type) + ); + }); +} + +export const AGGREGATABLE_GEO_FIELD_TYPES = [ES_GEO_FIELD_TYPE.GEO_POINT]; + +export function getAggregatableGeoFields(fields) { + return fields.filter(field => { + return ( + field.aggregatable && + !isNestedField(field) && + AGGREGATABLE_GEO_FIELD_TYPES.includes(field.type) + ); + }); +} + +// Returns filtered fields list containing only fields that exist in _source. +export function getSourceFields(fields) { + return fields.filter(field => { + // Multi fields are not stored in _source and only exist in index. + const isMultiField = field.subType && field.subType.multi; + return !isMultiField && !isNestedField(field); + }); +} diff --git a/x-pack/plugins/maps/public/index_pattern_util.test.js b/x-pack/plugins/maps/public/index_pattern_util.test.js new file mode 100644 index 0000000000000..7f8f1c175cf15 --- /dev/null +++ b/x-pack/plugins/maps/public/index_pattern_util.test.js @@ -0,0 +1,29 @@ +/* + * 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. + */ + +jest.mock('./kibana_services', () => ({})); + +import { getSourceFields } from './index_pattern_util'; + +describe('getSourceFields', () => { + test('Should remove multi fields from field list', () => { + const fields = [ + { + name: 'agent', + }, + { + name: 'agent.keyword', + subType: { + multi: { + parent: 'agent', + }, + }, + }, + ]; + const sourceFields = getSourceFields(fields); + expect(sourceFields).toEqual([{ name: 'agent' }]); + }); +}); diff --git a/x-pack/plugins/maps/public/inspector/adapters/map_adapter.js b/x-pack/plugins/maps/public/inspector/adapters/map_adapter.js new file mode 100644 index 0000000000000..60a6cab7df3d1 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/adapters/map_adapter.js @@ -0,0 +1,24 @@ +/* + * 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 { EventEmitter } from 'events'; + +class MapAdapter extends EventEmitter { + setMapState({ stats, style }) { + this.stats = stats; + this.style = style; + this._onChange(); + } + + getMapState() { + return { stats: this.stats, style: this.style }; + } + + _onChange() { + this.emit('change'); + } +} + +export { MapAdapter }; diff --git a/x-pack/plugins/maps/public/inspector/views/map_details.js b/x-pack/plugins/maps/public/inspector/views/map_details.js new file mode 100644 index 0000000000000..e84cf51ed34c1 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/views/map_details.js @@ -0,0 +1,128 @@ +/* + * 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, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiTab, + EuiTabs, + EuiCodeBlock, + EuiTable, + EuiTableBody, + EuiTableRow, + EuiTableRowCell, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +const DETAILS_TAB_ID = 'details'; +const STYLE_TAB_ID = 'mapStyle'; + +class MapDetails extends Component { + tabs = [ + { + id: DETAILS_TAB_ID, + name: i18n.translate('xpack.maps.inspector.mapDetailsTitle', { + defaultMessage: 'Map details', + }), + dataTestSubj: 'mapDetailsTab', + }, + { + id: STYLE_TAB_ID, + name: i18n.translate('xpack.maps.inspector.mapboxStyleTitle', { + defaultMessage: 'Mapbox style', + }), + dataTestSubj: 'mapboxStyleTab', + }, + ]; + + state = { + selectedTabId: DETAILS_TAB_ID, + }; + + onSelectedTabChanged = id => { + this.setState({ + selectedTabId: id, + }); + }; + + renderTab = () => { + if (STYLE_TAB_ID === this.state.selectedTabId) { + return ( +
+ + {JSON.stringify(this.props.mapStyle, null, 2)} + +
+ ); + } + + return ( + + + + + + + {this.props.centerLon} + + + + + + + {this.props.centerLat} + + + + + + + {this.props.zoom} + + + + ); + }; + + renderTabs() { + return this.tabs.map((tab, index) => ( + this.onSelectedTabChanged(tab.id)} + isSelected={tab.id === this.state.selectedTabId} + key={index} + data-test-subj={tab.dataTestSubj} + > + {tab.name} + + )); + } + + render() { + return ( +
+ {this.renderTabs()} + + {this.renderTab()} +
+ ); + } +} + +MapDetails.propTypes = { + centerLon: PropTypes.number.isRequired, + centerLat: PropTypes.number.isRequired, + zoom: PropTypes.number.isRequired, + mapStyle: PropTypes.object.isRequired, +}; + +export { MapDetails }; diff --git a/x-pack/plugins/maps/public/inspector/views/map_view.js b/x-pack/plugins/maps/public/inspector/views/map_view.js new file mode 100644 index 0000000000000..db96e77c93984 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/views/map_view.js @@ -0,0 +1,66 @@ +/* + * 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, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { MapDetails } from './map_details'; +import { i18n } from '@kbn/i18n'; + +class MapViewComponent extends Component { + constructor(props) { + super(props); + props.adapters.map.on('change', this._onMapChange); + + const { stats, style } = props.adapters.map.getMapState(); + this.state = { + stats, + mapStyle: style, + }; + } + + _onMapChange = () => { + const { stats, style } = this.props.adapters.map.getMapState(); + this.setState({ + stats, + mapStyle: style, + }); + }; + + componentWillUnmount() { + this.props.adapters.map.removeListener('change', this._onMapChange); + } + + render() { + return ( + + ); + } +} + +MapViewComponent.propTypes = { + adapters: PropTypes.object.isRequired, +}; + +const MapView = { + title: i18n.translate('xpack.maps.inspector.mapDetailsViewTitle', { + defaultMessage: 'Map details', + }), + order: 30, + help: i18n.translate('xpack.maps.inspector.mapDetailsViewHelpText', { + defaultMessage: 'View the map state', + }), + shouldShow(adapters) { + return Boolean(adapters.map); + }, + component: MapViewComponent, +}; + +export { MapView }; diff --git a/x-pack/plugins/maps/public/inspector/views/register_views.ts b/x-pack/plugins/maps/public/inspector/views/register_views.ts new file mode 100644 index 0000000000000..59c0595668300 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/views/register_views.ts @@ -0,0 +1,12 @@ +/* + * 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 { npSetup } from 'ui/new_platform'; + +// @ts-ignore +import { MapView } from './map_view'; + +npSetup.plugins.inspector.registerView(MapView); diff --git a/x-pack/plugins/maps/public/layers/_index.scss b/x-pack/plugins/maps/public/layers/_index.scss new file mode 100644 index 0000000000000..a2ce58e0381af --- /dev/null +++ b/x-pack/plugins/maps/public/layers/_index.scss @@ -0,0 +1 @@ +@import './styles/index'; diff --git a/x-pack/plugins/maps/public/layers/fields/ems_file_field.js b/x-pack/plugins/maps/public/layers/fields/ems_file_field.js new file mode 100644 index 0000000000000..2a8732042a0e0 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/fields/ems_file_field.js @@ -0,0 +1,25 @@ +/* + * 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 { AbstractField } from './field'; +import { TooltipProperty } from '../tooltips/tooltip_property'; + +export class EMSFileField extends AbstractField { + static type = 'EMS_FILE'; + + async getLabel() { + const emsFileLayer = await this._source.getEMSFileLayer(); + const emsFields = emsFileLayer.getFieldsInLanguage(); + // Map EMS field name to language specific label + const emsField = emsFields.find(field => field.name === this.getName()); + return emsField ? emsField.description : this.getName(); + } + + async createTooltipProperty(value) { + const label = await this.getLabel(); + return new TooltipProperty(this.getName(), label, value); + } +} diff --git a/x-pack/plugins/maps/public/layers/fields/es_agg_field.js b/x-pack/plugins/maps/public/layers/fields/es_agg_field.js new file mode 100644 index 0000000000000..65109cb99809f --- /dev/null +++ b/x-pack/plugins/maps/public/layers/fields/es_agg_field.js @@ -0,0 +1,88 @@ +/* + * 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 { AbstractField } from './field'; +import { COUNT_AGG_TYPE } from '../../../common/constants'; +import { isMetricCountable } from '../util/is_metric_countable'; +import { ESAggMetricTooltipProperty } from '../tooltips/es_aggmetric_tooltip_property'; + +export class ESAggMetricField extends AbstractField { + static type = 'ES_AGG'; + + constructor({ label, source, aggType, esDocField, origin }) { + super({ source, origin }); + this._label = label; + this._aggType = aggType; + this._esDocField = esDocField; + } + + getName() { + return this._source.formatMetricKey(this.getAggType(), this.getESDocFieldName()); + } + + async getLabel() { + return this._label + ? await this._label + : this._source.formatMetricLabel(this.getAggType(), this.getESDocFieldName()); + } + + getAggType() { + return this._aggType; + } + + isValid() { + return this.getAggType() === COUNT_AGG_TYPE ? true : !!this._esDocField; + } + + async getDataType() { + // aggregations only provide numerical data + return 'number'; + } + + getESDocFieldName() { + return this._esDocField ? this._esDocField.getName() : ''; + } + + getRequestDescription() { + return this.getAggType() !== COUNT_AGG_TYPE + ? `${this.getAggType()} ${this.getESDocFieldName()}` + : COUNT_AGG_TYPE; + } + + async createTooltipProperty(value) { + const indexPattern = await this._source.getIndexPattern(); + return new ESAggMetricTooltipProperty( + this.getName(), + await this.getLabel(), + value, + indexPattern, + this + ); + } + + makeMetricAggConfig() { + const metricAggConfig = { + id: this.getName(), + enabled: true, + type: this.getAggType(), + schema: 'metric', + params: {}, + }; + if (this.getAggType() !== COUNT_AGG_TYPE) { + metricAggConfig.params = { field: this.getESDocFieldName() }; + } + return metricAggConfig; + } + + supportsFieldMeta() { + // count and sum aggregations are not within field bounds so they do not support field meta. + return !isMetricCountable(this.getAggType()); + } + + async getOrdinalFieldMetaRequest(config) { + return this._esDocField.getOrdinalFieldMetaRequest(config); + } +} diff --git a/x-pack/plugins/maps/public/layers/fields/es_agg_field.test.js b/x-pack/plugins/maps/public/layers/fields/es_agg_field.test.js new file mode 100644 index 0000000000000..2f18987513d92 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/fields/es_agg_field.test.js @@ -0,0 +1,28 @@ +/* + * 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 { ESAggMetricField } from './es_agg_field'; +import { METRIC_TYPE } from '../../../common/constants'; + +describe('supportsFieldMeta', () => { + test('Non-counting aggregations should support field meta', () => { + const avgMetric = new ESAggMetricField({ aggType: METRIC_TYPE.AVG }); + expect(avgMetric.supportsFieldMeta()).toBe(true); + const maxMetric = new ESAggMetricField({ aggType: METRIC_TYPE.MAX }); + expect(maxMetric.supportsFieldMeta()).toBe(true); + const minMetric = new ESAggMetricField({ aggType: METRIC_TYPE.MIN }); + expect(minMetric.supportsFieldMeta()).toBe(true); + }); + + test('Counting aggregations should not support field meta', () => { + const countMetric = new ESAggMetricField({ aggType: METRIC_TYPE.COUNT }); + expect(countMetric.supportsFieldMeta()).toBe(false); + const sumMetric = new ESAggMetricField({ aggType: METRIC_TYPE.SUM }); + expect(sumMetric.supportsFieldMeta()).toBe(false); + const uniqueCountMetric = new ESAggMetricField({ aggType: METRIC_TYPE.UNIQUE_COUNT }); + expect(uniqueCountMetric.supportsFieldMeta()).toBe(false); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/fields/es_doc_field.js b/x-pack/plugins/maps/public/layers/fields/es_doc_field.js new file mode 100644 index 0000000000000..31e7cbf7366ce --- /dev/null +++ b/x-pack/plugins/maps/public/layers/fields/es_doc_field.js @@ -0,0 +1,82 @@ +/* + * 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 { AbstractField } from './field'; +import { ESTooltipProperty } from '../tooltips/es_tooltip_property'; +import { COLOR_PALETTE_MAX_SIZE } from '../../../common/constants'; +import { isNestedField } from '../../../../../../src/plugins/data/public'; + +export class ESDocField extends AbstractField { + static type = 'ES_DOC'; + + async _getField() { + const indexPattern = await this._source.getIndexPattern(); + const field = indexPattern.fields.getByName(this._fieldName); + return isNestedField(field) ? undefined : field; + } + + async createTooltipProperty(value) { + const indexPattern = await this._source.getIndexPattern(); + return new ESTooltipProperty(this.getName(), this.getName(), value, indexPattern); + } + + async getDataType() { + const field = await this._getField(); + return field.type; + } + + supportsFieldMeta() { + return true; + } + + async getOrdinalFieldMetaRequest() { + const field = await this._getField(); + + if (field.type !== 'number' && field.type !== 'date') { + return null; + } + + const extendedStats = {}; + if (field.scripted) { + extendedStats.script = { + source: field.script, + lang: field.lang, + }; + } else { + extendedStats.field = this._fieldName; + } + return { + [this._fieldName]: { + extended_stats: extendedStats, + }, + }; + } + + async getCategoricalFieldMetaRequest() { + const field = await this._getField(); + if (field.type !== 'string') { + //UX does not support categorical styling for number/date fields + return null; + } + + const topTerms = { + size: COLOR_PALETTE_MAX_SIZE - 1, //need additional color for the "other"-value + }; + if (field.scripted) { + topTerms.script = { + source: field.script, + lang: field.lang, + }; + } else { + topTerms.field = this._fieldName; + } + return { + [this._fieldName]: { + terms: topTerms, + }, + }; + } +} diff --git a/x-pack/plugins/maps/public/layers/fields/field.js b/x-pack/plugins/maps/public/layers/fields/field.js new file mode 100644 index 0000000000000..b5d157ad1697a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/fields/field.js @@ -0,0 +1,55 @@ +/* + * 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 { FIELD_ORIGIN } from '../../../common/constants'; + +export class AbstractField { + constructor({ fieldName, source, origin }) { + this._fieldName = fieldName; + this._source = source; + this._origin = origin || FIELD_ORIGIN.SOURCE; + } + + getName() { + return this._fieldName; + } + + getSource() { + return this._source; + } + + isValid() { + return !!this._fieldName; + } + + async getDataType() { + return 'string'; + } + + async getLabel() { + return this._fieldName; + } + + async createTooltipProperty() { + throw new Error('must implement Field#createTooltipProperty'); + } + + getOrigin() { + return this._origin; + } + + supportsFieldMeta() { + return false; + } + + async getOrdinalFieldMetaRequest(/* config */) { + return null; + } + + async getCategoricalFieldMetaRequest() { + return null; + } +} diff --git a/x-pack/plugins/maps/public/layers/fields/kibana_region_field.js b/x-pack/plugins/maps/public/layers/fields/kibana_region_field.js new file mode 100644 index 0000000000000..41c77c4ccb223 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/fields/kibana_region_field.js @@ -0,0 +1,23 @@ +/* + * 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 { AbstractField } from './field'; +import { TooltipProperty } from '../tooltips/tooltip_property'; + +export class KibanaRegionField extends AbstractField { + static type = 'KIBANA_REGION'; + + async getLabel() { + const meta = await this._source.getVectorFileMeta(); + const field = meta.fields.find(f => f.name === this._fieldName); + return field ? field.description : this._fieldName; + } + + async createTooltipProperty(value) { + const label = await this.getLabel(); + return new TooltipProperty(this.getName(), label, value); + } +} diff --git a/x-pack/plugins/maps/public/layers/grid_resolution.js b/x-pack/plugins/maps/public/layers/grid_resolution.js new file mode 100644 index 0000000000000..a5d39a8ff5ed0 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/grid_resolution.js @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export const GRID_RESOLUTION = { + COARSE: 'COARSE', + FINE: 'FINE', + MOST_FINE: 'MOST_FINE', +}; diff --git a/x-pack/plugins/maps/public/layers/heatmap_layer.js b/x-pack/plugins/maps/public/layers/heatmap_layer.js new file mode 100644 index 0000000000000..29223d6a67c6b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/heatmap_layer.js @@ -0,0 +1,109 @@ +/* + * 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 { AbstractLayer } from './layer'; +import { VectorLayer } from './vector_layer'; +import { HeatmapStyle } from './styles/heatmap/heatmap_style'; +import { EMPTY_FEATURE_COLLECTION, LAYER_TYPE } from '../../common/constants'; + +const SCALED_PROPERTY_NAME = '__kbn_heatmap_weight__'; //unique name to store scaled value for weighting + +export class HeatmapLayer extends VectorLayer { + static type = LAYER_TYPE.HEATMAP; + + static createDescriptor(options) { + const heatmapLayerDescriptor = super.createDescriptor(options); + heatmapLayerDescriptor.type = HeatmapLayer.type; + heatmapLayerDescriptor.style = HeatmapStyle.createDescriptor(); + return heatmapLayerDescriptor; + } + + constructor({ layerDescriptor, source }) { + super({ layerDescriptor, source }); + if (!layerDescriptor.style) { + const defaultStyle = HeatmapStyle.createDescriptor(); + this._style = new HeatmapStyle(defaultStyle); + } else { + this._style = new HeatmapStyle(layerDescriptor.style); + } + } + + _getPropKeyOfSelectedMetric() { + const metricfields = this._source.getMetricFields(); + return metricfields[0].getName(); + } + + _getHeatmapLayerId() { + return this.makeMbLayerId('heatmap'); + } + + getMbLayerIds() { + return [this._getHeatmapLayerId()]; + } + + ownsMbLayerId(mbLayerId) { + return this._getHeatmapLayerId() === mbLayerId; + } + + syncLayerWithMB(mbMap) { + super._syncSourceBindingWithMb(mbMap); + + const heatmapLayerId = this._getHeatmapLayerId(); + if (!mbMap.getLayer(heatmapLayerId)) { + mbMap.addLayer({ + id: heatmapLayerId, + type: 'heatmap', + source: this.getId(), + paint: {}, + }); + } + + const mbSourceAfter = mbMap.getSource(this.getId()); + const sourceDataRequest = this.getSourceDataRequest(); + const featureCollection = sourceDataRequest ? sourceDataRequest.getData() : null; + if (!featureCollection) { + mbSourceAfter.setData(EMPTY_FEATURE_COLLECTION); + return; + } + + const propertyKey = this._getPropKeyOfSelectedMetric(); + const dataBoundToMap = AbstractLayer.getBoundDataForSource(mbMap, this.getId()); + if (featureCollection !== dataBoundToMap) { + let max = 0; + for (let i = 0; i < featureCollection.features.length; i++) { + max = Math.max(featureCollection.features[i].properties[propertyKey], max); + } + for (let i = 0; i < featureCollection.features.length; i++) { + featureCollection.features[i].properties[SCALED_PROPERTY_NAME] = + featureCollection.features[i].properties[propertyKey] / max; + } + mbSourceAfter.setData(featureCollection); + } + + this.syncVisibilityWithMb(mbMap, heatmapLayerId); + this._style.setMBPaintProperties({ + mbMap, + layerId: heatmapLayerId, + propertyName: SCALED_PROPERTY_NAME, + resolution: this._source.getGridResolution(), + }); + mbMap.setPaintProperty(heatmapLayerId, 'heatmap-opacity', this.getAlpha()); + mbMap.setLayerZoomRange(heatmapLayerId, this._descriptor.minZoom, this._descriptor.maxZoom); + } + + getLayerTypeIconName() { + return 'heatmap'; + } + + async hasLegendDetails() { + return true; + } + + renderLegendDetails() { + const metricFields = this._source.getMetricFields(); + return this._style.renderLegendDetails(metricFields[0]); + } +} diff --git a/x-pack/plugins/maps/public/layers/joins/inner_join.js b/x-pack/plugins/maps/public/layers/joins/inner_join.js new file mode 100644 index 0000000000000..13a2e05ab8eeb --- /dev/null +++ b/x-pack/plugins/maps/public/layers/joins/inner_join.js @@ -0,0 +1,109 @@ +/* + * 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 { ESTermSource } from '../sources/es_term_source'; +import { getComputedFieldNamePrefix } from '../styles/vector/style_util'; +import { META_ID_ORIGIN_SUFFIX, FORMATTERS_ID_ORIGIN_SUFFIX } from '../../../common/constants'; + +export class InnerJoin { + constructor(joinDescriptor, leftSource) { + this._descriptor = joinDescriptor; + const inspectorAdapters = leftSource.getInspectorAdapters(); + this._rightSource = new ESTermSource(joinDescriptor.right, inspectorAdapters); + this._leftField = this._descriptor.leftField + ? leftSource.createField({ fieldName: joinDescriptor.leftField }) + : null; + } + + destroy() { + this._rightSource.destroy(); + } + + hasCompleteConfig() { + if (this._leftField && this._rightSource) { + return this._rightSource.hasCompleteConfig(); + } + + return false; + } + + getJoinFields() { + return this._rightSource.getMetricFields(); + } + + // Source request id must be static and unique because the re-fetch logic uses the id to locate the previous request. + // Elasticsearch sources have a static and unique id so that requests can be modified in the inspector. + // Using the right source id as the source request id because it meets the above criteria. + getSourceDataRequestId() { + return `join_source_${this._rightSource.getId()}`; + } + + getSourceMetaDataRequestId() { + return `${this.getSourceDataRequestId()}_${META_ID_ORIGIN_SUFFIX}`; + } + + getSourceFormattersDataRequestId() { + return `${this.getSourceDataRequestId()}_${FORMATTERS_ID_ORIGIN_SUFFIX}`; + } + + getLeftField() { + return this._leftField; + } + + joinPropertiesToFeature(feature, propertiesMap) { + const rightMetricFields = this._rightSource.getMetricFields(); + // delete feature properties added by previous join + for (let j = 0; j < rightMetricFields.length; j++) { + const metricPropertyKey = rightMetricFields[j].getName(); + delete feature.properties[metricPropertyKey]; + + // delete all dynamic properties for metric field + const stylePropertyPrefix = getComputedFieldNamePrefix(metricPropertyKey); + Object.keys(feature.properties).forEach(featurePropertyKey => { + if ( + featurePropertyKey.length >= stylePropertyPrefix.length && + featurePropertyKey.substring(0, stylePropertyPrefix.length) === stylePropertyPrefix + ) { + delete feature.properties[featurePropertyKey]; + } + }); + } + + const joinKey = feature.properties[this._leftField.getName()]; + const coercedKey = + typeof joinKey === 'undefined' || joinKey === null ? null : joinKey.toString(); + if (propertiesMap && coercedKey !== null && propertiesMap.has(coercedKey)) { + Object.assign(feature.properties, propertiesMap.get(coercedKey)); + return true; + } else { + return false; + } + } + + getRightJoinSource() { + return this._rightSource; + } + + toDescriptor() { + return this._descriptor; + } + + async filterAndFormatPropertiesForTooltip(properties) { + return await this._rightSource.filterAndFormatPropertiesToHtml(properties); + } + + getIndexPatternIds() { + return this._rightSource.getIndexPatternIds(); + } + + getQueryableIndexPatternIds() { + return this._rightSource.getQueryableIndexPatternIds(); + } + + getWhereQuery() { + return this._rightSource.getWhereQuery(); + } +} diff --git a/x-pack/plugins/maps/public/layers/joins/inner_join.test.js b/x-pack/plugins/maps/public/layers/joins/inner_join.test.js new file mode 100644 index 0000000000000..05b177b361449 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/joins/inner_join.test.js @@ -0,0 +1,163 @@ +/* + * 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 { InnerJoin } from './inner_join'; + +jest.mock('../../kibana_services', () => {}); +jest.mock('ui/agg_types', () => { + class MockSchemas {} + return { + Schemas: MockSchemas, + }; +}); +jest.mock('ui/timefilter', () => {}); +jest.mock('../vector_layer', () => {}); + +const rightSource = { + id: 'd3625663-5b34-4d50-a784-0d743f676a0c', + indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247', + indexPatternTitle: 'kibana_sample_data_logs', + term: 'geo.dest', + metrics: [{ type: 'count' }], +}; + +const mockSource = { + getInspectorAdapters() {}, + createField({ fieldName: name }) { + return { + getName() { + return name; + }, + }; + }, +}; + +const leftJoin = new InnerJoin( + { + leftField: 'iso2', + right: rightSource, + }, + mockSource +); +const COUNT_PROPERTY_NAME = '__kbnjoin__count_groupby_kibana_sample_data_logs.geo.dest'; + +describe('joinPropertiesToFeature', () => { + it('Should add join property to features in feature collection', () => { + const feature = { + properties: { + iso2: 'CN', + }, + }; + const propertiesMap = new Map(); + propertiesMap.set('CN', { [COUNT_PROPERTY_NAME]: 61 }); + + leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ + { + propertyKey: COUNT_PROPERTY_NAME, + }, + ]); + expect(feature.properties).toEqual({ + iso2: 'CN', + [COUNT_PROPERTY_NAME]: 61, + }); + }); + + it('Should delete previous join property values from feature', () => { + const feature = { + properties: { + iso2: 'CN', + [COUNT_PROPERTY_NAME]: 61, + [`__kbn__dynamic__${COUNT_PROPERTY_NAME}__fillColor`]: 1, + }, + }; + const propertiesMap = new Map(); + + leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ + { + propertyKey: COUNT_PROPERTY_NAME, + }, + ]); + expect(feature.properties).toEqual({ + iso2: 'CN', + }); + }); + + it('Should coerce to string before joining', () => { + const leftJoin = new InnerJoin( + { + leftField: 'zipcode', + right: rightSource, + }, + mockSource + ); + + const feature = { + properties: { + zipcode: 40204, + }, + }; + const propertiesMap = new Map(); + propertiesMap.set('40204', { [COUNT_PROPERTY_NAME]: 61 }); + + leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ + { + propertyKey: COUNT_PROPERTY_NAME, + }, + ]); + expect(feature.properties).toEqual({ + zipcode: 40204, + [COUNT_PROPERTY_NAME]: 61, + }); + }); + + it('Should handle undefined values', () => { + const feature = { + //this feature does not have the iso2 field + properties: { + zipcode: 40204, + }, + }; + const propertiesMap = new Map(); + propertiesMap.set('40204', { [COUNT_PROPERTY_NAME]: 61 }); + + leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ + { + propertyKey: COUNT_PROPERTY_NAME, + }, + ]); + expect(feature.properties).toEqual({ + zipcode: 40204, + }); + }); + + it('Should handle falsy values', () => { + const leftJoin = new InnerJoin( + { + leftField: 'code', + right: rightSource, + }, + mockSource + ); + + const feature = { + properties: { + code: 0, + }, + }; + const propertiesMap = new Map(); + propertiesMap.set('0', { [COUNT_PROPERTY_NAME]: 61 }); + + leftJoin.joinPropertiesToFeature(feature, propertiesMap, [ + { + propertyKey: COUNT_PROPERTY_NAME, + }, + ]); + expect(feature.properties).toEqual({ + code: 0, + [COUNT_PROPERTY_NAME]: 61, + }); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/layer.js b/x-pack/plugins/maps/public/layers/layer.js new file mode 100644 index 0000000000000..b76f1ebce15d2 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/layer.js @@ -0,0 +1,358 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; +import { DataRequest } from './util/data_request'; +import { + MAX_ZOOM, + MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, + MIN_ZOOM, + SOURCE_DATA_ID_ORIGIN, +} from '../../common/constants'; +import uuid from 'uuid/v4'; +import { copyPersistentState } from '../reducers/util'; +import { i18n } from '@kbn/i18n'; + +export class AbstractLayer { + constructor({ layerDescriptor, source }) { + this._descriptor = AbstractLayer.createDescriptor(layerDescriptor); + this._source = source; + if (this._descriptor.__dataRequests) { + this._dataRequests = this._descriptor.__dataRequests.map( + dataRequest => new DataRequest(dataRequest) + ); + } else { + this._dataRequests = []; + } + } + + static getBoundDataForSource(mbMap, sourceId) { + const mbStyle = mbMap.getStyle(); + return mbStyle.sources[sourceId].data; + } + + static createDescriptor(options = {}) { + const layerDescriptor = { ...options }; + + layerDescriptor.__dataRequests = _.get(options, '__dataRequests', []); + layerDescriptor.id = _.get(options, 'id', uuid()); + layerDescriptor.label = options.label && options.label.length > 0 ? options.label : null; + layerDescriptor.minZoom = _.get(options, 'minZoom', MIN_ZOOM); + layerDescriptor.maxZoom = _.get(options, 'maxZoom', MAX_ZOOM); + layerDescriptor.alpha = _.get(options, 'alpha', 0.75); + layerDescriptor.visible = _.get(options, 'visible', true); + layerDescriptor.style = _.get(options, 'style', {}); + + return layerDescriptor; + } + + destroy() { + if (this._source) { + this._source.destroy(); + } + } + + async cloneDescriptor() { + const clonedDescriptor = copyPersistentState(this._descriptor); + // layer id is uuid used to track styles/layers in mapbox + clonedDescriptor.id = uuid(); + const displayName = await this.getDisplayName(); + clonedDescriptor.label = `Clone of ${displayName}`; + clonedDescriptor.sourceDescriptor = this._source.cloneDescriptor(); + if (clonedDescriptor.joins) { + clonedDescriptor.joins.forEach(joinDescriptor => { + // right.id is uuid used to track requests in inspector + joinDescriptor.right.id = uuid(); + }); + } + return clonedDescriptor; + } + + makeMbLayerId(layerNameSuffix) { + return `${this.getId()}${MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER}${layerNameSuffix}`; + } + + isJoinable() { + return this._source.isJoinable(); + } + + supportsElasticsearchFilters() { + return this._source.isESSource(); + } + + async supportsFitToBounds() { + return await this._source.supportsFitToBounds(); + } + + async getDisplayName() { + if (this._descriptor.label) { + return this._descriptor.label; + } + + return (await this._source.getDisplayName()) || `Layer ${this._descriptor.id}`; + } + + async getAttributions() { + if (!this.hasErrors()) { + return await this._source.getAttributions(); + } + return []; + } + + getLabel() { + return this._descriptor.label ? this._descriptor.label : ''; + } + + getCustomIconAndTooltipContent() { + return { + icon: , + }; + } + + getIconAndTooltipContent(zoomLevel, isUsingSearch) { + let icon; + let tooltipContent = null; + const footnotes = []; + if (this.hasErrors()) { + icon = ( + + ); + tooltipContent = this.getErrors(); + } else if (this.isLayerLoading()) { + icon = ; + } else if (!this.isVisible()) { + icon = ; + tooltipContent = i18n.translate('xpack.maps.layer.layerHiddenTooltip', { + defaultMessage: `Layer is hidden.`, + }); + } else if (!this.showAtZoomLevel(zoomLevel)) { + const { minZoom, maxZoom } = this.getZoomConfig(); + icon = ; + tooltipContent = i18n.translate('xpack.maps.layer.zoomFeedbackTooltip', { + defaultMessage: `Layer is visible between zoom levels {minZoom} and {maxZoom}.`, + values: { minZoom, maxZoom }, + }); + } else { + const customIconAndTooltipContent = this.getCustomIconAndTooltipContent(); + if (customIconAndTooltipContent) { + icon = customIconAndTooltipContent.icon; + if (!customIconAndTooltipContent.areResultsTrimmed) { + tooltipContent = customIconAndTooltipContent.tooltipContent; + } else { + footnotes.push({ + icon: , + message: customIconAndTooltipContent.tooltipContent, + }); + } + } + + if (isUsingSearch && this.getQueryableIndexPatternIds().length) { + footnotes.push({ + icon: , + message: i18n.translate('xpack.maps.layer.isUsingSearchMsg', { + defaultMessage: 'Results narrowed by search bar', + }), + }); + } + } + + return { + icon, + tooltipContent, + footnotes, + }; + } + + async hasLegendDetails() { + return false; + } + + renderLegendDetails() { + return null; + } + + getId() { + return this._descriptor.id; + } + + getSource() { + return this._source; + } + + isVisible() { + return this._descriptor.visible; + } + + showAtZoomLevel(zoom) { + return zoom >= this._descriptor.minZoom && zoom <= this._descriptor.maxZoom; + } + + getMinZoom() { + return this._descriptor.minZoom; + } + + getMaxZoom() { + return this._descriptor.maxZoom; + } + + getAlpha() { + return this._descriptor.alpha; + } + + getQuery() { + return this._descriptor.query; + } + + getZoomConfig() { + return { + minZoom: this._descriptor.minZoom, + maxZoom: this._descriptor.maxZoom, + }; + } + + getCurrentStyle() { + return this._style; + } + + async getImmutableSourceProperties() { + return this._source.getImmutableProperties(); + } + + renderSourceSettingsEditor = ({ onChange }) => { + return this._source.renderSourceSettingsEditor({ onChange }); + }; + + getPrevRequestToken(dataId) { + const prevDataRequest = this.getDataRequest(dataId); + if (!prevDataRequest) { + return; + } + + return prevDataRequest.getRequestToken(); + } + + getInFlightRequestTokens() { + if (!this._dataRequests) { + return []; + } + + const requestTokens = this._dataRequests.map(dataRequest => dataRequest.getRequestToken()); + return _.compact(requestTokens); + } + + getSourceDataRequest() { + return this.getDataRequest(SOURCE_DATA_ID_ORIGIN); + } + + getDataRequest(id) { + return this._dataRequests.find(dataRequest => dataRequest.getDataId() === id); + } + + isLayerLoading() { + return this._dataRequests.some(dataRequest => dataRequest.isLoading()); + } + + hasErrors() { + return _.get(this._descriptor, '__isInErrorState', false); + } + + getErrors() { + return this.hasErrors() ? this._descriptor.__errorMessage : ''; + } + + toLayerDescriptor() { + return this._descriptor; + } + + async syncData() { + //no-op by default + } + + getMbLayerIds() { + throw new Error('Should implement AbstractLayer#getMbLayerIds'); + } + + ownsMbLayerId() { + throw new Error('Should implement AbstractLayer#ownsMbLayerId'); + } + + ownsMbSourceId() { + throw new Error('Should implement AbstractLayer#ownsMbSourceId'); + } + + canShowTooltip() { + return false; + } + + syncLayerWithMB() { + throw new Error('Should implement AbstractLayer#syncLayerWithMB'); + } + + getLayerTypeIconName() { + throw new Error('should implement Layer#getLayerTypeIconName'); + } + + isDataLoaded() { + const sourceDataRequest = this.getSourceDataRequest(); + return sourceDataRequest && sourceDataRequest.hasData(); + } + + async getBounds() { + return { + min_lon: -180, + max_lon: 180, + min_lat: -89, + max_lat: 89, + }; + } + + renderStyleEditor({ onStyleDescriptorChange }) { + if (!this._style) { + return null; + } + return this._style.renderEditor({ layer: this, onStyleDescriptorChange }); + } + + getIndexPatternIds() { + return []; + } + + getQueryableIndexPatternIds() { + return []; + } + + async getDateFields() { + return []; + } + + async getNumberFields() { + return []; + } + + async getCategoricalFields() { + return []; + } + + async getFields() { + return []; + } + + syncVisibilityWithMb(mbMap, mbLayerId) { + mbMap.setLayoutProperty(mbLayerId, 'visibility', this.isVisible() ? 'visible' : 'none'); + } + + getType() { + return this._descriptor.type; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/all_sources.js b/x-pack/plugins/maps/public/layers/sources/all_sources.js new file mode 100644 index 0000000000000..6a518609dd77f --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/all_sources.js @@ -0,0 +1,29 @@ +/* + * 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 { EMSFileSource } from './ems_file_source'; +import { GeojsonFileSource } from './client_file_source'; +import { KibanaRegionmapSource } from './kibana_regionmap_source'; +import { XYZTMSSource } from './xyz_tms_source'; +import { EMSTMSSource } from './ems_tms_source'; +import { WMSSource } from './wms_source'; +import { KibanaTilemapSource } from './kibana_tilemap_source'; +import { ESGeoGridSource } from './es_geo_grid_source'; +import { ESSearchSource } from './es_search_source'; +import { ESPewPewSource } from './es_pew_pew_source/es_pew_pew_source'; + +export const ALL_SOURCES = [ + GeojsonFileSource, + ESSearchSource, + ESGeoGridSource, + ESPewPewSource, + EMSFileSource, + EMSTMSSource, + KibanaRegionmapSource, + KibanaTilemapSource, + XYZTMSSource, + WMSSource, +]; diff --git a/x-pack/plugins/maps/public/layers/sources/client_file_source/create_client_file_source_editor.js b/x-pack/plugins/maps/public/layers/sources/client_file_source/create_client_file_source_editor.js new file mode 100644 index 0000000000000..41f456e848658 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/client_file_source/create_client_file_source_editor.js @@ -0,0 +1,31 @@ +/* + * 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 from 'react'; + +// TODO +// import { start as fileUpload } from '../../../../../file_upload/public/legacy'; + +export function ClientFileCreateSourceEditor({ + previewGeojsonFile, + isIndexingTriggered = false, + onIndexingComplete, + onRemove, + onIndexReady, +}) { + // return ( + // + // ); + return
TODO
+} diff --git a/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js b/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js new file mode 100644 index 0000000000000..a38669fcd1d1a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js @@ -0,0 +1,164 @@ +/* + * 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 { AbstractVectorSource } from '../vector_source'; +import React from 'react'; +import { + ES_GEO_FIELD_TYPE, + GEOJSON_FILE, + DEFAULT_MAX_RESULT_WINDOW, +} from '../../../../common/constants'; +import { ClientFileCreateSourceEditor } from './create_client_file_source_editor'; +import { ESSearchSource } from '../es_search_source'; +import uuid from 'uuid/v4'; +import _ from 'lodash'; +import { i18n } from '@kbn/i18n'; + +export class GeojsonFileSource extends AbstractVectorSource { + static type = GEOJSON_FILE; + static title = i18n.translate('xpack.maps.source.geojsonFileTitle', { + defaultMessage: 'Uploaded GeoJSON', + }); + static description = i18n.translate('xpack.maps.source.geojsonFileDescription', { + defaultMessage: 'Upload and index GeoJSON data in Elasticsearch', + }); + static icon = 'importAction'; + static isIndexingSource = true; + + static createDescriptor(geoJson, name) { + // Wrap feature as feature collection if needed + let featureCollection; + + if (!geoJson) { + featureCollection = { + type: 'FeatureCollection', + features: [], + }; + } else if (geoJson.type === 'FeatureCollection') { + featureCollection = geoJson; + } else if (geoJson.type === 'Feature') { + featureCollection = { + type: 'FeatureCollection', + features: [geoJson], + }; + } else { + // Missing or incorrect type + featureCollection = { + type: 'FeatureCollection', + features: [], + }; + } + + return { + type: GeojsonFileSource.type, + __featureCollection: featureCollection, + name, + }; + } + + static viewIndexedData = ( + addAndViewSource, + inspectorAdapters, + importSuccessHandler, + importErrorHandler + ) => { + return (indexResponses = {}) => { + const { indexDataResp, indexPatternResp } = indexResponses; + + const indexCreationFailed = !(indexDataResp && indexDataResp.success); + const allDocsFailed = indexDataResp.failures.length === indexDataResp.docCount; + const indexPatternCreationFailed = !(indexPatternResp && indexPatternResp.success); + + if (indexCreationFailed || allDocsFailed || indexPatternCreationFailed) { + importErrorHandler(indexResponses); + return; + } + const { fields, id } = indexPatternResp; + const geoFieldArr = fields.filter(field => + Object.values(ES_GEO_FIELD_TYPE).includes(field.type) + ); + const geoField = _.get(geoFieldArr, '[0].name'); + const indexPatternId = id; + if (!indexPatternId || !geoField) { + addAndViewSource(null); + } else { + // Only turn on bounds filter for large doc counts + const filterByMapBounds = indexDataResp.docCount > DEFAULT_MAX_RESULT_WINDOW; + const source = new ESSearchSource( + { + id: uuid(), + indexPatternId, + geoField, + filterByMapBounds, + }, + inspectorAdapters + ); + addAndViewSource(source); + importSuccessHandler(indexResponses); + } + }; + }; + + static previewGeojsonFile = (onPreviewSource, inspectorAdapters) => { + return (geojsonFile, name) => { + if (!geojsonFile) { + onPreviewSource(null); + return; + } + const sourceDescriptor = GeojsonFileSource.createDescriptor(geojsonFile, name); + const source = new GeojsonFileSource(sourceDescriptor, inspectorAdapters); + onPreviewSource(source); + }; + }; + + static renderEditor({ + onPreviewSource, + inspectorAdapters, + addAndViewSource, + isIndexingTriggered, + onRemove, + onIndexReady, + importSuccessHandler, + importErrorHandler, + }) { + return ( + + ); + } + + async getGeoJsonWithMeta() { + return { + data: this._descriptor.__featureCollection, + meta: {}, + }; + } + + async getDisplayName() { + return this._descriptor.name; + } + + canFormatFeatureProperties() { + return true; + } + + shouldBeIndexed() { + return GeojsonFileSource.isIndexingSource; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/client_file_source/index.js b/x-pack/plugins/maps/public/layers/sources/client_file_source/index.js new file mode 100644 index 0000000000000..cf0d15dcb747a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/client_file_source/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { GeojsonFileSource } from './geojson_file_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/ems_file_source/create_source_editor.js new file mode 100644 index 0000000000000..47a4879acb58c --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/create_source_editor.js @@ -0,0 +1,88 @@ +/* + * 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 from 'react'; +import { EuiComboBox, EuiFormRow } from '@elastic/eui'; + +import { getEMSClient } from '../../../meta'; +import { getEmsUnavailableMessage } from '../ems_unavailable_message'; +import { i18n } from '@kbn/i18n'; + +export class EMSFileCreateSourceEditor extends React.Component { + state = { + emsFileOptionsRaw: null, + selectedOption: null, + }; + + _loadFileOptions = async () => { + const emsClient = getEMSClient(); + const fileLayers = await emsClient.getFileLayers(); + const options = fileLayers.map(fileLayer => { + return { + id: fileLayer.getId(), + name: fileLayer.getDisplayName(), + }; + }); + if (this._isMounted) { + this.setState({ + emsFileOptionsRaw: options, + }); + } + }; + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + this._loadFileOptions(); + } + + _onChange = selectedOptions => { + if (selectedOptions.length === 0) { + return; + } + + this.setState({ selectedOption: selectedOptions[0] }); + + const emsFileId = selectedOptions[0].value; + this.props.onSourceConfigChange({ id: emsFileId }); + }; + + render() { + if (!this.state.emsFileOptionsRaw) { + // TODO display loading message + return null; + } + + const options = this.state.emsFileOptionsRaw.map(({ id, name }) => { + return { label: name, value: id }; + }); + + return ( + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js new file mode 100644 index 0000000000000..524f030862768 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js @@ -0,0 +1,169 @@ +/* + * 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 { AbstractVectorSource } from '../vector_source'; +import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; +import React from 'react'; +import { EMS_FILE, FIELD_ORIGIN } from '../../../../common/constants'; +import { getEMSClient } from '../../../meta'; +import { EMSFileCreateSourceEditor } from './create_source_editor'; +import { i18n } from '@kbn/i18n'; +import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import { UpdateSourceEditor } from './update_source_editor'; +import { EMSFileField } from '../../fields/ems_file_field'; + +export class EMSFileSource extends AbstractVectorSource { + static type = EMS_FILE; + static title = i18n.translate('xpack.maps.source.emsFileTitle', { + defaultMessage: 'EMS Boundaries', + }); + static description = i18n.translate('xpack.maps.source.emsFileDescription', { + defaultMessage: 'Administrative boundaries from Elastic Maps Service', + }); + static icon = 'emsApp'; + + static createDescriptor({ id, tooltipProperties = [] }) { + return { + type: EMSFileSource.type, + id, + tooltipProperties, + }; + } + + static renderEditor({ onPreviewSource, inspectorAdapters }) { + const onSourceConfigChange = sourceConfig => { + const sourceDescriptor = EMSFileSource.createDescriptor(sourceConfig); + const source = new EMSFileSource(sourceDescriptor, inspectorAdapters); + onPreviewSource(source); + }; + return ; + } + + constructor(descriptor, inspectorAdapters) { + super(EMSFileSource.createDescriptor(descriptor), inspectorAdapters); + this._tooltipFields = this._descriptor.tooltipProperties.map(propertyKey => + this.createField({ fieldName: propertyKey }) + ); + } + + createField({ fieldName }) { + return new EMSFileField({ + fieldName, + source: this, + origin: FIELD_ORIGIN.SOURCE, + }); + } + + renderSourceSettingsEditor({ onChange }) { + return ( + + ); + } + + async getEMSFileLayer() { + const emsClient = getEMSClient(); + const emsFileLayers = await emsClient.getFileLayers(); + const emsFileLayer = emsFileLayers.find(fileLayer => fileLayer.getId() === this._descriptor.id); + if (!emsFileLayer) { + throw new Error( + i18n.translate('xpack.maps.source.emsFile.unableToFindIdErrorMessage', { + defaultMessage: `Unable to find EMS vector shapes for id: {id}`, + values: { + id: this._descriptor.id, + }, + }) + ); + } + return emsFileLayer; + } + + async getGeoJsonWithMeta() { + const emsFileLayer = await this.getEMSFileLayer(); + const featureCollection = await AbstractVectorSource.getGeoJson({ + format: emsFileLayer.getDefaultFormatType(), + featureCollectionPath: 'data', + fetchUrl: emsFileLayer.getDefaultFormatUrl(), + }); + + const emsIdField = emsFileLayer._config.fields.find(field => { + return field.type === 'id'; + }); + featureCollection.features.forEach((feature, index) => { + feature.id = emsIdField ? feature.properties[emsIdField.id] : index; + }); + + return { + data: featureCollection, + meta: {}, + }; + } + + async getImmutableProperties() { + let emsLink; + try { + const emsFileLayer = await this.getEMSFileLayer(); + emsLink = emsFileLayer.getEMSHotLink(); + } catch (error) { + // ignore error if EMS layer id could not be found + } + + return [ + { + label: getDataSourceLabel(), + value: EMSFileSource.title, + }, + { + label: i18n.translate('xpack.maps.source.emsFile.layerLabel', { + defaultMessage: `Layer`, + }), + value: this._descriptor.id, + link: emsLink, + }, + ]; + } + + async getDisplayName() { + try { + const emsFileLayer = await this.getEMSFileLayer(); + return emsFileLayer.getDisplayName(); + } catch (error) { + return this._descriptor.id; + } + } + + async getAttributions() { + const emsFileLayer = await this.getEMSFileLayer(); + return emsFileLayer.getAttributions(); + } + + async getLeftJoinFields() { + const emsFileLayer = await this.getEMSFileLayer(); + const fields = emsFileLayer.getFieldsInLanguage(); + return fields.map(f => this.createField({ fieldName: f.name })); + } + + canFormatFeatureProperties() { + return this._tooltipFields.length > 0; + } + + async filterAndFormatPropertiesToHtml(properties) { + const tooltipProperties = this._tooltipFields.map(field => { + const value = properties[field.getName()]; + return field.createTooltipProperty(value); + }); + + return Promise.all(tooltipProperties); + } + + async getSupportedShapeTypes() { + return [VECTOR_SHAPE_TYPES.POLYGON]; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.test.js b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.test.js new file mode 100644 index 0000000000000..93c9af98eb17f --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.test.js @@ -0,0 +1,60 @@ +/* + * 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 { EMSFileSource } from './ems_file_source'; + +jest.mock('ui/new_platform'); +jest.mock('../../vector_layer', () => {}); + +function makeEMSFileSource(tooltipProperties) { + const emsFileSource = new EMSFileSource({ + tooltipProperties: tooltipProperties, + }); + emsFileSource.getEMSFileLayer = () => { + return { + getFieldsInLanguage() { + return [ + { + name: 'iso2', + description: 'ISO 2 CODE', + }, + ]; + }, + }; + }; + return emsFileSource; +} + +describe('EMS file source', () => { + describe('filterAndFormatPropertiesToHtml', () => { + it('should create tooltip-properties with human readable label', async () => { + const mockEMSFileSource = makeEMSFileSource(['iso2']); + const out = await mockEMSFileSource.filterAndFormatPropertiesToHtml({ + iso2: 'US', + }); + + expect(out.length).toEqual(1); + expect(out[0].getPropertyKey()).toEqual('iso2'); + expect(out[0].getPropertyName()).toEqual('ISO 2 CODE'); + expect(out[0].getHtmlDisplayValue()).toEqual('US'); + }); + + it('should order tooltip-properties', async () => { + const tooltipProperties = ['iso3', 'iso2', 'name']; + const mockEMSFileSource = makeEMSFileSource(tooltipProperties); + const out = await mockEMSFileSource.filterAndFormatPropertiesToHtml({ + name: 'United States', + iso3: 'USA', + iso2: 'US', + }); + + expect(out.length).toEqual(3); + expect(out[0].getPropertyKey()).toEqual(tooltipProperties[0]); + expect(out[1].getPropertyKey()).toEqual(tooltipProperties[1]); + expect(out[2].getPropertyKey()).toEqual(tooltipProperties[2]); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/index.js b/x-pack/plugins/maps/public/layers/sources/ems_file_source/index.js new file mode 100644 index 0000000000000..9d0e503eb08ba --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { EMSFileSource } from './ems_file_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/ems_file_source/update_source_editor.js b/x-pack/plugins/maps/public/layers/sources/ems_file_source/update_source_editor.js new file mode 100644 index 0000000000000..b7687fec43272 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_file_source/update_source_editor.js @@ -0,0 +1,82 @@ +/* + * 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, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { TooltipSelector } from '../../../components/tooltip_selector'; +import { getEMSClient } from '../../../meta'; +import { EuiTitle, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export class UpdateSourceEditor extends Component { + static propTypes = { + onChange: PropTypes.func.isRequired, + tooltipFields: PropTypes.arrayOf(PropTypes.object).isRequired, + source: PropTypes.object, + }; + + state = { + fields: null, + }; + + componentDidMount() { + this._isMounted = true; + this.loadFields(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async loadFields() { + let fields; + try { + const emsClient = getEMSClient(); + const emsFiles = await emsClient.getFileLayers(); + const emsFile = emsFiles.find(emsFile => emsFile.getId() === this.props.layerId); + const emsFields = emsFile.getFieldsInLanguage(); + fields = emsFields.map(field => this.props.source.createField({ fieldName: field.name })); + } catch (e) { + //swallow this error. when a matching EMS-config cannot be found, the source already will have thrown errors during the data request. This will propagate to the vector-layer and be displayed in the UX + fields = []; + } + + if (this._isMounted) { + this.setState({ fields: fields }); + } + } + + _onTooltipPropertiesSelect = propertyNames => { + this.props.onChange({ propName: 'tooltipProperties', value: propertyNames }); + }; + + render() { + return ( + + + +
+ +
+
+ + + + +
+ + +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js new file mode 100644 index 0000000000000..0f11a2bbe1818 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.js @@ -0,0 +1,161 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { AbstractTMSSource } from '../tms_source'; +import { VectorTileLayer } from '../../vector_tile_layer'; + +import { getEMSClient } from '../../../meta'; +import { TileServiceSelect } from './tile_service_select'; +import { UpdateSourceEditor } from './update_source_editor'; +import { i18n } from '@kbn/i18n'; +import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import { EMS_TMS } from '../../../../common/constants'; + +// TODO +import chrome from 'ui/chrome'; + +export class EMSTMSSource extends AbstractTMSSource { + static type = EMS_TMS; + static title = i18n.translate('xpack.maps.source.emsTileTitle', { + defaultMessage: 'EMS Basemaps', + }); + static description = i18n.translate('xpack.maps.source.emsTileDescription', { + defaultMessage: 'Tile map service from Elastic Maps Service', + }); + static icon = 'emsApp'; + + static createDescriptor(sourceConfig) { + return { + type: EMSTMSSource.type, + id: sourceConfig.id, + isAutoSelect: sourceConfig.isAutoSelect, + }; + } + + static renderEditor({ onPreviewSource, inspectorAdapters }) { + const onSourceConfigChange = sourceConfig => { + const descriptor = EMSTMSSource.createDescriptor(sourceConfig); + const source = new EMSTMSSource(descriptor, inspectorAdapters); + onPreviewSource(source); + }; + + return ; + } + + constructor(descriptor, inspectorAdapters) { + super( + { + id: descriptor.id, + type: EMSTMSSource.type, + isAutoSelect: _.get(descriptor, 'isAutoSelect', false), + }, + inspectorAdapters + ); + } + + renderSourceSettingsEditor({ onChange }) { + return ; + } + + async getImmutableProperties() { + const displayName = await this.getDisplayName(); + const autoSelectMsg = i18n.translate('xpack.maps.source.emsTile.isAutoSelectLabel', { + defaultMessage: 'autoselect based on Kibana theme', + }); + + return [ + { + label: getDataSourceLabel(), + value: EMSTMSSource.title, + }, + { + label: i18n.translate('xpack.maps.source.emsTile.serviceId', { + defaultMessage: `Tile service`, + }), + value: this._descriptor.isAutoSelect ? `${displayName} - ${autoSelectMsg}` : displayName, + }, + ]; + } + + async _getEMSTMSService() { + const emsClient = getEMSClient(); + const emsTMSServices = await emsClient.getTMSServices(); + const emsTileLayerId = this.getTileLayerId(); + const tmsService = emsTMSServices.find(tmsService => tmsService.getId() === emsTileLayerId); + if (!tmsService) { + throw new Error( + i18n.translate('xpack.maps.source.emsTile.errorMessage', { + defaultMessage: `Unable to find EMS tile configuration for id: {id}`, + values: { id: emsTileLayerId }, + }) + ); + } + return tmsService; + } + + _createDefaultLayerDescriptor(options) { + return VectorTileLayer.createDescriptor({ + sourceDescriptor: this._descriptor, + ...options, + }); + } + + createDefaultLayer(options) { + return new VectorTileLayer({ + layerDescriptor: this._createDefaultLayerDescriptor(options), + source: this, + }); + } + + async getDisplayName() { + try { + const emsTMSService = await this._getEMSTMSService(); + return emsTMSService.getDisplayName(); + } catch (error) { + return this.getTileLayerId(); + } + } + + async getAttributions() { + const emsTMSService = await this._getEMSTMSService(); + const markdown = emsTMSService.getMarkdownAttribution(); + if (!markdown) { + return []; + } + return this.convertMarkdownLinkToObjectArr(markdown); + } + + async getUrlTemplate() { + const emsTMSService = await this._getEMSTMSService(); + return await emsTMSService.getUrlTemplate(); + } + + getSpriteNamespacePrefix() { + return 'ems/' + this.getTileLayerId(); + } + + async getVectorStyleSheetAndSpriteMeta(isRetina) { + const emsTMSService = await this._getEMSTMSService(); + const styleSheet = await emsTMSService.getVectorStyleSheet(); + const spriteMeta = await emsTMSService.getSpriteSheetMeta(isRetina); + return { + vectorStyleSheet: styleSheet, + spriteMeta: spriteMeta, + }; + } + + getTileLayerId() { + if (!this._descriptor.isAutoSelect) { + return this._descriptor.id; + } + + const isDarkMode = chrome.getUiSettingsClient().get('theme:darkMode', false); + const emsTileLayerId = chrome.getInjected('emsTileLayerId'); + return isDarkMode ? emsTileLayerId.dark : emsTileLayerId.bright; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.test.js b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.test.js new file mode 100644 index 0000000000000..08c54299d721b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/ems_tms_source.test.js @@ -0,0 +1,60 @@ +/* + * 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. + */ + +jest.mock('../../../meta', () => { + return { + getEMSClient: () => { + class MockTMSService { + constructor(config) { + this._config = config; + } + getMarkdownAttribution() { + return this._config.attributionMarkdown; + } + getId() { + return this._config.id; + } + } + + return { + async getTMSServices() { + return [ + new MockTMSService({ + id: 'road_map', + attributionMarkdown: '[foobar](http://foobar.org) | [foobaz](http://foobaz.org)', + }), + new MockTMSService({ + id: 'satellite', + attributionMarkdown: '[satellite](http://satellite.org)', + }), + ]; + }, + }; + }, + }; +}); + +import { EMSTMSSource } from './ems_tms_source'; + +describe('EMSTMSSource', () => { + it('should get attribution from markdown (tiles v2 legacy format)', async () => { + const emsTmsSource = new EMSTMSSource({ + id: 'road_map', + }); + + const attributions = await emsTmsSource.getAttributions(); + expect(attributions).toEqual([ + { + label: 'foobar', + url: 'http://foobar.org', + }, + { + label: 'foobaz', + url: 'http://foobaz.org', + }, + ]); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/index.js b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/index.js new file mode 100644 index 0000000000000..81306578db4ae --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { EMSTMSSource } from './ems_tms_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/tile_service_select.js b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/tile_service_select.js new file mode 100644 index 0000000000000..337fc7aa46693 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/tile_service_select.js @@ -0,0 +1,94 @@ +/* + * 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 from 'react'; +import { EuiSelect, EuiFormRow } from '@elastic/eui'; + +import { getEMSClient } from '../../../meta'; +import { getEmsUnavailableMessage } from '../ems_unavailable_message'; +import { i18n } from '@kbn/i18n'; + +export const AUTO_SELECT = 'auto_select'; + +export class TileServiceSelect extends React.Component { + state = { + emsTmsOptions: [], + hasLoaded: false, + }; + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + this._loadTmsOptions(); + } + + _loadTmsOptions = async () => { + const emsClient = getEMSClient(); + const emsTMSServices = await emsClient.getTMSServices(); + + if (!this._isMounted) { + return; + } + + const emsTmsOptions = emsTMSServices.map(tmsService => { + return { + value: tmsService.getId(), + text: tmsService.getDisplayName() ? tmsService.getDisplayName() : tmsService.getId(), + }; + }); + emsTmsOptions.unshift({ + value: AUTO_SELECT, + text: i18n.translate('xpack.maps.source.emsTile.autoLabel', { + defaultMessage: 'Autoselect based on Kibana theme', + }), + }); + this.setState({ emsTmsOptions, hasLoaded: true }); + }; + + _onChange = e => { + const value = e.target.value; + const isAutoSelect = value === AUTO_SELECT; + this.props.onTileSelect({ + id: isAutoSelect ? null : value, + isAutoSelect, + }); + }; + + render() { + const helpText = + this.state.hasLoaded && this.state.emsTmsOptions.length === 0 + ? getEmsUnavailableMessage() + : null; + + let selectedId; + if (this.props.config) { + selectedId = this.props.config.isAutoSelect ? AUTO_SELECT : this.props.config.id; + } + + return ( + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/ems_tms_source/update_source_editor.js b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/update_source_editor.js new file mode 100644 index 0000000000000..4d567b8dbb32a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_tms_source/update_source_editor.js @@ -0,0 +1,38 @@ +/* + * 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, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { TileServiceSelect } from './tile_service_select'; + +export function UpdateSourceEditor({ onChange, config }) { + const _onTileSelect = ({ id, isAutoSelect }) => { + onChange({ propName: 'id', value: id }); + onChange({ propName: 'isAutoSelect', value: isAutoSelect }); + }; + + return ( + + + +
+ +
+
+ + + + +
+ + +
+ ); +} diff --git a/x-pack/plugins/maps/public/layers/sources/ems_unavailable_message.js b/x-pack/plugins/maps/public/layers/sources/ems_unavailable_message.js new file mode 100644 index 0000000000000..22b1088047539 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/ems_unavailable_message.js @@ -0,0 +1,23 @@ +/* + * 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 chrome from 'ui/chrome'; +import { i18n } from '@kbn/i18n'; + +export function getEmsUnavailableMessage() { + const isEmsEnabled = chrome.getInjected('isEmsEnabled', true); + if (isEmsEnabled) { + return i18n.translate('xpack.maps.source.ems.noAccessDescription', { + defaultMessage: + 'Kibana is unable to access Elastic Maps Service. Contact your system administrator', + }); + } + + return i18n.translate('xpack.maps.source.ems.disabledDescription', { + defaultMessage: + 'Access to Elastic Maps Service has been disabled. Ask your system administrator to set "map.includeElasticMapsService" in kibana.yml.', + }); +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_agg_source.js b/x-pack/plugins/maps/public/layers/sources/es_agg_source.js new file mode 100644 index 0000000000000..967a3c41aec26 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_agg_source.js @@ -0,0 +1,127 @@ +/* + * 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 { AbstractESSource } from './es_source'; +import { ESAggMetricField } from '../fields/es_agg_field'; +import { ESDocField } from '../fields/es_doc_field'; +import { + METRIC_TYPE, + COUNT_AGG_TYPE, + COUNT_PROP_LABEL, + COUNT_PROP_NAME, + FIELD_ORIGIN, +} from '../../../common/constants'; + +export const AGG_DELIMITER = '_of_'; + +export class AbstractESAggSource extends AbstractESSource { + static METRIC_SCHEMA_CONFIG = { + group: 'metrics', + name: 'metric', + title: 'Value', + min: 1, + max: Infinity, + aggFilter: [ + METRIC_TYPE.AVG, + METRIC_TYPE.COUNT, + METRIC_TYPE.MAX, + METRIC_TYPE.MIN, + METRIC_TYPE.SUM, + METRIC_TYPE.UNIQUE_COUNT, + ], + defaults: [{ schema: 'metric', type: METRIC_TYPE.COUNT }], + }; + + constructor(descriptor, inspectorAdapters) { + super(descriptor, inspectorAdapters); + this._metricFields = this._descriptor.metrics + ? this._descriptor.metrics.map(metric => { + const esDocField = metric.field + ? new ESDocField({ fieldName: metric.field, source: this }) + : null; + return new ESAggMetricField({ + label: metric.label, + esDocField: esDocField, + aggType: metric.type, + source: this, + origin: this.getOriginForField(), + }); + }) + : []; + } + + getFieldByName(name) { + return this.getMetricFieldForName(name); + } + + createField() { + throw new Error('Cannot create a new field from just a fieldname for an es_agg_source.'); + } + + hasMatchingMetricField(fieldName) { + const matchingField = this.getMetricFieldForName(fieldName); + return !!matchingField; + } + + getMetricFieldForName(fieldName) { + return this.getMetricFields().find(metricField => { + return metricField.getName() === fieldName; + }); + } + + getOriginForField() { + return FIELD_ORIGIN.SOURCE; + } + + getMetricFields() { + const metrics = this._metricFields.filter(esAggField => esAggField.isValid()); + if (metrics.length === 0) { + metrics.push( + new ESAggMetricField({ + aggType: COUNT_AGG_TYPE, + source: this, + origin: this.getOriginForField(), + }) + ); + } + return metrics; + } + + formatMetricKey(aggType, fieldName) { + return aggType !== COUNT_AGG_TYPE ? `${aggType}${AGG_DELIMITER}${fieldName}` : COUNT_PROP_NAME; + } + + formatMetricLabel(aggType, fieldName) { + return aggType !== COUNT_AGG_TYPE ? `${aggType} of ${fieldName}` : COUNT_PROP_LABEL; + } + + createMetricAggConfigs() { + return this.getMetricFields().map(esAggMetric => esAggMetric.makeMetricAggConfig()); + } + + async getNumberFields() { + return this.getMetricFields(); + } + + async filterAndFormatPropertiesToHtmlForMetricFields(properties) { + const metricFields = this.getMetricFields(); + const tooltipPropertiesPromises = []; + metricFields.forEach(metricField => { + let value; + for (const key in properties) { + if (properties.hasOwnProperty(key) && metricField.getName() === key) { + value = properties[key]; + break; + } + } + + const tooltipPromise = metricField.createTooltipProperty(value); + tooltipPropertiesPromises.push(tooltipPromise); + }); + + return await Promise.all(tooltipPropertiesPromises); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.js new file mode 100644 index 0000000000000..4e15d1c927c36 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.js @@ -0,0 +1,102 @@ +/* + * 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 { RENDER_AS } from './render_as'; +import { getTileBoundingBox } from './geo_tile_utils'; +import { EMPTY_FEATURE_COLLECTION } from '../../../../common/constants'; + +export function convertToGeoJson({ table, renderAs }) { + if (!table || !table.rows) { + return EMPTY_FEATURE_COLLECTION; + } + + const geoGridColumn = table.columns.find( + column => column.aggConfig.type.dslName === 'geotile_grid' + ); + if (!geoGridColumn) { + return EMPTY_FEATURE_COLLECTION; + } + + const metricColumns = table.columns.filter(column => { + return ( + column.aggConfig.type.type === 'metrics' && column.aggConfig.type.dslName !== 'geo_centroid' + ); + }); + const geocentroidColumn = table.columns.find( + column => column.aggConfig.type.dslName === 'geo_centroid' + ); + if (!geocentroidColumn) { + return EMPTY_FEATURE_COLLECTION; + } + + const features = []; + table.rows.forEach(row => { + const gridKey = row[geoGridColumn.id]; + if (!gridKey) { + return; + } + + const properties = {}; + metricColumns.forEach(metricColumn => { + properties[metricColumn.aggConfig.id] = row[metricColumn.id]; + }); + + features.push({ + type: 'Feature', + geometry: rowToGeometry({ + row, + gridKey, + geocentroidColumn, + renderAs, + }), + id: gridKey, + properties, + }); + }); + + return { + featureCollection: { + type: 'FeatureCollection', + features: features, + }, + }; +} + +function rowToGeometry({ row, gridKey, geocentroidColumn, renderAs }) { + const { top, bottom, right, left } = getTileBoundingBox(gridKey); + + if (renderAs === RENDER_AS.GRID) { + return { + type: 'Polygon', + coordinates: [ + [ + [right, top], + [left, top], + [left, bottom], + [right, bottom], + [right, top], + ], + ], + }; + } + + // see https://github.com/elastic/elasticsearch/issues/24694 for why clampGrid is used + const pointCoordinates = [ + clampGrid(row[geocentroidColumn.id].lon, left, right), + clampGrid(row[geocentroidColumn.id].lat, bottom, top), + ]; + + return { + type: 'Point', + coordinates: pointCoordinates, + }; +} + +function clampGrid(val, min, max) { + if (val > max) val = max; + else if (val < min) val = min; + return val; +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/create_source_editor.js new file mode 100644 index 0000000000000..bd074386edb3f --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/create_source_editor.js @@ -0,0 +1,251 @@ +/* + * 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 _ from 'lodash'; +import React, { Fragment, Component } from 'react'; +import PropTypes from 'prop-types'; + +import { SingleFieldSelect } from '../../../components/single_field_select'; +import { RENDER_AS } from './render_as'; +import { indexPatternService } from '../../../kibana_services'; +import { NoIndexPatternCallout } from '../../../components/no_index_pattern_callout'; +import { i18n } from '@kbn/i18n'; + +import { EuiFormRow, EuiComboBox, EuiSpacer } from '@elastic/eui'; +import { + AGGREGATABLE_GEO_FIELD_TYPES, + getAggregatableGeoFields, +} from '../../../index_pattern_util'; + +import { npStart } from 'ui/new_platform'; +const { IndexPatternSelect } = npStart.plugins.data.ui; + +const requestTypeOptions = [ + { + label: i18n.translate('xpack.maps.source.esGeoGrid.gridRectangleDropdownOption', { + defaultMessage: 'grid rectangles', + }), + value: RENDER_AS.GRID, + }, + { + label: i18n.translate('xpack.maps.source.esGeoGrid.heatmapDropdownOption', { + defaultMessage: 'heat map', + }), + value: RENDER_AS.HEATMAP, + }, + { + label: i18n.translate('xpack.maps.source.esGeoGrid.pointsDropdownOption', { + defaultMessage: 'clusters', + }), + value: RENDER_AS.POINT, + }, +]; + +export class CreateSourceEditor extends Component { + static propTypes = { + onSourceConfigChange: PropTypes.func.isRequired, + }; + + state = { + isLoadingIndexPattern: false, + indexPatternId: '', + geoField: '', + requestType: requestTypeOptions[0], + noGeoIndexPatternsExist: false, + }; + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + } + + onIndexPatternSelect = indexPatternId => { + this.setState( + { + indexPatternId, + }, + this.loadIndexPattern.bind(null, indexPatternId) + ); + }; + + loadIndexPattern = indexPatternId => { + this.setState( + { + isLoadingIndexPattern: true, + indexPattern: undefined, + geoField: undefined, + }, + this.debouncedLoad.bind(null, indexPatternId) + ); + }; + + debouncedLoad = _.debounce(async indexPatternId => { + if (!indexPatternId || indexPatternId.length === 0) { + return; + } + + let indexPattern; + try { + indexPattern = await indexPatternService.get(indexPatternId); + } catch (err) { + // index pattern no longer exists + return; + } + + if (!this._isMounted) { + return; + } + + // props.indexPatternId may be updated before getIndexPattern returns + // ignore response when fetched index pattern does not match active index pattern + if (indexPattern.id !== indexPatternId) { + return; + } + + this.setState({ + isLoadingIndexPattern: false, + indexPattern: indexPattern, + }); + + //make default selection + const geoFields = getAggregatableGeoFields(indexPattern.fields); + if (geoFields[0]) { + this._onGeoFieldSelect(geoFields[0].name); + } + }, 300); + + _onGeoFieldSelect = geoField => { + this.setState( + { + geoField, + }, + this.previewLayer + ); + }; + + _onRequestTypeSelect = selectedOptions => { + this.setState( + { + requestType: selectedOptions[0], + }, + this.previewLayer + ); + }; + + previewLayer = () => { + const { indexPatternId, geoField, requestType } = this.state; + + const sourceConfig = + indexPatternId && geoField + ? { indexPatternId, geoField, requestType: requestType.value } + : null; + this.props.onSourceConfigChange(sourceConfig); + }; + + _onNoIndexPatterns = () => { + this.setState({ noGeoIndexPatternsExist: true }); + }; + + _renderGeoSelect() { + if (!this.state.indexPattern) { + return null; + } + + return ( + + + + ); + } + + _renderLayerSelect() { + if (!this.state.indexPattern) { + return null; + } + + return ( + + + + ); + } + + _renderIndexPatternSelect() { + return ( + + + + ); + } + + _renderNoIndexPatternWarning() { + if (!this.state.noGeoIndexPatternsExist) { + return null; + } + + return ( + + + + + ); + } + + render() { + return ( + + {this._renderNoIndexPatternWarning()} + {this._renderIndexPatternSelect()} + {this._renderGeoSelect()} + {this._renderLayerSelect()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js new file mode 100644 index 0000000000000..cb8b43a6c312b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js @@ -0,0 +1,329 @@ +/* + * 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 from 'react'; +import uuid from 'uuid/v4'; + +import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; +import { HeatmapLayer } from '../../heatmap_layer'; +import { VectorLayer } from '../../vector_layer'; +import { AggConfigs, Schemas } from 'ui/agg_types'; +import { tabifyAggResponse } from 'ui/agg_response/tabify'; +import { convertToGeoJson } from './convert_to_geojson'; +import { VectorStyle } from '../../styles/vector/vector_style'; +import { + getDefaultDynamicProperties, + VECTOR_STYLES, +} from '../../styles/vector/vector_style_defaults'; +import { COLOR_GRADIENTS } from '../../styles/color_utils'; +import { RENDER_AS } from './render_as'; +import { CreateSourceEditor } from './create_source_editor'; +import { UpdateSourceEditor } from './update_source_editor'; +import { GRID_RESOLUTION } from '../../grid_resolution'; +import { + SOURCE_DATA_ID_ORIGIN, + ES_GEO_GRID, + COUNT_PROP_NAME, + COLOR_MAP_TYPE, +} from '../../../../common/constants'; +import { i18n } from '@kbn/i18n'; +import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import { AbstractESAggSource } from '../es_agg_source'; +import { DynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property'; +import { StaticStyleProperty } from '../../styles/vector/properties/static_style_property'; + +const MAX_GEOTILE_LEVEL = 29; + +const aggSchemas = new Schemas([ + AbstractESAggSource.METRIC_SCHEMA_CONFIG, + { + group: 'buckets', + name: 'segment', + title: 'Geo Grid', + aggFilter: 'geotile_grid', + min: 1, + max: 1, + }, +]); + +export class ESGeoGridSource extends AbstractESAggSource { + static type = ES_GEO_GRID; + static title = i18n.translate('xpack.maps.source.esGridTitle', { + defaultMessage: 'Grid aggregation', + }); + static description = i18n.translate('xpack.maps.source.esGridDescription', { + defaultMessage: 'Geospatial data grouped in grids with metrics for each gridded cell', + }); + + static createDescriptor({ indexPatternId, geoField, requestType, resolution }) { + return { + type: ESGeoGridSource.type, + id: uuid(), + indexPatternId: indexPatternId, + geoField: geoField, + requestType: requestType, + resolution: resolution ? resolution : GRID_RESOLUTION.COARSE, + }; + } + + static renderEditor({ onPreviewSource, inspectorAdapters }) { + const onSourceConfigChange = sourceConfig => { + if (!sourceConfig) { + onPreviewSource(null); + return; + } + + const sourceDescriptor = ESGeoGridSource.createDescriptor(sourceConfig); + const source = new ESGeoGridSource(sourceDescriptor, inspectorAdapters); + onPreviewSource(source); + }; + + return ; + } + + renderSourceSettingsEditor({ onChange }) { + return ( + + ); + } + + async getImmutableProperties() { + let indexPatternTitle = this._descriptor.indexPatternId; + try { + const indexPattern = await this.getIndexPattern(); + indexPatternTitle = indexPattern.title; + } catch (error) { + // ignore error, title will just default to id + } + + return [ + { + label: getDataSourceLabel(), + value: ESGeoGridSource.title, + }, + { + label: i18n.translate('xpack.maps.source.esGrid.indexPatternLabel', { + defaultMessage: 'Index pattern', + }), + value: indexPatternTitle, + }, + { + label: i18n.translate('xpack.maps.source.esGrid.geospatialFieldLabel', { + defaultMessage: 'Geospatial field', + }), + value: this._descriptor.geoField, + }, + { + label: i18n.translate('xpack.maps.source.esGrid.showasFieldLabel', { + defaultMessage: 'Show as', + }), + value: this._descriptor.requestType, + }, + ]; + } + + getFieldNames() { + return this.getMetricFields().map(esAggMetricField => esAggMetricField.getName()); + } + + isGeoGridPrecisionAware() { + return true; + } + + isJoinable() { + return false; + } + + getGridResolution() { + return this._descriptor.resolution; + } + + getGeoGridPrecision(zoom) { + const targetGeotileLevel = Math.ceil(zoom) + this._getGeoGridPrecisionResolutionDelta(); + return Math.min(targetGeotileLevel, MAX_GEOTILE_LEVEL); + } + + _getGeoGridPrecisionResolutionDelta() { + if (this._descriptor.resolution === GRID_RESOLUTION.COARSE) { + return 2; + } + + if (this._descriptor.resolution === GRID_RESOLUTION.FINE) { + return 3; + } + + if (this._descriptor.resolution === GRID_RESOLUTION.MOST_FINE) { + return 4; + } + + throw new Error( + i18n.translate('xpack.maps.source.esGrid.resolutionParamErrorMessage', { + defaultMessage: `Grid resolution param not recognized: {resolution}`, + values: { + resolution: this._descriptor.resolution, + }, + }) + ); + } + + async getGeoJsonWithMeta(layerName, searchFilters, registerCancelCallback) { + const indexPattern = await this.getIndexPattern(); + const searchSource = await this._makeSearchSource(searchFilters, 0); + const aggConfigs = new AggConfigs( + indexPattern, + this._makeAggConfigs(searchFilters.geogridPrecision), + aggSchemas.all + ); + searchSource.setField('aggs', aggConfigs.toDsl()); + const esResponse = await this._runEsQuery({ + requestId: this.getId(), + requestName: layerName, + searchSource, + registerCancelCallback, + requestDescription: i18n.translate('xpack.maps.source.esGrid.inspectorDescription', { + defaultMessage: 'Elasticsearch geo grid aggregation request', + }), + }); + + const tabifiedResp = tabifyAggResponse(aggConfigs, esResponse); + const { featureCollection } = convertToGeoJson({ + table: tabifiedResp, + renderAs: this._descriptor.requestType, + }); + + return { + data: featureCollection, + meta: { + areResultsTrimmed: false, + }, + }; + } + + isFilterByMapBounds() { + return true; + } + + _makeAggConfigs(precision) { + const metricAggConfigs = this.createMetricAggConfigs(); + return [ + ...metricAggConfigs, + { + id: 'grid', + enabled: true, + type: 'geotile_grid', + schema: 'segment', + params: { + field: this._descriptor.geoField, + useGeocentroid: true, + precision: precision, + }, + }, + ]; + } + + _createHeatmapLayerDescriptor(options) { + return HeatmapLayer.createDescriptor({ + sourceDescriptor: this._descriptor, + ...options, + }); + } + + _createVectorLayerDescriptor(options) { + const descriptor = VectorLayer.createDescriptor({ + sourceDescriptor: this._descriptor, + ...options, + }); + + const defaultDynamicProperties = getDefaultDynamicProperties(); + + descriptor.style = VectorStyle.createDescriptor({ + [VECTOR_STYLES.FILL_COLOR]: { + type: DynamicStyleProperty.type, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.FILL_COLOR].options, + field: { + name: COUNT_PROP_NAME, + origin: SOURCE_DATA_ID_ORIGIN, + }, + color: COLOR_GRADIENTS[0].value, + type: COLOR_MAP_TYPE.ORDINAL, + }, + }, + [VECTOR_STYLES.LINE_COLOR]: { + type: StaticStyleProperty.type, + options: { + color: '#FFF', + }, + }, + [VECTOR_STYLES.LINE_WIDTH]: { + type: StaticStyleProperty.type, + options: { + size: 0, + }, + }, + [VECTOR_STYLES.ICON_SIZE]: { + type: DynamicStyleProperty.type, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE].options, + field: { + name: COUNT_PROP_NAME, + origin: SOURCE_DATA_ID_ORIGIN, + }, + }, + }, + [VECTOR_STYLES.LABEL_TEXT]: { + type: DynamicStyleProperty.type, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT].options, + field: { + name: COUNT_PROP_NAME, + origin: SOURCE_DATA_ID_ORIGIN, + }, + }, + }, + }); + return descriptor; + } + + createDefaultLayer(options) { + if (this._descriptor.requestType === RENDER_AS.HEATMAP) { + return new HeatmapLayer({ + layerDescriptor: this._createHeatmapLayerDescriptor(options), + source: this, + }); + } + + const layerDescriptor = this._createVectorLayerDescriptor(options); + const style = new VectorStyle(layerDescriptor.style, this); + return new VectorLayer({ + layerDescriptor, + source: this, + style, + }); + } + + canFormatFeatureProperties() { + return true; + } + + async filterAndFormatPropertiesToHtml(properties) { + return await this.filterAndFormatPropertiesToHtmlForMetricFields(properties); + } + + async getSupportedShapeTypes() { + if (this._descriptor.requestType === RENDER_AS.GRID) { + return [VECTOR_SHAPE_TYPES.POLYGON]; + } + + return [VECTOR_SHAPE_TYPES.POINT]; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/geo_tile_utils.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/geo_tile_utils.js new file mode 100644 index 0000000000000..da0bc1685f223 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/geo_tile_utils.js @@ -0,0 +1,114 @@ +/* + * 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 _ from 'lodash'; +import { DECIMAL_DEGREES_PRECISION } from '../../../../common/constants'; + +const ZOOM_TILE_KEY_INDEX = 0; +const X_TILE_KEY_INDEX = 1; +const Y_TILE_KEY_INDEX = 2; + +function getTileCount(zoom) { + return Math.pow(2, zoom); +} + +export function parseTileKey(tileKey) { + const tileKeyParts = tileKey.split('/'); + + if (tileKeyParts.length !== 3) { + throw new Error(`Invalid tile key, expecting "zoom/x/y" format but got ${tileKey}`); + } + + const zoom = parseInt(tileKeyParts[ZOOM_TILE_KEY_INDEX], 10); + const x = parseInt(tileKeyParts[X_TILE_KEY_INDEX], 10); + const y = parseInt(tileKeyParts[Y_TILE_KEY_INDEX], 10); + const tileCount = getTileCount(zoom); + + if (x >= tileCount) { + throw new Error( + `Tile key is malformed, expected x to be less than ${tileCount}, you provided ${x}` + ); + } + if (y >= tileCount) { + throw new Error( + `Tile key is malformed, expected y to be less than ${tileCount}, you provided ${y}` + ); + } + + return { x, y, zoom, tileCount }; +} + +function sinh(x) { + return (Math.exp(x) - Math.exp(-x)) / 2; +} + +// Calculate the minimum precision required to adequtely draw the box +// bounds. +// +// ceil(abs(log10(tileSize))) tells us how many decimals of precision +// are minimally required to represent the number after rounding. +// +// We add one extra decimal level of precision because, at high zoom +// levels rounding exactly can cause the boxes to render as uneven sizes +// (some will be slightly larger and some slightly smaller) +function precisionRounding(v, minPrecision, binSize) { + let precision = Math.ceil(Math.abs(Math.log10(binSize))) + 1; + precision = Math.max(precision, minPrecision); + return _.round(v, precision); +} + +function tileToLatitude(y, tileCount) { + const radians = Math.atan(sinh(Math.PI - (2 * Math.PI * y) / tileCount)); + const lat = (180 / Math.PI) * radians; + return precisionRounding(lat, DECIMAL_DEGREES_PRECISION, 180 / tileCount); +} + +function tileToLongitude(x, tileCount) { + const lon = (x / tileCount) * 360 - 180; + return precisionRounding(lon, DECIMAL_DEGREES_PRECISION, 360 / tileCount); +} + +export function getTileBoundingBox(tileKey) { + const { x, y, tileCount } = parseTileKey(tileKey); + + return { + top: tileToLatitude(y, tileCount), + bottom: tileToLatitude(y + 1, tileCount), + left: tileToLongitude(x, tileCount), + right: tileToLongitude(x + 1, tileCount), + }; +} + +function sec(value) { + return 1 / Math.cos(value); +} + +function latitudeToTile(lat, tileCount) { + const radians = (lat * Math.PI) / 180; + const y = ((1 - Math.log(Math.tan(radians) + sec(radians)) / Math.PI) / 2) * tileCount; + return Math.floor(y); +} + +function longitudeToTile(lon, tileCount) { + const x = ((lon + 180) / 360) * tileCount; + return Math.floor(x); +} + +export function expandToTileBoundaries(extent, zoom) { + const tileCount = getTileCount(zoom); + + const upperLeftX = longitudeToTile(extent.minLon, tileCount); + const upperLeftY = latitudeToTile(Math.min(extent.maxLat, 90), tileCount); + const lowerRightX = longitudeToTile(extent.maxLon, tileCount); + const lowerRightY = latitudeToTile(Math.max(extent.minLat, -90), tileCount); + + return { + minLon: tileToLongitude(upperLeftX, tileCount), + minLat: tileToLatitude(lowerRightY + 1, tileCount), + maxLon: tileToLongitude(lowerRightX + 1, tileCount), + maxLat: tileToLatitude(upperLeftY, tileCount), + }; +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/geo_tile_utils.test.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/geo_tile_utils.test.js new file mode 100644 index 0000000000000..ae2623e168766 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/geo_tile_utils.test.js @@ -0,0 +1,52 @@ +/* + * 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 { parseTileKey, getTileBoundingBox, expandToTileBoundaries } from './geo_tile_utils'; + +it('Should parse tile key', () => { + expect(parseTileKey('15/23423/1867')).toEqual({ + zoom: 15, + x: 23423, + y: 1867, + tileCount: Math.pow(2, 15), + }); +}); + +it('Should convert tile key to geojson Polygon', () => { + const geometry = getTileBoundingBox('15/23423/1867'); + expect(geometry).toEqual({ + top: 82.92546, + bottom: 82.92411, + right: 77.34375, + left: 77.33276, + }); +}); + +it('Should convert tile key to geojson Polygon with extra precision', () => { + const geometry = getTileBoundingBox('26/19762828/25222702'); + expect(geometry).toEqual({ + top: 40.7491508, + bottom: 40.7491467, + right: -73.9839238, + left: -73.9839292, + }); +}); + +it('Should expand extent to align boundaries with tile boundaries', () => { + const extent = { + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }; + const tileAlignedExtent = expandToTileBoundaries(extent, 7); + expect(tileAlignedExtent).toEqual({ + maxLat: 13.9234, + maxLon: 104.0625, + minLat: 0, + minLon: 90, + }); +}); diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/index.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/index.js new file mode 100644 index 0000000000000..58d74c04c5552 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { ESGeoGridSource } from './es_geo_grid_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/render_as.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/render_as.js new file mode 100644 index 0000000000000..caf5324d9ecc8 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/render_as.js @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export const RENDER_AS = { + HEATMAP: 'heatmap', + POINT: 'point', + GRID: 'grid', +}; diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/resolution_editor.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/resolution_editor.js new file mode 100644 index 0000000000000..ff3e7c3458a5a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/resolution_editor.js @@ -0,0 +1,49 @@ +/* + * 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 from 'react'; +import { GRID_RESOLUTION } from '../../grid_resolution'; +import { EuiSelect, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const OPTIONS = [ + { + value: GRID_RESOLUTION.COARSE, + text: i18n.translate('xpack.maps.source.esGrid.coarseDropdownOption', { + defaultMessage: 'coarse', + }), + }, + { + value: GRID_RESOLUTION.FINE, + text: i18n.translate('xpack.maps.source.esGrid.fineDropdownOption', { + defaultMessage: 'fine', + }), + }, + { + value: GRID_RESOLUTION.MOST_FINE, + text: i18n.translate('xpack.maps.source.esGrid.finestDropdownOption', { + defaultMessage: 'finest', + }), + }, +]; + +export function ResolutionEditor({ resolution, onChange }) { + return ( + + onChange(e.target.value)} + compressed + /> + + ); +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/update_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/update_source_editor.js new file mode 100644 index 0000000000000..a6f9bed79e839 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_geo_grid_source/update_source_editor.js @@ -0,0 +1,119 @@ +/* + * 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, Component } from 'react'; + +import { RENDER_AS } from './render_as'; +import { MetricsEditor } from '../../../components/metrics_editor'; +import { indexPatternService } from '../../../kibana_services'; +import { ResolutionEditor } from './resolution_editor'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { isMetricCountable } from '../../util/is_metric_countable'; +import { isNestedField } from '../../../../../../../src/plugins/data/public'; + +export class UpdateSourceEditor extends Component { + state = { + fields: null, + }; + + componentDidMount() { + this._isMounted = true; + this._loadFields(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadFields() { + let indexPattern; + try { + indexPattern = await indexPatternService.get(this.props.indexPatternId); + } catch (err) { + if (this._isMounted) { + this.setState({ + loadError: i18n.translate('xpack.maps.source.esGrid.noIndexPatternErrorMessage', { + defaultMessage: `Unable to find Index pattern {id}`, + values: { + id: this.props.indexPatternId, + }, + }), + }); + } + return; + } + + if (!this._isMounted) { + return; + } + + this.setState({ fields: indexPattern.fields.filter(field => !isNestedField(field)) }); + } + + _onMetricsChange = metrics => { + this.props.onChange({ propName: 'metrics', value: metrics }); + }; + + _onResolutionChange = e => { + this.props.onChange({ propName: 'resolution', value: e }); + }; + + _renderMetricsPanel() { + const metricsFilter = + this.props.renderAs === RENDER_AS.HEATMAP + ? metric => { + //these are countable metrics, where blending heatmap color blobs make sense + return isMetricCountable(metric.value); + } + : null; + const allowMultipleMetrics = this.props.renderAs !== RENDER_AS.HEATMAP; + return ( + + +
+ +
+
+ + +
+ ); + } + + render() { + return ( + + {this._renderMetricsPanel()} + + + + +
+ +
+
+ + +
+ +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.js b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.js new file mode 100644 index 0000000000000..2057949c30c88 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.js @@ -0,0 +1,57 @@ +/* + * 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 _ from 'lodash'; + +const LAT_INDEX = 0; +const LON_INDEX = 1; + +function parsePointFromKey(key) { + const split = key.split(','); + const lat = parseFloat(split[LAT_INDEX]); + const lon = parseFloat(split[LON_INDEX]); + return [lon, lat]; +} + +export function convertToLines(esResponse) { + const lineFeatures = []; + + const destBuckets = _.get(esResponse, 'aggregations.destSplit.buckets', []); + for (let i = 0; i < destBuckets.length; i++) { + const destBucket = destBuckets[i]; + const dest = parsePointFromKey(destBucket.key); + const sourceBuckets = _.get(destBucket, 'sourceGrid.buckets', []); + for (let j = 0; j < sourceBuckets.length; j++) { + const { key, sourceCentroid, ...rest } = sourceBuckets[j]; + + // flatten metrics + Object.keys(rest).forEach(key => { + if (_.has(rest[key], 'value')) { + rest[key] = rest[key].value; + } + }); + + lineFeatures.push({ + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [[sourceCentroid.location.lon, sourceCentroid.location.lat], dest], + }, + id: `${dest.join()},${key}`, + properties: { + ...rest, + }, + }); + } + } + + return { + featureCollection: { + type: 'FeatureCollection', + features: lineFeatures, + }, + }; +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/create_source_editor.js new file mode 100644 index 0000000000000..5e4727cd7ab0c --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/create_source_editor.js @@ -0,0 +1,213 @@ +/* + * 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 _ from 'lodash'; +import React, { Fragment, Component } from 'react'; +import PropTypes from 'prop-types'; + +import { SingleFieldSelect } from '../../../components/single_field_select'; +import { indexPatternService } from '../../../kibana_services'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiFormRow, EuiCallOut } from '@elastic/eui'; +import { + AGGREGATABLE_GEO_FIELD_TYPES, + getAggregatableGeoFields, +} from '../../../index_pattern_util'; + +import { npStart } from 'ui/new_platform'; +const { IndexPatternSelect } = npStart.plugins.data.ui; + +export class CreateSourceEditor extends Component { + static propTypes = { + onSourceConfigChange: PropTypes.func.isRequired, + }; + + state = { + isLoadingIndexPattern: false, + indexPattern: undefined, + indexPatternId: undefined, + sourceGeoField: undefined, + destGeoField: undefined, + indexPatternHasMultipleGeoFields: false, + }; + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + } + + onIndexPatternSelect = indexPatternId => { + this.setState( + { + indexPatternId, + }, + this.loadIndexPattern.bind(null, indexPatternId) + ); + }; + + loadIndexPattern = indexPatternId => { + this.setState( + { + isLoadingIndexPattern: true, + indexPattern: undefined, + sourceGeoField: undefined, + destGeoField: undefined, + indexPatternHasMultipleGeoFields: false, + }, + this.debouncedLoad.bind(null, indexPatternId) + ); + }; + + debouncedLoad = _.debounce(async indexPatternId => { + if (!indexPatternId || indexPatternId.length === 0) { + return; + } + + let indexPattern; + try { + indexPattern = await indexPatternService.get(indexPatternId); + } catch (err) { + // index pattern no longer exists + return; + } + + if (!this._isMounted) { + return; + } + + // props.indexPatternId may be updated before getIndexPattern returns + // ignore response when fetched index pattern does not match active index pattern + if (this.state.indexPatternId !== indexPatternId) { + return; + } + + const geoFields = getAggregatableGeoFields(indexPattern.fields); + this.setState({ + isLoadingIndexPattern: false, + indexPattern: indexPattern, + indexPatternHasMultipleGeoFields: geoFields.length >= 2, + }); + }, 300); + + _onSourceGeoSelect = sourceGeoField => { + this.setState( + { + sourceGeoField, + }, + this.previewLayer + ); + }; + + _onDestGeoSelect = destGeoField => { + this.setState( + { + destGeoField, + }, + this.previewLayer + ); + }; + + previewLayer = () => { + const { indexPatternId, sourceGeoField, destGeoField } = this.state; + + const sourceConfig = + indexPatternId && sourceGeoField && destGeoField + ? { indexPatternId, sourceGeoField, destGeoField } + : null; + this.props.onSourceConfigChange(sourceConfig); + }; + + _renderGeoSelects() { + if (!this.state.indexPattern || !this.state.indexPatternHasMultipleGeoFields) { + return null; + } + + const fields = this.state.indexPattern + ? getAggregatableGeoFields(this.state.indexPattern.fields) + : undefined; + return ( + + + + + + + + + + ); + } + + _renderIndexPatternSelect() { + return ( + + + + ); + } + + render() { + let callout; + if (this.state.indexPattern && !this.state.indexPatternHasMultipleGeoFields) { + callout = ( + +

+ +

+
+ ); + } + + return ( + + {callout} + {this._renderIndexPatternSelect()} + {this._renderGeoSelects()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js new file mode 100644 index 0000000000000..91865ab1867fd --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js @@ -0,0 +1,253 @@ +/* + * 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 from 'react'; +import uuid from 'uuid/v4'; + +import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; +import { VectorLayer } from '../../vector_layer'; +import { CreateSourceEditor } from './create_source_editor'; +import { UpdateSourceEditor } from './update_source_editor'; +import { VectorStyle } from '../../styles/vector/vector_style'; +import { + getDefaultDynamicProperties, + VECTOR_STYLES, +} from '../../styles/vector/vector_style_defaults'; +import { i18n } from '@kbn/i18n'; +import { SOURCE_DATA_ID_ORIGIN, ES_PEW_PEW, COUNT_PROP_NAME } from '../../../../common/constants'; +import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import { convertToLines } from './convert_to_lines'; +import { AbstractESAggSource } from '../es_agg_source'; +import { DynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property'; +import { COLOR_GRADIENTS } from '../../styles/color_utils'; +import { isNestedField } from '../../../../../../../src/plugins/data/public'; + +// TODO +import { AggConfigs, Schemas } from 'ui/agg_types'; + +const MAX_GEOTILE_LEVEL = 29; + +const aggSchemas = new Schemas([AbstractESAggSource.METRIC_SCHEMA_CONFIG]); + +export class ESPewPewSource extends AbstractESAggSource { + static type = ES_PEW_PEW; + static title = i18n.translate('xpack.maps.source.pewPewTitle', { + defaultMessage: 'Point to point', + }); + static description = i18n.translate('xpack.maps.source.pewPewDescription', { + defaultMessage: 'Aggregated data paths between the source and destination', + }); + + static createDescriptor({ indexPatternId, sourceGeoField, destGeoField }) { + return { + type: ESPewPewSource.type, + id: uuid(), + indexPatternId: indexPatternId, + sourceGeoField, + destGeoField, + }; + } + + static renderEditor({ onPreviewSource, inspectorAdapters }) { + const onSourceConfigChange = sourceConfig => { + if (!sourceConfig) { + onPreviewSource(null); + return; + } + + const sourceDescriptor = ESPewPewSource.createDescriptor(sourceConfig); + const source = new ESPewPewSource(sourceDescriptor, inspectorAdapters); + onPreviewSource(source); + }; + + return ; + } + + renderSourceSettingsEditor({ onChange }) { + return ( + + ); + } + + isFilterByMapBounds() { + return true; + } + + isJoinable() { + return false; + } + + isGeoGridPrecisionAware() { + return true; + } + + async getSupportedShapeTypes() { + return [VECTOR_SHAPE_TYPES.LINE]; + } + + async getImmutableProperties() { + let indexPatternTitle = this._descriptor.indexPatternId; + try { + const indexPattern = await this.getIndexPattern(); + indexPatternTitle = indexPattern.title; + } catch (error) { + // ignore error, title will just default to id + } + + return [ + { + label: getDataSourceLabel(), + value: ESPewPewSource.title, + }, + { + label: i18n.translate('xpack.maps.source.pewPew.indexPatternLabel', { + defaultMessage: 'Index pattern', + }), + value: indexPatternTitle, + }, + { + label: i18n.translate('xpack.maps.source.pewPew.sourceGeoFieldLabel', { + defaultMessage: 'Source', + }), + value: this._descriptor.sourceGeoField, + }, + { + label: i18n.translate('xpack.maps.source.pewPew.destGeoFieldLabel', { + defaultMessage: 'Destination', + }), + value: this._descriptor.destGeoField, + }, + ]; + } + + createDefaultLayer(options) { + const defaultDynamicProperties = getDefaultDynamicProperties(); + const styleDescriptor = VectorStyle.createDescriptor({ + [VECTOR_STYLES.LINE_COLOR]: { + type: DynamicStyleProperty.type, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.LINE_COLOR].options, + field: { + name: COUNT_PROP_NAME, + origin: SOURCE_DATA_ID_ORIGIN, + }, + color: COLOR_GRADIENTS[0].value, + }, + }, + [VECTOR_STYLES.LINE_WIDTH]: { + type: DynamicStyleProperty.type, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.LINE_WIDTH].options, + field: { + name: COUNT_PROP_NAME, + origin: SOURCE_DATA_ID_ORIGIN, + }, + }, + }, + }); + + return new VectorLayer({ + layerDescriptor: VectorLayer.createDescriptor({ + ...options, + sourceDescriptor: this._descriptor, + style: styleDescriptor, + }), + source: this, + style: new VectorStyle(styleDescriptor, this), + }); + } + + getGeoGridPrecision(zoom) { + const targetGeotileLevel = Math.ceil(zoom) + 2; + return Math.min(targetGeotileLevel, MAX_GEOTILE_LEVEL); + } + + async getGeoJsonWithMeta(layerName, searchFilters, registerCancelCallback) { + const indexPattern = await this.getIndexPattern(); + const metricAggConfigs = this.createMetricAggConfigs(); + const aggConfigs = new AggConfigs(indexPattern, metricAggConfigs, aggSchemas.all); + + const searchSource = await this._makeSearchSource(searchFilters, 0); + searchSource.setField('aggs', { + destSplit: { + terms: { + script: { + source: `doc['${this._descriptor.destGeoField}'].value.toString()`, + lang: 'painless', + }, + order: { + _count: 'desc', + }, + size: 100, + }, + aggs: { + sourceGrid: { + geotile_grid: { + field: this._descriptor.sourceGeoField, + precision: searchFilters.geogridPrecision, + size: 500, + }, + aggs: { + sourceCentroid: { + geo_centroid: { + field: this._descriptor.sourceGeoField, + }, + }, + ...aggConfigs.toDsl(), + }, + }, + }, + }, + }); + + const esResponse = await this._runEsQuery({ + requestId: this.getId(), + requestName: layerName, + searchSource, + registerCancelCallback, + requestDescription: i18n.translate('xpack.maps.source.pewPew.inspectorDescription', { + defaultMessage: 'Source-destination connections request', + }), + }); + + const { featureCollection } = convertToLines(esResponse); + + return { + data: featureCollection, + meta: { + areResultsTrimmed: false, + }, + }; + } + + async _getGeoField() { + const indexPattern = await this.getIndexPattern(); + const field = indexPattern.fields.getByName(this._descriptor.destGeoField); + const geoField = isNestedField(field) ? undefined : field; + if (!geoField) { + throw new Error( + i18n.translate('xpack.maps.source.esSource.noGeoFieldErrorMessage', { + defaultMessage: `Index pattern {indexPatternTitle} no longer contains the geo field {geoField}`, + values: { indexPatternTitle: indexPattern.title, geoField: this._descriptor.geoField }, + }) + ); + } + return geoField; + } + + canFormatFeatureProperties() { + return true; + } + + async filterAndFormatPropertiesToHtml(properties) { + return await this.filterAndFormatPropertiesToHtmlForMetricFields(properties); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/update_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/update_source_editor.js new file mode 100644 index 0000000000000..4e92915e91166 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_pew_pew_source/update_source_editor.js @@ -0,0 +1,83 @@ +/* + * 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, { Component, Fragment } from 'react'; + +import { MetricsEditor } from '../../../components/metrics_editor'; +import { indexPatternService } from '../../../kibana_services'; +import { i18n } from '@kbn/i18n'; +import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { isNestedField } from '../../../../../../../src/plugins/data/public'; + +export class UpdateSourceEditor extends Component { + state = { + fields: null, + }; + + componentDidMount() { + this._isMounted = true; + this._loadFields(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadFields() { + let indexPattern; + try { + indexPattern = await indexPatternService.get(this.props.indexPatternId); + } catch (err) { + if (this._isMounted) { + this.setState({ + loadError: i18n.translate('xpack.maps.source.pewPew.noIndexPatternErrorMessage', { + defaultMessage: `Unable to find Index pattern {id}`, + values: { + id: this.props.indexPatternId, + }, + }), + }); + } + return; + } + + if (!this._isMounted) { + return; + } + + this.setState({ fields: indexPattern.fields.filter(field => !isNestedField(field)) }); + } + + _onMetricsChange = metrics => { + this.props.onChange({ propName: 'metrics', value: metrics }); + }; + + render() { + return ( + + + +
+ +
+
+ + +
+ +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap b/x-pack/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap new file mode 100644 index 0000000000000..80368fd5d5e3e --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap @@ -0,0 +1,374 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should enable sort order select when sort field provided 1`] = ` + + + +
+ +
+
+ + +
+ + + +
+ +
+
+ + + + + + + + + + + +
+ +
+`; + +exports[`should render top hits form when useTopHits is true 1`] = ` + + + +
+ +
+
+ + +
+ + + +
+ +
+
+ + + + + + + + + + + + + + + + + +
+ +
+`; + +exports[`should render update source editor 1`] = ` + + + +
+ +
+
+ + +
+ + + +
+ +
+
+ + + + + + + + + + + +
+ +
+`; diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/constants.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/constants.js new file mode 100644 index 0000000000000..d7d11440c360b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/constants.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export const DEFAULT_FILTER_BY_MAP_BOUNDS = true; diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/create_source_editor.js new file mode 100644 index 0000000000000..01ecc3a2827e9 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/create_source_editor.js @@ -0,0 +1,284 @@ +/* + * 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 _ from 'lodash'; +import React, { Fragment, Component } from 'react'; +import PropTypes from 'prop-types'; +import { EuiFormRow, EuiSpacer, EuiSwitch, EuiCallOut } from '@elastic/eui'; + +import { SingleFieldSelect } from '../../../components/single_field_select'; +import { indexPatternService } from '../../../kibana_services'; +import { NoIndexPatternCallout } from '../../../components/no_index_pattern_callout'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + ES_GEO_FIELD_TYPE, + GIS_API_PATH, + DEFAULT_MAX_RESULT_WINDOW, +} from '../../../../common/constants'; +import { DEFAULT_FILTER_BY_MAP_BOUNDS } from './constants'; +import { isNestedField} from '../../../../../../../src/plugins/data/common/index_patterns/fields'; + +// TODO +import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; + +const { IndexPatternSelect } = npStart.plugins.data.ui; + +function getGeoFields(fields) { + return fields.filter(field => { + return ( + !isNestedField(field) && + [ES_GEO_FIELD_TYPE.GEO_POINT, ES_GEO_FIELD_TYPE.GEO_SHAPE].includes(field.type) + ); + }); +} +const RESET_INDEX_PATTERN_STATE = { + indexPattern: undefined, + geoField: undefined, + filterByMapBounds: DEFAULT_FILTER_BY_MAP_BOUNDS, + showFilterByBoundsSwitch: false, +}; + +export class CreateSourceEditor extends Component { + static propTypes = { + onSourceConfigChange: PropTypes.func.isRequired, + }; + + state = { + isLoadingIndexPattern: false, + noGeoIndexPatternsExist: false, + ...RESET_INDEX_PATTERN_STATE, + }; + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + this.loadIndexPattern(this.state.indexPatternId); + } + + onIndexPatternSelect = indexPatternId => { + this.setState( + { + indexPatternId, + }, + this.loadIndexPattern(indexPatternId) + ); + }; + + loadIndexPattern = indexPatternId => { + this.setState( + { + isLoadingIndexPattern: true, + ...RESET_INDEX_PATTERN_STATE, + }, + this.debouncedLoad.bind(null, indexPatternId) + ); + }; + + loadIndexDocCount = async indexPatternTitle => { + const { count } = await kfetch({ + pathname: `../${GIS_API_PATH}/indexCount`, + query: { + index: indexPatternTitle, + }, + }); + return count; + }; + + debouncedLoad = _.debounce(async indexPatternId => { + if (!indexPatternId || indexPatternId.length === 0) { + return; + } + + let indexPattern; + try { + indexPattern = await indexPatternService.get(indexPatternId); + } catch (err) { + // index pattern no longer exists + return; + } + + let indexHasSmallDocCount = false; + try { + const indexDocCount = await this.loadIndexDocCount(indexPattern.title); + indexHasSmallDocCount = indexDocCount <= DEFAULT_MAX_RESULT_WINDOW; + } catch (error) { + // retrieving index count is a nice to have and is not essential + // do not interrupt user flow if unable to retrieve count + } + + if (!this._isMounted) { + return; + } + + // props.indexPatternId may be updated before getIndexPattern returns + // ignore response when fetched index pattern does not match active index pattern + if (indexPattern.id !== indexPatternId) { + return; + } + + this.setState({ + isLoadingIndexPattern: false, + indexPattern: indexPattern, + filterByMapBounds: !indexHasSmallDocCount, // Turn off filterByMapBounds when index contains a limited number of documents + showFilterByBoundsSwitch: indexHasSmallDocCount, + }); + + //make default selection + const geoFields = getGeoFields(indexPattern.fields); + if (geoFields[0]) { + this.onGeoFieldSelect(geoFields[0].name); + } + }, 300); + + onGeoFieldSelect = geoField => { + this.setState( + { + geoField, + }, + this.previewLayer + ); + }; + + onFilterByMapBoundsChange = event => { + this.setState( + { + filterByMapBounds: event.target.checked, + }, + this.previewLayer + ); + }; + + previewLayer = () => { + const { indexPatternId, geoField, filterByMapBounds } = this.state; + + const sourceConfig = + indexPatternId && geoField ? { indexPatternId, geoField, filterByMapBounds } : null; + this.props.onSourceConfigChange(sourceConfig); + }; + + _onNoIndexPatterns = () => { + this.setState({ noGeoIndexPatternsExist: true }); + }; + + _renderGeoSelect() { + if (!this.state.indexPattern) { + return; + } + + return ( + + + + ); + } + + _renderFilterByMapBounds() { + if (!this.state.showFilterByBoundsSwitch) { + return null; + } + + return ( + + +

+ +

+

+ +

+
+ + + + +
+ ); + } + + _renderNoIndexPatternWarning() { + if (!this.state.noGeoIndexPatternsExist) { + return null; + } + + return ( + + + + + ); + } + + render() { + return ( + + {this._renderNoIndexPatternWarning()} + + + + + + {this._renderGeoSelect()} + + {this._renderFilterByMapBounds()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js new file mode 100644 index 0000000000000..b8644adddcf7e --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -0,0 +1,613 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import uuid from 'uuid/v4'; + +import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; +import { AbstractESSource } from '../es_source'; +import { SearchSource } from '../../../kibana_services'; +import { hitsToGeoJson } from '../../../elasticsearch_geo_utils'; +import { CreateSourceEditor } from './create_source_editor'; +import { UpdateSourceEditor } from './update_source_editor'; +import { + ES_SEARCH, + ES_GEO_FIELD_TYPE, + DEFAULT_MAX_BUCKETS_LIMIT, + SORT_ORDER, + CATEGORICAL_DATA_TYPES, +} from '../../../../common/constants'; +import { i18n } from '@kbn/i18n'; +import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import { getSourceFields } from '../../../index_pattern_util'; +import { loadIndexSettings } from './load_index_settings'; + +import { DEFAULT_FILTER_BY_MAP_BOUNDS } from './constants'; +import { ESDocField } from '../../fields/es_doc_field'; + +export class ESSearchSource extends AbstractESSource { + static type = ES_SEARCH; + static title = i18n.translate('xpack.maps.source.esSearchTitle', { + defaultMessage: 'Documents', + }); + static description = i18n.translate('xpack.maps.source.esSearchDescription', { + defaultMessage: 'Vector data from a Kibana index pattern', + }); + + static renderEditor({ onPreviewSource, inspectorAdapters }) { + const onSourceConfigChange = sourceConfig => { + if (!sourceConfig) { + onPreviewSource(null); + return; + } + + const source = new ESSearchSource( + { + id: uuid(), + ...sourceConfig, + }, + inspectorAdapters + ); + onPreviewSource(source); + }; + return ; + } + + constructor(descriptor, inspectorAdapters) { + super( + { + ...descriptor, + id: descriptor.id, + type: ESSearchSource.type, + indexPatternId: descriptor.indexPatternId, + geoField: descriptor.geoField, + filterByMapBounds: _.get(descriptor, 'filterByMapBounds', DEFAULT_FILTER_BY_MAP_BOUNDS), + tooltipProperties: _.get(descriptor, 'tooltipProperties', []), + sortField: _.get(descriptor, 'sortField', ''), + sortOrder: _.get(descriptor, 'sortOrder', SORT_ORDER.DESC), + useTopHits: _.get(descriptor, 'useTopHits', false), + topHitsSplitField: descriptor.topHitsSplitField, + topHitsSize: _.get(descriptor, 'topHitsSize', 1), + }, + inspectorAdapters + ); + + this._tooltipFields = this._descriptor.tooltipProperties.map(property => + this.createField({ fieldName: property }) + ); + } + + createField({ fieldName }) { + return new ESDocField({ + fieldName, + source: this, + }); + } + + renderSourceSettingsEditor({ onChange }) { + return ( + + ); + } + + async getNumberFields() { + try { + const indexPattern = await this.getIndexPattern(); + return indexPattern.fields.getByType('number').map(field => { + return this.createField({ fieldName: field.name }); + }); + } catch (error) { + return []; + } + } + + async getDateFields() { + try { + const indexPattern = await this.getIndexPattern(); + return indexPattern.fields.getByType('date').map(field => { + return this.createField({ fieldName: field.name }); + }); + } catch (error) { + return []; + } + } + + async getCategoricalFields() { + try { + const indexPattern = await this.getIndexPattern(); + + const aggFields = []; + CATEGORICAL_DATA_TYPES.forEach(dataType => { + indexPattern.fields.getByType(dataType).forEach(field => { + if (field.aggregatable) { + aggFields.push(field); + } + }); + }); + return aggFields.map(field => { + return this.createField({ fieldName: field.name }); + }); + } catch (error) { + //error surfaces in the LayerTOC UI + return []; + } + } + + async getFields() { + try { + const indexPattern = await this.getIndexPattern(); + return indexPattern.fields + .filter(field => { + // Ensure fielddata is enabled for field. + // Search does not request _source + return field.aggregatable; + }) + .map(field => { + return this.createField({ fieldName: field.name }); + }); + } catch (error) { + // failed index-pattern retrieval will show up as error-message in the layer-toc-entry + return []; + } + } + + getFieldNames() { + return [this._descriptor.geoField]; + } + + async getImmutableProperties() { + let indexPatternTitle = this._descriptor.indexPatternId; + let geoFieldType = ''; + try { + const indexPattern = await this.getIndexPattern(); + indexPatternTitle = indexPattern.title; + const geoField = await this._getGeoField(); + geoFieldType = geoField.type; + } catch (error) { + // ignore error, title will just default to id + } + + return [ + { + label: getDataSourceLabel(), + value: ESSearchSource.title, + }, + { + label: i18n.translate('xpack.maps.source.esSearch.indexPatternLabel', { + defaultMessage: `Index pattern`, + }), + value: indexPatternTitle, + }, + { + label: i18n.translate('xpack.maps.source.esSearch.geoFieldLabel', { + defaultMessage: 'Geospatial field', + }), + value: this._descriptor.geoField, + }, + { + label: i18n.translate('xpack.maps.source.esSearch.geoFieldTypeLabel', { + defaultMessage: 'Geospatial field type', + }), + value: geoFieldType, + }, + ]; + } + + // Returns sort content for an Elasticsearch search body + _buildEsSort() { + const { sortField, sortOrder } = this._descriptor; + return [ + { + [sortField]: { + order: sortOrder, + }, + }, + ]; + } + + async _excludeDateFields(fieldNames) { + const dateFieldNames = (await this.getDateFields()).map(field => field.getName()); + return fieldNames.filter(field => { + return !dateFieldNames.includes(field); + }); + } + + // Returns docvalue_fields array for the union of indexPattern's dateFields and request's field names. + async _getDateDocvalueFields(searchFields) { + const dateFieldNames = (await this.getDateFields()).map(field => field.getName()); + return searchFields + .filter(fieldName => { + return dateFieldNames.includes(fieldName); + }) + .map(fieldName => { + return { + field: fieldName, + format: 'epoch_millis', + }; + }); + } + + async _getTopHits(layerName, searchFilters, registerCancelCallback) { + const { topHitsSplitField, topHitsSize } = this._descriptor; + + const indexPattern = await this.getIndexPattern(); + const geoField = await this._getGeoField(); + + const scriptFields = {}; + searchFilters.fieldNames.forEach(fieldName => { + const field = indexPattern.fields.getByName(fieldName); + if (field && field.scripted) { + scriptFields[field.name] = { + script: { + source: field.script, + lang: field.lang, + }, + }; + } + }); + + const topHits = { + size: topHitsSize, + script_fields: scriptFields, + docvalue_fields: await this._getDateDocvalueFields(searchFilters.fieldNames), + }; + const nonDateFieldNames = await this._excludeDateFields(searchFilters.fieldNames); + + if (this._hasSort()) { + topHits.sort = this._buildEsSort(); + } + if (geoField.type === ES_GEO_FIELD_TYPE.GEO_POINT) { + topHits._source = false; + topHits.docvalue_fields.push(...nonDateFieldNames); + } else { + topHits._source = { + includes: nonDateFieldNames, + }; + } + + const searchSource = await this._makeSearchSource(searchFilters, 0); + searchSource.setField('aggs', { + totalEntities: { + cardinality: { + field: topHitsSplitField, + precision_threshold: 1, + }, + }, + entitySplit: { + terms: { + field: topHitsSplitField, + size: DEFAULT_MAX_BUCKETS_LIMIT, + shard_size: DEFAULT_MAX_BUCKETS_LIMIT, + }, + aggs: { + entityHits: { + top_hits: topHits, + }, + }, + }, + }); + + const resp = await this._runEsQuery({ + requestId: this.getId(), + requestName: layerName, + searchSource, + registerCancelCallback, + requestDescription: 'Elasticsearch document top hits request', + }); + + const allHits = []; + const entityBuckets = _.get(resp, 'aggregations.entitySplit.buckets', []); + const totalEntities = _.get(resp, 'aggregations.totalEntities.value', 0); + // can not compare entityBuckets.length to totalEntities because totalEntities is an approximate + const areEntitiesTrimmed = entityBuckets.length >= DEFAULT_MAX_BUCKETS_LIMIT; + let areTopHitsTrimmed = false; + entityBuckets.forEach(entityBucket => { + const total = _.get(entityBucket, 'entityHits.hits.total', 0); + const hits = _.get(entityBucket, 'entityHits.hits.hits', []); + // Reverse hits list so top documents by sort are drawn on top + allHits.push(...hits.reverse()); + if (total > hits.length) { + areTopHitsTrimmed = true; + } + }); + + return { + hits: allHits, + meta: { + areResultsTrimmed: areEntitiesTrimmed || areTopHitsTrimmed, // used to force re-fetch when zooming in + areEntitiesTrimmed, + entityCount: entityBuckets.length, + totalEntities, + }, + }; + } + + // searchFilters.fieldNames contains geo field and any fields needed for styling features + // Performs Elasticsearch search request being careful to pull back only required fields to minimize response size + async _getSearchHits(layerName, searchFilters, maxResultWindow, registerCancelCallback) { + const initialSearchContext = { + docvalue_fields: await this._getDateDocvalueFields(searchFilters.fieldNames), + }; + const geoField = await this._getGeoField(); + + let searchSource; + if (geoField.type === ES_GEO_FIELD_TYPE.GEO_POINT) { + // Request geo_point and style fields in docvalue_fields insted of _source + // 1) Returns geo_point in a consistent format regardless of how geo_point is stored in source + // 2) Setting _source to false so we avoid pulling back unneeded fields. + initialSearchContext.docvalue_fields.push( + ...(await this._excludeDateFields(searchFilters.fieldNames)) + ); + searchSource = await this._makeSearchSource( + searchFilters, + maxResultWindow, + initialSearchContext + ); + searchSource.setField('source', false); // do not need anything from _source + searchSource.setField('fields', searchFilters.fieldNames); // Setting "fields" filters out unused scripted fields + } else { + // geo_shape fields do not support docvalue_fields yet, so still have to be pulled from _source + searchSource = await this._makeSearchSource( + searchFilters, + maxResultWindow, + initialSearchContext + ); + // Setting "fields" instead of "source: { includes: []}" + // because SearchSource automatically adds the following by default + // 1) all scripted fields + // 2) docvalue_fields value is added for each date field in an index - see getComputedFields + // By setting "fields", SearchSource removes all of defaults + searchSource.setField('fields', searchFilters.fieldNames); + } + + if (this._hasSort()) { + searchSource.setField('sort', this._buildEsSort()); + } + + const resp = await this._runEsQuery({ + requestId: this.getId(), + requestName: layerName, + searchSource, + registerCancelCallback, + requestDescription: 'Elasticsearch document request', + }); + + return { + hits: resp.hits.hits.reverse(), // Reverse hits so top documents by sort are drawn on top + meta: { + areResultsTrimmed: resp.hits.total > resp.hits.hits.length, + }, + }; + } + + _isTopHits() { + const { useTopHits, topHitsSplitField } = this._descriptor; + return !!(useTopHits && topHitsSplitField); + } + + _hasSort() { + const { sortField, sortOrder } = this._descriptor; + return !!sortField && !!sortOrder; + } + + async getGeoJsonWithMeta(layerName, searchFilters, registerCancelCallback) { + const indexPattern = await this.getIndexPattern(); + + const indexSettings = await loadIndexSettings(indexPattern.title); + + const { hits, meta } = this._isTopHits() + ? await this._getTopHits(layerName, searchFilters, registerCancelCallback) + : await this._getSearchHits( + layerName, + searchFilters, + indexSettings.maxResultWindow, + registerCancelCallback + ); + + const unusedMetaFields = indexPattern.metaFields.filter(metaField => { + return !['_id', '_index'].includes(metaField); + }); + const flattenHit = hit => { + const properties = indexPattern.flattenHit(hit); + // remove metaFields + unusedMetaFields.forEach(metaField => { + delete properties[metaField]; + }); + return properties; + }; + + let featureCollection; + try { + const geoField = await this._getGeoField(); + featureCollection = hitsToGeoJson(hits, flattenHit, geoField.name, geoField.type); + } catch (error) { + throw new Error( + i18n.translate('xpack.maps.source.esSearch.convertToGeoJsonErrorMsg', { + defaultMessage: + 'Unable to convert search response to geoJson feature collection, error: {errorMsg}', + values: { errorMsg: error.message }, + }) + ); + } + + return { + data: featureCollection, + meta, + }; + } + + canFormatFeatureProperties() { + return this._tooltipFields.length > 0; + } + + async _loadTooltipProperties(docId, index, indexPattern) { + if (this._tooltipFields.length === 0) { + return {}; + } + + const searchSource = new SearchSource(); + searchSource.setField('index', indexPattern); + searchSource.setField('size', 1); + const query = { + language: 'kuery', + query: `_id:"${docId}" and _index:"${index}"`, + }; + searchSource.setField('query', query); + searchSource.setField('fields', this._getTooltipPropertyNames()); + + const resp = await searchSource.fetch(); + + const hit = _.get(resp, 'hits.hits[0]'); + if (!hit) { + throw new Error( + i18n.translate('xpack.maps.source.esSearch.loadTooltipPropertiesErrorMsg', { + defaultMessage: 'Unable to find document, _id: {docId}', + values: { docId }, + }) + ); + } + + const properties = indexPattern.flattenHit(hit); + indexPattern.metaFields.forEach(metaField => { + if (!this._getTooltipPropertyNames().includes(metaField)) { + delete properties[metaField]; + } + }); + return properties; + } + + async filterAndFormatPropertiesToHtml(properties) { + const indexPattern = await this.getIndexPattern(); + const propertyValues = await this._loadTooltipProperties( + properties._id, + properties._index, + indexPattern + ); + const tooltipProperties = this._tooltipFields.map(field => { + const value = propertyValues[field.getName()]; + return field.createTooltipProperty(value); + }); + return Promise.all(tooltipProperties); + } + + isFilterByMapBounds() { + return _.get(this._descriptor, 'filterByMapBounds', false); + } + + isFilterByMapBoundsConfigurable() { + return true; + } + + async getLeftJoinFields() { + const indexPattern = await this.getIndexPattern(); + // Left fields are retrieved from _source. + return getSourceFields(indexPattern.fields).map(field => + this.createField({ fieldName: field.name }) + ); + } + + async getSupportedShapeTypes() { + let geoFieldType; + try { + const geoField = await this._getGeoField(); + geoFieldType = geoField.type; + } catch (error) { + // ignore exeception + } + + if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT) { + return [VECTOR_SHAPE_TYPES.POINT]; + } + + return [VECTOR_SHAPE_TYPES.POINT, VECTOR_SHAPE_TYPES.LINE, VECTOR_SHAPE_TYPES.POLYGON]; + } + + getSourceTooltipContent(sourceDataRequest) { + const featureCollection = sourceDataRequest ? sourceDataRequest.getData() : null; + const meta = sourceDataRequest ? sourceDataRequest.getMeta() : null; + if (!featureCollection || !meta) { + // no tooltip content needed when there is no feature collection or meta + return { + tooltipContent: null, + areResultsTrimmed: false, + }; + } + + if (this._isTopHits()) { + const entitiesFoundMsg = meta.areEntitiesTrimmed + ? i18n.translate('xpack.maps.esSearch.topHitsResultsTrimmedMsg', { + defaultMessage: `Results limited to first {entityCount} entities of ~{totalEntities}.`, + values: { + entityCount: meta.entityCount, + totalEntities: meta.totalEntities, + }, + }) + : i18n.translate('xpack.maps.esSearch.topHitsEntitiesCountMsg', { + defaultMessage: `Found {entityCount} entities.`, + values: { entityCount: meta.entityCount }, + }); + const docsPerEntityMsg = i18n.translate('xpack.maps.esSearch.topHitsSizeMsg', { + defaultMessage: `Showing top {topHitsSize} documents per entity.`, + values: { topHitsSize: this._descriptor.topHitsSize }, + }); + + return { + tooltipContent: `${entitiesFoundMsg} ${docsPerEntityMsg}`, + // Used to show trimmed icon in legend + // user only needs to be notified of trimmed results when entities are trimmed + areResultsTrimmed: meta.areEntitiesTrimmed, + }; + } + + if (meta.areResultsTrimmed) { + return { + tooltipContent: i18n.translate('xpack.maps.esSearch.resultsTrimmedMsg', { + defaultMessage: `Results limited to first {count} documents.`, + values: { count: featureCollection.features.length }, + }), + areResultsTrimmed: true, + }; + } + + return { + tooltipContent: i18n.translate('xpack.maps.esSearch.featureCountMsg', { + defaultMessage: `Found {count} documents.`, + values: { count: featureCollection.features.length }, + }), + areResultsTrimmed: false, + }; + } + + getSyncMeta() { + return { + sortField: this._descriptor.sortField, + sortOrder: this._descriptor.sortOrder, + useTopHits: this._descriptor.useTopHits, + topHitsSplitField: this._descriptor.topHitsSplitField, + topHitsSize: this._descriptor.topHitsSize, + }; + } + + async getPreIndexedShape(properties) { + const geoField = await this._getGeoField(); + return { + index: properties._index, // Can not use index pattern title because it may reference many indices + id: properties._id, + path: geoField.name, + }; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/index.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/index.js new file mode 100644 index 0000000000000..5fea38ee274d9 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { ESSearchSource } from './es_search_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/load_index_settings.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/load_index_settings.js new file mode 100644 index 0000000000000..1a58b5b073b08 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/load_index_settings.js @@ -0,0 +1,58 @@ +/* + * 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 { + DEFAULT_MAX_RESULT_WINDOW, + DEFAULT_MAX_INNER_RESULT_WINDOW, + INDEX_SETTINGS_API_PATH, +} from '../../../../common/constants'; +import { kfetch } from 'ui/kfetch'; +import { toastNotifications } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; + +let toastDisplayed = false; +const indexSettings = new Map(); + +export async function loadIndexSettings(indexPatternTitle) { + if (indexSettings.has(indexPatternTitle)) { + return indexSettings.get(indexPatternTitle); + } + + const fetchPromise = fetchIndexSettings(indexPatternTitle); + indexSettings.set(indexPatternTitle, fetchPromise); + return fetchPromise; +} + +async function fetchIndexSettings(indexPatternTitle) { + try { + const indexSettings = await kfetch({ + pathname: `../${INDEX_SETTINGS_API_PATH}`, + query: { + indexPatternTitle, + }, + }); + return indexSettings; + } catch (err) { + const warningMsg = i18n.translate('xpack.maps.indexSettings.fetchErrorMsg', { + defaultMessage: `Unable to fetch index settings for index pattern '{indexPatternTitle}'. + Ensure you have '{viewIndexMetaRole}' role.`, + values: { + indexPatternTitle, + viewIndexMetaRole: 'view_index_metadata', + }, + }); + if (!toastDisplayed) { + // Only show toast for first failure to avoid flooding user with warnings + toastDisplayed = true; + toastNotifications.addWarning(warningMsg); + } + console.warn(warningMsg); + return { + maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, + maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, + }; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js new file mode 100644 index 0000000000000..026cdcb6b40f7 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js @@ -0,0 +1,306 @@ +/* + * 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, Component } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFormRow, + EuiSwitch, + EuiSelect, + EuiTitle, + EuiPanel, + EuiSpacer, + EuiHorizontalRule, +} from '@elastic/eui'; +import { SingleFieldSelect } from '../../../components/single_field_select'; +import { TooltipSelector } from '../../../components/tooltip_selector'; + +import { indexPatternService } from '../../../kibana_services'; +import { i18n } from '@kbn/i18n'; +import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; +import { ValidatedRange } from '../../../components/validated_range'; +import { DEFAULT_MAX_INNER_RESULT_WINDOW, SORT_ORDER } from '../../../../common/constants'; +import { ESDocField } from '../../fields/es_doc_field'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { loadIndexSettings } from './load_index_settings'; +import { isNestedField} from '../../../../../../../src/plugins/data/common/index_patterns/fields'; + +export class UpdateSourceEditor extends Component { + static propTypes = { + indexPatternId: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + tooltipFields: PropTypes.arrayOf(PropTypes.object).isRequired, + sortField: PropTypes.string, + sortOrder: PropTypes.string.isRequired, + useTopHits: PropTypes.bool.isRequired, + topHitsSplitField: PropTypes.string, + topHitsSize: PropTypes.number.isRequired, + source: PropTypes.object, + }; + + state = { + sourceFields: null, + termFields: null, + sortFields: null, + maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, + }; + + componentDidMount() { + this._isMounted = true; + this.loadFields(); + this.loadIndexSettings(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async loadIndexSettings() { + try { + const indexPattern = await indexPatternService.get(this.props.indexPatternId); + const { maxInnerResultWindow } = await loadIndexSettings(indexPattern.title); + if (this._isMounted) { + this.setState({ maxInnerResultWindow }); + } + } catch (err) { + return; + } + } + + async loadFields() { + let indexPattern; + try { + indexPattern = await indexPatternService.get(this.props.indexPatternId); + } catch (err) { + if (this._isMounted) { + this.setState({ + loadError: i18n.translate('xpack.maps.source.esSearch.loadErrorMessage', { + defaultMessage: `Unable to find Index pattern {id}`, + values: { + id: this.props.indexPatternId, + }, + }), + }); + } + return; + } + + if (!this._isMounted) { + return; + } + + //todo move this all to the source + const rawTooltipFields = getSourceFields(indexPattern.fields); + const sourceFields = rawTooltipFields.map(field => { + return new ESDocField({ + fieldName: field.name, + source: this.props.source, + }); + }); + + this.setState({ + sourceFields: sourceFields, + termFields: getTermsFields(indexPattern.fields), //todo change term fields to use fields + sortFields: indexPattern.fields.filter(field => field.sortable && !isNestedField(field)), //todo change sort fields to use fields + }); + } + _onTooltipPropertiesChange = propertyNames => { + this.props.onChange({ propName: 'tooltipProperties', value: propertyNames }); + }; + + onUseTopHitsChange = event => { + this.props.onChange({ propName: 'useTopHits', value: event.target.checked }); + }; + + onTopHitsSplitFieldChange = topHitsSplitField => { + this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); + }; + + onSortFieldChange = sortField => { + this.props.onChange({ propName: 'sortField', value: sortField }); + }; + + onSortOrderChange = e => { + this.props.onChange({ propName: 'sortOrder', value: e.target.value }); + }; + + onTopHitsSizeChange = size => { + this.props.onChange({ propName: 'topHitsSize', value: size }); + }; + + renderTopHitsForm() { + const topHitsSwitch = ( + + + + ); + + if (!this.props.useTopHits) { + return topHitsSwitch; + } + + let sizeSlider; + if (this.props.topHitsSplitField) { + sizeSlider = ( + + + + ); + } + + return ( + + {topHitsSwitch} + + + + + {sizeSlider} + + ); + } + + _renderTooltipsPanel() { + return ( + + +
+ +
+
+ + + + +
+ ); + } + + _renderSortPanel() { + return ( + + +
+ +
+
+ + + + + + + + + + + + + {this.renderTopHitsForm()} +
+ ); + } + + render() { + return ( + + {this._renderTooltipsPanel()} + + + {this._renderSortPanel()} + + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js b/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js new file mode 100644 index 0000000000000..badfba7665dfd --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js @@ -0,0 +1,47 @@ +/* + * 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. + */ + +jest.mock('../../../kibana_services', () => ({})); + +jest.mock('./load_index_settings', () => ({ + loadIndexSettings: async () => { + return { maxInnerResultWindow: 100 }; + }, +})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { UpdateSourceEditor } from './update_source_editor'; + +const defaultProps = { + indexPatternId: 'indexPattern1', + onChange: () => {}, + filterByMapBounds: true, + tooltipFields: [], + sortOrder: 'DESC', + useTopHits: false, + topHitsSplitField: 'trackId', + topHitsSize: 1, +}; + +test('should render update source editor', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should enable sort order select when sort field provided', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render top hits form when useTopHits is true', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/layers/sources/es_source.js b/x-pack/plugins/maps/public/layers/sources/es_source.js new file mode 100644 index 0000000000000..26cc7ece66753 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_source.js @@ -0,0 +1,347 @@ +/* + * 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 { AbstractVectorSource } from './vector_source'; +import { + fetchSearchSourceAndRecordWithInspector, + indexPatternService, + SearchSource, +} from '../../kibana_services'; +import { createExtentFilter } from '../../elasticsearch_geo_utils'; +import { timefilter } from 'ui/timefilter'; +import _ from 'lodash'; +import { AggConfigs } from 'ui/agg_types'; +import { i18n } from '@kbn/i18n'; +import uuid from 'uuid/v4'; +import { copyPersistentState } from '../../reducers/util'; +import { ES_GEO_FIELD_TYPE, METRIC_TYPE } from '../../../common/constants'; +import { DataRequestAbortError } from '../util/data_request'; +import { expandToTileBoundaries } from './es_geo_grid_source/geo_tile_utils'; + +export class AbstractESSource extends AbstractVectorSource { + static icon = 'logoElasticsearch'; + + constructor(descriptor, inspectorAdapters) { + super( + { + ...descriptor, + applyGlobalQuery: _.get(descriptor, 'applyGlobalQuery', true), + }, + inspectorAdapters + ); + } + + isFieldAware() { + return true; + } + + isRefreshTimerAware() { + return true; + } + + isQueryAware() { + return true; + } + + getIndexPatternIds() { + return [this._descriptor.indexPatternId]; + } + + getQueryableIndexPatternIds() { + if (this.getApplyGlobalQuery()) { + return [this._descriptor.indexPatternId]; + } + return []; + } + + isESSource() { + return true; + } + + destroy() { + this._inspectorAdapters.requests.resetRequest(this.getId()); + } + + cloneDescriptor() { + const clonedDescriptor = copyPersistentState(this._descriptor); + // id used as uuid to track requests in inspector + clonedDescriptor.id = uuid(); + return clonedDescriptor; + } + + getMetricFields() { + return []; + } + + async _runEsQuery({ + requestId, + requestName, + requestDescription, + searchSource, + registerCancelCallback, + }) { + const abortController = new AbortController(); + registerCancelCallback(() => abortController.abort()); + + try { + return await fetchSearchSourceAndRecordWithInspector({ + inspectorAdapters: this._inspectorAdapters, + searchSource, + requestName, + requestId, + requestDesc: requestDescription, + abortSignal: abortController.signal, + }); + } catch (error) { + if (error.name === 'AbortError') { + throw new DataRequestAbortError(); + } + + throw new Error( + i18n.translate('xpack.maps.source.esSource.requestFailedErrorMessage', { + defaultMessage: `Elasticsearch search request failed, error: {message}`, + values: { message: error.message }, + }) + ); + } + } + + async _makeSearchSource(searchFilters, limit, initialSearchContext) { + const indexPattern = await this.getIndexPattern(); + const isTimeAware = await this.isTimeAware(); + const applyGlobalQuery = _.get(searchFilters, 'applyGlobalQuery', true); + const globalFilters = applyGlobalQuery ? searchFilters.filters : []; + const allFilters = [...globalFilters]; + if (this.isFilterByMapBounds() && searchFilters.buffer) { + //buffer can be empty + const geoField = await this._getGeoField(); + const buffer = this.isGeoGridPrecisionAware() + ? expandToTileBoundaries(searchFilters.buffer, searchFilters.geogridPrecision) + : searchFilters.buffer; + allFilters.push(createExtentFilter(buffer, geoField.name, geoField.type)); + } + if (isTimeAware) { + allFilters.push(timefilter.createFilter(indexPattern, searchFilters.timeFilters)); + } + + const searchSource = new SearchSource(initialSearchContext); + searchSource.setField('index', indexPattern); + searchSource.setField('size', limit); + searchSource.setField('filter', allFilters); + if (applyGlobalQuery) { + searchSource.setField('query', searchFilters.query); + } + + if (searchFilters.sourceQuery) { + const layerSearchSource = new SearchSource(); + layerSearchSource.setField('index', indexPattern); + layerSearchSource.setField('query', searchFilters.sourceQuery); + searchSource.setParent(layerSearchSource); + } + + return searchSource; + } + + async getBoundsForFilters({ sourceQuery, query, timeFilters, filters, applyGlobalQuery }) { + const searchSource = await this._makeSearchSource( + { sourceQuery, query, timeFilters, filters, applyGlobalQuery }, + 0 + ); + const geoField = await this._getGeoField(); + const indexPattern = await this.getIndexPattern(); + + const geoBoundsAgg = [ + { + type: 'geo_bounds', + enabled: true, + params: { + field: geoField, + }, + schema: 'metric', + }, + ]; + + const aggConfigs = new AggConfigs(indexPattern, geoBoundsAgg); + searchSource.setField('aggs', aggConfigs.toDsl()); + + let esBounds; + try { + const esResp = await searchSource.fetch(); + esBounds = _.get(esResp, 'aggregations.1.bounds'); + } catch (error) { + esBounds = { + top_left: { + lat: 90, + lon: -180, + }, + bottom_right: { + lat: -90, + lon: 180, + }, + }; + } + + return { + min_lon: esBounds.top_left.lon, + max_lon: esBounds.bottom_right.lon, + min_lat: esBounds.bottom_right.lat, + max_lat: esBounds.top_left.lat, + }; + } + + async isTimeAware() { + try { + const indexPattern = await this.getIndexPattern(); + const timeField = indexPattern.timeFieldName; + return !!timeField; + } catch (error) { + return false; + } + } + + async getIndexPattern() { + if (this.indexPattern) { + return this.indexPattern; + } + + try { + this.indexPattern = await indexPatternService.get(this._descriptor.indexPatternId); + return this.indexPattern; + } catch (error) { + throw new Error( + i18n.translate('xpack.maps.source.esSource.noIndexPatternErrorMessage', { + defaultMessage: `Unable to find Index pattern for id: {indexPatternId}`, + values: { indexPatternId: this._descriptor.indexPatternId }, + }) + ); + } + } + + async supportsFitToBounds() { + try { + const geoField = await this._getGeoField(); + // geo_bounds aggregation only supports geo_point + // there is currently no backend support for getting bounding box of geo_shape field + return geoField.type !== ES_GEO_FIELD_TYPE.GEO_SHAPE; + } catch (error) { + return false; + } + } + + async _getGeoField() { + const indexPattern = await this.getIndexPattern(); + const geoField = indexPattern.fields.getByName(this._descriptor.geoField); + if (!geoField) { + throw new Error( + i18n.translate('xpack.maps.source.esSource.noGeoFieldErrorMessage', { + defaultMessage: `Index pattern {indexPatternTitle} no longer contains the geo field {geoField}`, + values: { indexPatternTitle: indexPattern.title, geoField: this._descriptor.geoField }, + }) + ); + } + return geoField; + } + + async getDisplayName() { + try { + const indexPattern = await this.getIndexPattern(); + return indexPattern.title; + } catch (error) { + // Unable to load index pattern, just return id as display name + return this._descriptor.indexPatternId; + } + } + + isBoundsAware() { + return true; + } + + getId() { + return this._descriptor.id; + } + + async getFieldFormatter(fieldName) { + const metricField = this.getMetricFields().find(field => field.getName() === fieldName); + + // Do not use field formatters for counting metrics + if ( + metricField && + (metricField.type === METRIC_TYPE.COUNT || metricField.type === METRIC_TYPE.UNIQUE_COUNT) + ) { + return null; + } + + // fieldName could be an aggregation so it needs to be unpacked to expose raw field. + const realFieldName = metricField ? metricField.getESDocFieldName() : fieldName; + if (!realFieldName) { + return null; + } + + let indexPattern; + try { + indexPattern = await this.getIndexPattern(); + } catch (error) { + return null; + } + + const fieldFromIndexPattern = indexPattern.fields.getByName(realFieldName); + if (!fieldFromIndexPattern) { + return null; + } + + return fieldFromIndexPattern.format.getConverterFor('text'); + } + + async loadStylePropsMeta( + layerName, + style, + dynamicStyleProps, + registerCancelCallback, + searchFilters + ) { + const promises = dynamicStyleProps.map(dynamicStyleProp => { + return dynamicStyleProp.getFieldMetaRequest(); + }); + + const fieldAggRequests = await Promise.all(promises); + const aggs = fieldAggRequests.reduce((aggs, fieldAggRequest) => { + return fieldAggRequest ? { ...aggs, ...fieldAggRequest } : aggs; + }, {}); + + const indexPattern = await this.getIndexPattern(); + const searchSource = new SearchSource(); + searchSource.setField('index', indexPattern); + searchSource.setField('size', 0); + searchSource.setField('aggs', aggs); + if (searchFilters.sourceQuery) { + searchSource.setField('query', searchFilters.sourceQuery); + } + if (style.isTimeAware() && (await this.isTimeAware())) { + searchSource.setField('filter', [ + timefilter.createFilter(indexPattern, searchFilters.timeFilters), + ]); + } + + const resp = await this._runEsQuery({ + requestId: `${this.getId()}_styleMeta`, + requestName: i18n.translate('xpack.maps.source.esSource.stylePropsMetaRequestName', { + defaultMessage: '{layerName} - metadata', + values: { layerName }, + }), + searchSource, + registerCancelCallback, + requestDescription: i18n.translate( + 'xpack.maps.source.esSource.stylePropsMetaRequestDescription', + { + defaultMessage: + 'Elasticsearch request retrieving field metadata used for calculating symbolization bands.', + } + ), + }); + + return resp.aggregations; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_term_source.js b/x-pack/plugins/maps/public/layers/sources/es_term_source.js new file mode 100644 index 0000000000000..7d7a2e159d128 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_term_source.js @@ -0,0 +1,196 @@ +/* + * 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 _ from 'lodash'; + +import { AggConfigs, Schemas } from 'ui/agg_types'; +import { i18n } from '@kbn/i18n'; +import { + COUNT_PROP_LABEL, + DEFAULT_MAX_BUCKETS_LIMIT, + FIELD_ORIGIN, + METRIC_TYPE, +} from '../../../common/constants'; +import { ESDocField } from '../fields/es_doc_field'; +import { AbstractESAggSource, AGG_DELIMITER } from './es_agg_source'; + +const TERMS_AGG_NAME = 'join'; + +const FIELD_NAME_PREFIX = '__kbnjoin__'; +const GROUP_BY_DELIMITER = '_groupby_'; + +const aggSchemas = new Schemas([ + AbstractESAggSource.METRIC_SCHEMA_CONFIG, + { + group: 'buckets', + name: 'segment', + title: 'Terms', + aggFilter: 'terms', + min: 1, + max: 1, + }, +]); + +export function extractPropertiesMap(rawEsData, propertyNames, countPropertyName) { + const propertiesMap = new Map(); + _.get(rawEsData, ['aggregations', TERMS_AGG_NAME, 'buckets'], []).forEach(termBucket => { + const properties = {}; + if (countPropertyName) { + properties[countPropertyName] = termBucket.doc_count; + } + propertyNames.forEach(propertyName => { + if (_.has(termBucket, [propertyName, 'value'])) { + properties[propertyName] = _.get(termBucket, [propertyName, 'value']); + } + }); + propertiesMap.set(termBucket.key.toString(), properties); + }); + return propertiesMap; +} + +export class ESTermSource extends AbstractESAggSource { + static type = 'ES_TERM_SOURCE'; + + constructor(descriptor, inspectorAdapters) { + super(descriptor, inspectorAdapters); + this._termField = new ESDocField({ + fieldName: descriptor.term, + source: this, + origin: this.getOriginForField(), + }); + } + + static renderEditor({}) { + //no need to localize. this editor is never rendered. + return `
editor details
`; + } + + hasCompleteConfig() { + return _.has(this._descriptor, 'indexPatternId') && _.has(this._descriptor, 'term'); + } + + getIndexPatternIds() { + return [this._descriptor.indexPatternId]; + } + + getTermField() { + return this._termField; + } + + getOriginForField() { + return FIELD_ORIGIN.JOIN; + } + + getWhereQuery() { + return this._descriptor.whereQuery; + } + + formatMetricKey(aggType, fieldName) { + const metricKey = + aggType !== METRIC_TYPE.COUNT ? `${aggType}${AGG_DELIMITER}${fieldName}` : aggType; + return `${FIELD_NAME_PREFIX}${metricKey}${GROUP_BY_DELIMITER}${ + this._descriptor.indexPatternTitle + }.${this._termField.getName()}`; + } + + formatMetricLabel(type, fieldName) { + const metricLabel = type !== METRIC_TYPE.COUNT ? `${type} ${fieldName}` : COUNT_PROP_LABEL; + return `${metricLabel} of ${this._descriptor.indexPatternTitle}:${this._termField.getName()}`; + } + + async getPropertiesMap(searchFilters, leftSourceName, leftFieldName, registerCancelCallback) { + if (!this.hasCompleteConfig()) { + return []; + } + + const indexPattern = await this.getIndexPattern(); + const searchSource = await this._makeSearchSource(searchFilters, 0); + const configStates = this._makeAggConfigs(); + const aggConfigs = new AggConfigs(indexPattern, configStates, aggSchemas.all); + searchSource.setField('aggs', aggConfigs.toDsl()); + + const rawEsData = await this._runEsQuery({ + requestId: this.getId(), + requestName: `${this._descriptor.indexPatternTitle}.${this._termField.getName()}`, + searchSource, + registerCancelCallback, + requestDescription: this._getRequestDescription(leftSourceName, leftFieldName), + }); + + const metricPropertyNames = configStates + .filter(configState => { + return configState.schema === 'metric' && configState.type !== METRIC_TYPE.COUNT; + }) + .map(configState => { + return configState.id; + }); + const countConfigState = configStates.find(configState => { + return configState.type === METRIC_TYPE.COUNT; + }); + const countPropertyName = _.get(countConfigState, 'id'); + return { + propertiesMap: extractPropertiesMap(rawEsData, metricPropertyNames, countPropertyName), + }; + } + + isFilterByMapBounds() { + return false; + } + + _getRequestDescription(leftSourceName, leftFieldName) { + const metrics = this.getMetricFields().map(esAggMetric => esAggMetric.getRequestDescription()); + const joinStatement = []; + joinStatement.push( + i18n.translate('xpack.maps.source.esJoin.joinLeftDescription', { + defaultMessage: `Join {leftSourceName}:{leftFieldName} with`, + values: { leftSourceName, leftFieldName }, + }) + ); + joinStatement.push(`${this._descriptor.indexPatternTitle}:${this._termField.getName()}`); + joinStatement.push( + i18n.translate('xpack.maps.source.esJoin.joinMetricsDescription', { + defaultMessage: `for metrics {metrics}`, + values: { metrics: metrics.join(',') }, + }) + ); + return i18n.translate('xpack.maps.source.esJoin.joinDescription', { + defaultMessage: `Elasticsearch terms aggregation request for {description}`, + values: { + description: joinStatement.join(' '), + }, + }); + } + + _makeAggConfigs() { + const metricAggConfigs = this.createMetricAggConfigs(); + return [ + ...metricAggConfigs, + { + id: TERMS_AGG_NAME, + enabled: true, + type: 'terms', + schema: 'segment', + params: { + field: this._termField.getName(), + size: DEFAULT_MAX_BUCKETS_LIMIT, + }, + }, + ]; + } + + async getDisplayName() { + //no need to localize. this is never rendered. + return `es_table ${this._descriptor.indexPatternId}`; + } + + async filterAndFormatPropertiesToHtml(properties) { + return await this.filterAndFormatPropertiesToHtmlForMetricFields(properties); + } + + getFieldNames() { + return this.getMetricFields().map(esAggMetricField => esAggMetricField.getName()); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/es_term_source.test.js b/x-pack/plugins/maps/public/layers/sources/es_term_source.test.js new file mode 100644 index 0000000000000..ffaaf2d705b5c --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/es_term_source.test.js @@ -0,0 +1,195 @@ +/* + * 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 { ESTermSource, extractPropertiesMap } from './es_term_source'; + +jest.mock('ui/new_platform'); +jest.mock('../vector_layer', () => {}); +jest.mock('ui/agg_types', () => ({ + Schemas: function() {}, +})); +jest.mock('ui/timefilter', () => {}); + +const indexPatternTitle = 'myIndex'; +const termFieldName = 'myTermField'; +const sumFieldName = 'myFieldGettingSummed'; +const metricExamples = [ + { + type: 'sum', + field: sumFieldName, + label: 'my custom label', + }, + { + // metric config is invalid beause field is missing + type: 'max', + }, + { + // metric config is valid because "count" metric does not need to provide field + type: 'count', + label: '', // should ignore empty label fields + }, +]; + +describe('getMetricFields', () => { + it('should add default "count" metric when no metrics are provided', async () => { + const source = new ESTermSource({ + indexPatternTitle: indexPatternTitle, + term: termFieldName, + }); + const metrics = source.getMetricFields(); + expect(metrics.length).toBe(1); + + expect(metrics[0].getAggType()).toEqual('count'); + expect(metrics[0].getName()).toEqual('__kbnjoin__count_groupby_myIndex.myTermField'); + expect(await metrics[0].getLabel()).toEqual('count of myIndex:myTermField'); + }); + + it('should remove incomplete metric configurations', async () => { + const source = new ESTermSource({ + indexPatternTitle: indexPatternTitle, + term: termFieldName, + metrics: metricExamples, + }); + const metrics = source.getMetricFields(); + expect(metrics.length).toBe(2); + + expect(metrics[0].getAggType()).toEqual('sum'); + expect(metrics[0].getESDocFieldName()).toEqual(sumFieldName); + expect(metrics[0].getName()).toEqual( + '__kbnjoin__sum_of_myFieldGettingSummed_groupby_myIndex.myTermField' + ); + expect(await metrics[0].getLabel()).toEqual('my custom label'); + + expect(metrics[1].getAggType()).toEqual('count'); + expect(metrics[1].getName()).toEqual('__kbnjoin__count_groupby_myIndex.myTermField'); + expect(await metrics[1].getLabel()).toEqual('count of myIndex:myTermField'); + }); +}); + +describe('_makeAggConfigs', () => { + describe('no metrics', () => { + let aggConfigs; + beforeAll(() => { + const source = new ESTermSource({ + indexPatternTitle: indexPatternTitle, + term: termFieldName, + }); + aggConfigs = source._makeAggConfigs(); + }); + + it('should make default "count" metric agg config', () => { + expect(aggConfigs.length).toBe(2); + expect(aggConfigs[0]).toEqual({ + id: '__kbnjoin__count_groupby_myIndex.myTermField', + enabled: true, + type: 'count', + schema: 'metric', + params: {}, + }); + }); + + it('should make "terms" buckets agg config', () => { + expect(aggConfigs.length).toBe(2); + expect(aggConfigs[1]).toEqual({ + id: 'join', + enabled: true, + type: 'terms', + schema: 'segment', + params: { + field: termFieldName, + size: 10000, + }, + }); + }); + }); + + describe('metrics', () => { + let aggConfigs; + beforeAll(() => { + const source = new ESTermSource({ + indexPatternTitle: indexPatternTitle, + term: 'myTermField', + metrics: metricExamples, + }); + aggConfigs = source._makeAggConfigs(); + }); + + it('should ignore invalid metrics configs', () => { + expect(aggConfigs.length).toBe(3); + }); + + it('should make agg config for each valid metric', () => { + expect(aggConfigs[0]).toEqual({ + id: '__kbnjoin__sum_of_myFieldGettingSummed_groupby_myIndex.myTermField', + enabled: true, + type: 'sum', + schema: 'metric', + params: { + field: sumFieldName, + }, + }); + expect(aggConfigs[1]).toEqual({ + id: '__kbnjoin__count_groupby_myIndex.myTermField', + enabled: true, + type: 'count', + schema: 'metric', + params: {}, + }); + }); + }); +}); + +describe('extractPropertiesMap', () => { + const responseWithNumberTypes = { + aggregations: { + join: { + buckets: [ + { + key: 109, + doc_count: 1130, + '__kbnjoin__min_of_avlAirTemp_groupby_kibana_sample_data_ky_avl.kytcCountyNmbr': { + value: 36, + }, + }, + { + key: 62, + doc_count: 448, + '__kbnjoin__min_of_avlAirTemp_groupby_kibana_sample_data_ky_avl.kytcCountyNmbr': { + value: 0, + }, + }, + ], + }, + }, + }; + const countPropName = '__kbnjoin__count_groupby_kibana_sample_data_ky_avl.kytcCountyNmbr'; + const minPropName = + '__kbnjoin__min_of_avlAirTemp_groupby_kibana_sample_data_ky_avl.kytcCountyNmbr'; + let propertiesMap; + beforeAll(() => { + propertiesMap = extractPropertiesMap(responseWithNumberTypes, [minPropName], countPropName); + }); + + it('should create key for each join term', () => { + expect(propertiesMap.has('109')).toBe(true); + expect(propertiesMap.has('62')).toBe(true); + }); + + it('should extract count property', () => { + const properties = propertiesMap.get('109'); + expect(properties[countPropName]).toBe(1130); + }); + + it('should extract min property', () => { + const properties = propertiesMap.get('109'); + expect(properties[minPropName]).toBe(36); + }); + + it('should extract property with value of "0"', () => { + const properties = propertiesMap.get('62'); + expect(properties[minPropName]).toBe(0); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/create_source_editor.js new file mode 100644 index 0000000000000..5e28916e79f3f --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/create_source_editor.js @@ -0,0 +1,52 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { EuiSelect, EuiFormRow } from '@elastic/eui'; +import { getKibanaRegionList } from '../../../meta'; +import { i18n } from '@kbn/i18n'; + +export function CreateSourceEditor({ onSourceConfigChange }) { + const onChange = ({ target }) => { + const selectedName = target.options[target.selectedIndex].text; + onSourceConfigChange({ name: selectedName }); + }; + + const regionmapOptions = getKibanaRegionList().map(({ name, url }) => { + return { + value: url, + text: name, + }; + }); + + const helpText = + regionmapOptions.length === 0 + ? i18n.translate('xpack.maps.source.kbnRegionMap.noLayerAvailableHelptext', { + defaultMessage: `No vector layers are available. Ask your system administrator to set "map.regionmap" in kibana.yml.`, + }) + : null; + + return ( + + + + ); +} + +CreateSourceEditor.propTypes = { + onSourceConfigChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/index.js b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/index.js new file mode 100644 index 0000000000000..d54b135239a63 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { KibanaRegionmapSource } from './kibana_regionmap_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js new file mode 100644 index 0000000000000..276a3377aaae2 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js @@ -0,0 +1,110 @@ +/* + * 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 { AbstractVectorSource } from '../vector_source'; +import React from 'react'; +import { CreateSourceEditor } from './create_source_editor'; +import { getKibanaRegionList } from '../../../meta'; +import { i18n } from '@kbn/i18n'; +import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import { FIELD_ORIGIN } from '../../../../common/constants'; +import { KibanaRegionField } from '../../fields/kibana_region_field'; + +export class KibanaRegionmapSource extends AbstractVectorSource { + static type = 'REGIONMAP_FILE'; + static title = i18n.translate('xpack.maps.source.kbnRegionMapTitle', { + defaultMessage: 'Configured GeoJSON', + }); + static description = i18n.translate('xpack.maps.source.kbnRegionMapDescription', { + defaultMessage: 'Vector data from hosted GeoJSON configured in kibana.yml', + }); + static icon = 'logoKibana'; + + static createDescriptor({ name }) { + return { + type: KibanaRegionmapSource.type, + name: name, + }; + } + + static renderEditor = ({ onPreviewSource, inspectorAdapters }) => { + const onSourceConfigChange = sourceConfig => { + const sourceDescriptor = KibanaRegionmapSource.createDescriptor(sourceConfig); + const source = new KibanaRegionmapSource(sourceDescriptor, inspectorAdapters); + onPreviewSource(source); + }; + + return ; + }; + + createField({ fieldName }) { + return new KibanaRegionField({ + fieldName, + source: this, + origin: FIELD_ORIGIN.SOURCE, + }); + } + + async getImmutableProperties() { + return [ + { + label: getDataSourceLabel(), + value: KibanaRegionmapSource.title, + }, + { + label: i18n.translate('xpack.maps.source.kbnRegionMap.vectorLayerLabel', { + defaultMessage: 'Vector layer', + }), + value: this._descriptor.name, + }, + ]; + } + + async getVectorFileMeta() { + const regionList = getKibanaRegionList(); + const meta = regionList.find(source => source.name === this._descriptor.name); + if (!meta) { + throw new Error( + i18n.translate('xpack.maps.source.kbnRegionMap.noConfigErrorMessage', { + defaultMessage: `Unable to find map.regionmap configuration for {name}`, + values: { + name: this._descriptor.name, + }, + }) + ); + } + return meta; + } + + async getGeoJsonWithMeta() { + const vectorFileMeta = await this.getVectorFileMeta(); + const featureCollection = await AbstractVectorSource.getGeoJson({ + format: vectorFileMeta.format.type, + featureCollectionPath: vectorFileMeta.meta.feature_collection_path, + fetchUrl: vectorFileMeta.url, + }); + return { + data: featureCollection, + }; + } + + async getLeftJoinFields() { + const vectorFileMeta = await this.getVectorFileMeta(); + return vectorFileMeta.fields.map(f => this.createField({ fieldName: f.name })); + } + + async getDisplayName() { + return this._descriptor.name; + } + + async isTimeAware() { + return false; + } + + canFormatFeatureProperties() { + return true; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/create_source_editor.js new file mode 100644 index 0000000000000..a0a507ff9d32d --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/create_source_editor.js @@ -0,0 +1,42 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; + +import { getKibanaTileMap } from '../../../meta'; +import { i18n } from '@kbn/i18n'; + +export function CreateSourceEditor({ onSourceConfigChange }) { + const tilemap = getKibanaTileMap(); + + if (tilemap.url) { + onSourceConfigChange(); + } + + return ( + + + + ); +} + +CreateSourceEditor.propTypes = { + onSourceConfigChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/index.js b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/index.js new file mode 100644 index 0000000000000..3226fb89b700b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { KibanaTilemapSource } from './kibana_tilemap_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js new file mode 100644 index 0000000000000..21ab2ba42c7bb --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/kibana_tilemap_source/kibana_tilemap_source.js @@ -0,0 +1,96 @@ +/* + * 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 from 'react'; +import { AbstractTMSSource } from '../tms_source'; +import { TileLayer } from '../../tile_layer'; +import { CreateSourceEditor } from './create_source_editor'; +import { getKibanaTileMap } from '../../../meta'; +import { i18n } from '@kbn/i18n'; +import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import _ from 'lodash'; + +export class KibanaTilemapSource extends AbstractTMSSource { + static type = 'KIBANA_TILEMAP'; + static title = i18n.translate('xpack.maps.source.kbnTMSTitle', { + defaultMessage: 'Configured Tile Map Service', + }); + static description = i18n.translate('xpack.maps.source.kbnTMSDescription', { + defaultMessage: 'Tile map service configured in kibana.yml', + }); + + static icon = 'logoKibana'; + + static createDescriptor() { + return { + type: KibanaTilemapSource.type, + }; + } + + static renderEditor = ({ onPreviewSource, inspectorAdapters }) => { + const onSourceConfigChange = () => { + const sourceDescriptor = KibanaTilemapSource.createDescriptor(); + const source = new KibanaTilemapSource(sourceDescriptor, inspectorAdapters); + onPreviewSource(source); + }; + return ; + }; + + async getImmutableProperties() { + return [ + { + label: getDataSourceLabel(), + value: KibanaTilemapSource.title, + }, + { + label: i18n.translate('xpack.maps.source.kbnTMS.urlLabel', { + defaultMessage: 'Tilemap url', + }), + value: await this.getUrlTemplate(), + }, + ]; + } + + _createDefaultLayerDescriptor(options) { + return TileLayer.createDescriptor({ + sourceDescriptor: this._descriptor, + ...options, + }); + } + + createDefaultLayer(options) { + return new TileLayer({ + layerDescriptor: this._createDefaultLayerDescriptor(options), + source: this, + }); + } + + async getUrlTemplate() { + const tilemap = getKibanaTileMap(); + if (!tilemap.url) { + throw new Error( + i18n.translate('xpack.maps.source.kbnTMS.noConfigErrorMessage', { + defaultMessage: `Unable to find map.tilemap.url configuration in the kibana.yml`, + }) + ); + } + return tilemap.url; + } + + async getAttributions() { + const tilemap = getKibanaTileMap(); + const markdown = _.get(tilemap, 'options.attribution', ''); + const objArr = this.convertMarkdownLinkToObjectArr(markdown); + return objArr; + } + + async getDisplayName() { + try { + return await this.getUrlTemplate(); + } catch (e) { + return ''; + } + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/source.js b/x-pack/plugins/maps/public/layers/sources/source.js new file mode 100644 index 0000000000000..cc5d62bbdfeef --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/source.js @@ -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 { copyPersistentState } from '../../reducers/util'; + +export class AbstractSource { + static isIndexingSource = false; + + static renderEditor() { + throw new Error('Must implement Source.renderEditor'); + } + + static createDescriptor() { + throw new Error('Must implement Source.createDescriptor'); + } + + constructor(descriptor, inspectorAdapters) { + this._descriptor = descriptor; + this._inspectorAdapters = inspectorAdapters; + } + + destroy() {} + + cloneDescriptor() { + return copyPersistentState(this._descriptor); + } + + async supportsFitToBounds() { + return true; + } + + /** + * return list of immutable source properties. + * Immutable source properties are properties that can not be edited by the user. + */ + async getImmutableProperties() { + return []; + } + + getInspectorAdapters() { + return this._inspectorAdapters; + } + + _createDefaultLayerDescriptor() { + throw new Error(`Source#createDefaultLayerDescriptor not implemented`); + } + + createDefaultLayer() { + throw new Error(`Source#createDefaultLayer not implemented`); + } + + async getDisplayName() { + console.warn('Source should implement Source#getDisplayName'); + return ''; + } + + /** + * return attribution for this layer as array of objects with url and label property. + * e.g. [{ url: 'example.com', label: 'foobar' }] + * @return {Promise} + */ + async getAttributions() { + return []; + } + + isFieldAware() { + return false; + } + + isRefreshTimerAware() { + return false; + } + + isGeoGridPrecisionAware() { + return false; + } + + isQueryAware() { + return false; + } + + getFieldNames() { + return []; + } + + hasCompleteConfig() { + throw new Error(`Source#hasCompleteConfig not implemented`); + } + + renderSourceSettingsEditor() { + return null; + } + + getApplyGlobalQuery() { + return !!this._descriptor.applyGlobalQuery; + } + + getIndexPatternIds() { + return []; + } + + getQueryableIndexPatternIds() { + return []; + } + + getGeoGridPrecision() { + return 0; + } + + getSyncMeta() { + return {}; + } + + isJoinable() { + return false; + } + + shouldBeIndexed() { + return AbstractSource.isIndexingSource; + } + + isESSource() { + return false; + } + + // Returns geo_shape indexed_shape context for spatial quering by pre-indexed shapes + async getPreIndexedShape(/* properties */) { + return null; + } + + // Returns function used to format value + async getFieldFormatter(/* fieldName */) { + return null; + } + + async loadStylePropsMeta() { + throw new Error(`Source#loadStylePropsMeta not implemented`); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/tms_source.js b/x-pack/plugins/maps/public/layers/sources/tms_source.js new file mode 100644 index 0000000000000..f2ec9f2a29378 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/tms_source.js @@ -0,0 +1,26 @@ +/* + * 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 { AbstractSource } from './source'; + +export class AbstractTMSSource extends AbstractSource { + async getUrlTemplate() { + throw new Error('Should implement TMSSource#getUrlTemplate'); + } + + convertMarkdownLinkToObjectArr(markdown) { + return markdown.split('|').map(attribution => { + attribution = attribution.trim(); + //this assumes attribution is plain markdown link + const extractLink = /\[(.*)\]\((.*)\)/; + const result = extractLink.exec(attribution); + return { + label: result ? result[1] : null, + url: result ? result[2] : null, + }; + }); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/vector_feature_types.js b/x-pack/plugins/maps/public/layers/sources/vector_feature_types.js new file mode 100644 index 0000000000000..cc5f30389c4f3 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/vector_feature_types.js @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export const VECTOR_SHAPE_TYPES = { + POINT: 'POINT', + LINE: 'LINE', + POLYGON: 'POLYGON', +}; diff --git a/x-pack/plugins/maps/public/layers/sources/vector_source.js b/x-pack/plugins/maps/public/layers/sources/vector_source.js new file mode 100644 index 0000000000000..3952aacf03b33 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/vector_source.js @@ -0,0 +1,170 @@ +/* + * 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 { VectorLayer } from '../vector_layer'; +import { TooltipProperty } from '../tooltips/tooltip_property'; +import { VectorStyle } from '../styles/vector/vector_style'; +import { AbstractSource } from './source'; +import * as topojson from 'topojson-client'; +import _ from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { VECTOR_SHAPE_TYPES } from './vector_feature_types'; + +export class AbstractVectorSource extends AbstractSource { + static async getGeoJson({ format, featureCollectionPath, fetchUrl }) { + let fetchedJson; + try { + // TODO proxy map.regionmap url requests through kibana server and then use kfetch + // Can not use kfetch because fetchUrl may point to external URL. (map.regionmap) + const response = await fetch(fetchUrl); + if (!response.ok) { + throw new Error('Request failed'); + } + fetchedJson = await response.json(); + } catch (e) { + throw new Error( + i18n.translate('xpack.maps.source.vetorSource.requestFailedErrorMessage', { + defaultMessage: `Unable to fetch vector shapes from url: {fetchUrl}`, + values: { fetchUrl }, + }) + ); + } + + if (format === 'geojson') { + return fetchedJson; + } + + if (format === 'topojson') { + const features = _.get(fetchedJson, `objects.${featureCollectionPath}`); + return topojson.feature(fetchedJson, features); + } + + throw new Error( + i18n.translate('xpack.maps.source.vetorSource.formatErrorMessage', { + defaultMessage: `Unable to fetch vector shapes from url: {format}`, + values: { format }, + }) + ); + } + + /** + * factory function creating a new field-instance + * @param fieldName + * @param label + * @returns {ESAggMetricField} + */ + createField() { + throw new Error(`Should implemement ${this.constructor.type} ${this}`); + } + + /** + * Retrieves a field. This may be an existing instance. + * @param fieldName + * @param label + * @returns {ESAggMetricField} + */ + getFieldByName(name) { + return this.createField({ fieldName: name }); + } + + _createDefaultLayerDescriptor(options, mapColors) { + return VectorLayer.createDescriptor( + { + sourceDescriptor: this._descriptor, + ...options, + }, + mapColors + ); + } + + _getTooltipPropertyNames() { + return this._tooltipFields.map(field => field.getName()); + } + + createDefaultLayer(options, mapColors) { + const layerDescriptor = this._createDefaultLayerDescriptor(options, mapColors); + const style = new VectorStyle(layerDescriptor.style, this); + return new VectorLayer({ + layerDescriptor: layerDescriptor, + source: this, + style, + }); + } + + isFilterByMapBounds() { + return false; + } + + isFilterByMapBoundsConfigurable() { + return false; + } + + isBoundsAware() { + return false; + } + + async getBoundsForFilters() { + console.warn('Should implement AbstractVectorSource#getBoundsForFilters'); + return null; + } + + async getDateFields() { + return []; + } + + async getNumberFields() { + return []; + } + + async getFields() { + return [...(await this.getDateFields()), ...(await this.getNumberFields())]; + } + + async getCategoricalFields() { + return []; + } + + async getLeftJoinFields() { + return []; + } + + async getGeoJsonWithMeta() { + throw new Error('Should implement VectorSource#getGeoJson'); + } + + canFormatFeatureProperties() { + return false; + } + + // Allow source to filter and format feature properties before displaying to user + async filterAndFormatPropertiesToHtml(properties) { + const tooltipProperties = []; + for (const key in properties) { + if (key.startsWith('__kbn')) { + //these are system properties and should be ignored + continue; + } + tooltipProperties.push(new TooltipProperty(key, key, properties[key])); + } + return tooltipProperties; + } + + async isTimeAware() { + return false; + } + + isJoinable() { + return true; + } + + async getSupportedShapeTypes() { + return [VECTOR_SHAPE_TYPES.POINT, VECTOR_SHAPE_TYPES.LINE, VECTOR_SHAPE_TYPES.POLYGON]; + } + + getSourceTooltipContent(/* sourceDataRequest */) { + return { tooltipContent: null, areResultsTrimmed: false }; + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/wms_source/index.js b/x-pack/plugins/maps/public/layers/sources/wms_source/index.js new file mode 100644 index 0000000000000..22bc50e601f56 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/wms_source/index.js @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { WMSSource } from './wms_source'; diff --git a/x-pack/plugins/maps/public/layers/sources/wms_source/wms_client.js b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_client.js new file mode 100644 index 0000000000000..2d897f7c9227b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_client.js @@ -0,0 +1,206 @@ +/* + * 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 _ from 'lodash'; +import { parseXmlString } from '../../../../common/parse_xml_string'; +import fetch from 'node-fetch'; +import { parse, format } from 'url'; + +export class WmsClient { + constructor({ serviceUrl }) { + this._serviceUrl = serviceUrl; + } + + async _fetch(url) { + return fetch(url); + } + + _createUrl(defaultQueryParams) { + const serviceUrl = parse(this._serviceUrl, true); + const queryParams = { + ...serviceUrl.query, + ...defaultQueryParams, + }; + return format({ + protocol: serviceUrl.protocol, + hostname: serviceUrl.hostname, + port: serviceUrl.port, + pathname: serviceUrl.pathname, + query: queryParams, + }); + } + + getUrlTemplate(layers, styles) { + const urlTemplate = this._createUrl({ + format: 'image/png', + service: 'WMS', + version: '1.1.1', + request: 'GetMap', + srs: 'EPSG:3857', + transparent: 'true', + width: '256', + height: '256', + layers, + styles, + }); + //TODO Find a better way to avoid URL encoding the template braces + return `${urlTemplate}&bbox={bbox-epsg-3857}`; + } + + /** + * Extend any query parameters supplied in the URL but override with required defaults + * (ex. service must be WMS) + */ + async _fetchCapabilities() { + const getCapabilitiesUrl = parse(this._serviceUrl, true); + const queryParams = { + ...getCapabilitiesUrl.query, + ...{ + version: '1.1.1', + request: 'GetCapabilities', + service: 'WMS', + }, + }; + const resp = await this._fetch( + format({ + protocol: getCapabilitiesUrl.protocol, + hostname: getCapabilitiesUrl.hostname, + port: getCapabilitiesUrl.port, + pathname: getCapabilitiesUrl.pathname, + query: queryParams, + }) + ); + if (resp.status >= 400) { + throw new Error(`Unable to access ${this.state.serviceUrl}`); + } + const body = await resp.text(); + + return await parseXmlString(body); + } + + async getCapabilities() { + const rawCapabilities = await this._fetchCapabilities(); + + const { layers, styles } = reduceLayers( + [], + _.get(rawCapabilities, 'WMT_MS_Capabilities.Capability[0].Layer', []) + ); + + return { + layers: groupCapabilities(layers), + styles: groupCapabilities(styles), + }; + } +} + +function reduceLayers(path, layers) { + const emptyCapabilities = { + layers: [], + styles: [], + }; + function createOption(optionPath, optionTitle, optionName) { + return { + path: [...optionPath, optionTitle], + value: optionName, + }; + } + + return layers.reduce((accumulatedCapabilities, layer) => { + // Layer is hierarchical, continue traversing + if (layer.Layer) { + const hierarchicalCapabilities = reduceLayers([...path, layer.Title[0]], layer.Layer); + return { + layers: [...accumulatedCapabilities.layers, ...hierarchicalCapabilities.layers], + styles: [...accumulatedCapabilities.styles, ...hierarchicalCapabilities.styles], + }; + } + + const updatedStyles = [...accumulatedCapabilities.styles]; + if (_.has(layer, 'Style[0]')) { + updatedStyles.push( + createOption(path, _.get(layer, 'Style[0].Title[0]'), _.get(layer, 'Style[0].Name[0]')) + ); + } + return { + layers: [ + ...accumulatedCapabilities.layers, + createOption(path, layer.Title[0], layer.Name[0]), + ], + styles: updatedStyles, + }; + }, emptyCapabilities); +} + +// Avoid filling select box option label with text that is all the same +// Create a single group from common parts of Layer hierarchy +function groupCapabilities(list) { + if (list.length === 0) { + return []; + } + + let maxPathDepth = 0; + list.forEach(({ path }) => { + if (path.length > maxPathDepth) { + maxPathDepth = path.length; + } + }); + + let rootCommonPath = list[0].path; + for (let listIndex = 1; listIndex < list.length; listIndex++) { + if (rootCommonPath.length === 0) { + // No commonality in root path, nothing left to verify + break; + } + + const path = list[listIndex].path; + for ( + let pathIndex = 0; + pathIndex < path.length && pathIndex < rootCommonPath.length; + pathIndex++ + ) { + if (rootCommonPath[pathIndex] !== path[pathIndex]) { + // truncate root common path at location of divergence + rootCommonPath = rootCommonPath.slice(0, pathIndex); + break; + } + } + } + + const labelMap = new Map(); + const options = list.map(({ path, value }) => { + const title = path[path.length - 1]; + const hierachyWithTitle = + rootCommonPath.length === path.length + ? title // entire path is common, only use title + : path.splice(rootCommonPath.length).join(' - '); + const label = title === value ? hierachyWithTitle : `${hierachyWithTitle} (${value})`; + + // labels are used as keys in react elements so uniqueness must be guaranteed + let uniqueLabel; + if (labelMap.has(label)) { + const counter = labelMap.get(label); + const nextCounter = counter + 1; + labelMap.set(label, nextCounter); + uniqueLabel = `${label}:${nextCounter}`; + } else { + labelMap.set(label, 0); + uniqueLabel = label; + } + return { label: uniqueLabel, value }; + }); + + // no common path or all at same depth path + if (rootCommonPath.length === 0 || rootCommonPath.length === maxPathDepth) { + return options; + } + + return [ + { + label: rootCommonPath.join(' - '), + options, + }, + ]; +} diff --git a/x-pack/plugins/maps/public/layers/sources/wms_source/wms_client.test.js b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_client.test.js new file mode 100644 index 0000000000000..f4694572ffb1f --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_client.test.js @@ -0,0 +1,258 @@ +/* + * 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 { WmsClient } from './wms_client'; + +describe('getCapabilities', () => { + it('Should extract flat Layer elements', async () => { + const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + wmsClient._fetch = () => { + return { + status: 200, + text: () => { + return ` + + + + layer1 + 1 + + + + layer2 + 2 + + + + + `; + }, + }; + }; + const capabilities = await wmsClient.getCapabilities(); + expect(capabilities.layers).toEqual([ + { label: 'layer1 (1)', value: '1' }, + { label: 'layer2 (2)', value: '2' }, + ]); + expect(capabilities.styles).toEqual([ + { label: 'defaultStyle (default)', value: 'default' }, + { label: 'fancyStyle (fancy)', value: 'fancy' }, + ]); + }); + + // Good example of Layer hierarchy in the wild can be found at + // https://idpgis.ncep.noaa.gov/arcgis/services/NWS_Forecasts_Guidance_Warnings/NDFD_temp/MapServer/WMSServer + it('Should extract hierarchical Layer elements', async () => { + const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + wmsClient._fetch = () => { + return { + status: 200, + text: () => { + return ` + + + + <![CDATA[hierarchyLevel1PathA]]> + + hierarchyLevel2 + + layer1 + 1 + + + + layer2 + 2 + + + + + hierarchyLevel1PathB + + layer3 + 3 + + + + + + `; + }, + }; + }; + const capabilities = await wmsClient.getCapabilities(); + expect(capabilities.layers).toEqual([ + { label: 'hierarchyLevel1PathA - hierarchyLevel2 - layer1 (1)', value: '1' }, + { label: 'hierarchyLevel1PathA - hierarchyLevel2 - layer2 (2)', value: '2' }, + { label: 'hierarchyLevel1PathB - layer3 (3)', value: '3' }, + ]); + expect(capabilities.styles).toEqual([ + { + label: 'hierarchyLevel1PathA - hierarchyLevel2 - defaultStyle (default)', + value: 'default', + }, + { label: 'hierarchyLevel1PathB - fancyStyle (fancy)', value: 'fancy' }, + ]); + }); + + it('Should create group from common parts of Layer hierarchy', async () => { + const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + wmsClient._fetch = () => { + return { + status: 200, + text: () => { + return ` + + + + hierarchyLevel1PathA + + hierarchyLevel2 + + layer1 + 1 + + + + + + hierarchyLevel1PathA + + hierarchyLevel2 + + layer2 + 2 + + + + + + + `; + }, + }; + }; + const capabilities = await wmsClient.getCapabilities(); + expect(capabilities.layers).toEqual([ + { + label: 'hierarchyLevel1PathA - hierarchyLevel2', + options: [ + { label: 'layer1 (1)', value: '1' }, + { label: 'layer2 (2)', value: '2' }, + ], + }, + ]); + expect(capabilities.styles).toEqual([ + { + label: 'hierarchyLevel1PathA - hierarchyLevel2', + options: [ + { label: 'defaultStyle (default)', value: 'default' }, + { label: 'fancyStyle (fancy)', value: 'fancy' }, + ], + }, + ]); + }); + + it('Should ensure no option labels have name collisions', async () => { + const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + wmsClient._fetch = () => { + return { + status: 200, + text: () => { + return ` + + + + mylayer + my_layer + + + + mylayer + my_layer + + + + mylayer + my_layer + + + + + `; + }, + }; + }; + const capabilities = await wmsClient.getCapabilities(); + expect(capabilities.layers).toEqual([ + { label: 'mylayer (my_layer)', value: 'my_layer' }, + { label: 'mylayer (my_layer):1', value: 'my_layer' }, + { label: 'mylayer (my_layer):2', value: 'my_layer' }, + ]); + }); + + it('Should not create group common hierarchy when there is only a single layer', async () => { + const wmsClient = new WmsClient({ serviceUrl: 'myWMSUrl' }); + wmsClient._fetch = () => { + return { + status: 200, + text: () => { + return ` + + + + layer1 + 1 + + + + `; + }, + }; + }; + const capabilities = await wmsClient.getCapabilities(); + expect(capabilities.layers).toEqual([{ label: 'layer1 (1)', value: '1' }]); + }); +}); + +describe('getUrlTemplate', () => { + it('Should not overwrite specific query parameters when defined in the url', async () => { + const urlWithQuery = + 'http://example.com/wms?map=MyMap&format=image/jpeg&service=NotWMS&version=0&request=GetNull&srs=Invalid&transparent=false&width=1024&height=640'; + const wmsClient = new WmsClient({ serviceUrl: urlWithQuery }); + const urlTemplate = await wmsClient.getUrlTemplate('MyLayer', 'MyStyle'); + expect(urlTemplate).toEqual( + 'http://example.com/wms?map=MyMap&format=image%2Fpng&service=WMS&version=1.1.1&request=GetMap&srs=EPSG%3A3857&transparent=true&width=256&height=256&layers=MyLayer&styles=MyStyle&bbox={bbox-epsg-3857}' + ); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/sources/wms_source/wms_create_source_editor.js b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_create_source_editor.js new file mode 100644 index 0000000000000..f676abc668341 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_create_source_editor.js @@ -0,0 +1,309 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiCallOut, + EuiComboBox, + EuiFieldText, + EuiFormRow, + EuiForm, + EuiSpacer, +} from '@elastic/eui'; +import { WmsClient } from './wms_client'; +import _ from 'lodash'; + +const LAYERS_LABEL = i18n.translate('xpack.maps.source.wms.layersLabel', { + defaultMessage: 'Layers', +}); +const STYLES_LABEL = i18n.translate('xpack.maps.source.wms.stylesLabel', { + defaultMessage: 'Styles', +}); + +export class WMSCreateSourceEditor extends Component { + state = { + serviceUrl: '', + layers: '', + styles: '', + isLoadingCapabilities: false, + getCapabilitiesError: null, + hasAttemptedToLoadCapabilities: false, + layerOptions: [], + styleOptions: [], + selectedLayerOptions: [], + selectedStyleOptions: [], + attributionText: '', + attributionUrl: '', + }; + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + _previewIfPossible = _.debounce(() => { + const { serviceUrl, layers, styles, attributionText, attributionUrl } = this.state; + + const sourceConfig = + serviceUrl && layers + ? { + serviceUrl, + layers, + styles, + attributionText, + attributionUrl, + } + : null; + this.props.onSourceConfigChange(sourceConfig); + }, 2000); + + _loadCapabilities = async () => { + if (!this.state.serviceUrl) { + return; + } + + this.setState({ + hasAttemptedToLoadCapabilities: true, + isLoadingCapabilities: true, + getCapabilitiesError: null, + }); + + const wmsClient = new WmsClient({ serviceUrl: this.state.serviceUrl }); + + let capabilities; + try { + capabilities = await wmsClient.getCapabilities(); + } catch (error) { + if (this._isMounted) { + this.setState({ + isLoadingCapabilities: false, + getCapabilitiesError: error.message, + }); + } + return; + } + + if (!this._isMounted) { + return; + } + + this.setState({ + isLoadingCapabilities: false, + layerOptions: capabilities.layers, + styleOptions: capabilities.styles, + }); + }; + + _handleServiceUrlChange = e => { + this.setState( + { + serviceUrl: e.target.value, + hasAttemptedToLoadCapabilities: false, + layerOptions: [], + styleOptions: [], + selectedLayerOptions: [], + selectedStyleOptions: [], + layers: '', + styles: '', + }, + this._previewIfPossible + ); + }; + + _handleLayersChange = e => { + this.setState({ layers: e.target.value }, this._previewIfPossible); + }; + + _handleLayerOptionsChange = selectedOptions => { + this.setState( + { + selectedLayerOptions: selectedOptions, + layers: selectedOptions + .map(selectedOption => { + return selectedOption.value; + }) + .join(','), + }, + this._previewIfPossible + ); + }; + + _handleStylesChange = e => { + this.setState({ styles: e.target.value }, this._previewIfPossible); + }; + + _handleStyleOptionsChange = selectedOptions => { + this.setState( + { + selectedStyleOptions: selectedOptions, + styles: selectedOptions + .map(selectedOption => { + return selectedOption.value; + }) + .join(','), + }, + this._previewIfPossible + ); + }; + + _handleWMSAttributionChange(attributionUpdate) { + const { attributionText, attributionUrl } = this.state; + this.setState(attributionUpdate, () => { + if (attributionText && attributionUrl) { + this._previewIfPossible(); + } + }); + } + + _renderLayerAndStyleInputs() { + if (!this.state.hasAttemptedToLoadCapabilities || this.state.isLoadingCapabilities) { + return null; + } + + if (this.state.getCapabilitiesError || this.state.layerOptions.length === 0) { + return ( + + + +

{this.state.getCapabilitiesError}

+
+ + + + + + + + + +
+ ); + } + + return ( + + + + + + + + + ); + } + + _renderGetCapabilitiesButton() { + if (!this.state.isLoadingCapabilities && this.state.hasAttemptedToLoadCapabilities) { + return null; + } + + return ( + + + + + + ); + } + + _renderAttributionInputs() { + if (!this.state.layers) { + return; + } + + const { attributionText, attributionUrl } = this.state; + + return ( + + + + this._handleWMSAttributionChange({ attributionText: target.value }) + } + /> + + + + this._handleWMSAttributionChange({ attributionUrl: target.value }) + } + /> + + + ); + } + + render() { + return ( + + + + + + {this._renderGetCapabilitiesButton()} + + {this._renderLayerAndStyleInputs()} + + {this._renderAttributionInputs()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js new file mode 100644 index 0000000000000..61955df94e451 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/wms_source/wms_source.js @@ -0,0 +1,106 @@ +/* + * 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 from 'react'; + +import { AbstractTMSSource } from '../tms_source'; +import { TileLayer } from '../../tile_layer'; +import { WMSCreateSourceEditor } from './wms_create_source_editor'; +import { i18n } from '@kbn/i18n'; +import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters'; +import { WmsClient } from './wms_client'; + +export class WMSSource extends AbstractTMSSource { + static type = 'WMS'; + static title = i18n.translate('xpack.maps.source.wmsTitle', { + defaultMessage: 'Web Map Service', + }); + static description = i18n.translate('xpack.maps.source.wmsDescription', { + defaultMessage: 'Maps from OGC Standard WMS', + }); + static icon = 'grid'; + + static createDescriptor({ serviceUrl, layers, styles, attributionText, attributionUrl }) { + return { + type: WMSSource.type, + serviceUrl, + layers, + styles, + attributionText, + attributionUrl, + }; + } + + static renderEditor({ onPreviewSource, inspectorAdapters }) { + const onSourceConfigChange = sourceConfig => { + if (!sourceConfig) { + onPreviewSource(null); + return; + } + + const sourceDescriptor = WMSSource.createDescriptor(sourceConfig); + const source = new WMSSource(sourceDescriptor, inspectorAdapters); + onPreviewSource(source); + }; + return ; + } + + async getImmutableProperties() { + return [ + { label: getDataSourceLabel(), value: WMSSource.title }, + { label: getUrlLabel(), value: this._descriptor.serviceUrl }, + { + label: i18n.translate('xpack.maps.source.wms.layersLabel', { + defaultMessage: 'Layers', + }), + value: this._descriptor.layers, + }, + { + label: i18n.translate('xpack.maps.source.wms.stylesLabel', { + defaultMessage: 'Styles', + }), + value: this._descriptor.styles, + }, + ]; + } + + _createDefaultLayerDescriptor(options) { + return TileLayer.createDescriptor({ + sourceDescriptor: this._descriptor, + ...options, + }); + } + + createDefaultLayer(options) { + return new TileLayer({ + layerDescriptor: this._createDefaultLayerDescriptor(options), + source: this, + }); + } + + async getDisplayName() { + return this._descriptor.serviceUrl; + } + + getAttributions() { + const { attributionText, attributionUrl } = this._descriptor; + const attributionComplete = !!attributionText && !!attributionUrl; + + return attributionComplete + ? [ + { + url: attributionUrl, + label: attributionText, + }, + ] + : []; + } + + getUrlTemplate() { + const client = new WmsClient({ serviceUrl: this._descriptor.serviceUrl }); + return client.getUrlTemplate(this._descriptor.layers, this._descriptor.styles || ''); + } +} diff --git a/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.js b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.js new file mode 100644 index 0000000000000..a4d331f48beae --- /dev/null +++ b/x-pack/plugins/maps/public/layers/sources/xyz_tms_source.js @@ -0,0 +1,176 @@ +/* + * 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 { EuiFieldText, EuiFormRow } from '@elastic/eui'; + +import { AbstractTMSSource } from './tms_source'; +import { TileLayer } from '../tile_layer'; +import { i18n } from '@kbn/i18n'; +import { getDataSourceLabel, getUrlLabel } from '../../../common/i18n_getters'; +import _ from 'lodash'; + +export class XYZTMSSource extends AbstractTMSSource { + static type = 'EMS_XYZ'; + static title = i18n.translate('xpack.maps.source.ems_xyzTitle', { + defaultMessage: 'Tile Map Service', + }); + static description = i18n.translate('xpack.maps.source.ems_xyzDescription', { + defaultMessage: 'Tile map service configured in interface', + }); + static icon = 'grid'; + + static createDescriptor({ urlTemplate, attributionText, attributionUrl }) { + return { + type: XYZTMSSource.type, + urlTemplate, + attributionText, + attributionUrl, + }; + } + + static renderEditor({ onPreviewSource, inspectorAdapters }) { + const onSourceConfigChange = sourceConfig => { + const sourceDescriptor = XYZTMSSource.createDescriptor(sourceConfig); + const source = new XYZTMSSource(sourceDescriptor, inspectorAdapters); + onPreviewSource(source); + }; + return ; + } + + async getImmutableProperties() { + return [ + { label: getDataSourceLabel(), value: XYZTMSSource.title }, + { label: getUrlLabel(), value: this._descriptor.urlTemplate }, + ]; + } + + _createDefaultLayerDescriptor(options) { + return TileLayer.createDescriptor({ + sourceDescriptor: this._descriptor, + ...options, + }); + } + + createDefaultLayer(options) { + return new TileLayer({ + layerDescriptor: this._createDefaultLayerDescriptor(options), + source: this, + }); + } + + async getDisplayName() { + return this._descriptor.urlTemplate; + } + + getAttributions() { + const { attributionText, attributionUrl } = this._descriptor; + const attributionComplete = !!attributionText && !!attributionUrl; + + return attributionComplete + ? [ + { + url: attributionUrl, + label: attributionText, + }, + ] + : []; + } + + getUrlTemplate() { + return this._descriptor.urlTemplate; + } +} + +class XYZTMSEditor extends React.Component { + state = { + tmsInput: '', + tmsCanPreview: false, + attributionText: '', + attributionUrl: '', + }; + + _sourceConfigChange = _.debounce(updatedSourceConfig => { + if (this.state.tmsCanPreview) { + this.props.onSourceConfigChange(updatedSourceConfig); + } + }, 2000); + + _handleTMSInputChange(e) { + const url = e.target.value; + + const canPreview = + url.indexOf('{x}') >= 0 && url.indexOf('{y}') >= 0 && url.indexOf('{z}') >= 0; + this.setState( + { + tmsInput: url, + tmsCanPreview: canPreview, + }, + () => this._sourceConfigChange({ urlTemplate: url }) + ); + } + + _handleTMSAttributionChange(attributionUpdate) { + this.setState(attributionUpdate, () => { + const { attributionText, attributionUrl, tmsInput } = this.state; + + if (tmsInput && attributionText && attributionUrl) { + this._sourceConfigChange({ + urlTemplate: tmsInput, + attributionText, + attributionUrl, + }); + } + }); + } + + render() { + const { attributionText, attributionUrl } = this.state; + + return ( + + + this._handleTMSInputChange(e)} + /> + + + + this._handleTMSAttributionChange({ attributionText: target.value }) + } + /> + + + + this._handleTMSAttributionChange({ attributionUrl: target.value }) + } + /> + + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/_index.scss b/x-pack/plugins/maps/public/layers/styles/_index.scss new file mode 100644 index 0000000000000..b5d9113619c76 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/_index.scss @@ -0,0 +1,4 @@ +@import './components/color_gradient'; +@import './vector/components/style_prop_editor'; +@import './vector/components/color/color_stops'; +@import './vector/components/symbol/icon_select'; diff --git a/x-pack/plugins/maps/public/layers/styles/abstract_style.js b/x-pack/plugins/maps/public/layers/styles/abstract_style.js new file mode 100644 index 0000000000000..3e7a3dbf7ed20 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/abstract_style.js @@ -0,0 +1,29 @@ +/* + * 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. + */ + +export class AbstractStyle { + getDescriptorWithMissingStylePropsRemoved(/* nextOrdinalFields */) { + return { + hasChanges: false, + }; + } + + async pluckStyleMetaFromSourceDataRequest(/* sourceDataRequest */) { + return {}; + } + + getDescriptor() { + return this._descriptor; + } + + renderEditor(/* { layer, onStyleDescriptorChange } */) { + return null; + } + + getSourceFieldNames() { + return []; + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/color_utils.js b/x-pack/plugins/maps/public/layers/styles/color_utils.js new file mode 100644 index 0000000000000..1702eb045621e --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/color_utils.js @@ -0,0 +1,128 @@ +/* + * 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 from 'react'; +import tinycolor from 'tinycolor2'; +import chroma from 'chroma-js'; + +import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; + +import { getLegendColors, getColor } from 'ui/vis/map/color_util'; + +import { ColorGradient } from './components/color_gradient'; +import { COLOR_PALETTE_MAX_SIZE } from '../../../common/constants'; +import { vislibColorMaps } from '../../../../../../src/plugins/charts/public'; + +const GRADIENT_INTERVALS = 8; + +export const DEFAULT_FILL_COLORS = euiPaletteColorBlind(); +export const DEFAULT_LINE_COLORS = [ + ...DEFAULT_FILL_COLORS.map(color => + tinycolor(color) + .darken() + .toHexString() + ), + // Explicitly add black & white as border color options + '#000', + '#FFF', +]; + +function getColorRamp(colorRampName) { + const colorRamp = vislibColorMaps[colorRampName]; + if (!colorRamp) { + throw new Error( + `${colorRampName} not found. Expected one of following values: ${Object.keys( + vislibColorMaps + )}` + ); + } + return colorRamp; +} + +export function getRGBColorRangeStrings(colorRampName, numberColors = GRADIENT_INTERVALS) { + const colorRamp = getColorRamp(colorRampName); + return getLegendColors(colorRamp.value, numberColors); +} + +export function getHexColorRangeStrings(colorRampName, numberColors = GRADIENT_INTERVALS) { + return getRGBColorRangeStrings(colorRampName, numberColors).map(rgbColor => + chroma(rgbColor).hex() + ); +} + +export function getColorRampCenterColor(colorRampName) { + if (!colorRampName) { + return null; + } + const colorRamp = getColorRamp(colorRampName); + const centerIndex = Math.floor(colorRamp.value.length / 2); + return getColor(colorRamp.value, centerIndex); +} + +// Returns an array of color stops +// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] +export function getOrdinalColorRampStops(colorRampName, numberColors = GRADIENT_INTERVALS) { + if (!colorRampName) { + return null; + } + return getHexColorRangeStrings(colorRampName, numberColors).reduce( + (accu, stopColor, idx, srcArr) => { + const stopNumber = idx / srcArr.length; // number between 0 and 1, increasing as index increases + return [...accu, stopNumber, stopColor]; + }, + [] + ); +} + +export const COLOR_GRADIENTS = Object.keys(vislibColorMaps).map(colorRampName => ({ + value: colorRampName, + inputDisplay: , +})); + +export const COLOR_RAMP_NAMES = Object.keys(vislibColorMaps); + +export function getLinearGradient(colorStrings) { + const intervals = colorStrings.length; + let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`; + for (let i = 1; i < intervals - 1; i++) { + linearGradient = `${linearGradient} ${colorStrings[i]} \ + ${Math.floor((100 * i) / (intervals - 1))}%,`; + } + return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`; +} + +const COLOR_PALETTES_CONFIGS = [ + { + id: 'palette_0', + colors: DEFAULT_FILL_COLORS.slice(0, COLOR_PALETTE_MAX_SIZE), + }, +]; + +export function getColorPalette(paletteId) { + const palette = COLOR_PALETTES_CONFIGS.find(palette => palette.id === paletteId); + return palette ? palette.colors : null; +} + +export const COLOR_PALETTES = COLOR_PALETTES_CONFIGS.map(palette => { + const paletteDisplay = palette.colors.map(color => { + const style = { + backgroundColor: color, + width: '10%', + position: 'relative', + height: '100%', + display: 'inline-block', + }; + return ( +
+   +
+ ); + }); + return { + value: palette.id, + inputDisplay:
{paletteDisplay}
, + }; +}); diff --git a/x-pack/plugins/maps/public/layers/styles/color_utils.test.js b/x-pack/plugins/maps/public/layers/styles/color_utils.test.js new file mode 100644 index 0000000000000..5a8289ba903f3 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/color_utils.test.js @@ -0,0 +1,101 @@ +/* + * 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 { + COLOR_GRADIENTS, + getColorRampCenterColor, + getOrdinalColorRampStops, + getHexColorRangeStrings, + getLinearGradient, + getRGBColorRangeStrings, +} from './color_utils'; + +jest.mock('ui/new_platform'); + +describe('COLOR_GRADIENTS', () => { + it('Should contain EuiSuperSelect options list of color ramps', () => { + expect(COLOR_GRADIENTS.length).toBe(6); + const colorGradientOption = COLOR_GRADIENTS[0]; + expect(colorGradientOption.value).toBe('Blues'); + }); +}); + +describe('getRGBColorRangeStrings', () => { + it('Should create RGB color ramp', () => { + expect(getRGBColorRangeStrings('Blues')).toEqual([ + 'rgb(247,250,255)', + 'rgb(221,234,247)', + 'rgb(197,218,238)', + 'rgb(157,201,224)', + 'rgb(106,173,213)', + 'rgb(65,145,197)', + 'rgb(32,112,180)', + 'rgb(7,47,107)', + ]); + }); +}); + +describe('getHexColorRangeStrings', () => { + it('Should create HEX color ramp', () => { + expect(getHexColorRangeStrings('Blues')).toEqual([ + '#f7faff', + '#ddeaf7', + '#c5daee', + '#9dc9e0', + '#6aadd5', + '#4191c5', + '#2070b4', + '#072f6b', + ]); + }); +}); + +describe('getColorRampCenterColor', () => { + it('Should get center color from color ramp', () => { + expect(getColorRampCenterColor('Blues')).toBe('rgb(106,173,213)'); + }); +}); + +describe('getColorRampStops', () => { + it('Should create color stops for color ramp', () => { + expect(getOrdinalColorRampStops('Blues')).toEqual([ + 0, + '#f7faff', + 0.125, + '#ddeaf7', + 0.25, + '#c5daee', + 0.375, + '#9dc9e0', + 0.5, + '#6aadd5', + 0.625, + '#4191c5', + 0.75, + '#2070b4', + 0.875, + '#072f6b', + ]); + }); +}); + +describe('getLinearGradient', () => { + it('Should create linear gradient from color ramp', () => { + const colorRamp = [ + 'rgb(247,250,255)', + 'rgb(221,234,247)', + 'rgb(197,218,238)', + 'rgb(157,201,224)', + 'rgb(106,173,213)', + 'rgb(65,145,197)', + 'rgb(32,112,180)', + 'rgb(7,47,107)', + ]; + expect(getLinearGradient(colorRamp)).toBe( + 'linear-gradient(to right, rgb(247,250,255) 0%, rgb(221,234,247) 14%, rgb(197,218,238) 28%, rgb(157,201,224) 42%, rgb(106,173,213) 57%, rgb(65,145,197) 71%, rgb(32,112,180) 85%, rgb(7,47,107) 100%)' + ); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/styles/components/_color_gradient.scss b/x-pack/plugins/maps/public/layers/styles/components/_color_gradient.scss new file mode 100644 index 0000000000000..dbe9575ce5698 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/components/_color_gradient.scss @@ -0,0 +1,13 @@ +.mapColorGradient { + width: 100%; + height: $euiSizeXS; + position: relative; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +.mapFillableCircle { + overflow: visible; +} diff --git a/x-pack/plugins/maps/public/layers/styles/components/color_gradient.js b/x-pack/plugins/maps/public/layers/styles/components/color_gradient.js new file mode 100644 index 0000000000000..8772f33b76fd7 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/components/color_gradient.js @@ -0,0 +1,20 @@ +/* + * 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 from 'react'; +import { COLOR_RAMP_NAMES, getRGBColorRangeStrings, getLinearGradient } from '../color_utils'; +import classNames from 'classnames'; + +export const ColorGradient = ({ colorRamp, colorRampName, className }) => { + if (!colorRamp && (!colorRampName || !COLOR_RAMP_NAMES.includes(colorRampName))) { + return null; + } + + const classes = classNames('mapColorGradient', className); + const rgbColorStrings = colorRampName ? getRGBColorRangeStrings(colorRampName) : colorRamp; + const background = getLinearGradient(rgbColorStrings); + return
; +}; diff --git a/x-pack/plugins/maps/public/layers/styles/components/ranged_style_legend_row.js b/x-pack/plugins/maps/public/layers/styles/components/ranged_style_legend_row.js new file mode 100644 index 0000000000000..3eb34ec1406d2 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/components/ranged_style_legend_row.js @@ -0,0 +1,48 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; + +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer, EuiToolTip } from '@elastic/eui'; + +export function RangedStyleLegendRow({ header, minLabel, maxLabel, propertyLabel, fieldLabel }) { + return ( +
+ + {header} + + + + {minLabel} + + + + + + + {fieldLabel} + + + + + + + {maxLabel} + + + +
+ ); +} + +RangedStyleLegendRow.propTypes = { + header: PropTypes.node.isRequired, + minLabel: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + maxLabel: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + propertyLabel: PropTypes.string.isRequired, + fieldLabel: PropTypes.string.isRequired, +}; diff --git a/x-pack/plugins/maps/public/layers/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.js.snap b/x-pack/plugins/maps/public/layers/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.js.snap new file mode 100644 index 0000000000000..9d07b9c641e0f --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.js.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeatmapStyleEditor is rendered 1`] = ` + + , + "text": "theclassic", + "value": "theclassic", + }, + Object { + "inputDisplay": , + "value": "Blues", + }, + Object { + "inputDisplay": , + "value": "Greens", + }, + Object { + "inputDisplay": , + "value": "Greys", + }, + Object { + "inputDisplay": , + "value": "Reds", + }, + Object { + "inputDisplay": , + "value": "Yellow to Red", + }, + Object { + "inputDisplay": , + "value": "Green to Red", + }, + ] + } + valueOfSelected="Blues" + /> + +`; diff --git a/x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_constants.js b/x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_constants.js new file mode 100644 index 0000000000000..583c78e56581b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_constants.js @@ -0,0 +1,22 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +// Color stops from default Mapbox heatmap-color +export const DEFAULT_RGB_HEATMAP_COLOR_RAMP = [ + 'rgb(65, 105, 225)', // royalblue + 'rgb(0, 256, 256)', // cyan + 'rgb(0, 256, 0)', // lime + 'rgb(256, 256, 0)', // yellow + 'rgb(256, 0, 0)', // red +]; + +export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic'; + +export const HEATMAP_COLOR_RAMP_LABEL = i18n.translate('xpack.maps.heatmap.colorRampLabel', { + defaultMessage: 'Color range', +}); diff --git a/x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_style_editor.js b/x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_style_editor.js new file mode 100644 index 0000000000000..a0f86dcf5130b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_style_editor.js @@ -0,0 +1,45 @@ +/* + * 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 from 'react'; + +import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; +import { COLOR_GRADIENTS } from '../../color_utils'; +import { ColorGradient } from '../../components/color_gradient'; +import { + DEFAULT_RGB_HEATMAP_COLOR_RAMP, + DEFAULT_HEATMAP_COLOR_RAMP_NAME, + HEATMAP_COLOR_RAMP_LABEL, +} from './heatmap_constants'; + +export function HeatmapStyleEditor({ colorRampName, onHeatmapColorChange }) { + const onColorRampChange = selectedColorRampName => { + onHeatmapColorChange({ + colorRampName: selectedColorRampName, + }); + }; + + const colorRampOptions = [ + { + value: DEFAULT_HEATMAP_COLOR_RAMP_NAME, + text: DEFAULT_HEATMAP_COLOR_RAMP_NAME, + inputDisplay: , + }, + ...COLOR_GRADIENTS, + ]; + + return ( + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_style_editor.test.js b/x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_style_editor.test.js new file mode 100644 index 0000000000000..aa4dbc67e8e4d --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/heatmap/components/heatmap_style_editor.test.js @@ -0,0 +1,22 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; + +import { HeatmapStyleEditor } from './heatmap_style_editor'; + +jest.mock('ui/new_platform'); + +describe('HeatmapStyleEditor', () => { + test('is rendered', () => { + const component = shallow( + {}} /> + ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/styles/heatmap/components/legend/heatmap_legend.js b/x-pack/plugins/maps/public/layers/styles/heatmap/components/legend/heatmap_legend.js new file mode 100644 index 0000000000000..1d8dfe9c7bdbf --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/heatmap/components/legend/heatmap_legend.js @@ -0,0 +1,66 @@ +/* + * 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 from 'react'; + +import { i18n } from '@kbn/i18n'; +import { ColorGradient } from '../../../components/color_gradient'; +import { RangedStyleLegendRow } from '../../../components/ranged_style_legend_row'; +import { + DEFAULT_RGB_HEATMAP_COLOR_RAMP, + DEFAULT_HEATMAP_COLOR_RAMP_NAME, + HEATMAP_COLOR_RAMP_LABEL, +} from '../heatmap_constants'; + +export class HeatmapLegend extends React.Component { + constructor() { + super(); + this.state = { label: '' }; + } + + componentDidUpdate() { + this._loadLabel(); + } + + componentDidMount() { + this._isMounted = true; + this._loadLabel(); + } + componentWillUnmount() { + this._isMounted = false; + } + + async _loadLabel() { + const label = await this.props.field.getLabel(); + if (this._isMounted && this.state.label !== label) { + this.setState({ label }); + } + } + + render() { + const colorRampName = this.props.colorRampName; + const header = + colorRampName === DEFAULT_HEATMAP_COLOR_RAMP_NAME ? ( + + ) : ( + + ); + + return ( + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/heatmap/heatmap_style.js b/x-pack/plugins/maps/public/layers/styles/heatmap/heatmap_style.js new file mode 100644 index 0000000000000..1dd219d4c4cad --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/heatmap/heatmap_style.js @@ -0,0 +1,113 @@ +/* + * 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 from 'react'; +import { GRID_RESOLUTION } from '../../grid_resolution'; +import { AbstractStyle } from '../abstract_style'; +import { HeatmapStyleEditor } from './components/heatmap_style_editor'; +import { HeatmapLegend } from './components/legend/heatmap_legend'; +import { DEFAULT_HEATMAP_COLOR_RAMP_NAME } from './components/heatmap_constants'; +import { LAYER_STYLE_TYPE } from '../../../../common/constants'; +import { getOrdinalColorRampStops } from '../color_utils'; +import { i18n } from '@kbn/i18n'; +import { EuiIcon } from '@elastic/eui'; + +export class HeatmapStyle extends AbstractStyle { + static type = LAYER_STYLE_TYPE.HEATMAP; + + constructor(descriptor = {}) { + super(); + this._descriptor = HeatmapStyle.createDescriptor(descriptor.colorRampName); + } + + static createDescriptor(colorRampName) { + return { + type: HeatmapStyle.type, + colorRampName: colorRampName ? colorRampName : DEFAULT_HEATMAP_COLOR_RAMP_NAME, + }; + } + + static getDisplayName() { + return i18n.translate('xpack.maps.style.heatmap.displayNameLabel', { + defaultMessage: 'Heatmap style', + }); + } + + renderEditor({ onStyleDescriptorChange }) { + const onHeatmapColorChange = ({ colorRampName }) => { + const styleDescriptor = HeatmapStyle.createDescriptor(colorRampName); + onStyleDescriptorChange(styleDescriptor); + }; + + return ( + + ); + } + + renderLegendDetails(field) { + return ; + } + + getIcon() { + return ; + } + + setMBPaintProperties({ mbMap, layerId, propertyName, resolution }) { + let radius; + if (resolution === GRID_RESOLUTION.COARSE) { + radius = 128; + } else if (resolution === GRID_RESOLUTION.FINE) { + radius = 64; + } else if (resolution === GRID_RESOLUTION.MOST_FINE) { + radius = 32; + } else { + const errorMessage = i18n.translate('xpack.maps.style.heatmap.resolutionStyleErrorMessage', { + defaultMessage: `Resolution param not recognized: {resolution}`, + values: { resolution }, + }); + throw new Error(errorMessage); + } + mbMap.setPaintProperty(layerId, 'heatmap-radius', radius); + mbMap.setPaintProperty(layerId, 'heatmap-weight', { + type: 'identity', + property: propertyName, + }); + + const { colorRampName } = this._descriptor; + if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) { + const colorStops = getOrdinalColorRampStops(colorRampName); + mbMap.setPaintProperty(layerId, 'heatmap-color', [ + 'interpolate', + ['linear'], + ['heatmap-density'], + 0, + 'rgba(0, 0, 255, 0)', + ...colorStops.slice(2), // remove first stop from colorStops to avoid conflict with transparent stop at zero + ]); + } else { + mbMap.setPaintProperty(layerId, 'heatmap-color', [ + 'interpolate', + ['linear'], + ['heatmap-density'], + 0, + 'rgba(0, 0, 255, 0)', + 0.1, + 'royalblue', + 0.3, + 'cyan', + 0.5, + 'lime', + 0.7, + 'yellow', + 1, + 'red', + ]); + } + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/_style_prop_editor.scss b/x-pack/plugins/maps/public/layers/styles/vector/components/_style_prop_editor.scss new file mode 100644 index 0000000000000..138605b1a7dcc --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/_style_prop_editor.scss @@ -0,0 +1,3 @@ +.mapStyleFormDisabledTooltip { + width: 100%; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss b/x-pack/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss new file mode 100644 index 0000000000000..001ca0685d0e9 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss @@ -0,0 +1,39 @@ +.mapColorStop { + position: relative; + padding-right: $euiSizeXL + $euiSizeS; + + & + & { + margin-top: $euiSizeS; + } + + &:hover, + &:focus { + .mapColorStop__icons { + visibility: visible; + opacity: 1; + display: block; + animation: mapColorStopBecomeVisible $euiAnimSpeedFast $euiAnimSlightResistance; + } + } +} + +.mapColorStop__icons { + flex-shrink: 0; + display: none; + position: absolute; + right: 0; + top: 50%; + margin-right: -$euiSizeS; + margin-top: -$euiSizeM; +} + +@keyframes mapColorStopBecomeVisible { + + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_map_select.js b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_map_select.js new file mode 100644 index 0000000000000..fde088ab4475e --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_map_select.js @@ -0,0 +1,111 @@ +/* + * 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, { Component, Fragment } from 'react'; + +import { EuiSuperSelect, EuiSpacer } from '@elastic/eui'; +import { ColorStopsOrdinal } from './color_stops_ordinal'; +import { COLOR_MAP_TYPE } from '../../../../../../common/constants'; +import { ColorStopsCategorical } from './color_stops_categorical'; + +const CUSTOM_COLOR_MAP = 'CUSTOM_COLOR_MAP'; + +export class ColorMapSelect extends Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.customColorMap === prevState.prevPropsCustomColorMap) { + return null; + } + + return { + prevPropsCustomColorMap: nextProps.customColorMap, // reset tracker to latest value + customColorMap: nextProps.customColorMap, // reset customColorMap to latest value + }; + } + + _onColorMapSelect = selectedValue => { + const useCustomColorMap = selectedValue === CUSTOM_COLOR_MAP; + this.props.onChange({ + color: useCustomColorMap ? null : selectedValue, + useCustomColorMap, + type: this.props.colorMapType, + }); + }; + + _onCustomColorMapChange = ({ colorStops, isInvalid }) => { + // Manage invalid custom color map in local state + if (isInvalid) { + this.setState({ customColorMap: colorStops }); + return; + } + + this.props.onChange({ + useCustomColorMap: true, + customColorMap: colorStops, + type: this.props.colorMapType, + }); + }; + + _renderColorStopsInput() { + if (!this.props.useCustomColorMap) { + return null; + } + + if (this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL) { + return ( + + + + + ); + } + + return ( + + + + + ); + } + + render() { + const colorMapOptionsWithCustom = [ + { + value: CUSTOM_COLOR_MAP, + inputDisplay: this.props.customOptionLabel, + }, + ...this.props.colorMapOptions, + ]; + + let valueOfSelected; + if (this.props.useCustomColorMap) { + valueOfSelected = CUSTOM_COLOR_MAP; + } else { + valueOfSelected = this.props.colorMapOptions.find(option => option.value === this.props.color) + ? this.props.color + : ''; + } + + return ( + + + {this._renderColorStopsInput()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops.js b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops.js new file mode 100644 index 0000000000000..6b403ff61532d --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops.js @@ -0,0 +1,141 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { removeRow, isColorInvalid } from './color_stops_utils'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonIcon, EuiColorPicker, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; + +function getColorStopRow({ index, errors, stopInput, colorInput, deleteButton, onAdd }) { + return ( + +
+ + {stopInput} + {colorInput} + +
+ {deleteButton} + +
+
+
+ ); +} + +export function getDeleteButton(onRemove) { + return ( + + ); +} + +export const ColorStops = ({ + onChange, + colorStops, + isStopsInvalid, + sanitizeStopInput, + getStopError, + renderStopInput, + addNewRow, + canDeleteStop, +}) => { + function getStopInput(stop, index) { + const onStopChange = e => { + const newColorStops = _.cloneDeep(colorStops); + newColorStops[index].stop = sanitizeStopInput(e.target.value); + const invalid = isStopsInvalid(newColorStops); + onChange({ + colorStops: newColorStops, + isInvalid: invalid, + }); + }; + + const error = getStopError(stop, index); + return { + stopError: error, + stopInput: renderStopInput(stop, onStopChange, index), + }; + } + + function getColorInput(onColorChange, color) { + return { + colorError: isColorInvalid(color) + ? i18n.translate('xpack.maps.styles.colorStops.hexWarningLabel', { + defaultMessage: 'Color must provide a valid hex value', + }) + : undefined, + colorInput: , + }; + } + + const rows = colorStops.map((colorStop, index) => { + const onColorChange = color => { + const newColorStops = _.cloneDeep(colorStops); + newColorStops[index].color = color; + onChange({ + colorStops: newColorStops, + isInvalid: isStopsInvalid(newColorStops), + }); + }; + + const { stopError, stopInput } = getStopInput(colorStop.stop, index); + const { colorError, colorInput } = getColorInput(onColorChange, colorStop.color); + const errors = []; + if (stopError) { + errors.push(stopError); + } + if (colorError) { + errors.push(colorError); + } + + const onAdd = () => { + const newColorStops = addNewRow(colorStops, index); + onChange({ + colorStops: newColorStops, + isInvalid: isStopsInvalid(newColorStops), + }); + }; + + let deleteButton; + if (canDeleteStop(colorStops, index)) { + const onRemove = () => { + const newColorStops = removeRow(colorStops, index); + onChange({ + colorStops: newColorStops, + isInvalid: isStopsInvalid(newColorStops), + }); + }; + deleteButton = getDeleteButton(onRemove); + } + + return getColorStopRow({ index, errors, stopInput, colorInput, deleteButton, onAdd }); + }); + + return
{rows}
; +}; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_categorical.js b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_categorical.js new file mode 100644 index 0000000000000..d52c3dbcfa1df --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_categorical.js @@ -0,0 +1,117 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; + +import { EuiFieldText } from '@elastic/eui'; +import { + addCategoricalRow, + isCategoricalStopsInvalid, + DEFAULT_CUSTOM_COLOR, + DEFAULT_NEXT_COLOR, +} from './color_stops_utils'; +import { i18n } from '@kbn/i18n'; +import { ColorStops } from './color_stops'; +import { getOtherCategoryLabel } from '../../style_util'; + +export const ColorStopsCategorical = ({ + colorStops = [ + { stop: null, color: DEFAULT_CUSTOM_COLOR }, //first stop is the "other" color + { stop: '', color: DEFAULT_NEXT_COLOR }, + ], + onChange, +}) => { + const sanitizeStopInput = value => { + return value; + }; + + const getStopError = (stop, index) => { + let count = 0; + for (let i = 1; i < colorStops.length; i++) { + if (colorStops[i].stop === stop && i !== index) { + count++; + } + } + + return count + ? i18n.translate('xpack.maps.styles.colorStops.categoricalStop.noDupesWarningLabel', { + defaultMessage: 'Stop values must be unique', + }) + : null; + }; + + const renderStopInput = (stop, onStopChange, index) => { + const stopValue = typeof stop === 'string' ? stop : ''; + if (index === 0) { + return ( + + ); + } else { + return ( + + ); + } + }; + + const canDeleteStop = (colorStops, index) => { + return colorStops.length > 2 && index !== 0; + }; + + return ( + + ); +}; + +ColorStopsCategorical.propTypes = { + /** + * Array of { stop, color }. + * Stops are any strings + * Stops cannot include duplicates + * Colors are color hex strings (3 or 6 character). + */ + colorStops: PropTypes.arrayOf( + PropTypes.shape({ + stopKey: PropTypes.number, + color: PropTypes.string, + }) + ), + /** + * Callback for when the color stops changes. Called with { colorStops, isInvalid } + */ + onChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_ordinal.js b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_ordinal.js new file mode 100644 index 0000000000000..61fbb376ad601 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_ordinal.js @@ -0,0 +1,94 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; + +import { ColorStops } from './color_stops'; +import { EuiFieldNumber } from '@elastic/eui'; +import { + addOrdinalRow, + isOrdinalStopInvalid, + isOrdinalStopsInvalid, + DEFAULT_CUSTOM_COLOR, +} from './color_stops_utils'; +import { i18n } from '@kbn/i18n'; + +export const ColorStopsOrdinal = ({ + colorStops = [{ stop: 0, color: DEFAULT_CUSTOM_COLOR }], + onChange, +}) => { + const sanitizeStopInput = value => { + const sanitizedValue = parseFloat(value); + return isNaN(sanitizedValue) ? '' : sanitizedValue; + }; + + const getStopError = (stop, index) => { + let error; + if (isOrdinalStopInvalid(stop)) { + error = i18n.translate('xpack.maps.styles.colorStops.ordinalStop.numberWarningLabel', { + defaultMessage: 'Stop must be a number', + }); + } else if (index !== 0 && colorStops[index - 1].stop >= stop) { + error = i18n.translate( + 'xpack.maps.styles.colorStops.ordinalStop.numberOrderingWarningLabel', + { + defaultMessage: 'Stop must be greater than previous stop value', + } + ); + } + return error; + }; + + const renderStopInput = (stop, onStopChange) => { + return ( + + ); + }; + + const canDeleteStop = colorStops => { + return colorStops.length > 1; + }; + + return ( + + ); +}; + +ColorStopsOrdinal.propTypes = { + /** + * Array of { stop, color }. + * Stops are numbers in strictly ascending order. + * The range is from the given stop number (inclusive) to the next stop number (exclusive). + * Colors are color hex strings (3 or 6 character). + */ + colorStops: PropTypes.arrayOf( + PropTypes.shape({ + stopKey: PropTypes.number, + color: PropTypes.string, + }) + ), + /** + * Callback for when the color stops changes. Called with { colorStops, isInvalid } + */ + onChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_utils.js b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_utils.js new file mode 100644 index 0000000000000..3eaa6acf435dc --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/color/color_stops_utils.js @@ -0,0 +1,86 @@ +/* + * 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 { isValidHex } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; + +export const DEFAULT_CUSTOM_COLOR = '#FF0000'; +export const DEFAULT_NEXT_COLOR = '#00FF00'; + +export function removeRow(colorStops, index) { + if (colorStops.length === 1) { + return colorStops; + } + + return [...colorStops.slice(0, index), ...colorStops.slice(index + 1)]; +} + +export function addOrdinalRow(colorStops, index) { + const currentStop = colorStops[index].stop; + let delta = 1; + if (index === colorStops.length - 1) { + // Adding row to end of list. + if (index !== 0) { + const prevStop = colorStops[index - 1].stop; + delta = currentStop - prevStop; + } + } else { + // Adding row in middle of list. + const nextStop = colorStops[index + 1].stop; + delta = (nextStop - currentStop) / 2; + } + const nextValue = currentStop + delta; + return addRow(colorStops, index, nextValue); +} + +export function addCategoricalRow(colorStops, index) { + const currentStop = colorStops[index].stop; + const nextValue = currentStop === '' ? currentStop + 'a' : ''; + return addRow(colorStops, index, nextValue); +} + +function addRow(colorStops, index, nextValue) { + const newRow = { + stop: nextValue, + color: DEFAULT_CUSTOM_COLOR, + }; + return [...colorStops.slice(0, index + 1), newRow, ...colorStops.slice(index + 1)]; +} + +export function isColorInvalid(color) { + return !isValidHex(color) || color === ''; +} + +export function isOrdinalStopInvalid(stop) { + return stop === '' || isNaN(stop); +} + +export function isCategoricalStopsInvalid(colorStops) { + const nonDefaults = colorStops.slice(1); // + const values = nonDefaults.map(stop => stop.stop); + const uniques = _.uniq(values); + return values.length !== uniques.length; +} + +export function isOrdinalStopsInvalid(colorStops) { + return colorStops.some((colorStop, index) => { + // expect stops to be in ascending order + let isDescending = false; + if (index !== 0) { + const prevStop = colorStops[index - 1].stop; + isDescending = prevStop >= colorStop.stop; + } + + return isColorInvalid(colorStop.color) || isOrdinalStopInvalid(colorStop.stop) || isDescending; + }); +} + +export function getOtherCategoryLabel() { + return i18n.translate('xpack.maps.styles.categorical.otherCategoryLabel', { + defaultMessage: 'Other', + }); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js new file mode 100644 index 0000000000000..5491d5d567f84 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js @@ -0,0 +1,108 @@ +/* + * 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 _ from 'lodash'; +import React, { Fragment } from 'react'; +import { FieldSelect } from '../field_select'; +import { ColorMapSelect } from './color_map_select'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { CATEGORICAL_DATA_TYPES, COLOR_MAP_TYPE } from '../../../../../../common/constants'; +import { COLOR_GRADIENTS, COLOR_PALETTES } from '../../../color_utils'; +import { i18n } from '@kbn/i18n'; + +export function DynamicColorForm({ + fields, + onDynamicStyleChange, + staticDynamicSelect, + styleProperty, +}) { + const styleOptions = styleProperty.getOptions(); + + const onColorMapSelect = ({ color, customColorMap, type, useCustomColorMap }) => { + const newColorOptions = { + ...styleOptions, + type, + }; + if (type === COLOR_MAP_TYPE.ORDINAL) { + newColorOptions.useCustomColorRamp = useCustomColorMap; + newColorOptions.customColorRamp = customColorMap; + newColorOptions.color = color; + } else { + newColorOptions.useCustomColorPalette = useCustomColorMap; + newColorOptions.customColorPalette = customColorMap; + newColorOptions.colorCategory = color; + } + + onDynamicStyleChange(styleProperty.getStyleName(), newColorOptions); + }; + + const onFieldChange = async ({ field }) => { + const { name, origin, type } = field; + onDynamicStyleChange(styleProperty.getStyleName(), { + ...styleOptions, + field: { name, origin }, + type: CATEGORICAL_DATA_TYPES.includes(type) + ? COLOR_MAP_TYPE.CATEGORICAL + : COLOR_MAP_TYPE.ORDINAL, + }); + }; + + const renderColorMapSelect = () => { + if (!styleOptions.field || !styleOptions.field.name) { + return null; + } + + if (styleProperty.isOrdinal()) { + return ( + + ); + } + + return ( + + ); + }; + + return ( + + + {staticDynamicSelect} + + + + + + {renderColorMapSelect()} + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/color/static_color_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/color/static_color_form.js new file mode 100644 index 0000000000000..48befa1ca74c0 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/color/static_color_form.js @@ -0,0 +1,33 @@ +/* + * 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 from 'react'; +import { EuiColorPicker, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function StaticColorForm({ + onStaticStyleChange, + staticDynamicSelect, + styleProperty, + swatches, +}) { + const onColorChange = color => { + onStaticStyleChange(styleProperty.getStyleName(), { color }); + }; + + return ( + + {staticDynamicSelect} + + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/color/vector_style_color_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/color/vector_style_color_editor.js new file mode 100644 index 0000000000000..43e7050b3d1d2 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/color/vector_style_color_editor.js @@ -0,0 +1,34 @@ +/* + * 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 from 'react'; + +import { StylePropEditor } from '../style_prop_editor'; +import { DynamicColorForm } from './dynamic_color_form'; +import { StaticColorForm } from './static_color_form'; +import { i18n } from '@kbn/i18n'; + +export function VectorStyleColorEditor(props) { + const colorForm = props.styleProperty.isDynamic() ? ( + + ) : ( + + ); + + return ( + + {colorForm} + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/field_select.js b/x-pack/plugins/maps/public/layers/styles/vector/components/field_select.js new file mode 100644 index 0000000000000..056b62bfee8d4 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/field_select.js @@ -0,0 +1,113 @@ +/* + * 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 PropTypes from 'prop-types'; +import React from 'react'; + +import { EuiComboBox, EuiHighlight } from '@elastic/eui'; +import { FIELD_ORIGIN } from '../../../../../common/constants'; +import { i18n } from '@kbn/i18n'; +import { FieldIcon } from '../../../../../../../../src/plugins/kibana_react/public'; + +function renderOption(option, searchValue, contentClassName) { + return ( + + +   + {option.label} + + ); +} + +function groupFieldsByOrigin(fields) { + const fieldsByOriginMap = new Map(); + fields.forEach(field => { + if (fieldsByOriginMap.has(field.origin)) { + const fieldsList = fieldsByOriginMap.get(field.origin); + fieldsList.push(field); + fieldsByOriginMap.set(field.origin, fieldsList); + } else { + fieldsByOriginMap.set(field.origin, [field]); + } + }); + + function fieldsListToOptions(fieldsList) { + return fieldsList + .map(field => { + return { value: field, label: field.label }; + }) + .sort((a, b) => { + return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); + }); + } + + if (fieldsByOriginMap.size === 1) { + // do not show origin group if all fields are from same origin + const onlyOriginKey = fieldsByOriginMap.keys().next().value; + const fieldsList = fieldsByOriginMap.get(onlyOriginKey); + return fieldsListToOptions(fieldsList); + } + + const optionGroups = []; + fieldsByOriginMap.forEach((fieldsList, fieldOrigin) => { + optionGroups.push({ + label: i18n.translate('xpack.maps.style.fieldSelect.OriginLabel', { + defaultMessage: 'Fields from {fieldOrigin}', + values: { fieldOrigin }, + }), + options: fieldsListToOptions(fieldsList), + }); + }); + + optionGroups.sort((a, b) => { + return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); + }); + + return optionGroups; +} + +export function FieldSelect({ fields, selectedFieldName, onChange, ...rest }) { + const onFieldChange = selectedFields => { + onChange({ + field: selectedFields.length > 0 ? selectedFields[0].value : null, + }); + }; + + let selectedOption; + if (selectedFieldName) { + selectedOption = fields.find(field => { + return field.name === selectedFieldName; + }); + } + + return ( + + ); +} + +export const fieldShape = PropTypes.shape({ + name: PropTypes.string.isRequired, + origin: PropTypes.oneOf(Object.values(FIELD_ORIGIN)).isRequired, + type: PropTypes.string.isRequired, +}); + +FieldSelect.propTypes = { + selectedFieldName: PropTypes.string, + fields: PropTypes.arrayOf(fieldShape).isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js b/x-pack/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js new file mode 100644 index 0000000000000..35e6fa60b28e7 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js @@ -0,0 +1,67 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +import { VECTOR_STYLES } from '../vector_style_defaults'; + +export function getDisabledByMessage(styleName) { + return i18n.translate('xpack.maps.styles.vector.disabledByMessage', { + defaultMessage: `Set '{styleLabel}' to enable`, + values: { styleLabel: getVectorStyleLabel(styleName) }, + }); +} + +export function getVectorStyleLabel(styleName) { + switch (styleName) { + case VECTOR_STYLES.FILL_COLOR: + return i18n.translate('xpack.maps.styles.vector.fillColorLabel', { + defaultMessage: 'Fill color', + }); + case VECTOR_STYLES.LINE_COLOR: + return i18n.translate('xpack.maps.styles.vector.borderColorLabel', { + defaultMessage: 'Border color', + }); + case VECTOR_STYLES.LINE_WIDTH: + return i18n.translate('xpack.maps.styles.vector.borderWidthLabel', { + defaultMessage: 'Border width', + }); + case VECTOR_STYLES.ICON: + return i18n.translate('xpack.maps.styles.vector.iconLabel', { + defaultMessage: 'Icon', + }); + case VECTOR_STYLES.ICON_SIZE: + return i18n.translate('xpack.maps.styles.vector.symbolSizeLabel', { + defaultMessage: 'Symbol size', + }); + case VECTOR_STYLES.ICON_ORIENTATION: + return i18n.translate('xpack.maps.styles.vector.orientationLabel', { + defaultMessage: 'Symbol orientation', + }); + case VECTOR_STYLES.LABEL_TEXT: + return i18n.translate('xpack.maps.styles.vector.labelLabel', { + defaultMessage: 'Label', + }); + case VECTOR_STYLES.LABEL_COLOR: + return i18n.translate('xpack.maps.styles.vector.labelColorLabel', { + defaultMessage: 'Label color', + }); + case VECTOR_STYLES.LABEL_SIZE: + return i18n.translate('xpack.maps.styles.vector.labelSizeLabel', { + defaultMessage: 'Label size', + }); + case VECTOR_STYLES.LABEL_BORDER_COLOR: + return i18n.translate('xpack.maps.styles.vector.labelBorderColorLabel', { + defaultMessage: 'Label border color', + }); + case VECTOR_STYLES.LABEL_BORDER_SIZE: + return i18n.translate('xpack.maps.styles.vector.labelBorderWidthLabel', { + defaultMessage: 'Label border width', + }); + default: + return styleName; + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_form.js new file mode 100644 index 0000000000000..7ad541efe0ab7 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_form.js @@ -0,0 +1,36 @@ +/* + * 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 from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FieldSelect } from '../field_select'; + +export function DynamicLabelForm({ + fields, + onDynamicStyleChange, + staticDynamicSelect, + styleProperty, +}) { + const styleOptions = styleProperty.getOptions(); + + const onFieldChange = ({ field }) => { + onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field }); + }; + + return ( + + {staticDynamicSelect} + + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/label/static_label_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/label/static_label_form.js new file mode 100644 index 0000000000000..721487b5d8ff0 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/label/static_label_form.js @@ -0,0 +1,34 @@ +/* + * 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 from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFieldText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function StaticLabelForm({ onStaticStyleChange, staticDynamicSelect, styleProperty }) { + const onValueChange = event => { + onStaticStyleChange(styleProperty.getStyleName(), { value: event.target.value }); + }; + + return ( + + {staticDynamicSelect} + + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js new file mode 100644 index 0000000000000..04bb800eb1ecf --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js @@ -0,0 +1,84 @@ +/* + * 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 from 'react'; + +import { EuiFormRow, EuiSelect, EuiToolTip } from '@elastic/eui'; +import { LABEL_BORDER_SIZES, VECTOR_STYLES } from '../../vector_style_defaults'; +import { getVectorStyleLabel, getDisabledByMessage } from '../get_vector_style_label'; +import { i18n } from '@kbn/i18n'; + +const options = [ + { + value: LABEL_BORDER_SIZES.NONE, + text: i18n.translate('xpack.maps.styles.labelBorderSize.noneLabel', { + defaultMessage: 'None', + }), + }, + { + value: LABEL_BORDER_SIZES.SMALL, + text: i18n.translate('xpack.maps.styles.labelBorderSize.smallLabel', { + defaultMessage: 'Small', + }), + }, + { + value: LABEL_BORDER_SIZES.MEDIUM, + text: i18n.translate('xpack.maps.styles.labelBorderSize.mediumLabel', { + defaultMessage: 'Medium', + }), + }, + { + value: LABEL_BORDER_SIZES.LARGE, + text: i18n.translate('xpack.maps.styles.labelBorderSize.largeLabel', { + defaultMessage: 'Large', + }), + }, +]; + +export function VectorStyleLabelBorderSizeEditor({ + disabled, + disabledBy, + handlePropertyChange, + styleProperty, +}) { + function onChange(e) { + const styleDescriptor = { + options: { size: e.target.value }, + }; + handlePropertyChange(styleProperty.getStyleName(), styleDescriptor); + } + + const labelBorderSizeForm = ( + + + + ); + + if (!disabled) { + return labelBorderSizeForm; + } + + return ( + + {labelBorderSizeForm} + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_editor.js new file mode 100644 index 0000000000000..aaa21ea315f36 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_editor.js @@ -0,0 +1,21 @@ +/* + * 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 from 'react'; + +import { StylePropEditor } from '../style_prop_editor'; +import { DynamicLabelForm } from './dynamic_label_form'; +import { StaticLabelForm } from './static_label_form'; + +export function VectorStyleLabelEditor(props) { + const labelForm = props.styleProperty.isDynamic() ? ( + + ) : ( + + ); + + return {labelForm}; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap new file mode 100644 index 0000000000000..5837a80ec3083 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Renders CircleIcon 1`] = ` + +`; + +exports[`Renders LineIcon 1`] = ` + +`; + +exports[`Renders PolygonIcon 1`] = ` + +`; + +exports[`Renders SymbolIcon 1`] = ` + +`; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/category.js b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/category.js new file mode 100644 index 0000000000000..cf65a807ae83e --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/category.js @@ -0,0 +1,43 @@ +/* + * 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 from 'react'; +import { VECTOR_STYLES } from '../../vector_style_defaults'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { VectorIcon } from './vector_icon'; + +export function Category({ styleName, label, color, isLinesOnly, isPointsOnly, symbolId }) { + function renderIcon() { + if (styleName === VECTOR_STYLES.LABEL_COLOR) { + return ( + + Tx + + ); + } + + return ( + + ); + } + + return ( + + + + {label} + + {renderIcon()} + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/circle_icon.js b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/circle_icon.js new file mode 100644 index 0000000000000..5efba64360f23 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/circle_icon.js @@ -0,0 +1,44 @@ +/* + * 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 from 'react'; + +export const CircleIcon = ({ style }) => ( + + + + + + + + +); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/extract_color_from_style_property.js b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/extract_color_from_style_property.js new file mode 100644 index 0000000000000..2c41fb20bd4c0 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/extract_color_from_style_property.js @@ -0,0 +1,58 @@ +/* + * 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 { VectorStyle } from '../../vector_style'; +import { getColorRampCenterColor, getColorPalette } from '../../../color_utils'; +import { COLOR_MAP_TYPE } from '../../../../../../common/constants'; + +export function extractColorFromStyleProperty(colorStyleProperty, defaultColor) { + if (!colorStyleProperty) { + return defaultColor; + } + + if (colorStyleProperty.type === VectorStyle.STYLE_TYPE.STATIC) { + return colorStyleProperty.options.color; + } + + // Do not use dynamic color unless configuration is complete + if (!colorStyleProperty.options.field || !colorStyleProperty.options.field.name) { + return defaultColor; + } + + if (colorStyleProperty.options.type === COLOR_MAP_TYPE.CATEGORICAL) { + if (colorStyleProperty.options.useCustomColorPalette) { + return colorStyleProperty.options.customColorPalette && + colorStyleProperty.options.customColorPalette.length + ? colorStyleProperty.options.customColorPalette[0].colorCategory + : defaultColor; + } + + if (!colorStyleProperty.options.colorCategory) { + return null; + } + + const palette = getColorPalette(colorStyleProperty.options.colorCategory); + return palette[0]; + } else { + // return middle of gradient for dynamic style property + if (colorStyleProperty.options.useCustomColorRamp) { + if ( + !colorStyleProperty.options.customColorRamp || + !colorStyleProperty.options.customColorRamp.length + ) { + return defaultColor; + } + // favor the lowest color in even arrays + const middleIndex = Math.floor((colorStyleProperty.options.customColorRamp.length - 1) / 2); + return colorStyleProperty.options.customColorRamp[middleIndex].color; + } + + if (!colorStyleProperty.options.color) { + return null; + } + return getColorRampCenterColor(colorStyleProperty.options.color); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/line_icon.js b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/line_icon.js new file mode 100644 index 0000000000000..0f5b6e4b470bf --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/line_icon.js @@ -0,0 +1,13 @@ +/* + * 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 from 'react'; + +export const LineIcon = ({ style }) => ( + + + +); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/polygon_icon.js b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/polygon_icon.js new file mode 100644 index 0000000000000..4210b59f0d676 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/polygon_icon.js @@ -0,0 +1,13 @@ +/* + * 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 from 'react'; + +export const PolygonIcon = ({ style }) => ( + + + +); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/symbol_icon.js b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/symbol_icon.js new file mode 100644 index 0000000000000..301d64e676703 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/symbol_icon.js @@ -0,0 +1,104 @@ +/* + * 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, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { getMakiSymbolSvg, styleSvg, buildSrcUrl } from '../../symbol_utils'; + +export class SymbolIcon extends Component { + state = { + imgDataUrl: undefined, + prevSymbolId: undefined, + prevFill: undefined, + prevStroke: undefined, + prevStrokeWidth: undefined, + }; + + componentDidMount() { + this._isMounted = true; + this._loadSymbol( + this.props.symbolId, + this.props.fill, + this.props.stroke, + this.props.strokeWidth + ); + } + + componentDidUpdate() { + this._loadSymbol( + this.props.symbolId, + this.props.fill, + this.props.stroke, + this.props.strokeWidth + ); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadSymbol(nextSymbolId, nextFill, nextStroke, nextStrokeWidth) { + if ( + nextSymbolId === this.state.prevSymbolId && + nextFill === this.state.prevFill && + nextStroke === this.state.prevStroke && + nextStrokeWidth === this.state.prevStrokeWidth + ) { + return; + } + + let imgDataUrl; + try { + const svg = getMakiSymbolSvg(nextSymbolId); + const styledSvg = await styleSvg(svg, nextFill, nextStroke, nextStrokeWidth); + imgDataUrl = buildSrcUrl(styledSvg); + } catch (error) { + // ignore failures - component will just not display an icon + } + + if (this._isMounted) { + this.setState({ + imgDataUrl, + prevSymbolId: nextSymbolId, + prevFill: nextFill, + prevStroke: nextStroke, + prevStrokeWidth: nextStrokeWidth, + }); + } + } + + render() { + if (!this.state.imgDataUrl) { + return null; + } + + const { + symbolId, // eslint-disable-line no-unused-vars + fill, // eslint-disable-line no-unused-vars + stroke, // eslint-disable-line no-unused-vars + strokeWidth, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + + return ( + {this.props.symbolId} + ); + } +} + +SymbolIcon.propTypes = { + symbolId: PropTypes.string.isRequired, + fill: PropTypes.string.isRequired, + stroke: PropTypes.string.isRequired, + strokeWidth: PropTypes.string.isRequired, +}; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.js b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.js new file mode 100644 index 0000000000000..29429b5b29aff --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.js @@ -0,0 +1,54 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; + +import { CircleIcon } from './circle_icon'; +import { LineIcon } from './line_icon'; +import { PolygonIcon } from './polygon_icon'; +import { SymbolIcon } from './symbol_icon'; + +export function VectorIcon({ fillColor, isPointsOnly, isLinesOnly, strokeColor, symbolId }) { + if (isLinesOnly) { + const style = { + stroke: strokeColor, + strokeWidth: '4px', + }; + return ; + } + + const style = { + stroke: strokeColor, + strokeWidth: '1px', + fill: fillColor, + }; + + if (!isPointsOnly) { + return ; + } + + if (!symbolId) { + return ; + } + + return ( + + ); +} + +VectorIcon.propTypes = { + fillColor: PropTypes.string, + isPointsOnly: PropTypes.bool.isRequired, + isLinesOnly: PropTypes.bool.isRequired, + strokeColor: PropTypes.string.isRequired, + symbolId: PropTypes.string, +}; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js new file mode 100644 index 0000000000000..9d1a4d75beba2 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js @@ -0,0 +1,58 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; + +import { VectorIcon } from './vector_icon'; + +test('Renders PolygonIcon', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); + +test('Renders LineIcon', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); + +test('Renders CircleIcon', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); + +test('Renders SymbolIcon', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js new file mode 100644 index 0000000000000..a7e98c83468ae --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js @@ -0,0 +1,21 @@ +/* + * 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'; + +export function VectorStyleLegend({ isLinesOnly, isPointsOnly, styles, symbolId }) { + return styles.map(style => { + return ( + + {style.renderLegendDetailRow({ + isLinesOnly, + isPointsOnly, + symbolId, + })} + + ); + }); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/ordinal_field_meta_options_popover.js b/x-pack/plugins/maps/public/layers/styles/vector/components/ordinal_field_meta_options_popover.js new file mode 100644 index 0000000000000..dee333f163960 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/ordinal_field_meta_options_popover.js @@ -0,0 +1,130 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { EuiButtonIcon, EuiFormRow, EuiPopover, EuiRange, EuiSwitch } from '@elastic/eui'; +import { VECTOR_STYLES } from '../vector_style_defaults'; +import { i18n } from '@kbn/i18n'; + +function getIsEnableToggleLabel(styleName) { + switch (styleName) { + case VECTOR_STYLES.FILL_COLOR: + case VECTOR_STYLES.LINE_COLOR: + return i18n.translate('xpack.maps.styles.fieldMetaOptions.isEnabled.colorLabel', { + defaultMessage: 'Calculate color ramp range from indices', + }); + case VECTOR_STYLES.LINE_WIDTH: + return i18n.translate('xpack.maps.styles.fieldMetaOptions.isEnabled.widthLabel', { + defaultMessage: 'Calculate border width range from indices', + }); + case VECTOR_STYLES.ICON_SIZE: + return i18n.translate('xpack.maps.styles.fieldMetaOptions.isEnabled.sizeLabel', { + defaultMessage: 'Calculate symbol size range from indices', + }); + default: + return i18n.translate('xpack.maps.styles.fieldMetaOptions.isEnabled.defaultLabel', { + defaultMessage: 'Calculate symbolization range from indices', + }); + } +} + +export class OrdinalFieldMetaOptionsPopover extends Component { + state = { + isPopoverOpen: false, + }; + + _togglePopover = () => { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + }; + + _closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + _onIsEnabledChange = event => { + this.props.onChange({ + ...this.props.styleProperty.getFieldMetaOptions(), + isEnabled: event.target.checked, + }); + }; + + _onSigmaChange = event => { + this.props.onChange({ + ...this.props.styleProperty.getFieldMetaOptions(), + sigma: event.target.value, + }); + }; + + _renderButton() { + return ( + + ); + } + + _renderContent() { + return ( + + + + + + + + + + ); + } + + render() { + if (!this.props.styleProperty.supportsFieldMeta()) { + return null; + } + + return ( + + {this._renderContent()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_form.js new file mode 100644 index 0000000000000..df0ae6513f8ed --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_form.js @@ -0,0 +1,39 @@ +/* + * 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 from 'react'; +import { FieldSelect } from '../field_select'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function DynamicOrientationForm({ + fields, + onDynamicStyleChange, + staticDynamicSelect, + styleProperty, +}) { + const styleOptions = styleProperty.getOptions(); + + const onFieldChange = ({ field }) => { + onDynamicStyleChange(styleProperty.getStyleName(), { + ...styleOptions, + field, + }); + }; + + return ( + + {staticDynamicSelect} + + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/orientation/orientation_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/orientation/orientation_editor.js new file mode 100644 index 0000000000000..915fc92c9fb38 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/orientation/orientation_editor.js @@ -0,0 +1,21 @@ +/* + * 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 from 'react'; + +import { StylePropEditor } from '../style_prop_editor'; +import { DynamicOrientationForm } from './dynamic_orientation_form'; +import { StaticOrientationForm } from './static_orientation_form'; + +export function OrientationEditor(props) { + const orientationForm = props.styleProperty.isDynamic() ? ( + + ) : ( + + ); + + return {orientationForm}; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_form.js new file mode 100644 index 0000000000000..8c4418f95e1d2 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_form.js @@ -0,0 +1,33 @@ +/* + * 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 from 'react'; +import { ValidatedRange } from '../../../../../components/validated_range'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function StaticOrientationForm({ onStaticStyleChange, staticDynamicSelect, styleProperty }) { + const onOrientationChange = orientation => { + onStaticStyleChange(styleProperty.getStyleName(), { orientation }); + }; + + return ( + + {staticDynamicSelect} + + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_form.js new file mode 100644 index 0000000000000..141a941017790 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_form.js @@ -0,0 +1,62 @@ +/* + * 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 { FieldSelect } from '../field_select'; +import { SizeRangeSelector } from './size_range_selector'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; + +export function DynamicSizeForm({ + fields, + onDynamicStyleChange, + staticDynamicSelect, + styleProperty, +}) { + const styleOptions = styleProperty.getOptions(); + + const onFieldChange = ({ field }) => { + onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field }); + }; + + const onSizeRangeChange = ({ minSize, maxSize }) => { + onDynamicStyleChange(styleProperty.getStyleName(), { + ...styleOptions, + minSize, + maxSize, + }); + }; + + let sizeRange; + if (styleOptions.field && styleOptions.field.name) { + sizeRange = ( + + ); + } + + return ( + + + {staticDynamicSelect} + + + + + + {sizeRange} + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/size/size_range_selector.js b/x-pack/plugins/maps/public/layers/styles/vector/components/size/size_range_selector.js new file mode 100644 index 0000000000000..1d5815a84920c --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/size/size_range_selector.js @@ -0,0 +1,44 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { ValidatedDualRange } from 'ui/validated_range'; +import { MIN_SIZE, MAX_SIZE } from '../../vector_style_defaults'; +import { i18n } from '@kbn/i18n'; + +export function SizeRangeSelector({ minSize, maxSize, onChange, ...rest }) { + const onSizeChange = ([min, max]) => { + onChange({ + minSize: Math.max(MIN_SIZE, parseInt(min, 10)), + maxSize: Math.min(MAX_SIZE, parseInt(max, 10)), + }); + }; + + return ( + + ); +} + +SizeRangeSelector.propTypes = { + minSize: PropTypes.number.isRequired, + maxSize: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/size/static_size_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/size/static_size_form.js new file mode 100644 index 0000000000000..d8fe1322db3e3 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/size/static_size_form.js @@ -0,0 +1,37 @@ +/* + * 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 from 'react'; +import { ValidatedRange } from '../../../../../components/validated_range'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function StaticSizeForm({ onStaticStyleChange, staticDynamicSelect, styleProperty }) { + const onSizeChange = size => { + onStaticStyleChange(styleProperty.getStyleName(), { size }); + }; + + return ( + + {staticDynamicSelect} + + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/size/vector_style_size_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/size/vector_style_size_editor.js new file mode 100644 index 0000000000000..e344f72bd429a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/size/vector_style_size_editor.js @@ -0,0 +1,21 @@ +/* + * 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 from 'react'; + +import { StylePropEditor } from '../style_prop_editor'; +import { DynamicSizeForm } from './dynamic_size_form'; +import { StaticSizeForm } from './static_size_form'; + +export function VectorStyleSizeEditor(props) { + const sizeForm = props.styleProperty.isDynamic() ? ( + + ) : ( + + ); + + return {sizeForm}; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/style_map_select.js b/x-pack/plugins/maps/public/layers/styles/vector/components/style_map_select.js new file mode 100644 index 0000000000000..28d5454afa4ba --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/style_map_select.js @@ -0,0 +1,92 @@ +/* + * 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, { Component, Fragment } from 'react'; + +import { EuiSuperSelect, EuiSpacer } from '@elastic/eui'; + +const CUSTOM_MAP = 'CUSTOM_MAP'; + +export class StyleMapSelect extends Component { + state = {}; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.customMapStops === prevState.prevPropsCustomMapStops) { + return null; + } + + return { + prevPropsCustomMapStops: nextProps.customMapStops, // reset tracker to latest value + customMapStops: nextProps.customMapStops, // reset customMapStops to latest value + }; + } + + _onMapSelect = selectedValue => { + const useCustomMap = selectedValue === CUSTOM_MAP; + this.props.onChange({ + selectedMapId: useCustomMap ? null : selectedValue, + useCustomMap, + }); + }; + + _onCustomMapChange = ({ customMapStops, isInvalid }) => { + // Manage invalid custom map in local state + if (isInvalid) { + this.setState({ customMapStops }); + return; + } + + this.props.onChange({ + useCustomMap: true, + customMapStops, + }); + }; + + _renderCustomStopsInput() { + if (!this.props.useCustomMap) { + return null; + } + + return ( + + + {this.props.renderCustomStopsInput(this._onCustomMapChange)} + + ); + } + + render() { + const mapOptionsWithCustom = [ + { + value: CUSTOM_MAP, + inputDisplay: this.props.customOptionLabel, + }, + ...this.props.options, + ]; + + let valueOfSelected; + if (this.props.useCustomMap) { + valueOfSelected = CUSTOM_MAP; + } else { + valueOfSelected = this.props.options.find(option => option.value === this.props.selectedMapId) + ? this.props.selectedMapId + : ''; + } + + return ( + + + {this._renderCustomStopsInput()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/style_option_shapes.js b/x-pack/plugins/maps/public/layers/styles/vector/components/style_option_shapes.js new file mode 100644 index 0000000000000..d2b5178174e12 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/style_option_shapes.js @@ -0,0 +1,37 @@ +/* + * 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 PropTypes from 'prop-types'; +import { fieldShape } from './field_select'; + +export const staticColorShape = PropTypes.shape({ + color: PropTypes.string.isRequired, +}); + +export const dynamicColorShape = PropTypes.shape({ + color: PropTypes.string, + field: fieldShape, + customColorRamp: PropTypes.array, + useCustomColorRamp: PropTypes.bool, +}); + +export const staticOrientationShape = PropTypes.shape({ + orientation: PropTypes.number.isRequired, +}); + +export const dynamicOrientationShape = PropTypes.shape({ + field: fieldShape, +}); + +export const staticSizeShape = PropTypes.shape({ + size: PropTypes.number.isRequired, +}); + +export const dynamicSizeShape = PropTypes.shape({ + minSize: PropTypes.number.isRequired, + maxSize: PropTypes.number.isRequired, + field: fieldShape, +}); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js new file mode 100644 index 0000000000000..f1180a3a56494 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js @@ -0,0 +1,125 @@ +/* + * 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, { Component, Fragment } from 'react'; +import { getVectorStyleLabel, getDisabledByMessage } from './get_vector_style_label'; +import { + EuiFormRow, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiToolTip, +} from '@elastic/eui'; +import { VectorStyle } from '../vector_style'; +import { i18n } from '@kbn/i18n'; + +export class StylePropEditor extends Component { + _prevStaticStyleOptions = this.props.defaultStaticStyleOptions; + _prevDynamicStyleOptions = this.props.defaultDynamicStyleOptions; + + _onTypeToggle = () => { + if (this.props.styleProperty.isDynamic()) { + // preserve current dynmaic style + this._prevDynamicStyleOptions = this.props.styleProperty.getOptions(); + // toggle to static style + this.props.onStaticStyleChange( + this.props.styleProperty.getStyleName(), + this._prevStaticStyleOptions + ); + } else { + // preserve current static style + this._prevStaticStyleOptions = this.props.styleProperty.getOptions(); + // toggle to dynamic style + this.props.onDynamicStyleChange( + this.props.styleProperty.getStyleName(), + this._prevDynamicStyleOptions + ); + } + }; + + _onFieldMetaOptionsChange = fieldMetaOptions => { + const options = { + ...this.props.styleProperty.getOptions(), + fieldMetaOptions, + }; + this.props.onDynamicStyleChange(this.props.styleProperty.getStyleName(), options); + }; + + renderStaticDynamicSelect() { + const options = [ + { + value: VectorStyle.STYLE_TYPE.STATIC, + text: this.props.customStaticOptionLabel + ? this.props.customStaticOptionLabel + : i18n.translate('xpack.maps.styles.staticDynamicSelect.staticLabel', { + defaultMessage: 'Fixed', + }), + }, + { + value: VectorStyle.STYLE_TYPE.DYNAMIC, + text: i18n.translate('xpack.maps.styles.staticDynamicSelect.dynamicLabel', { + defaultMessage: 'By value', + }), + }, + ]; + + return ( + + ); + } + + render() { + const fieldMetaOptionsPopover = this.props.styleProperty.renderFieldMetaPopover( + this._onFieldMetaOptionsChange + ); + + const staticDynamicSelect = this.renderStaticDynamicSelect(); + + const stylePropertyForm = this.props.disabled ? ( + + + {staticDynamicSelect} + + + + + + ) : ( + + {React.cloneElement(this.props.children, { + staticDynamicSelect, + })} + {fieldMetaOptionsPopover} + + ); + + return ( + + {stylePropertyForm} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap new file mode 100644 index 0000000000000..b4b7a3fcf28fa --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render icon select 1`] = ` + + + } + readOnly={true} + value="symbol1" + /> + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="s" +> + + , + "value": "symbol1", + }, + Object { + "label": "symbol2", + "prepend": , + "value": "symbol2", + }, + ] + } + searchable={true} + singleSelection={false} + > + + + + +`; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/_icon_select.scss b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/_icon_select.scss new file mode 100644 index 0000000000000..5e69d97131095 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/_icon_select.scss @@ -0,0 +1,3 @@ +.mapIconSelectSymbol__inputButton { + margin-left: $euiSizeS; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/dynamic_icon_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/dynamic_icon_form.js new file mode 100644 index 0000000000000..9a0d73cef616c --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/dynamic_icon_form.js @@ -0,0 +1,70 @@ +/* + * 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 _ from 'lodash'; +import React, { Fragment } from 'react'; +import { FieldSelect } from '../field_select'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { IconMapSelect } from './icon_map_select'; + +export function DynamicIconForm({ + fields, + isDarkMode, + onDynamicStyleChange, + staticDynamicSelect, + styleProperty, + symbolOptions, +}) { + const styleOptions = styleProperty.getOptions(); + + const onFieldChange = ({ field }) => { + const { name, origin } = field; + onDynamicStyleChange(styleProperty.getStyleName(), { + ...styleOptions, + field: { name, origin }, + }); + }; + + const onIconMapChange = newOptions => { + onDynamicStyleChange(styleProperty.getStyleName(), { + ...styleOptions, + ...newOptions, + }); + }; + + function renderIconMapSelect() { + if (!styleOptions.field || !styleOptions.field.name) { + return null; + } + + return ( + + ); + } + + return ( + + + {staticDynamicSelect} + + + + + + {renderIconMapSelect()} + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_map_select.js b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_map_select.js new file mode 100644 index 0000000000000..a8bb94d1d9ce4 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_map_select.js @@ -0,0 +1,54 @@ +/* + * 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 from 'react'; + +import { StyleMapSelect } from '../style_map_select'; +import { i18n } from '@kbn/i18n'; +import { getIconPaletteOptions } from '../../symbol_utils'; +import { IconStops } from './icon_stops'; + +export function IconMapSelect({ + customIconStops, + iconPaletteId, + isDarkMode, + onChange, + symbolOptions, + useCustomIconMap, +}) { + function onMapSelectChange({ customMapStops, selectedMapId, useCustomMap }) { + onChange({ + customIconStops: customMapStops, + iconPaletteId: selectedMapId, + useCustomIconMap: useCustomMap, + }); + } + + function renderCustomIconStopsInput(onCustomMapChange) { + return ( + + ); + } + + return ( + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_select.js b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_select.js new file mode 100644 index 0000000000000..03cd1ac14a013 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_select.js @@ -0,0 +1,138 @@ +/* + * 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, { Component } from 'react'; +import { + EuiFormControlLayout, + EuiFieldText, + EuiPopover, + EuiPopoverTitle, + EuiFocusTrap, + keyCodes, + EuiSelectable, +} from '@elastic/eui'; +import { SymbolIcon } from '../legend/symbol_icon'; + +function isKeyboardEvent(event) { + return typeof event === 'object' && 'keyCode' in event; +} + +export class IconSelect extends Component { + state = { + isPopoverOpen: false, + }; + + _closePopover = () => { + this.setState({ isPopoverOpen: false }); + }; + + _openPopover = () => { + this.setState({ isPopoverOpen: true }); + }; + + _togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + _handleKeyboardActivity = e => { + if (isKeyboardEvent(e)) { + if (e.keyCode === keyCodes.ENTER) { + e.preventDefault(); + this._togglePopover(); + } else if (e.keyCode === keyCodes.DOWN) { + this._openPopover(); + } + } + }; + + _onIconSelect = options => { + const selectedOption = options.find(option => { + return option.checked === 'on'; + }); + + if (selectedOption) { + this.props.onChange(selectedOption.value); + } + this._closePopover(); + }; + + _renderPopoverButton() { + const { isDarkMode, value } = this.props; + return ( + + + } + /> + + ); + } + + _renderIconSelectable() { + const { isDarkMode } = this.props; + const options = this.props.symbolOptions.map(({ value, label }) => { + return { + value, + label, + prepend: ( + + ), + }; + }); + + return ( + + {(list, search) => ( +
+ {search} + {list} +
+ )} +
+ ); + } + + render() { + return ( + + {this._renderIconSelectable()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_select.test.js b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_select.test.js new file mode 100644 index 0000000000000..56dce6fad8386 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_select.test.js @@ -0,0 +1,28 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; + +import { IconSelect } from './icon_select'; + +const symbolOptions = [ + { value: 'symbol1', label: 'symbol1' }, + { value: 'symbol2', label: 'symbol2' }, +]; + +test('Should render icon select', () => { + const component = shallow( + {}} + symbolOptions={symbolOptions} + isDarkMode={false} + /> + ); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.js b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.js new file mode 100644 index 0000000000000..a655a4434ddaa --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.js @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { DEFAULT_ICON } from '../../../../../../common/constants'; +import { i18n } from '@kbn/i18n'; +import { getOtherCategoryLabel } from '../../style_util'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { IconSelect } from './icon_select'; + +function isDuplicateStop(targetStop, iconStops) { + const stops = iconStops.filter(({ stop }) => { + return targetStop === stop; + }); + return stops.length > 1; +} + +const DEFAULT_ICON_STOPS = [ + { stop: null, icon: DEFAULT_ICON }, //first stop is the "other" color + { stop: '', icon: DEFAULT_ICON }, +]; + +export function IconStops({ iconStops = DEFAULT_ICON_STOPS, isDarkMode, onChange, symbolOptions }) { + return iconStops.map(({ stop, icon }, index) => { + const onIconSelect = selectedIconId => { + const newIconStops = [...iconStops]; + newIconStops[index] = { + ...iconStops[index], + icon: selectedIconId, + }; + onChange({ customMapStops: newIconStops }); + }; + const onStopChange = e => { + const newStopValue = e.target.value; + const newIconStops = [...iconStops]; + newIconStops[index] = { + ...iconStops[index], + stop: newStopValue, + }; + onChange({ + customMapStops: newIconStops, + isInvalid: isDuplicateStop(newStopValue, iconStops), + }); + }; + const onAdd = () => { + onChange({ + customMapStops: [ + ...iconStops.slice(0, index + 1), + { + stop: '', + icon: DEFAULT_ICON, + }, + ...iconStops.slice(index + 1), + ], + }); + }; + const onRemove = () => { + onChange({ + iconStops: [...iconStops.slice(0, index), ...iconStops.slice(index + 1)], + }); + }; + + let deleteButton; + if (index > 0) { + deleteButton = ( + + ); + } + + const errors = []; + // TODO check for duplicate values and add error messages here + + const isOtherCategoryRow = index === 0; + return ( + +
+ + + + + + + + +
+ {deleteButton} + +
+
+
+ ); + }); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/static_icon_form.js b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/static_icon_form.js new file mode 100644 index 0000000000000..b20d8f2eba162 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/static_icon_form.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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { IconSelect } from './icon_select'; + +export function StaticIconForm({ + isDarkMode, + onStaticStyleChange, + staticDynamicSelect, + styleProperty, + symbolOptions, +}) { + const onChange = selectedIconId => { + onStaticStyleChange(styleProperty.getStyleName(), { value: selectedIconId }); + }; + + return ( + + {staticDynamicSelect} + + + + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/vector_style_icon_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/vector_style_icon_editor.js new file mode 100644 index 0000000000000..d5ec09f515954 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/vector_style_icon_editor.js @@ -0,0 +1,31 @@ +/* + * 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 from 'react'; + +import chrome from 'ui/chrome'; +import { StylePropEditor } from '../style_prop_editor'; +import { DynamicIconForm } from './dynamic_icon_form'; +import { StaticIconForm } from './static_icon_form'; +import { SYMBOL_OPTIONS } from '../../symbol_utils'; + +export function VectorStyleIconEditor(props) { + const iconForm = props.styleProperty.isDynamic() ? ( + + ) : ( + + ); + + return {iconForm}; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/vector_style_symbolize_as_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/vector_style_symbolize_as_editor.js new file mode 100644 index 0000000000000..219fee311ba1b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/symbol/vector_style_symbolize_as_editor.js @@ -0,0 +1,81 @@ +/* + * 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 from 'react'; + +import { EuiFormRow, EuiButtonGroup, EuiToolTip } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { SYMBOLIZE_AS_TYPES } from '../../../../../../common/constants'; +import { VECTOR_STYLES } from '../../vector_style_defaults'; +import { getDisabledByMessage } from '../get_vector_style_label'; + +const SYMBOLIZE_AS_OPTIONS = [ + { + id: SYMBOLIZE_AS_TYPES.CIRCLE, + label: i18n.translate('xpack.maps.vector.symbolAs.circleLabel', { + defaultMessage: 'marker', + }), + }, + { + id: SYMBOLIZE_AS_TYPES.ICON, + label: i18n.translate('xpack.maps.vector.symbolAs.IconLabel', { + defaultMessage: 'icon', + }), + }, +]; + +export function VectorStyleSymbolizeAsEditor({ + disabled, + disabledBy, + styleProperty, + handlePropertyChange, +}) { + const styleOptions = styleProperty.getOptions(); + const selectedOption = SYMBOLIZE_AS_OPTIONS.find(({ id }) => { + return id === styleOptions.value; + }); + + const onSymbolizeAsChange = optionId => { + const styleDescriptor = { + options: { + value: optionId, + }, + }; + handlePropertyChange(VECTOR_STYLES.SYMBOLIZE_AS, styleDescriptor); + }; + + const symbolizeAsForm = ( + + + + ); + + if (!disabled) { + return symbolizeAsForm; + } + + return ( + + {symbolizeAsForm} + + ); +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js b/x-pack/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js new file mode 100644 index 0000000000000..441ebfb2d53bf --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js @@ -0,0 +1,514 @@ +/* + * 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 _ from 'lodash'; +import React, { Component, Fragment } from 'react'; + +import { VectorStyleColorEditor } from './color/vector_style_color_editor'; +import { VectorStyleSizeEditor } from './size/vector_style_size_editor'; +import { VectorStyleSymbolizeAsEditor } from './symbol/vector_style_symbolize_as_editor'; +import { VectorStyleIconEditor } from './symbol/vector_style_icon_editor'; +import { VectorStyleLabelEditor } from './label/vector_style_label_editor'; +import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_border_size_editor'; +import { VectorStyle } from '../vector_style'; +import { OrientationEditor } from './orientation/orientation_editor'; +import { + getDefaultDynamicProperties, + getDefaultStaticProperties, + LABEL_BORDER_SIZES, + VECTOR_STYLES, +} from '../vector_style_defaults'; +import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_utils'; +import { VECTOR_SHAPE_TYPES } from '../../../sources/vector_feature_types'; +import { i18n } from '@kbn/i18n'; + +import { EuiSpacer, EuiButtonGroup, EuiFormRow, EuiSwitch } from '@elastic/eui'; + +export class VectorStyleEditor extends Component { + state = { + dateFields: [], + numberFields: [], + categoricalFields: [], + fields: [], + defaultDynamicProperties: getDefaultDynamicProperties(), + defaultStaticProperties: getDefaultStaticProperties(), + supportedFeatures: undefined, + selectedFeatureType: undefined, + }; + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + this._loadFields(); + this._loadSupportedFeatures(); + } + + componentDidUpdate() { + this._loadFields(); + this._loadSupportedFeatures(); + } + + async _loadFields() { + const getFieldMeta = async field => { + return { + label: await field.getLabel(), + name: field.getName(), + origin: field.getOrigin(), + type: await field.getDataType(), + }; + }; + + const dateFields = await this.props.layer.getDateFields(); + const dateFieldPromises = dateFields.map(getFieldMeta); + const dateFieldsArray = await Promise.all(dateFieldPromises); + if (this._isMounted && !_.isEqual(dateFieldsArray, this.state.dateFields)) { + this.setState({ dateFields: dateFieldsArray }); + } + + const numberFields = await this.props.layer.getNumberFields(); + const numberFieldPromises = numberFields.map(getFieldMeta); + const numberFieldsArray = await Promise.all(numberFieldPromises); + if (this._isMounted && !_.isEqual(numberFieldsArray, this.state.numberFields)) { + this.setState({ numberFields: numberFieldsArray }); + } + + const categoricalFields = await this.props.layer.getCategoricalFields(); + const categoricalFieldMeta = categoricalFields.map(getFieldMeta); + const categoricalFieldsArray = await Promise.all(categoricalFieldMeta); + if (this._isMounted && !_.isEqual(categoricalFieldsArray, this.state.categoricalFields)) { + this.setState({ categoricalFields: categoricalFieldsArray }); + } + + const fields = await this.props.layer.getFields(); + const fieldPromises = fields.map(getFieldMeta); + const fieldsArray = await Promise.all(fieldPromises); + if (this._isMounted && !_.isEqual(fieldsArray, this.state.fields)) { + this.setState({ fields: fieldsArray }); + } + } + + async _loadSupportedFeatures() { + const supportedFeatures = await this.props.layer.getSource().getSupportedShapeTypes(); + if (!this._isMounted) { + return; + } + + let selectedFeature = VECTOR_SHAPE_TYPES.POLYGON; + if (this.props.isPointsOnly) { + selectedFeature = VECTOR_SHAPE_TYPES.POINT; + } else if (this.props.isLinesOnly) { + selectedFeature = VECTOR_SHAPE_TYPES.LINE; + } + + if ( + !_.isEqual(supportedFeatures, this.state.supportedFeatures) || + selectedFeature !== this.state.selectedFeature + ) { + this.setState({ supportedFeatures, selectedFeature }); + } + } + + _getOrdinalFields() { + return [...this.state.dateFields, ...this.state.numberFields]; + } + + _getOrdinalAndCategoricalFields() { + return [...this.state.dateFields, ...this.state.numberFields, ...this.state.categoricalFields]; + } + + _handleSelectedFeatureChange = selectedFeature => { + this.setState({ selectedFeature }); + }; + + _onIsTimeAwareChange = event => { + this.props.onIsTimeAwareChange(event.target.checked); + }; + + _onStaticStyleChange = (propertyName, options) => { + const styleDescriptor = { + type: VectorStyle.STYLE_TYPE.STATIC, + options, + }; + this.props.handlePropertyChange(propertyName, styleDescriptor); + }; + + _onDynamicStyleChange = (propertyName, options) => { + const styleDescriptor = { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options, + }; + this.props.handlePropertyChange(propertyName, styleDescriptor); + }; + + _hasBorder() { + const width = this.props.styleProperties[VECTOR_STYLES.LINE_WIDTH]; + return width.isDynamic() ? width.isComplete() : width.getOptions().size !== 0; + } + + _hasMarkerOrIcon() { + const iconSize = this.props.styleProperties[VECTOR_STYLES.ICON_SIZE]; + return !iconSize.isDynamic() && iconSize.getOptions().size > 0; + } + + _hasLabel() { + const label = this.props.styleProperties[VECTOR_STYLES.LABEL_TEXT]; + return label.isDynamic() + ? label.isComplete() + : label.getOptions().value != null && label.getOptions().value.length; + } + + _hasLabelBorder() { + const labelBorderSize = this.props.styleProperties[VECTOR_STYLES.LABEL_BORDER_SIZE]; + return labelBorderSize.getOptions().size !== LABEL_BORDER_SIZES.NONE; + } + + _renderFillColor(isPointFillColor = false) { + return ( + + ); + } + + _renderLineColor(isPointBorderColor = false) { + const disabledByIconSize = isPointBorderColor && !this._hasMarkerOrIcon(); + return ( + + ); + } + + _renderLineWidth(isPointBorderWidth = false) { + return ( + + ); + } + + _renderLabelProperties() { + const hasLabel = this._hasLabel(); + const hasLabelBorder = this._hasLabelBorder(); + return ( + + + + + + + + + + + + + + + + + ); + } + + _renderPointProperties() { + const hasMarkerOrIcon = this._hasMarkerOrIcon(); + let iconOrientationEditor; + let iconEditor; + if (this.props.styleProperties[VECTOR_STYLES.SYMBOLIZE_AS].isSymbolizedAsIcon()) { + iconOrientationEditor = ( + + + + + ); + iconEditor = ( + + + + + ); + } + + return ( + + + + + {iconEditor} + + {this._renderFillColor(true)} + + + {this._renderLineColor(true)} + + + {this._renderLineWidth(true)} + + + {iconOrientationEditor} + + + + + {this._renderLabelProperties()} + + ); + } + + _renderLineProperties() { + return ( + + {this._renderLineColor()} + + + {this._renderLineWidth()} + + ); + } + + _renderPolygonProperties() { + return ( + + {this._renderFillColor()} + + + {this._renderLineColor()} + + + {this._renderLineWidth()} + + ); + } + + _renderProperties() { + const { supportedFeatures, selectedFeature } = this.state; + + if (!supportedFeatures) { + return null; + } + + if (supportedFeatures.length === 1) { + switch (supportedFeatures[0]) { + case VECTOR_SHAPE_TYPES.POINT: + return this._renderPointProperties(); + case VECTOR_SHAPE_TYPES.LINE: + return this._renderLineProperties(); + case VECTOR_SHAPE_TYPES.POLYGON: + return this._renderPolygonProperties(); + } + } + + const featureButtons = [ + { + id: VECTOR_SHAPE_TYPES.POINT, + label: i18n.translate('xpack.maps.vectorStyleEditor.pointLabel', { + defaultMessage: 'Points', + }), + }, + { + id: VECTOR_SHAPE_TYPES.LINE, + label: i18n.translate('xpack.maps.vectorStyleEditor.lineLabel', { + defaultMessage: 'Lines', + }), + }, + { + id: VECTOR_SHAPE_TYPES.POLYGON, + label: i18n.translate('xpack.maps.vectorStyleEditor.polygonLabel', { + defaultMessage: 'Polygons', + }), + }, + ]; + + let styleProperties = this._renderPolygonProperties(); + if (selectedFeature === VECTOR_SHAPE_TYPES.LINE) { + styleProperties = this._renderLineProperties(); + } else if (selectedFeature === VECTOR_SHAPE_TYPES.POINT) { + styleProperties = this._renderPointProperties(); + } + + return ( + + + + + + {styleProperties} + + ); + } + + _renderIsTimeAwareSwitch() { + if (!this.props.showIsTimeAware) { + return null; + } + + return ( + + + + ); + } + + render() { + return ( + + {this._renderProperties()} + {this._renderIsTimeAwareSwitch()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap b/x-pack/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap new file mode 100644 index 0000000000000..ab47e88bb3143 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render categorical legend with breaks from custom 1`] = `""`; + +exports[`Should render categorical legend with breaks from default 1`] = ` +
+ + + + + + Other + + } + styleName="lineColor" + /> + + + + + + + + foobar_label + + + + + + +
+`; + +exports[`Should render ordinal legend 1`] = ` + + } + maxLabel="100_format" + minLabel="0_format" + propertyLabel="Border color" +/> +`; + +exports[`Should render ordinal legend with breaks 1`] = ` +
+ + + + + + + + + + + + foobar_label + + + + + + +
+`; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/components/categorical_legend.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/components/categorical_legend.js new file mode 100644 index 0000000000000..a46492b6034a7 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/components/categorical_legend.js @@ -0,0 +1,48 @@ +/* + * 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 from 'react'; +import _ from 'lodash'; +const EMPTY_VALUE = ''; + +export class CategoricalLegend extends React.Component { + state = { + label: EMPTY_VALUE, + }; + + componentDidMount() { + this._isMounted = true; + this._loadParams(); + } + + componentDidUpdate() { + this._loadParams(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadParams() { + const label = await this.props.style.getField().getLabel(); + const newState = { label }; + if (this._isMounted && !_.isEqual(this.state, newState)) { + this.setState(newState); + } + } + + render() { + if (this.state.label === EMPTY_VALUE) { + return null; + } + return this.props.style.renderBreakedLegend({ + fieldLabel: this.state.label, + isLinesOnly: this.props.isLinesOnly, + isPointsOnly: this.props.isPointsOnly, + symbolId: this.props.symbolId, + }); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/components/ordinal_legend.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/components/ordinal_legend.js new file mode 100644 index 0000000000000..564bae3ef3f72 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/components/ordinal_legend.js @@ -0,0 +1,78 @@ +/* + * 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 from 'react'; +import _ from 'lodash'; +import { RangedStyleLegendRow } from '../../../components/ranged_style_legend_row'; +const EMPTY_VALUE = ''; + +export class OrdinalLegend extends React.Component { + constructor() { + super(); + this._isMounted = false; + this.state = { + label: EMPTY_VALUE, + }; + } + + async _loadParams() { + const label = await this.props.style.getField().getLabel(); + const newState = { label }; + if (this._isMounted && !_.isEqual(this.state, newState)) { + this.setState(newState); + } + } + + _formatValue(value) { + if (value === EMPTY_VALUE) { + return value; + } + return this.props.style.formatField(value); + } + + componentDidUpdate() { + this._loadParams(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + this._loadParams(); + } + render() { + const fieldMeta = this.props.style.getFieldMeta(); + + let minLabel = EMPTY_VALUE; + let maxLabel = EMPTY_VALUE; + if (fieldMeta) { + const range = { min: fieldMeta.min, max: fieldMeta.max }; + const min = this._formatValue(_.get(range, 'min', EMPTY_VALUE)); + minLabel = + this.props.style.isFieldMetaEnabled() && range && range.isMinOutsideStdRange + ? `< ${min}` + : min; + + const max = this._formatValue(_.get(range, 'max', EMPTY_VALUE)); + maxLabel = + this.props.style.isFieldMetaEnabled() && range && range.isMaxOutsideStdRange + ? `> ${max}` + : max; + } + + return ( + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js new file mode 100644 index 0000000000000..70e905907bc79 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -0,0 +1,317 @@ +/* + * 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 { DynamicStyleProperty } from './dynamic_style_property'; +import _ from 'lodash'; +import { getComputedFieldName, getOtherCategoryLabel } from '../style_util'; +import { getOrdinalColorRampStops, getColorPalette } from '../../color_utils'; +import { ColorGradient } from '../../components/color_gradient'; +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiToolTip, + EuiTextColor, +} from '@elastic/eui'; +import { Category } from '../components/legend/category'; +import { COLOR_MAP_TYPE } from '../../../../../common/constants'; +import { isCategoricalStopsInvalid } from '../components/color/color_stops_utils'; + +const EMPTY_STOPS = { stops: [], defaultColor: null }; + +export class DynamicColorProperty extends DynamicStyleProperty { + syncCircleColorWithMb(mbLayerId, mbMap, alpha) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'circle-color', color); + mbMap.setPaintProperty(mbLayerId, 'circle-opacity', alpha); + } + + syncIconColorWithMb(mbLayerId, mbMap) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'icon-color', color); + } + + syncHaloBorderColorWithMb(mbLayerId, mbMap) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'icon-halo-color', color); + } + + syncCircleStrokeWithMb(pointLayerId, mbMap, alpha) { + const color = this._getMbColor(); + mbMap.setPaintProperty(pointLayerId, 'circle-stroke-color', color); + mbMap.setPaintProperty(pointLayerId, 'circle-stroke-opacity', alpha); + } + + syncFillColorWithMb(mbLayerId, mbMap, alpha) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'fill-color', color); + mbMap.setPaintProperty(mbLayerId, 'fill-opacity', alpha); + } + + syncLineColorWithMb(mbLayerId, mbMap, alpha) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'line-color', color); + mbMap.setPaintProperty(mbLayerId, 'line-opacity', alpha); + } + + syncLabelColorWithMb(mbLayerId, mbMap, alpha) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'text-color', color); + mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha); + } + + syncLabelBorderColorWithMb(mbLayerId, mbMap) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'text-halo-color', color); + } + + isOrdinal() { + return ( + typeof this._options.type === 'undefined' || this._options.type === COLOR_MAP_TYPE.ORDINAL + ); + } + + isCategorical() { + return this._options.type === COLOR_MAP_TYPE.CATEGORICAL; + } + + isCustomOrdinalColorRamp() { + return this._options.useCustomColorRamp; + } + + supportsFeatureState() { + return true; + } + + isOrdinalScaled() { + return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); + } + + isOrdinalRanged() { + return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); + } + + hasOrdinalBreaks() { + return (this.isOrdinal() && this.isCustomOrdinalColorRamp()) || this.isCategorical(); + } + + _getMbColor() { + const isDynamicConfigComplete = + _.has(this._options, 'field.name') && _.has(this._options, 'color'); + if (!isDynamicConfigComplete) { + return null; + } + + const targetName = getComputedFieldName(this._styleName, this._options.field.name); + if (this.isCategorical()) { + return this._getMbDataDrivenCategoricalColor({ targetName }); + } else { + return this._getMbDataDrivenOrdinalColor({ targetName }); + } + } + + _getMbDataDrivenOrdinalColor({ targetName }) { + if ( + this._options.useCustomColorRamp && + (!this._options.customColorRamp || !this._options.customColorRamp.length) + ) { + return null; + } + + const colorStops = this._getMbOrdinalColorStops(); + if (!colorStops) { + return null; + } + + if (this._options.useCustomColorRamp) { + const firstStopValue = colorStops[0]; + const lessThenFirstStopValue = firstStopValue - 1; + return [ + 'step', + ['coalesce', ['feature-state', targetName], lessThenFirstStopValue], + 'rgba(0,0,0,0)', // MB will assign the base value to any features that is below the first stop value + ...colorStops, + ]; + } + return [ + 'interpolate', + ['linear'], + ['coalesce', ['feature-state', targetName], -1], + -1, + 'rgba(0,0,0,0)', + ...colorStops, + ]; + } + + _getColorPaletteStops() { + if (this._options.useCustomColorPalette && this._options.customColorPalette) { + if (isCategoricalStopsInvalid(this._options.customColorPalette)) { + return EMPTY_STOPS; + } + + const stops = []; + for (let i = 1; i < this._options.customColorPalette.length; i++) { + const config = this._options.customColorPalette[i]; + stops.push({ + stop: config.stop, + color: config.color, + }); + } + + return { + defaultColor: this._options.customColorPalette[0].color, + stops, + }; + } + + const fieldMeta = this.getFieldMeta(); + if (!fieldMeta || !fieldMeta.categories) { + return EMPTY_STOPS; + } + + const colors = getColorPalette(this._options.colorCategory); + if (!colors) { + return EMPTY_STOPS; + } + + const maxLength = Math.min(colors.length, fieldMeta.categories.length + 1); + const stops = []; + + for (let i = 0; i < maxLength - 1; i++) { + stops.push({ + stop: fieldMeta.categories[i].key, + color: colors[i], + }); + } + return { + stops, + defaultColor: colors[maxLength - 1], + }; + } + + _getMbDataDrivenCategoricalColor() { + if ( + this._options.useCustomColorPalette && + (!this._options.customColorPalette || !this._options.customColorPalette.length) + ) { + return null; + } + + const { stops, defaultColor } = this._getColorPaletteStops(); + if (stops.length < 1) { + //occurs when no data + return null; + } + + if (!defaultColor) { + return null; + } + + const mbStops = []; + for (let i = 0; i < stops.length; i++) { + const stop = stops[i]; + const branch = `${stop.stop}`; + if (typeof branch === 'string') { + mbStops.push(branch); + mbStops.push(stop.color); + } + } + + mbStops.push(defaultColor); //last color is default color + return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + } + + _getMbOrdinalColorStops() { + if (this._options.useCustomColorRamp) { + return this._options.customColorRamp.reduce((accumulatedStops, nextStop) => { + return [...accumulatedStops, nextStop.stop, nextStop.color]; + }, []); + } else { + return getOrdinalColorRampStops(this._options.color); + } + } + + renderRangeLegendHeader() { + if (this._options.color) { + return ; + } else { + return null; + } + } + + _getColorRampStops() { + return this._options.useCustomColorRamp && this._options.customColorRamp + ? this._options.customColorRamp + : []; + } + + _getColorStops() { + if (this.isOrdinal()) { + return { + stops: this._getColorRampStops(), + defaultColor: null, + }; + } else if (this.isCategorical()) { + return this._getColorPaletteStops(); + } else { + return EMPTY_STOPS; + } + } + + renderBreakedLegend({ fieldLabel, isPointsOnly, isLinesOnly, symbolId }) { + const categories = []; + const { stops, defaultColor } = this._getColorStops(); + stops.map(({ stop, color }) => { + categories.push( + + ); + }); + + if (defaultColor) { + categories.push( + {getOtherCategoryLabel()}} + color={defaultColor} + isLinesOnly={isLinesOnly} + isPointsOnly={isPointsOnly} + symbolId={symbolId} + /> + ); + } + + return ( +
+ + + {categories} + + + + + + + {fieldLabel} + + + + + +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js new file mode 100644 index 0000000000000..6b08fc2a105c3 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -0,0 +1,224 @@ +/* + * 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. + */ + +jest.mock('ui/new_platform'); +jest.mock('../components/vector_style_editor', () => ({ + VectorStyleEditor: () => { + return
mockVectorStyleEditor
; + }, +})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { VECTOR_STYLES } from '../vector_style_defaults'; +import { DynamicColorProperty } from './dynamic_color_property'; +import { COLOR_MAP_TYPE } from '../../../../../common/constants'; + +const mockField = { + async getLabel() { + return 'foobar_label'; + }, + getName() { + return 'foobar'; + }, + supportsFieldMeta() { + return true; + }, +}; + +const getOrdinalFieldMeta = () => { + return { min: 0, max: 100 }; +}; + +const getCategoricalFieldMeta = () => { + return { + categories: [ + { + key: 'US', + count: 10, + }, + { + key: 'CN', + count: 8, + }, + ], + }; +}; +const makeProperty = (options, getFieldMeta) => { + return new DynamicColorProperty( + options, + VECTOR_STYLES.LINE_COLOR, + mockField, + getFieldMeta, + () => { + return x => x + '_format'; + } + ); +}; + +const defaultLegendParams = { + isPointsOnly: true, + isLinesOnly: false, +}; + +test('Should render ordinal legend', async () => { + const colorStyle = makeProperty( + { + color: 'Blues', + type: undefined, + }, + getOrdinalFieldMeta + ); + + const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); + + const component = shallow(legendRow); + + expect(component).toMatchSnapshot(); +}); + +test('Should render ordinal legend with breaks', async () => { + const colorStyle = makeProperty( + { + type: COLOR_MAP_TYPE.ORDINAL, + useCustomColorRamp: true, + customColorRamp: [ + { + stop: 0, + color: '#FF0000', + }, + { + stop: 10, + color: '#00FF00', + }, + ], + }, + getOrdinalFieldMeta + ); + + const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); + + const component = shallow(legendRow); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); +}); + +test('Should render categorical legend with breaks from default', async () => { + const colorStyle = makeProperty( + { + type: COLOR_MAP_TYPE.CATEGORICAL, + useCustomColorPalette: false, + colorCategory: 'palette_0', + }, + getCategoricalFieldMeta + ); + + const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); + + const component = shallow(legendRow); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); +}); + +test('Should render categorical legend with breaks from custom', async () => { + const colorStyle = makeProperty( + { + type: COLOR_MAP_TYPE.CATEGORICAL, + useCustomColorPalette: true, + customColorPalette: [ + { + stop: null, //should include the default stop + color: '#FFFF00', + }, + { + stop: 'US_STOP', + color: '#FF0000', + }, + { + stop: 'CN_STOP', + color: '#00FF00', + }, + ], + }, + getCategoricalFieldMeta + ); + + const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); + + const component = shallow(legendRow); + + expect(component).toMatchSnapshot(); +}); + +function makeFeatures(foobarPropValues) { + return foobarPropValues.map(value => { + return { + type: 'Feature', + properties: { + foobar: value, + }, + }; + }); +} + +test('Should pluck the categorical style-meta', async () => { + const colorStyle = makeProperty({ + type: COLOR_MAP_TYPE.CATEGORICAL, + colorCategory: 'palette_0', + getCategoricalFieldMeta, + }); + + const features = makeFeatures(['CN', 'CN', 'US', 'CN', 'US', 'IN']); + const meta = colorStyle.pluckStyleMetaFromFeatures(features); + + expect(meta).toEqual({ + categories: [ + { key: 'CN', count: 3 }, + { key: 'US', count: 2 }, + { key: 'IN', count: 1 }, + ], + }); +}); + +test('Should pluck the categorical style-meta from fieldmeta', async () => { + const colorStyle = makeProperty({ + type: COLOR_MAP_TYPE.CATEGORICAL, + colorCategory: 'palette_0', + getCategoricalFieldMeta, + }); + + const meta = colorStyle.pluckStyleMetaFromFieldMetaData({ + foobar: { + buckets: [ + { + key: 'CN', + doc_count: 3, + }, + { key: 'US', doc_count: 2 }, + { key: 'IN', doc_count: 1 }, + ], + }, + }); + + expect(meta).toEqual({ + categories: [ + { key: 'CN', count: 3 }, + { key: 'US', count: 2 }, + { key: 'IN', count: 1 }, + ], + }); +}); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js new file mode 100644 index 0000000000000..c0e56f962db74 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js @@ -0,0 +1,159 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { getOtherCategoryLabel, assignCategoriesToPalette } from '../style_util'; +import { DynamicStyleProperty } from './dynamic_style_property'; +import { getIconPalette, getMakiIconId, getMakiSymbolAnchor } from '../symbol_utils'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiToolTip, + EuiTextColor, +} from '@elastic/eui'; +import { Category } from '../components/legend/category'; + +export class DynamicIconProperty extends DynamicStyleProperty { + isOrdinal() { + return false; + } + + isCategorical() { + return true; + } + + syncIconWithMb(symbolLayerId, mbMap, iconPixelSize) { + if (this._isIconDynamicConfigComplete()) { + mbMap.setLayoutProperty( + symbolLayerId, + 'icon-image', + this._getMbIconImageExpression(iconPixelSize) + ); + mbMap.setLayoutProperty(symbolLayerId, 'icon-anchor', this._getMbIconAnchorExpression()); + } else { + mbMap.setLayoutProperty(symbolLayerId, 'icon-image', null); + mbMap.setLayoutProperty(symbolLayerId, 'icon-anchor', null); + } + } + + _getPaletteStops() { + if (this._options.useCustomIconMap && this._options.customIconStops) { + const stops = []; + for (let i = 1; i < this._options.customIconStops.length; i++) { + const { stop, icon } = this._options.customIconStops[i]; + stops.push({ + stop, + style: icon, + }); + } + + return { + fallback: + this._options.customIconStops.length > 0 ? this._options.customIconStops[0].icon : null, + stops, + }; + } + + return assignCategoriesToPalette({ + categories: _.get(this.getFieldMeta(), 'categories', []), + paletteValues: getIconPalette(this._options.iconPaletteId), + }); + } + + _getMbIconImageExpression(iconPixelSize) { + const { stops, fallback } = this._getPaletteStops(); + + if (stops.length < 1 || !fallback) { + //occurs when no data + return null; + } + + const mbStops = []; + stops.forEach(({ stop, style }) => { + mbStops.push(`${stop}`); + mbStops.push(getMakiIconId(style, iconPixelSize)); + }); + mbStops.push(getMakiIconId(fallback, iconPixelSize)); //last item is fallback style for anything that does not match provided stops + return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + } + + _getMbIconAnchorExpression() { + const { stops, fallback } = this._getPaletteStops(); + + if (stops.length < 1 || !fallback) { + //occurs when no data + return null; + } + + const mbStops = []; + stops.forEach(({ stop, style }) => { + mbStops.push(`${stop}`); + mbStops.push(getMakiSymbolAnchor(style)); + }); + mbStops.push(getMakiSymbolAnchor(fallback)); //last item is fallback style for anything that does not match provided stops + return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + } + + _isIconDynamicConfigComplete() { + return this._field && this._field.isValid(); + } + + renderBreakedLegend({ fieldLabel, isPointsOnly, isLinesOnly }) { + const categories = []; + const { stops, fallback } = this._getPaletteStops(); + stops.map(({ stop, style }) => { + categories.push( + + ); + }); + + if (fallback) { + categories.push( + {getOtherCategoryLabel()}} + color="grey" + isLinesOnly={isLinesOnly} + isPointsOnly={isPointsOnly} + symbolId={fallback} + /> + ); + } + + return ( +
+ + + {categories} + + + + + + + {fieldLabel} + + + + + +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js new file mode 100644 index 0000000000000..1d2457142c082 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js @@ -0,0 +1,32 @@ +/* + * 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 { DynamicStyleProperty } from './dynamic_style_property'; +import { getComputedFieldName } from '../style_util'; +import { VECTOR_STYLES } from '../vector_style_defaults'; + +export class DynamicOrientationProperty extends DynamicStyleProperty { + syncIconRotationWithMb(symbolLayerId, mbMap) { + if (this._options.field && this._options.field.name) { + const targetName = getComputedFieldName( + VECTOR_STYLES.ICON_ORIENTATION, + this._options.field.name + ); + // Using property state instead of feature-state because layout properties do not support feature-state + mbMap.setLayoutProperty(symbolLayerId, 'icon-rotate', ['coalesce', ['get', targetName], 0]); + } else { + mbMap.setLayoutProperty(symbolLayerId, 'icon-rotate', 0); + } + } + + supportsFeatureState() { + return false; + } + + isOrdinalScaled() { + return false; + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js new file mode 100644 index 0000000000000..dfc5c530cc90f --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js @@ -0,0 +1,180 @@ +/* + * 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 { DynamicStyleProperty } from './dynamic_style_property'; + +import { + HALF_LARGE_MAKI_ICON_SIZE, + LARGE_MAKI_ICON_SIZE, + SMALL_MAKI_ICON_SIZE, +} from '../symbol_utils'; +import { VECTOR_STYLES } from '../vector_style_defaults'; +import _ from 'lodash'; +import { CircleIcon } from '../components/legend/circle_icon'; +import React, { Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; + +function getLineWidthIcons() { + const defaultStyle = { + stroke: 'grey', + fill: 'none', + width: '12px', + }; + return [ + , + , + , + ]; +} + +function getSymbolSizeIcons() { + const defaultStyle = { + stroke: 'grey', + fill: 'grey', + }; + return [ + , + , + , + ]; +} + +export class DynamicSizeProperty extends DynamicStyleProperty { + constructor(options, styleName, field, getFieldMeta, getFieldFormatter, isSymbolizedAsIcon) { + super(options, styleName, field, getFieldMeta, getFieldFormatter); + this._isSymbolizedAsIcon = isSymbolizedAsIcon; + } + + supportsFeatureState() { + // mb style "icon-size" does not support feature state + if (this.getStyleName() === VECTOR_STYLES.ICON_SIZE && this._isSymbolizedAsIcon) { + return false; + } + + // mb style "text-size" does not support feature state + if (this.getStyleName() === VECTOR_STYLES.LABEL_SIZE) { + return false; + } + + return true; + } + + syncHaloWidthWithMb(mbLayerId, mbMap) { + const haloWidth = this.getMbSizeExpression(); + mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', haloWidth); + } + + getIconPixelSize() { + return this._options.maxSize >= HALF_LARGE_MAKI_ICON_SIZE + ? LARGE_MAKI_ICON_SIZE + : SMALL_MAKI_ICON_SIZE; + } + + syncIconSizeWithMb(symbolLayerId, mbMap) { + if (this._isSizeDynamicConfigComplete(this._options)) { + const halfIconPixels = this.getIconPixelSize() / 2; + const targetName = this.getComputedFieldName(); + // Using property state instead of feature-state because layout properties do not support feature-state + mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [ + 'interpolate', + ['linear'], + ['coalesce', ['get', targetName], 0], + 0, + this._options.minSize / halfIconPixels, + 1, + this._options.maxSize / halfIconPixels, + ]); + } else { + mbMap.setLayoutProperty(symbolLayerId, 'icon-size', null); + } + } + + syncCircleStrokeWidthWithMb(mbLayerId, mbMap) { + const lineWidth = this.getMbSizeExpression(); + mbMap.setPaintProperty(mbLayerId, 'circle-stroke-width', lineWidth); + } + + syncCircleRadiusWithMb(mbLayerId, mbMap) { + const circleRadius = this.getMbSizeExpression(); + mbMap.setPaintProperty(mbLayerId, 'circle-radius', circleRadius); + } + + syncLineWidthWithMb(mbLayerId, mbMap) { + const lineWidth = this.getMbSizeExpression(); + mbMap.setPaintProperty(mbLayerId, 'line-width', lineWidth); + } + + syncLabelSizeWithMb(mbLayerId, mbMap) { + const lineWidth = this.getMbSizeExpression(); + mbMap.setLayoutProperty(mbLayerId, 'text-size', lineWidth); + } + + getMbSizeExpression() { + if (this._isSizeDynamicConfigComplete(this._options)) { + return this._getMbDataDrivenSize({ + targetName: this.getComputedFieldName(), + minSize: this._options.minSize, + maxSize: this._options.maxSize, + }); + } + return null; + } + + _getMbDataDrivenSize({ targetName, minSize, maxSize }) { + const lookup = this.supportsFeatureState() ? 'feature-state' : 'get'; + return [ + 'interpolate', + ['linear'], + ['coalesce', [lookup, targetName], 0], + 0, + minSize, + 1, + maxSize, + ]; + } + + _isSizeDynamicConfigComplete() { + return ( + this._field && + this._field.isValid() && + _.has(this._options, 'minSize') && + _.has(this._options, 'maxSize') + ); + } + + renderRangeLegendHeader() { + let icons; + if (this.getStyleName() === VECTOR_STYLES.LINE_WIDTH) { + icons = getLineWidthIcons(); + } else if (this.getStyleName() === VECTOR_STYLES.ICON_SIZE) { + icons = getSymbolSizeIcons(); + } else { + return null; + } + + return ( + + {icons.map((icon, index) => { + const isLast = index === icons.length - 1; + let spacer; + if (!isLast) { + spacer = ( + + + + ); + } + return ( + + {icon} + {spacer} + + ); + })} + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js new file mode 100644 index 0000000000000..37db54389866d --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -0,0 +1,295 @@ +/* + * 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 _ from 'lodash'; +import { AbstractStyleProperty } from './style_property'; +import { DEFAULT_SIGMA } from '../vector_style_defaults'; +import { COLOR_PALETTE_MAX_SIZE, STYLE_TYPE } from '../../../../../common/constants'; +import { scaleValue, getComputedFieldName } from '../style_util'; +import React from 'react'; +import { OrdinalLegend } from './components/ordinal_legend'; +import { CategoricalLegend } from './components/categorical_legend'; +import { OrdinalFieldMetaOptionsPopover } from '../components/ordinal_field_meta_options_popover'; + +export class DynamicStyleProperty extends AbstractStyleProperty { + static type = STYLE_TYPE.DYNAMIC; + + constructor(options, styleName, field, getFieldMeta, getFieldFormatter) { + super(options, styleName); + this._field = field; + this._getFieldMeta = getFieldMeta; + this._getFieldFormatter = getFieldFormatter; + } + + getFieldMeta() { + return this._getFieldMeta && this._field ? this._getFieldMeta(this._field.getName()) : null; + } + + getField() { + return this._field; + } + + getFieldName() { + return this._field ? this._field.getName() : ''; + } + + getComputedFieldName() { + if (!this.isComplete()) { + return null; + } + return getComputedFieldName(this._styleName, this.getField().getName()); + } + + isDynamic() { + return true; + } + + isOrdinal() { + return true; + } + + isCategorical() { + return false; + } + + hasOrdinalBreaks() { + return false; + } + + isOrdinalRanged() { + return true; + } + + isComplete() { + return !!this._field; + } + + getFieldOrigin() { + return this._field.getOrigin(); + } + + isFieldMetaEnabled() { + const fieldMetaOptions = this.getFieldMetaOptions(); + return this.supportsFieldMeta() && _.get(fieldMetaOptions, 'isEnabled', true); + } + + supportsFieldMeta() { + if (this.isOrdinal()) { + return this.isComplete() && this.isOrdinalScaled() && this._field.supportsFieldMeta(); + } else if (this.isCategorical()) { + return this.isComplete() && this._field.supportsFieldMeta(); + } else { + return false; + } + } + + async getFieldMetaRequest() { + if (this.isOrdinal()) { + const fieldMetaOptions = this.getFieldMetaOptions(); + return this._field.getOrdinalFieldMetaRequest({ + sigma: _.get(fieldMetaOptions, 'sigma', DEFAULT_SIGMA), + }); + } else if (this.isCategorical()) { + return this._field.getCategoricalFieldMetaRequest(); + } else { + return null; + } + } + + supportsFeatureState() { + return true; + } + + isOrdinalScaled() { + return true; + } + + getFieldMetaOptions() { + return _.get(this.getOptions(), 'fieldMetaOptions', {}); + } + + _pluckOrdinalStyleMetaFromFeatures(features) { + const name = this.getField().getName(); + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + const newValue = parseFloat(feature.properties[name]); + if (!isNaN(newValue)) { + min = Math.min(min, newValue); + max = Math.max(max, newValue); + } + } + + return min === Infinity || max === -Infinity + ? null + : { + min: min, + max: max, + delta: max - min, + }; + } + + _pluckCategoricalStyleMetaFromFeatures(features) { + const fieldName = this.getField().getName(); + const counts = new Map(); + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + const term = feature.properties[fieldName]; + //properties object may be sparse, so need to check if the field is effectively present + if (typeof term !== undefined) { + if (counts.has(term)) { + counts.set(term, counts.get(term) + 1); + } else { + counts.set(term, 1); + } + } + } + + const ordered = []; + for (const [key, value] of counts) { + ordered.push({ key, count: value }); + } + + ordered.sort((a, b) => { + return b.count - a.count; + }); + const truncated = ordered.slice(0, COLOR_PALETTE_MAX_SIZE); + return { + categories: truncated, + }; + } + + pluckStyleMetaFromFeatures(features) { + if (this.isOrdinal()) { + return this._pluckOrdinalStyleMetaFromFeatures(features); + } else if (this.isCategorical()) { + return this._pluckCategoricalStyleMetaFromFeatures(features); + } else { + return null; + } + } + + _pluckOrdinalStyleMetaFromFieldMetaData(fieldMetaData) { + const realFieldName = this._field.getESDocFieldName + ? this._field.getESDocFieldName() + : this._field.getName(); + const stats = fieldMetaData[realFieldName]; + if (!stats) { + return null; + } + + const sigma = _.get(this.getFieldMetaOptions(), 'sigma', DEFAULT_SIGMA); + const stdLowerBounds = stats.avg - stats.std_deviation * sigma; + const stdUpperBounds = stats.avg + stats.std_deviation * sigma; + const min = Math.max(stats.min, stdLowerBounds); + const max = Math.min(stats.max, stdUpperBounds); + return { + min, + max, + delta: max - min, + isMinOutsideStdRange: stats.min < stdLowerBounds, + isMaxOutsideStdRange: stats.max > stdUpperBounds, + }; + } + + _pluckCategoricalStyleMetaFromFieldMetaData(fieldMetaData) { + const name = this.getField().getName(); + if (!fieldMetaData[name] || !fieldMetaData[name].buckets) { + return null; + } + + const ordered = fieldMetaData[name].buckets.map(bucket => { + return { + key: bucket.key, + count: bucket.doc_count, + }; + }); + return { + categories: ordered, + }; + } + + pluckStyleMetaFromFieldMetaData(fieldMetaData) { + if (this.isOrdinal()) { + return this._pluckOrdinalStyleMetaFromFieldMetaData(fieldMetaData); + } else if (this.isCategorical()) { + return this._pluckCategoricalStyleMetaFromFieldMetaData(fieldMetaData); + } else { + return null; + } + } + + formatField(value) { + if (this.getField()) { + const fieldName = this.getField().getName(); + const fieldFormatter = this._getFieldFormatter(fieldName); + return fieldFormatter ? fieldFormatter(value) : value; + } else { + return value; + } + } + + getMbValue(value) { + if (!this.isOrdinal()) { + return this.formatField(value); + } + + const valueAsFloat = parseFloat(value); + if (this.isOrdinalScaled()) { + return scaleValue(valueAsFloat, this.getFieldMeta()); + } + if (isNaN(valueAsFloat)) { + return 0; + } + return valueAsFloat; + } + + renderBreakedLegend() { + return null; + } + + _renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }) { + return ( + + ); + } + + _renderRangeLegend() { + return ; + } + + renderLegendDetailRow({ isPointsOnly, isLinesOnly, symbolId }) { + if (this.isOrdinal()) { + if (this.isOrdinalRanged()) { + return this._renderRangeLegend(); + } else if (this.hasOrdinalBreaks()) { + return this._renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }); + } else { + return null; + } + } else if (this.isCategorical()) { + return this._renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }); + } else { + return null; + } + } + + renderFieldMetaPopover(onFieldMetaOptionsChange) { + if (!this.isOrdinal() || !this.supportsFieldMeta()) { + return null; + } + + return ( + + ); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js new file mode 100644 index 0000000000000..6a40a80a1a7a6 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.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 { DynamicStyleProperty } from './dynamic_style_property'; +import { getComputedFieldName } from '../style_util'; + +export class DynamicTextProperty extends DynamicStyleProperty { + syncTextFieldWithMb(mbLayerId, mbMap) { + if (this._field && this._field.isValid()) { + const targetName = getComputedFieldName(this._styleName, this._options.field.name); + mbMap.setLayoutProperty(mbLayerId, 'text-field', ['coalesce', ['get', targetName], '']); + } else { + mbMap.setLayoutProperty(mbLayerId, 'text-field', null); + } + } + + isOrdinal() { + return false; + } + + supportsFieldMeta() { + return false; + } + + supportsFeatureState() { + return false; + } + + isOrdinalScaled() { + return false; + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js new file mode 100644 index 0000000000000..e08c2875c310e --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js @@ -0,0 +1,50 @@ +/* + * 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 _ from 'lodash'; +import { AbstractStyleProperty } from './style_property'; +import { DEFAULT_LABEL_SIZE, LABEL_BORDER_SIZES } from '../vector_style_defaults'; + +const SMALL_SIZE = 1 / 16; +const MEDIUM_SIZE = 1 / 8; +const LARGE_SIZE = 1 / 5; // halo of 1/4 is just a square. Use smaller ratio to preserve contour on letters + +function getWidthRatio(size) { + switch (size) { + case LABEL_BORDER_SIZES.LARGE: + return LARGE_SIZE; + case LABEL_BORDER_SIZES.MEDIUM: + return MEDIUM_SIZE; + default: + return SMALL_SIZE; + } +} + +export class LabelBorderSizeProperty extends AbstractStyleProperty { + constructor(options, styleName, labelSizeProperty) { + super(options, styleName); + this._labelSizeProperty = labelSizeProperty; + } + + syncLabelBorderSizeWithMb(mbLayerId, mbMap) { + const widthRatio = getWidthRatio(this.getOptions().size); + + if (this.getOptions().size === LABEL_BORDER_SIZES.NONE) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', 0); + } else if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { + const labelSizeExpression = this._labelSizeProperty.getMbSizeExpression(); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ + 'max', + ['*', labelSizeExpression, widthRatio], + 1, + ]); + } else { + const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); + const labelBorderSize = Math.max(labelSize * widthRatio, 1); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); + } + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/static_color_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_color_property.js new file mode 100644 index 0000000000000..ebe2a322711fc --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_color_property.js @@ -0,0 +1,46 @@ +/* + * 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 { StaticStyleProperty } from './static_style_property'; + +export class StaticColorProperty extends StaticStyleProperty { + syncCircleColorWithMb(mbLayerId, mbMap, alpha) { + mbMap.setPaintProperty(mbLayerId, 'circle-color', this._options.color); + mbMap.setPaintProperty(mbLayerId, 'circle-opacity', alpha); + } + + syncFillColorWithMb(mbLayerId, mbMap, alpha) { + mbMap.setPaintProperty(mbLayerId, 'fill-color', this._options.color); + mbMap.setPaintProperty(mbLayerId, 'fill-opacity', alpha); + } + + syncIconColorWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'icon-color', this._options.color); + } + + syncHaloBorderColorWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'icon-halo-color', this._options.color); + } + + syncLineColorWithMb(mbLayerId, mbMap, alpha) { + mbMap.setPaintProperty(mbLayerId, 'line-color', this._options.color); + mbMap.setPaintProperty(mbLayerId, 'line-opacity', alpha); + } + + syncCircleStrokeWithMb(mbLayerId, mbMap, alpha) { + mbMap.setPaintProperty(mbLayerId, 'circle-stroke-color', this._options.color); + mbMap.setPaintProperty(mbLayerId, 'circle-stroke-opacity', alpha); + } + + syncLabelColorWithMb(mbLayerId, mbMap, alpha) { + mbMap.setPaintProperty(mbLayerId, 'text-color', this._options.color); + mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha); + } + + syncLabelBorderColorWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-color', this._options.color); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/static_icon_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_icon_property.js new file mode 100644 index 0000000000000..3b5be083dd3c9 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_icon_property.js @@ -0,0 +1,16 @@ +/* + * 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 { StaticStyleProperty } from './static_style_property'; +import { getMakiSymbolAnchor, getMakiIconId } from '../symbol_utils'; + +export class StaticIconProperty extends StaticStyleProperty { + syncIconWithMb(symbolLayerId, mbMap, iconPixelSize) { + const symbolId = this._options.value; + mbMap.setLayoutProperty(symbolLayerId, 'icon-anchor', getMakiSymbolAnchor(symbolId)); + mbMap.setLayoutProperty(symbolLayerId, 'icon-image', getMakiIconId(symbolId, iconPixelSize)); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/static_orientation_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_orientation_property.js new file mode 100644 index 0000000000000..0c8cae10d6189 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_orientation_property.js @@ -0,0 +1,21 @@ +/* + * 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 { StaticStyleProperty } from './static_style_property'; + +export class StaticOrientationProperty extends StaticStyleProperty { + constructor(options, styleName) { + if (typeof options.orientation !== 'number') { + super({ orientation: 0 }, styleName); + } else { + super(options, styleName); + } + } + + syncIconRotationWithMb(symbolLayerId, mbMap) { + mbMap.setLayoutProperty(symbolLayerId, 'icon-rotate', this._options.orientation); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/static_size_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_size_property.js new file mode 100644 index 0000000000000..2383a5932cb9b --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_size_property.js @@ -0,0 +1,53 @@ +/* + * 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 { StaticStyleProperty } from './static_style_property'; +import { + HALF_LARGE_MAKI_ICON_SIZE, + LARGE_MAKI_ICON_SIZE, + SMALL_MAKI_ICON_SIZE, +} from '../symbol_utils'; + +export class StaticSizeProperty extends StaticStyleProperty { + constructor(options, styleName) { + if (typeof options.size !== 'number') { + super({ size: 1 }, styleName); + } else { + super(options, styleName); + } + } + + syncHaloWidthWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', this._options.size); + } + + getIconPixelSize() { + return this._options.size >= HALF_LARGE_MAKI_ICON_SIZE + ? LARGE_MAKI_ICON_SIZE + : SMALL_MAKI_ICON_SIZE; + } + + syncIconSizeWithMb(symbolLayerId, mbMap) { + const halfIconPixels = this.getIconPixelSize() / 2; + mbMap.setLayoutProperty(symbolLayerId, 'icon-size', this._options.size / halfIconPixels); + } + + syncCircleStrokeWidthWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'circle-stroke-width', this._options.size); + } + + syncCircleRadiusWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'circle-radius', this._options.size); + } + + syncLineWidthWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'line-width', this._options.size); + } + + syncLabelSizeWithMb(mbLayerId, mbMap) { + mbMap.setLayoutProperty(mbLayerId, 'text-size', this._options.size); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/static_style_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_style_property.js new file mode 100644 index 0000000000000..a02aa15e28b28 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_style_property.js @@ -0,0 +1,12 @@ +/* + * 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 { AbstractStyleProperty } from './style_property'; +import { STYLE_TYPE } from '../../../../../common/constants'; + +export class StaticStyleProperty extends AbstractStyleProperty { + static type = STYLE_TYPE.STATIC; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/static_text_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_text_property.js new file mode 100644 index 0000000000000..7a4a4672152c0 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/static_text_property.js @@ -0,0 +1,21 @@ +/* + * 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 { StaticStyleProperty } from './static_style_property'; + +export class StaticTextProperty extends StaticStyleProperty { + isComplete() { + return this.getOptions().value.length > 0; + } + + syncTextFieldWithMb(mbLayerId, mbMap) { + if (this.getOptions().value.length) { + mbMap.setLayoutProperty(mbLayerId, 'text-field', this.getOptions().value); + } else { + mbMap.setLayoutProperty(mbLayerId, 'text-field', null); + } + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/style_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/style_property.js new file mode 100644 index 0000000000000..c49fe46664025 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/style_property.js @@ -0,0 +1,55 @@ +/* + * 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 { getVectorStyleLabel } from '../components/get_vector_style_label'; +export class AbstractStyleProperty { + constructor(options, styleName) { + this._options = options; + this._styleName = styleName; + } + + isDynamic() { + return false; + } + + /** + * Is the style fully defined and usable? (e.g. for rendering, in legend UX, ...) + * Why? during editing, partially-completed descriptors may be added to the layer-descriptor + * e.g. dynamic-fields can have an incomplete state when the field is not yet selected from the drop-down + * @returns {boolean} + */ + isComplete() { + return true; + } + + formatField(value) { + return value; + } + + getStyleName() { + return this._styleName; + } + + getOptions() { + return this._options || {}; + } + + renderRangeLegendHeader() { + return null; + } + + renderLegendDetailRow() { + return null; + } + + renderFieldMetaPopover() { + return null; + } + + getDisplayStyleName() { + return getVectorStyleLabel(this.getStyleName()); + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/symbolize_as_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/symbolize_as_property.js new file mode 100644 index 0000000000000..9ae1ef5054e30 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/symbolize_as_property.js @@ -0,0 +1,18 @@ +/* + * 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 { AbstractStyleProperty } from './style_property'; +import { SYMBOLIZE_AS_TYPES } from '../../../../../common/constants'; + +export class SymbolizeAsProperty extends AbstractStyleProperty { + constructor(options, styleName) { + super(options, styleName); + } + + isSymbolizedAsIcon = () => { + return this.getOptions().value === SYMBOLIZE_AS_TYPES.ICON; + }; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/style_util.js b/x-pack/plugins/maps/public/layers/styles/vector/style_util.js new file mode 100644 index 0000000000000..2859b8c0e5a56 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/style_util.js @@ -0,0 +1,72 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export function getOtherCategoryLabel() { + return i18n.translate('xpack.maps.styles.categorical.otherCategoryLabel', { + defaultMessage: 'Other', + }); +} + +export function getComputedFieldName(styleName, fieldName) { + return `${getComputedFieldNamePrefix(fieldName)}__${styleName}`; +} + +export function getComputedFieldNamePrefix(fieldName) { + return `__kbn__dynamic__${fieldName}`; +} + +export function isOnlySingleFeatureType(featureType, supportedFeatures, hasFeatureType) { + if (supportedFeatures.length === 1) { + return supportedFeatures[0] === featureType; + } + + const featureTypes = Object.keys(hasFeatureType); + return featureTypes.reduce((isOnlyTargetFeatureType, featureTypeKey) => { + const hasFeature = hasFeatureType[featureTypeKey]; + return featureTypeKey === featureType + ? isOnlyTargetFeatureType && hasFeature + : isOnlyTargetFeatureType && !hasFeature; + }, true); +} + +export function scaleValue(value, range) { + if (isNaN(value) || !range) { + return -1; //Nothing to scale, put outside scaled range + } + + if (range.delta === 0 || value >= range.max) { + return 1; //snap to end of scaled range + } + + if (value <= range.min) { + return 0; //snap to beginning of scaled range + } + + return (value - range.min) / range.delta; +} + +export function assignCategoriesToPalette({ categories, paletteValues }) { + const stops = []; + let fallback = null; + + if (categories && categories.length && paletteValues && paletteValues.length) { + const maxLength = Math.min(paletteValues.length, categories.length + 1); + fallback = paletteValues[maxLength - 1]; + for (let i = 0; i < maxLength - 1; i++) { + stops.push({ + stop: categories[i].key, + style: paletteValues[i], + }); + } + } + + return { + stops, + fallback, + }; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/style_util.test.js b/x-pack/plugins/maps/public/layers/styles/vector/style_util.test.js new file mode 100644 index 0000000000000..2be31c0107193 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/style_util.test.js @@ -0,0 +1,128 @@ +/* + * 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 { isOnlySingleFeatureType, scaleValue, assignCategoriesToPalette } from './style_util'; +import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; + +describe('isOnlySingleFeatureType', () => { + describe('source supports single feature type', () => { + const supportedFeatures = [VECTOR_SHAPE_TYPES.POINT]; + + test('Is only single feature type when only supported feature type is target feature type', () => { + expect(isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POINT, supportedFeatures)).toBe(true); + }); + + test('Is not single feature type when only supported feature type is not target feature type', () => { + expect(isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.LINE, supportedFeatures)).toBe(false); + }); + }); + + describe('source supports multiple feature types', () => { + const supportedFeatures = [ + VECTOR_SHAPE_TYPES.POINT, + VECTOR_SHAPE_TYPES.LINE, + VECTOR_SHAPE_TYPES.POLYGON, + ]; + + test('Is only single feature type when data only has target feature type', () => { + const hasFeatureType = { + [VECTOR_SHAPE_TYPES.POINT]: true, + [VECTOR_SHAPE_TYPES.LINE]: false, + [VECTOR_SHAPE_TYPES.POLYGON]: false, + }; + expect( + isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POINT, supportedFeatures, hasFeatureType) + ).toBe(true); + }); + + test('Is not single feature type when data has multiple feature types', () => { + const hasFeatureType = { + [VECTOR_SHAPE_TYPES.POINT]: true, + [VECTOR_SHAPE_TYPES.LINE]: true, + [VECTOR_SHAPE_TYPES.POLYGON]: true, + }; + expect( + isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.LINE, supportedFeatures, hasFeatureType) + ).toBe(false); + }); + + test('Is not single feature type when data does not have target feature types', () => { + const hasFeatureType = { + [VECTOR_SHAPE_TYPES.POINT]: false, + [VECTOR_SHAPE_TYPES.LINE]: true, + [VECTOR_SHAPE_TYPES.POLYGON]: false, + }; + expect( + isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POINT, supportedFeatures, hasFeatureType) + ).toBe(false); + }); + }); +}); + +describe('scaleValue', () => { + test('Should scale value between 0 and 1', () => { + expect(scaleValue(5, { min: 0, max: 10, delta: 10 })).toBe(0.5); + }); + + test('Should snap value less then range min to 0', () => { + expect(scaleValue(-1, { min: 0, max: 10, delta: 10 })).toBe(0); + }); + + test('Should snap value greater then range max to 1', () => { + expect(scaleValue(11, { min: 0, max: 10, delta: 10 })).toBe(1); + }); + + test('Should snap value to 1 when tere is not range delta', () => { + expect(scaleValue(10, { min: 10, max: 10, delta: 0 })).toBe(1); + }); + + test('Should put value as -1 when value is not provided', () => { + expect(scaleValue(undefined, { min: 0, max: 10, delta: 10 })).toBe(-1); + }); + + test('Should put value as -1 when range is not provided', () => { + expect(scaleValue(5, undefined)).toBe(-1); + }); +}); + +describe('assignCategoriesToPalette', () => { + test('Categories and palette values have same length', () => { + const categories = [{ key: 'alpah' }, { key: 'bravo' }, { key: 'charlie' }, { key: 'delta' }]; + const paletteValues = ['red', 'orange', 'yellow', 'green']; + expect(assignCategoriesToPalette({ categories, paletteValues })).toEqual({ + stops: [ + { stop: 'alpah', style: 'red' }, + { stop: 'bravo', style: 'orange' }, + { stop: 'charlie', style: 'yellow' }, + ], + fallback: 'green', + }); + }); + + test('Should More categories than palette values', () => { + const categories = [{ key: 'alpah' }, { key: 'bravo' }, { key: 'charlie' }, { key: 'delta' }]; + const paletteValues = ['red', 'orange', 'yellow']; + expect(assignCategoriesToPalette({ categories, paletteValues })).toEqual({ + stops: [ + { stop: 'alpah', style: 'red' }, + { stop: 'bravo', style: 'orange' }, + ], + fallback: 'yellow', + }); + }); + + test('Less categories than palette values', () => { + const categories = [{ key: 'alpah' }, { key: 'bravo' }]; + const paletteValues = ['red', 'orange', 'yellow', 'green', 'blue']; + expect(assignCategoriesToPalette({ categories, paletteValues })).toEqual({ + stops: [ + { stop: 'alpah', style: 'red' }, + { stop: 'bravo', style: 'orange' }, + ], + fallback: 'yellow', + }); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/symbol_utils.js b/x-pack/plugins/maps/public/layers/styles/vector/symbol_utils.js new file mode 100644 index 0000000000000..b577d4080b879 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/symbol_utils.js @@ -0,0 +1,138 @@ +/* + * 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 from 'react'; +import maki from '@elastic/maki'; +import xml2js from 'xml2js'; +import { parseXmlString } from '../../../../common/parse_xml_string'; +import { SymbolIcon } from './components/legend/symbol_icon'; + +export const LARGE_MAKI_ICON_SIZE = 15; +const LARGE_MAKI_ICON_SIZE_AS_STRING = LARGE_MAKI_ICON_SIZE.toString(); +export const SMALL_MAKI_ICON_SIZE = 11; +export const HALF_LARGE_MAKI_ICON_SIZE = Math.ceil(LARGE_MAKI_ICON_SIZE); + +export const SYMBOLS = {}; +maki.svgArray.forEach(svgString => { + const ID_FRAG = 'id="'; + const index = svgString.indexOf(ID_FRAG); + if (index !== -1) { + const idStartIndex = index + ID_FRAG.length; + const idEndIndex = svgString.substring(idStartIndex).indexOf('"') + idStartIndex; + const fullSymbolId = svgString.substring(idStartIndex, idEndIndex); + const symbolId = fullSymbolId.substring(0, fullSymbolId.length - 3); // remove '-15' or '-11' from id + const symbolSize = fullSymbolId.substring(fullSymbolId.length - 2); // grab last 2 chars from id + // only show large icons, small/large icon selection will based on configured size style + if (symbolSize === LARGE_MAKI_ICON_SIZE_AS_STRING) { + SYMBOLS[symbolId] = svgString; + } + } +}); + +export const SYMBOL_OPTIONS = Object.keys(SYMBOLS).map(symbolId => { + return { + value: symbolId, + label: symbolId, + }; +}); + +export function getMakiSymbolSvg(symbolId) { + if (!SYMBOLS[symbolId]) { + throw new Error(`Unable to find symbol: ${symbolId}`); + } + return SYMBOLS[symbolId]; +} + +export function getMakiSymbolAnchor(symbolId) { + switch (symbolId) { + case 'embassy': + case 'marker': + case 'marker-stroked': + return 'bottom'; + default: + return 'center'; + } +} + +// Style descriptor stores symbolId, for example 'aircraft' +// Icons are registered in Mapbox with full maki ids, for example 'aircraft-11' +export function getMakiIconId(symbolId, iconPixelSize) { + return `${symbolId}-${iconPixelSize}`; +} + +export function buildSrcUrl(svgString) { + const domUrl = window.URL || window.webkitURL || window; + const svg = new Blob([svgString], { type: 'image/svg+xml' }); + return domUrl.createObjectURL(svg); +} + +export async function styleSvg(svgString, fill, stroke, strokeWidth) { + const svgXml = await parseXmlString(svgString); + let style = ''; + if (fill) { + style += `fill:${fill};`; + } + if (stroke) { + style += `stroke:${stroke};`; + } + if (strokeWidth) { + style += `stroke-width:${strokeWidth};`; + } + if (style) svgXml.svg.$.style = style; + const builder = new xml2js.Builder(); + return builder.buildObject(svgXml); +} + +const ICON_PALETTES = [ + { + id: 'filledShapes', + icons: ['circle', 'marker', 'square', 'star', 'triangle', 'hospital'], + }, + { + id: 'hollowShapes', + icons: [ + 'circle-stroked', + 'marker-stroked', + 'square-stroked', + 'star-stroked', + 'triangle-stroked', + ], + }, +]; + +export function getIconPaletteOptions(isDarkMode) { + return ICON_PALETTES.map(({ id, icons }) => { + const iconsDisplay = icons.map(iconId => { + const style = { + width: '10%', + position: 'relative', + height: '100%', + display: 'inline-block', + paddingTop: '4px', + }; + return ( +
+ +
+ ); + }); + return { + value: id, + inputDisplay:
{iconsDisplay}
, + }; + }); +} + +export function getIconPalette(paletteId) { + const palette = ICON_PALETTES.find(({ id }) => id === paletteId); + return palette ? [...palette.icons] : null; +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/symbol_utils.test.js b/x-pack/plugins/maps/public/layers/styles/vector/symbol_utils.test.js new file mode 100644 index 0000000000000..ed59b1d5513a0 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/symbol_utils.test.js @@ -0,0 +1,52 @@ +/* + * 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 { getMakiSymbolSvg, styleSvg } from './symbol_utils'; + +describe('getMakiSymbolSvg', () => { + it('Should load symbol svg', () => { + const svgString = getMakiSymbolSvg('aerialway'); + expect(svgString.length).toBe(624); + }); +}); + +describe('styleSvg', () => { + it('Should not add style property when style not provided', async () => { + const unstyledSvgString = + ''; + const styledSvg = await styleSvg(unstyledSvgString); + expect(styledSvg.split('\n')[1]).toBe( + '' + ); + }); + + it('Should add fill style property to svg element', async () => { + const unstyledSvgString = + ''; + const styledSvg = await styleSvg(unstyledSvgString, 'red'); + expect(styledSvg.split('\n')[1]).toBe( + '' + ); + }); + + it('Should add stroke style property to svg element', async () => { + const unstyledSvgString = + ''; + const styledSvg = await styleSvg(unstyledSvgString, 'red', 'white'); + expect(styledSvg.split('\n')[1]).toBe( + '' + ); + }); + + it('Should add stroke-width style property to svg element', async () => { + const unstyledSvgString = + ''; + const styledSvg = await styleSvg(unstyledSvgString, 'red', 'white', '2px'); + expect(styledSvg.split('\n')[1]).toBe( + '' + ); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/plugins/maps/public/layers/styles/vector/vector_style.js new file mode 100644 index 0000000000000..97259a908f1e4 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/vector_style.js @@ -0,0 +1,691 @@ +/* + * 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 _ from 'lodash'; +import React from 'react'; +import { VectorStyleEditor } from './components/vector_style_editor'; +import { + getDefaultProperties, + LINE_STYLES, + POLYGON_STYLES, + VECTOR_STYLES, +} from './vector_style_defaults'; +import { AbstractStyle } from '../abstract_style'; +import { + GEO_JSON_TYPE, + FIELD_ORIGIN, + STYLE_TYPE, + SOURCE_META_ID_ORIGIN, + SOURCE_FORMATTERS_ID_ORIGIN, + LAYER_STYLE_TYPE, + DEFAULT_ICON, +} from '../../../../common/constants'; +import { VectorIcon } from './components/legend/vector_icon'; +import { VectorStyleLegend } from './components/legend/vector_style_legend'; +import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; +import { getComputedFieldName, isOnlySingleFeatureType } from './style_util'; +import { StaticStyleProperty } from './properties/static_style_property'; +import { DynamicStyleProperty } from './properties/dynamic_style_property'; +import { DynamicSizeProperty } from './properties/dynamic_size_property'; +import { StaticSizeProperty } from './properties/static_size_property'; +import { StaticColorProperty } from './properties/static_color_property'; +import { DynamicColorProperty } from './properties/dynamic_color_property'; +import { StaticOrientationProperty } from './properties/static_orientation_property'; +import { DynamicOrientationProperty } from './properties/dynamic_orientation_property'; +import { StaticTextProperty } from './properties/static_text_property'; +import { DynamicTextProperty } from './properties/dynamic_text_property'; +import { LabelBorderSizeProperty } from './properties/label_border_size_property'; +import { extractColorFromStyleProperty } from './components/legend/extract_color_from_style_property'; +import { SymbolizeAsProperty } from './properties/symbolize_as_property'; +import { StaticIconProperty } from './properties/static_icon_property'; +import { DynamicIconProperty } from './properties/dynamic_icon_property'; + +const POINTS = [GEO_JSON_TYPE.POINT, GEO_JSON_TYPE.MULTI_POINT]; +const LINES = [GEO_JSON_TYPE.LINE_STRING, GEO_JSON_TYPE.MULTI_LINE_STRING]; +const POLYGONS = [GEO_JSON_TYPE.POLYGON, GEO_JSON_TYPE.MULTI_POLYGON]; + +export class VectorStyle extends AbstractStyle { + static type = LAYER_STYLE_TYPE.VECTOR; + static STYLE_TYPE = STYLE_TYPE; + static createDescriptor(properties = {}, isTimeAware = true) { + return { + type: VectorStyle.type, + properties: { ...getDefaultProperties(), ...properties }, + isTimeAware, + }; + } + + static createDefaultStyleProperties(mapColors) { + return getDefaultProperties(mapColors); + } + + constructor(descriptor = {}, source, layer) { + super(); + this._source = source; + this._layer = layer; + this._descriptor = { + ...descriptor, + ...VectorStyle.createDescriptor(descriptor.properties, descriptor.isTimeAware), + }; + + this._symbolizeAsStyleProperty = new SymbolizeAsProperty( + this._descriptor.properties[VECTOR_STYLES.SYMBOLIZE_AS].options, + VECTOR_STYLES.SYMBOLIZE_AS + ); + this._lineColorStyleProperty = this._makeColorProperty( + this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], + VECTOR_STYLES.LINE_COLOR + ); + this._fillColorStyleProperty = this._makeColorProperty( + this._descriptor.properties[VECTOR_STYLES.FILL_COLOR], + VECTOR_STYLES.FILL_COLOR + ); + this._lineWidthStyleProperty = this._makeSizeProperty( + this._descriptor.properties[VECTOR_STYLES.LINE_WIDTH], + VECTOR_STYLES.LINE_WIDTH + ); + this._iconStyleProperty = this._makeIconProperty( + this._descriptor.properties[VECTOR_STYLES.ICON] + ); + this._iconSizeStyleProperty = this._makeSizeProperty( + this._descriptor.properties[VECTOR_STYLES.ICON_SIZE], + VECTOR_STYLES.ICON_SIZE, + this._symbolizeAsStyleProperty.isSymbolizedAsIcon() + ); + this._iconOrientationProperty = this._makeOrientationProperty( + this._descriptor.properties[VECTOR_STYLES.ICON_ORIENTATION], + VECTOR_STYLES.ICON_ORIENTATION + ); + this._labelStyleProperty = this._makeLabelProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_TEXT] + ); + this._labelSizeStyleProperty = this._makeSizeProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_SIZE], + VECTOR_STYLES.LABEL_SIZE + ); + this._labelColorStyleProperty = this._makeColorProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_COLOR], + VECTOR_STYLES.LABEL_COLOR + ); + this._labelBorderColorStyleProperty = this._makeColorProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_COLOR], + VECTOR_STYLES.LABEL_BORDER_COLOR + ); + this._labelBorderSizeStyleProperty = new LabelBorderSizeProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_SIZE].options, + VECTOR_STYLES.LABEL_BORDER_SIZE, + this._labelSizeStyleProperty + ); + } + + _getAllStyleProperties() { + return [ + this._symbolizeAsStyleProperty, + this._iconStyleProperty, + this._lineColorStyleProperty, + this._fillColorStyleProperty, + this._lineWidthStyleProperty, + this._iconSizeStyleProperty, + this._iconOrientationProperty, + this._labelStyleProperty, + this._labelSizeStyleProperty, + this._labelColorStyleProperty, + this._labelBorderColorStyleProperty, + this._labelBorderSizeStyleProperty, + ]; + } + + renderEditor({ layer, onStyleDescriptorChange }) { + const rawProperties = this.getRawProperties(); + const handlePropertyChange = (propertyName, settings) => { + rawProperties[propertyName] = settings; //override single property, but preserve the rest + const vectorStyleDescriptor = VectorStyle.createDescriptor(rawProperties, this.isTimeAware()); + onStyleDescriptorChange(vectorStyleDescriptor); + }; + + const onIsTimeAwareChange = isTimeAware => { + const vectorStyleDescriptor = VectorStyle.createDescriptor(rawProperties, isTimeAware); + onStyleDescriptorChange(vectorStyleDescriptor); + }; + + const propertiesWithFieldMeta = this.getDynamicPropertiesArray().filter(dynamicStyleProp => { + return dynamicStyleProp.isFieldMetaEnabled(); + }); + + const styleProperties = {}; + this._getAllStyleProperties().forEach(styleProperty => { + styleProperties[styleProperty.getStyleName()] = styleProperty; + }); + + return ( + 0} + /> + ); + } + + /* + * Changes to source descriptor and join descriptor will impact style properties. + * For instance, a style property may be dynamically tied to the value of an ordinal field defined + * by a join or a metric aggregation. The metric aggregation or join may be edited or removed. + * When this happens, the style will be linked to a no-longer-existing ordinal field. + * This method provides a way for a style to clean itself and return a descriptor that unsets any dynamic + * properties that are tied to missing oridinal fields + * + * This method does not update its descriptor. It just returns a new descriptor that the caller + * can then use to update store state via dispatch. + */ + getDescriptorWithMissingStylePropsRemoved(nextFields) { + const originalProperties = this.getRawProperties(); + const updatedProperties = {}; + + const dynamicProperties = Object.keys(originalProperties).filter(key => { + const { type, options } = originalProperties[key] || {}; + return type === STYLE_TYPE.DYNAMIC && options.field && options.field.name; + }); + + dynamicProperties.forEach(key => { + const dynamicProperty = originalProperties[key]; + const fieldName = + dynamicProperty && dynamicProperty.options.field && dynamicProperty.options.field.name; + if (!fieldName) { + return; + } + + const matchingOrdinalField = nextFields.find(ordinalField => { + return fieldName === ordinalField.getName(); + }); + + if (matchingOrdinalField) { + return; + } + + updatedProperties[key] = { + type: DynamicStyleProperty.type, + options: { + ...originalProperties[key].options, + }, + }; + delete updatedProperties[key].options.field; + }); + + if (Object.keys(updatedProperties).length === 0) { + return { + hasChanges: false, + nextStyleDescriptor: { ...this._descriptor }, + }; + } + + return { + hasChanges: true, + nextStyleDescriptor: VectorStyle.createDescriptor( + { + ...originalProperties, + ...updatedProperties, + }, + this.isTimeAware() + ), + }; + } + + async pluckStyleMetaFromSourceDataRequest(sourceDataRequest) { + const features = _.get(sourceDataRequest.getData(), 'features', []); + + const supportedFeatures = await this._source.getSupportedShapeTypes(); + const hasFeatureType = { + [VECTOR_SHAPE_TYPES.POINT]: false, + [VECTOR_SHAPE_TYPES.LINE]: false, + [VECTOR_SHAPE_TYPES.POLYGON]: false, + }; + if (supportedFeatures.length > 1) { + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + if (!hasFeatureType[VECTOR_SHAPE_TYPES.POINT] && POINTS.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPES.POINT] = true; + } + if (!hasFeatureType[VECTOR_SHAPE_TYPES.LINE] && LINES.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPES.LINE] = true; + } + if ( + !hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] && + POLYGONS.includes(feature.geometry.type) + ) { + hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] = true; + } + } + } + + const featuresMeta = { + geometryTypes: { + isPointsOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.POINT, + supportedFeatures, + hasFeatureType + ), + isLinesOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.LINE, + supportedFeatures, + hasFeatureType + ), + isPolygonsOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.POLYGON, + supportedFeatures, + hasFeatureType + ), + }, + }; + + const dynamicProperties = this.getDynamicPropertiesArray(); + if (dynamicProperties.length === 0 || features.length === 0) { + // no additional meta data to pull from source data request. + return featuresMeta; + } + + dynamicProperties.forEach(dynamicProperty => { + const styleMeta = dynamicProperty.pluckStyleMetaFromFeatures(features); + if (styleMeta) { + const name = dynamicProperty.getField().getName(); + featuresMeta[name] = styleMeta; + } + }); + + return featuresMeta; + } + + getSourceFieldNames() { + const fieldNames = []; + this.getDynamicPropertiesArray().forEach(styleProperty => { + if (styleProperty.getFieldOrigin() === FIELD_ORIGIN.SOURCE) { + fieldNames.push(styleProperty.getField().getName()); + } + }); + return fieldNames; + } + + isTimeAware() { + return this._descriptor.isTimeAware; + } + + getRawProperties() { + return this._descriptor.properties || {}; + } + + getDynamicPropertiesArray() { + const styleProperties = this._getAllStyleProperties(); + return styleProperties.filter( + styleProperty => styleProperty.isDynamic() && styleProperty.isComplete() + ); + } + + _getIsPointsOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isPointsOnly', false); + }; + + _getIsLinesOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isLinesOnly', false); + }; + + _getIsPolygonsOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isPolygonsOnly', false); + }; + + _getDynamicPropertyByFieldName(fieldName) { + const dynamicProps = this.getDynamicPropertiesArray(); + return dynamicProps.find(dynamicProp => { + return fieldName === dynamicProp.getField().getName(); + }); + } + + _getFieldMeta = fieldName => { + const fieldMetaFromLocalFeatures = _.get(this._descriptor, ['__styleMeta', fieldName]); + + const dynamicProp = this._getDynamicPropertyByFieldName(fieldName); + if (!dynamicProp || !dynamicProp.isFieldMetaEnabled()) { + return fieldMetaFromLocalFeatures; + } + + let dataRequestId; + if (dynamicProp.getFieldOrigin() === FIELD_ORIGIN.SOURCE) { + dataRequestId = SOURCE_META_ID_ORIGIN; + } else { + const join = this._layer.getValidJoins().find(join => { + return join.getRightJoinSource().hasMatchingMetricField(fieldName); + }); + if (join) { + dataRequestId = join.getSourceMetaDataRequestId(); + } + } + + if (!dataRequestId) { + return fieldMetaFromLocalFeatures; + } + + const styleMetaDataRequest = this._layer._findDataRequestById(dataRequestId); + if (!styleMetaDataRequest || !styleMetaDataRequest.hasData()) { + return fieldMetaFromLocalFeatures; + } + + const data = styleMetaDataRequest.getData(); + const fieldMeta = dynamicProp.pluckStyleMetaFromFieldMetaData(data); + return fieldMeta ? fieldMeta : fieldMetaFromLocalFeatures; + }; + + _getFieldFormatter = fieldName => { + const dynamicProp = this._getDynamicPropertyByFieldName(fieldName); + if (!dynamicProp) { + return null; + } + + let dataRequestId; + if (dynamicProp.getFieldOrigin() === FIELD_ORIGIN.SOURCE) { + dataRequestId = SOURCE_FORMATTERS_ID_ORIGIN; + } else { + const join = this._layer.getValidJoins().find(join => { + return join.getRightJoinSource().hasMatchingMetricField(fieldName); + }); + if (join) { + dataRequestId = join.getSourceFormattersDataRequestId(); + } + } + + if (!dataRequestId) { + return null; + } + + const formattersDataRequest = this._layer._findDataRequestById(dataRequestId); + if (!formattersDataRequest || !formattersDataRequest.hasData()) { + return null; + } + + const formatters = formattersDataRequest.getData(); + return formatters[fieldName]; + }; + + _getStyleMeta = () => { + return _.get(this._descriptor, '__styleMeta', {}); + }; + + _getSymbolId() { + return this.arePointsSymbolizedAsCircles() + ? undefined + : this._iconStyleProperty.getOptions().value; + } + + getIcon = () => { + const isLinesOnly = this._getIsLinesOnly(); + const strokeColor = isLinesOnly + ? extractColorFromStyleProperty(this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], 'grey') + : extractColorFromStyleProperty( + this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], + 'none' + ); + const fillColor = isLinesOnly + ? null + : extractColorFromStyleProperty( + this._descriptor.properties[VECTOR_STYLES.FILL_COLOR], + 'grey' + ); + + return ( + + ); + }; + + _getLegendDetailStyleProperties = () => { + return this.getDynamicPropertiesArray().filter(styleProperty => { + const styleName = styleProperty.getStyleName(); + if ([VECTOR_STYLES.ICON_ORIENTATION, VECTOR_STYLES.LABEL_TEXT].includes(styleName)) { + return false; + } + + if (this._getIsLinesOnly()) { + return LINE_STYLES.includes(styleName); + } + + if (this._getIsPolygonsOnly()) { + return POLYGON_STYLES.includes(styleName); + } + + return true; + }); + }; + + async hasLegendDetails() { + return this._getLegendDetailStyleProperties().length > 0; + } + + renderLegendDetails() { + return ( + + ); + } + + clearFeatureState(featureCollection, mbMap, sourceId) { + const tmpFeatureIdentifier = { + source: null, + id: null, + }; + for (let i = 0; i < featureCollection.features.length; i++) { + const feature = featureCollection.features[i]; + tmpFeatureIdentifier.source = sourceId; + tmpFeatureIdentifier.id = feature.id; + mbMap.removeFeatureState(tmpFeatureIdentifier); + } + } + + setFeatureStateAndStyleProps(featureCollection, mbMap, mbSourceId) { + if (!featureCollection) { + return; + } + + const dynamicStyleProps = this.getDynamicPropertiesArray(); + if (dynamicStyleProps.length === 0) { + return; + } + + const tmpFeatureIdentifier = { + source: null, + id: null, + }; + const tmpFeatureState = {}; + + for (let i = 0; i < featureCollection.features.length; i++) { + const feature = featureCollection.features[i]; + + for (let j = 0; j < dynamicStyleProps.length; j++) { + const dynamicStyleProp = dynamicStyleProps[j]; + const name = dynamicStyleProp.getField().getName(); + const computedName = getComputedFieldName(dynamicStyleProp.getStyleName(), name); + const styleValue = dynamicStyleProp.getMbValue(feature.properties[name]); + if (dynamicStyleProp.supportsFeatureState()) { + tmpFeatureState[computedName] = styleValue; + } else { + feature.properties[computedName] = styleValue; + } + } + tmpFeatureIdentifier.source = mbSourceId; + tmpFeatureIdentifier.id = feature.id; + mbMap.setFeatureState(tmpFeatureIdentifier, tmpFeatureState); + } + + //returns boolean indicating if styles do not support feature-state and some values are stored in geojson properties + //this return-value is used in an optimization for style-updates with mapbox-gl. + //`true` indicates the entire data needs to reset on the source (otherwise the style-rules will not be reapplied) + //`false` indicates the data does not need to be reset on the store, because styles are re-evaluated if they use featureState + return dynamicStyleProps.some(dynamicStyleProp => !dynamicStyleProp.supportsFeatureState()); + } + + arePointsSymbolizedAsCircles() { + return !this._symbolizeAsStyleProperty.isSymbolizedAsIcon(); + } + + setMBPaintProperties({ alpha, mbMap, fillLayerId, lineLayerId }) { + this._fillColorStyleProperty.syncFillColorWithMb(fillLayerId, mbMap, alpha); + this._lineColorStyleProperty.syncLineColorWithMb(lineLayerId, mbMap, alpha); + this._lineWidthStyleProperty.syncLineWidthWithMb(lineLayerId, mbMap); + } + + setMBPaintPropertiesForPoints({ alpha, mbMap, pointLayerId }) { + this._fillColorStyleProperty.syncCircleColorWithMb(pointLayerId, mbMap, alpha); + this._lineColorStyleProperty.syncCircleStrokeWithMb(pointLayerId, mbMap, alpha); + this._lineWidthStyleProperty.syncCircleStrokeWidthWithMb(pointLayerId, mbMap); + this._iconSizeStyleProperty.syncCircleRadiusWithMb(pointLayerId, mbMap); + } + + setMBPropertiesForLabelText({ alpha, mbMap, textLayerId }) { + mbMap.setLayoutProperty(textLayerId, 'icon-allow-overlap', true); + mbMap.setLayoutProperty(textLayerId, 'text-allow-overlap', true); + this._labelStyleProperty.syncTextFieldWithMb(textLayerId, mbMap); + this._labelColorStyleProperty.syncLabelColorWithMb(textLayerId, mbMap, alpha); + this._labelSizeStyleProperty.syncLabelSizeWithMb(textLayerId, mbMap); + this._labelBorderSizeStyleProperty.syncLabelBorderSizeWithMb(textLayerId, mbMap); + this._labelBorderColorStyleProperty.syncLabelBorderColorWithMb(textLayerId, mbMap); + } + + setMBSymbolPropertiesForPoints({ mbMap, symbolLayerId, alpha }) { + mbMap.setLayoutProperty(symbolLayerId, 'icon-ignore-placement', true); + mbMap.setPaintProperty(symbolLayerId, 'icon-opacity', alpha); + + this._iconStyleProperty.syncIconWithMb( + symbolLayerId, + mbMap, + this._iconSizeStyleProperty.getIconPixelSize() + ); + // icon-color is only supported on SDF icons. + this._fillColorStyleProperty.syncIconColorWithMb(symbolLayerId, mbMap); + this._lineColorStyleProperty.syncHaloBorderColorWithMb(symbolLayerId, mbMap); + this._lineWidthStyleProperty.syncHaloWidthWithMb(symbolLayerId, mbMap); + this._iconSizeStyleProperty.syncIconSizeWithMb(symbolLayerId, mbMap); + this._iconOrientationProperty.syncIconRotationWithMb(symbolLayerId, mbMap); + } + + _makeField(fieldDescriptor) { + if (!fieldDescriptor || !fieldDescriptor.name) { + return null; + } + + //fieldDescriptor.label is ignored. This is essentially cruft duplicating label-info from the metric-selection + //Ignore this custom label + if (fieldDescriptor.origin === FIELD_ORIGIN.SOURCE) { + return this._source.getFieldByName(fieldDescriptor.name); + } else if (fieldDescriptor.origin === FIELD_ORIGIN.JOIN) { + const join = this._layer.getValidJoins().find(join => { + return join.getRightJoinSource().hasMatchingMetricField(fieldDescriptor.name); + }); + return join ? join.getRightJoinSource().getMetricFieldForName(fieldDescriptor.name) : null; + } else { + throw new Error(`Unknown origin-type ${fieldDescriptor.origin}`); + } + } + + _makeSizeProperty(descriptor, styleName, isSymbolizedAsIcon) { + if (!descriptor || !descriptor.options) { + return new StaticSizeProperty({ size: 0 }, styleName); + } else if (descriptor.type === StaticStyleProperty.type) { + return new StaticSizeProperty(descriptor.options, styleName); + } else if (descriptor.type === DynamicStyleProperty.type) { + const field = this._makeField(descriptor.options.field); + return new DynamicSizeProperty( + descriptor.options, + styleName, + field, + this._getFieldMeta, + this._getFieldFormatter, + isSymbolizedAsIcon + ); + } else { + throw new Error(`${descriptor} not implemented`); + } + } + + _makeColorProperty(descriptor, styleName) { + if (!descriptor || !descriptor.options) { + return new StaticColorProperty({ color: null }, styleName); + } else if (descriptor.type === StaticStyleProperty.type) { + return new StaticColorProperty(descriptor.options, styleName); + } else if (descriptor.type === DynamicStyleProperty.type) { + const field = this._makeField(descriptor.options.field); + return new DynamicColorProperty( + descriptor.options, + styleName, + field, + this._getFieldMeta, + this._getFieldFormatter + ); + } else { + throw new Error(`${descriptor} not implemented`); + } + } + + _makeOrientationProperty(descriptor, styleName) { + if (!descriptor || !descriptor.options) { + return new StaticOrientationProperty({ orientation: 0 }, styleName); + } else if (descriptor.type === StaticStyleProperty.type) { + return new StaticOrientationProperty(descriptor.options, styleName); + } else if (descriptor.type === DynamicStyleProperty.type) { + const field = this._makeField(descriptor.options.field); + return new DynamicOrientationProperty(descriptor.options, styleName, field); + } else { + throw new Error(`${descriptor} not implemented`); + } + } + + _makeLabelProperty(descriptor) { + if (!descriptor || !descriptor.options) { + return new StaticTextProperty({ value: '' }, VECTOR_STYLES.LABEL_TEXT); + } else if (descriptor.type === StaticStyleProperty.type) { + return new StaticTextProperty(descriptor.options, VECTOR_STYLES.LABEL_TEXT); + } else if (descriptor.type === DynamicStyleProperty.type) { + const field = this._makeField(descriptor.options.field); + return new DynamicTextProperty( + descriptor.options, + VECTOR_STYLES.LABEL_TEXT, + field, + this._getFieldMeta, + this._getFieldFormatter + ); + } else { + throw new Error(`${descriptor} not implemented`); + } + } + + _makeIconProperty(descriptor) { + if (!descriptor || !descriptor.options) { + return new StaticIconProperty({ value: DEFAULT_ICON }, VECTOR_STYLES.ICON); + } else if (descriptor.type === StaticStyleProperty.type) { + return new StaticIconProperty(descriptor.options, VECTOR_STYLES.ICON); + } else if (descriptor.type === DynamicStyleProperty.type) { + const field = this._makeField(descriptor.options.field); + return new DynamicIconProperty( + descriptor.options, + VECTOR_STYLES.ICON, + field, + this._getFieldMeta, + this._getFieldFormatter + ); + } else { + throw new Error(`${descriptor} not implemented`); + } + } +} diff --git a/x-pack/plugins/maps/public/layers/styles/vector/vector_style.test.js b/x-pack/plugins/maps/public/layers/styles/vector/vector_style.test.js new file mode 100644 index 0000000000000..cc52d44aed8d3 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/vector_style.test.js @@ -0,0 +1,290 @@ +/* + * 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 { VectorStyle } from './vector_style'; +import { DataRequest } from '../../util/data_request'; +import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; +import { FIELD_ORIGIN } from '../../../../common/constants'; + +jest.mock('ui/new_platform'); + +class MockField { + constructor({ fieldName }) { + this._fieldName = fieldName; + } + + getName() { + return this._fieldName; + } + + isValid() { + return !!this._fieldName; + } +} + +class MockSource { + constructor({ supportedShapeTypes } = {}) { + this._supportedShapeTypes = supportedShapeTypes || Object.values(VECTOR_SHAPE_TYPES); + } + getSupportedShapeTypes() { + return this._supportedShapeTypes; + } + getFieldByName(fieldName) { + return new MockField({ fieldName }); + } + createField({ fieldName }) { + return new MockField({ fieldName }); + } +} + +describe('getDescriptorWithMissingStylePropsRemoved', () => { + const fieldName = 'doIStillExist'; + const properties = { + fillColor: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: {}, + }, + lineColor: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + field: { + name: fieldName, + origin: FIELD_ORIGIN.SOURCE, + }, + }, + }, + iconSize: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + color: 'a color', + field: { name: fieldName, origin: FIELD_ORIGIN.SOURCE }, + }, + }, + }; + + it('Should return no changes when next oridinal fields contain existing style property fields', () => { + const vectorStyle = new VectorStyle({ properties }, new MockSource()); + + const nextFields = [new MockField({ fieldName })]; + const { hasChanges } = vectorStyle.getDescriptorWithMissingStylePropsRemoved(nextFields); + expect(hasChanges).toBe(false); + }); + + it('Should clear missing fields when next oridinal fields do not contain existing style property fields', () => { + const vectorStyle = new VectorStyle({ properties }, new MockSource()); + + const nextFields = []; + const { + hasChanges, + nextStyleDescriptor, + } = vectorStyle.getDescriptorWithMissingStylePropsRemoved(nextFields); + expect(hasChanges).toBe(true); + expect(nextStyleDescriptor.properties).toEqual({ + fillColor: { + options: {}, + type: 'STATIC', + }, + icon: { + options: { + value: 'airfield', + }, + type: 'STATIC', + }, + iconOrientation: { + options: { + orientation: 0, + }, + type: 'STATIC', + }, + iconSize: { + options: { + color: 'a color', + }, + type: 'DYNAMIC', + }, + labelText: { + options: { + value: '', + }, + type: 'STATIC', + }, + labelBorderColor: { + options: { + color: '#FFFFFF', + }, + type: 'STATIC', + }, + labelBorderSize: { + options: { + size: 'SMALL', + }, + }, + labelColor: { + options: { + color: '#000000', + }, + type: 'STATIC', + }, + labelSize: { + options: { + size: 14, + }, + type: 'STATIC', + }, + lineColor: { + options: {}, + type: 'DYNAMIC', + }, + lineWidth: { + options: { + size: 1, + }, + type: 'STATIC', + }, + symbolizeAs: { + options: { + value: 'circle', + }, + }, + }); + }); +}); + +describe('pluckStyleMetaFromSourceDataRequest', () => { + describe('has features', () => { + it('Should identify when feature collection only contains points', async () => { + const sourceDataRequest = new DataRequest({ + data: { + type: 'FeatureCollection', + features: [ + { + geometry: { + type: 'Point', + }, + properties: {}, + }, + { + geometry: { + type: 'MultiPoint', + }, + properties: {}, + }, + ], + }, + }); + const vectorStyle = new VectorStyle({}, new MockSource()); + + const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(true); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(false); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); + }); + + it('Should identify when feature collection only contains lines', async () => { + const sourceDataRequest = new DataRequest({ + data: { + type: 'FeatureCollection', + features: [ + { + geometry: { + type: 'LineString', + }, + properties: {}, + }, + { + geometry: { + type: 'MultiLineString', + }, + properties: {}, + }, + ], + }, + }); + const vectorStyle = new VectorStyle({}, new MockSource()); + + const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(false); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(true); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); + }); + }); + + describe('scaled field range', () => { + const sourceDataRequest = new DataRequest({ + data: { + type: 'FeatureCollection', + features: [ + { + geometry: { + type: 'Point', + }, + properties: { + myDynamicField: 1, + }, + }, + { + geometry: { + type: 'Point', + }, + properties: { + myDynamicField: 10, + }, + }, + ], + }, + }); + + it('Should not extract scaled field range when scaled field has no values', async () => { + const vectorStyle = new VectorStyle( + { + properties: { + fillColor: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + field: { + origin: FIELD_ORIGIN.SOURCE, + name: 'myDynamicFieldWithNoValues', + }, + }, + }, + }, + }, + new MockSource() + ); + + const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(true); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(false); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); + }); + + it('Should extract scaled field range', async () => { + const vectorStyle = new VectorStyle( + { + properties: { + fillColor: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + field: { + origin: FIELD_ORIGIN.SOURCE, + name: 'myDynamicField', + }, + }, + }, + }, + }, + new MockSource() + ); + + const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); + expect(featuresMeta.myDynamicField).toEqual({ + delta: 9, + max: 10, + min: 1, + }); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/styles/vector/vector_style_defaults.js b/x-pack/plugins/maps/public/layers/styles/vector/vector_style_defaults.js new file mode 100644 index 0000000000000..952f8766a6156 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/styles/vector/vector_style_defaults.js @@ -0,0 +1,252 @@ +/* + * 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 { VectorStyle } from './vector_style'; +import { DEFAULT_ICON, SYMBOLIZE_AS_TYPES } from '../../../../common/constants'; +import { + COLOR_GRADIENTS, + COLOR_PALETTES, + DEFAULT_FILL_COLORS, + DEFAULT_LINE_COLORS, +} from '../color_utils'; +import chrome from 'ui/chrome'; + +export const MIN_SIZE = 1; +export const MAX_SIZE = 64; +export const DEFAULT_MIN_SIZE = 4; +export const DEFAULT_MAX_SIZE = 32; +export const DEFAULT_SIGMA = 3; +export const DEFAULT_LABEL_SIZE = 14; +export const DEFAULT_ICON_SIZE = 6; + +export const LABEL_BORDER_SIZES = { + NONE: 'NONE', + SMALL: 'SMALL', + MEDIUM: 'MEDIUM', + LARGE: 'LARGE', +}; + +export const VECTOR_STYLES = { + SYMBOLIZE_AS: 'symbolizeAs', + FILL_COLOR: 'fillColor', + LINE_COLOR: 'lineColor', + LINE_WIDTH: 'lineWidth', + ICON: 'icon', + ICON_SIZE: 'iconSize', + ICON_ORIENTATION: 'iconOrientation', + LABEL_TEXT: 'labelText', + LABEL_COLOR: 'labelColor', + LABEL_SIZE: 'labelSize', + LABEL_BORDER_COLOR: 'labelBorderColor', + LABEL_BORDER_SIZE: 'labelBorderSize', +}; + +export const LINE_STYLES = [VECTOR_STYLES.LINE_COLOR, VECTOR_STYLES.LINE_WIDTH]; +export const POLYGON_STYLES = [ + VECTOR_STYLES.FILL_COLOR, + VECTOR_STYLES.LINE_COLOR, + VECTOR_STYLES.LINE_WIDTH, +]; + +export function getDefaultProperties(mapColors = []) { + return { + ...getDefaultStaticProperties(mapColors), + [VECTOR_STYLES.SYMBOLIZE_AS]: { + options: { + value: SYMBOLIZE_AS_TYPES.CIRCLE, + }, + }, + [VECTOR_STYLES.LABEL_BORDER_SIZE]: { + options: { + size: LABEL_BORDER_SIZES.SMALL, + }, + }, + }; +} + +export function getDefaultStaticProperties(mapColors = []) { + // Colors must be state-aware to reduce unnecessary incrementation + const lastColor = mapColors.pop(); + const nextColorIndex = (DEFAULT_FILL_COLORS.indexOf(lastColor) + 1) % DEFAULT_FILL_COLORS.length; + const nextFillColor = DEFAULT_FILL_COLORS[nextColorIndex]; + const nextLineColor = DEFAULT_LINE_COLORS[nextColorIndex]; + + const isDarkMode = chrome.getUiSettingsClient().get('theme:darkMode', false); + + return { + [VECTOR_STYLES.ICON]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + value: DEFAULT_ICON, + }, + }, + [VECTOR_STYLES.FILL_COLOR]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + color: nextFillColor, + }, + }, + [VECTOR_STYLES.LINE_COLOR]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + color: nextLineColor, + }, + }, + [VECTOR_STYLES.LINE_WIDTH]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + size: 1, + }, + }, + [VECTOR_STYLES.ICON_SIZE]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + size: DEFAULT_ICON_SIZE, + }, + }, + [VECTOR_STYLES.ICON_ORIENTATION]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + orientation: 0, + }, + }, + [VECTOR_STYLES.LABEL_TEXT]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + value: '', + }, + }, + [VECTOR_STYLES.LABEL_COLOR]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + color: isDarkMode ? '#FFFFFF' : '#000000', + }, + }, + [VECTOR_STYLES.LABEL_SIZE]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + size: DEFAULT_LABEL_SIZE, + }, + }, + [VECTOR_STYLES.LABEL_BORDER_COLOR]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + color: isDarkMode ? '#000000' : '#FFFFFF', + }, + }, + }; +} + +export function getDefaultDynamicProperties() { + return { + [VECTOR_STYLES.ICON]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + iconPaletteId: 'filledShapes', + field: undefined, + }, + }, + [VECTOR_STYLES.FILL_COLOR]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + color: COLOR_GRADIENTS[0].value, + colorCategory: COLOR_PALETTES[0].value, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, + [VECTOR_STYLES.LINE_COLOR]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + color: undefined, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, + [VECTOR_STYLES.LINE_WIDTH]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + minSize: 1, + maxSize: 10, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, + [VECTOR_STYLES.ICON_SIZE]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + minSize: DEFAULT_MIN_SIZE, + maxSize: DEFAULT_MAX_SIZE, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, + [VECTOR_STYLES.ICON_ORIENTATION]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, + [VECTOR_STYLES.LABEL_TEXT]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + field: undefined, + }, + }, + [VECTOR_STYLES.LABEL_COLOR]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + color: COLOR_GRADIENTS[0].value, + colorCategory: COLOR_PALETTES[0].value, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, + [VECTOR_STYLES.LABEL_SIZE]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + minSize: DEFAULT_MIN_SIZE, + maxSize: DEFAULT_MAX_SIZE, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, + [VECTOR_STYLES.LABEL_BORDER_COLOR]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + color: COLOR_GRADIENTS[0].value, + colorCategory: COLOR_PALETTES[0].value, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, + }; +} diff --git a/x-pack/plugins/maps/public/layers/tile_layer.js b/x-pack/plugins/maps/public/layers/tile_layer.js new file mode 100644 index 0000000000000..b35adcad976c3 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/tile_layer.js @@ -0,0 +1,106 @@ +/* + * 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 { AbstractLayer } from './layer'; +import _ from 'lodash'; +import { SOURCE_DATA_ID_ORIGIN, LAYER_TYPE } from '../../common/constants'; + +export class TileLayer extends AbstractLayer { + static type = LAYER_TYPE.TILE; + + static createDescriptor(options) { + const tileLayerDescriptor = super.createDescriptor(options); + tileLayerDescriptor.type = TileLayer.type; + tileLayerDescriptor.alpha = _.get(options, 'alpha', 1); + return tileLayerDescriptor; + } + + async syncData({ startLoading, stopLoading, onLoadError, dataFilters }) { + if (!this.isVisible() || !this.showAtZoomLevel(dataFilters.zoom)) { + return; + } + const sourceDataRequest = this.getSourceDataRequest(); + if (sourceDataRequest) { + //data is immmutable + return; + } + const requestToken = Symbol(`layer-source-refresh:${this.getId()} - source`); + startLoading(SOURCE_DATA_ID_ORIGIN, requestToken, dataFilters); + try { + const url = await this._source.getUrlTemplate(); + stopLoading(SOURCE_DATA_ID_ORIGIN, requestToken, url, {}); + } catch (error) { + onLoadError(SOURCE_DATA_ID_ORIGIN, requestToken, error.message); + } + } + + _getMbLayerId() { + return this.makeMbLayerId('raster'); + } + + getMbLayerIds() { + return [this._getMbLayerId()]; + } + + ownsMbLayerId(mbLayerId) { + return this._getMbLayerId() === mbLayerId; + } + + ownsMbSourceId(mbSourceId) { + return this.getId() === mbSourceId; + } + + syncLayerWithMB(mbMap) { + const source = mbMap.getSource(this.getId()); + const mbLayerId = this._getMbLayerId(); + + if (!source) { + const sourceDataRequest = this.getSourceDataRequest(); + if (!sourceDataRequest) { + //this is possible if the layer was invisible at startup. + //the actions will not perform any data=syncing as an optimization when a layer is invisible + //when turning the layer back into visible, it's possible the url has not been resovled yet. + return; + } + const url = sourceDataRequest.getData(); + if (!url) { + return; + } + + const sourceId = this.getId(); + mbMap.addSource(sourceId, { + type: 'raster', + tiles: [url], + tileSize: 256, + scheme: 'xyz', + }); + + mbMap.addLayer({ + id: mbLayerId, + type: 'raster', + source: sourceId, + minzoom: this._descriptor.minZoom, + maxzoom: this._descriptor.maxZoom, + }); + } + + this._setTileLayerProperties(mbMap, mbLayerId); + } + + _setTileLayerProperties(mbMap, mbLayerId) { + this.syncVisibilityWithMb(mbMap, mbLayerId); + mbMap.setLayerZoomRange(mbLayerId, this._descriptor.minZoom, this._descriptor.maxZoom); + mbMap.setPaintProperty(mbLayerId, 'raster-opacity', this.getAlpha()); + } + + getLayerTypeIconName() { + return 'grid'; + } + + isLayerLoading() { + return false; + } +} diff --git a/x-pack/plugins/maps/public/layers/tooltips/es_aggmetric_tooltip_property.js b/x-pack/plugins/maps/public/layers/tooltips/es_aggmetric_tooltip_property.js new file mode 100644 index 0000000000000..7cfb60910c155 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/tooltips/es_aggmetric_tooltip_property.js @@ -0,0 +1,42 @@ +/* + * 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 { ESTooltipProperty } from './es_tooltip_property'; +import { METRIC_TYPE } from '../../../common/constants'; + +export class ESAggMetricTooltipProperty extends ESTooltipProperty { + constructor(propertyKey, propertyName, rawValue, indexPattern, metricField) { + super(propertyKey, propertyName, rawValue, indexPattern); + this._metricField = metricField; + } + + isFilterable() { + return false; + } + + getHtmlDisplayValue() { + if (typeof this._rawValue === 'undefined') { + return '-'; + } + if ( + this._metricField.getAggType() === METRIC_TYPE.COUNT || + this._metricField.getAggType() === METRIC_TYPE.UNIQUE_COUNT + ) { + return this._rawValue; + } + const indexPatternField = this._indexPattern.fields.getByName( + this._metricField.getESDocFieldName() + ); + if (!indexPatternField) { + return this._rawValue; + } + const htmlConverter = indexPatternField.format.getConverterFor('html'); + + return htmlConverter + ? htmlConverter(this._rawValue) + : indexPatternField.format.convert(this._rawValue); + } +} diff --git a/x-pack/plugins/maps/public/layers/tooltips/es_tooltip_property.js b/x-pack/plugins/maps/public/layers/tooltips/es_tooltip_property.js new file mode 100644 index 0000000000000..91195739e00d7 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/tooltips/es_tooltip_property.js @@ -0,0 +1,49 @@ +/* + * 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 { TooltipProperty } from './tooltip_property'; +import _ from 'lodash'; +import { esFilters } from '../../../../../../src/plugins/data/public'; +export class ESTooltipProperty extends TooltipProperty { + constructor(propertyKey, propertyName, rawValue, indexPattern) { + super(propertyKey, propertyName, rawValue); + this._indexPattern = indexPattern; + } + + getHtmlDisplayValue() { + if (typeof this._rawValue === 'undefined') { + return '-'; + } + + const field = this._indexPattern.fields.getByName(this._propertyName); + if (!field) { + return _.escape(this._rawValue); + } + const htmlConverter = field.format.getConverterFor('html'); + return htmlConverter ? htmlConverter(this._rawValue) : field.format.convert(this._rawValue); + } + + isFilterable() { + const field = this._indexPattern.fields.getByName(this._propertyName); + return ( + field && + (field.type === 'string' || + field.type === 'date' || + field.type === 'ip' || + field.type === 'number') + ); + } + + async getESFilters() { + return [ + esFilters.buildPhraseFilter( + this._indexPattern.fields.getByName(this._propertyName), + this._rawValue, + this._indexPattern + ), + ]; + } +} diff --git a/x-pack/plugins/maps/public/layers/tooltips/join_tooltip_property.js b/x-pack/plugins/maps/public/layers/tooltips/join_tooltip_property.js new file mode 100644 index 0000000000000..e62f93c959faa --- /dev/null +++ b/x-pack/plugins/maps/public/layers/tooltips/join_tooltip_property.js @@ -0,0 +1,55 @@ +/* + * 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 { TooltipProperty } from './tooltip_property'; + +export class JoinTooltipProperty extends TooltipProperty { + constructor(tooltipProperty, leftInnerJoins) { + super(); + this._tooltipProperty = tooltipProperty; + this._leftInnerJoins = leftInnerJoins; + } + + isFilterable() { + return true; + } + + getPropertyKey() { + return this._tooltipProperty.getPropertyKey(); + } + + getPropertyName() { + return this._tooltipProperty.getPropertyName(); + } + + getHtmlDisplayValue() { + return this._tooltipProperty.getHtmlDisplayValue(); + } + + async getESFilters() { + const esFilters = []; + if (this._tooltipProperty.isFilterable()) { + esFilters.push(...(await this._tooltipProperty.getESFilters())); + } + + for (let i = 0; i < this._leftInnerJoins.length; i++) { + const rightSource = this._leftInnerJoins[i].getRightJoinSource(); + const termField = rightSource.getTermField(); + try { + const esTooltipProperty = await termField.createTooltipProperty( + this._tooltipProperty.getRawValue() + ); + if (esTooltipProperty) { + esFilters.push(...(await esTooltipProperty.getESFilters())); + } + } catch (e) { + console.error('Cannot create joined filter', e); + } + } + + return esFilters; + } +} diff --git a/x-pack/plugins/maps/public/layers/tooltips/tooltip_property.js b/x-pack/plugins/maps/public/layers/tooltips/tooltip_property.js new file mode 100644 index 0000000000000..e063913abf433 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/tooltips/tooltip_property.js @@ -0,0 +1,39 @@ +/* + * 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 _ from 'lodash'; + +export class TooltipProperty { + constructor(propertyKey, propertyName, rawValue) { + this._propertyKey = propertyKey; + this._propertyName = propertyName; + this._rawValue = rawValue; + } + + getPropertyKey() { + return this._propertyKey; + } + + getPropertyName() { + return this._propertyName; + } + + getHtmlDisplayValue() { + return _.escape(this._rawValue); + } + + getRawValue() { + return this._rawValue; + } + + isFilterable() { + return false; + } + + async getESFilters() { + return []; + } +} diff --git a/x-pack/plugins/maps/public/layers/util/assign_feature_ids.js b/x-pack/plugins/maps/public/layers/util/assign_feature_ids.js new file mode 100644 index 0000000000000..a943b0b22a189 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/util/assign_feature_ids.js @@ -0,0 +1,56 @@ +/* + * 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 _ from 'lodash'; +import { FEATURE_ID_PROPERTY_NAME } from '../../../common/constants'; + +let idCounter = 0; + +function generateNumericalId() { + const newId = idCounter < Number.MAX_SAFE_INTEGER ? idCounter : 0; + idCounter = newId + 1; + return newId; +} + +export function assignFeatureIds(featureCollection) { + // wrt https://github.com/elastic/kibana/issues/39317 + // In constrained resource environments, mapbox-gl may throw a stackoverflow error due to hitting the browser's recursion limit. This crashes Kibana. + // This error is thrown in mapbox-gl's quicksort implementation, when it is sorting all the features by id. + // This is a work-around to avoid hitting such a worst-case + // This was tested as a suitable work-around for mapbox-gl 0.54 + // The core issue itself is likely related to https://github.com/mapbox/mapbox-gl-js/issues/6086 + + // This only shuffles the id-assignment, _not_ the features in the collection + // The reason for this is that we do not want to modify the feature-ordering, which is the responsiblity of the VectorSource#. + const ids = []; + for (let i = 0; i < featureCollection.features.length; i++) { + const id = generateNumericalId(); + ids.push(id); + } + + const randomizedIds = _.shuffle(ids); + const features = []; + for (let i = 0; i < featureCollection.features.length; i++) { + const numericId = randomizedIds[i]; + const feature = featureCollection.features[i]; + features.push({ + type: 'Feature', + geometry: feature.geometry, // do not copy geometry, this object can be massive + properties: { + // preserve feature id provided by source so features can be referenced across fetches + [FEATURE_ID_PROPERTY_NAME]: feature.id == null ? numericId : feature.id, + // create new object for properties so original is not polluted with kibana internal props + ...feature.properties, + }, + id: numericId, // Mapbox feature state id, must be integer + }); + } + + return { + type: 'FeatureCollection', + features, + }; +} diff --git a/x-pack/plugins/maps/public/layers/util/assign_feature_ids.test.js b/x-pack/plugins/maps/public/layers/util/assign_feature_ids.test.js new file mode 100644 index 0000000000000..cc8dff14ec4f0 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/util/assign_feature_ids.test.js @@ -0,0 +1,82 @@ +/* + * 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 { assignFeatureIds } from './assign_feature_ids'; +import { FEATURE_ID_PROPERTY_NAME } from '../../../common/constants'; + +const featureId = 'myFeature1'; + +test('should provide unique id when feature.id is not provided', () => { + const featureCollection = { + features: [ + { + properties: {}, + }, + { + properties: {}, + }, + ], + }; + + const updatedFeatureCollection = assignFeatureIds(featureCollection); + const feature1 = updatedFeatureCollection.features[0]; + const feature2 = updatedFeatureCollection.features[1]; + expect(typeof feature1.id).toBe('number'); + expect(typeof feature2.id).toBe('number'); + expect(feature1.id).toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); + expect(feature1.id).not.toBe(feature2.id); +}); + +test('should preserve feature id when provided', () => { + const featureCollection = { + features: [ + { + id: featureId, + properties: {}, + }, + ], + }; + + const updatedFeatureCollection = assignFeatureIds(featureCollection); + const feature1 = updatedFeatureCollection.features[0]; + expect(typeof feature1.id).toBe('number'); + expect(feature1.id).not.toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); + expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(featureId); +}); + +test('should preserve feature id for falsy value', () => { + const featureCollection = { + features: [ + { + id: 0, + properties: {}, + }, + ], + }; + + const updatedFeatureCollection = assignFeatureIds(featureCollection); + const feature1 = updatedFeatureCollection.features[0]; + expect(typeof feature1.id).toBe('number'); + expect(feature1.id).not.toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); + expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(0); +}); + +test('should not modify original feature properties', () => { + const featureProperties = {}; + const featureCollection = { + features: [ + { + id: featureId, + properties: featureProperties, + }, + ], + }; + + const updatedFeatureCollection = assignFeatureIds(featureCollection); + const feature1 = updatedFeatureCollection.features[0]; + expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(featureId); + expect(featureProperties).not.toHaveProperty(FEATURE_ID_PROPERTY_NAME); +}); diff --git a/x-pack/plugins/maps/public/layers/util/can_skip_fetch.js b/x-pack/plugins/maps/public/layers/util/can_skip_fetch.js new file mode 100644 index 0000000000000..7abfee1b184f0 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/util/can_skip_fetch.js @@ -0,0 +1,172 @@ +/* + * 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 _ from 'lodash'; +import turf from 'turf'; +import turfBooleanContains from '@turf/boolean-contains'; +import { isRefreshOnlyQuery } from './is_refresh_only_query'; + +const SOURCE_UPDATE_REQUIRED = true; +const NO_SOURCE_UPDATE_REQUIRED = false; + +export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { + const extentAware = source.isFilterByMapBounds(); + if (!extentAware) { + return NO_SOURCE_UPDATE_REQUIRED; + } + + const { buffer: previousBuffer } = prevMeta; + const { buffer: newBuffer } = nextMeta; + + if (!previousBuffer) { + return SOURCE_UPDATE_REQUIRED; + } + + if (_.isEqual(previousBuffer, newBuffer)) { + return NO_SOURCE_UPDATE_REQUIRED; + } + + const previousBufferGeometry = turf.bboxPolygon([ + previousBuffer.minLon, + previousBuffer.minLat, + previousBuffer.maxLon, + previousBuffer.maxLat, + ]); + const newBufferGeometry = turf.bboxPolygon([ + newBuffer.minLon, + newBuffer.minLat, + newBuffer.maxLon, + newBuffer.maxLat, + ]); + const doesPreviousBufferContainNewBuffer = turfBooleanContains( + previousBufferGeometry, + newBufferGeometry + ); + + const isTrimmed = _.get(prevMeta, 'areResultsTrimmed', false); + return doesPreviousBufferContainNewBuffer && !isTrimmed + ? NO_SOURCE_UPDATE_REQUIRED + : SOURCE_UPDATE_REQUIRED; +} + +export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) { + const timeAware = await source.isTimeAware(); + const refreshTimerAware = await source.isRefreshTimerAware(); + const extentAware = source.isFilterByMapBounds(); + const isFieldAware = source.isFieldAware(); + const isQueryAware = source.isQueryAware(); + const isGeoGridPrecisionAware = source.isGeoGridPrecisionAware(); + + if ( + !timeAware && + !refreshTimerAware && + !extentAware && + !isFieldAware && + !isQueryAware && + !isGeoGridPrecisionAware + ) { + return prevDataRequest && prevDataRequest.hasDataOrRequestInProgress(); + } + + if (!prevDataRequest) { + return false; + } + const prevMeta = prevDataRequest.getMeta(); + if (!prevMeta) { + return false; + } + + let updateDueToTime = false; + if (timeAware) { + updateDueToTime = !_.isEqual(prevMeta.timeFilters, nextMeta.timeFilters); + } + + let updateDueToRefreshTimer = false; + if (refreshTimerAware && nextMeta.refreshTimerLastTriggeredAt) { + updateDueToRefreshTimer = !_.isEqual( + prevMeta.refreshTimerLastTriggeredAt, + nextMeta.refreshTimerLastTriggeredAt + ); + } + + let updateDueToFields = false; + if (isFieldAware) { + updateDueToFields = !_.isEqual(prevMeta.fieldNames, nextMeta.fieldNames); + } + + let updateDueToQuery = false; + let updateDueToFilters = false; + let updateDueToSourceQuery = false; + let updateDueToApplyGlobalQuery = false; + if (isQueryAware) { + updateDueToApplyGlobalQuery = prevMeta.applyGlobalQuery !== nextMeta.applyGlobalQuery; + updateDueToSourceQuery = !_.isEqual(prevMeta.sourceQuery, nextMeta.sourceQuery); + if (nextMeta.applyGlobalQuery) { + updateDueToQuery = !_.isEqual(prevMeta.query, nextMeta.query); + updateDueToFilters = !_.isEqual(prevMeta.filters, nextMeta.filters); + } else { + // Global filters and query are not applied to layer search request so no re-fetch required. + // Exception is "Refresh" query. + updateDueToQuery = isRefreshOnlyQuery(prevMeta.query, nextMeta.query); + } + } + + let updateDueToPrecisionChange = false; + if (isGeoGridPrecisionAware) { + updateDueToPrecisionChange = !_.isEqual(prevMeta.geogridPrecision, nextMeta.geogridPrecision); + } + + const updateDueToExtentChange = updateDueToExtent(source, prevMeta, nextMeta); + + const updateDueToSourceMetaChange = !_.isEqual(prevMeta.sourceMeta, nextMeta.sourceMeta); + + return ( + !updateDueToTime && + !updateDueToRefreshTimer && + !updateDueToExtentChange && + !updateDueToFields && + !updateDueToQuery && + !updateDueToFilters && + !updateDueToSourceQuery && + !updateDueToApplyGlobalQuery && + !updateDueToPrecisionChange && + !updateDueToSourceMetaChange + ); +} + +export function canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }) { + if (!prevDataRequest) { + return false; + } + const prevMeta = prevDataRequest.getMeta(); + if (!prevMeta) { + return false; + } + + const updateDueToFields = !_.isEqual(prevMeta.dynamicStyleFields, nextMeta.dynamicStyleFields); + + const updateDueToSourceQuery = !_.isEqual(prevMeta.sourceQuery, nextMeta.sourceQuery); + + const updateDueToIsTimeAware = nextMeta.isTimeAware !== prevMeta.isTimeAware; + const updateDueToTime = nextMeta.isTimeAware + ? !_.isEqual(prevMeta.timeFilters, nextMeta.timeFilters) + : false; + + return ( + !updateDueToFields && !updateDueToSourceQuery && !updateDueToIsTimeAware && !updateDueToTime + ); +} + +export function canSkipFormattersUpdate({ prevDataRequest, nextMeta }) { + if (!prevDataRequest) { + return false; + } + const prevMeta = prevDataRequest.getMeta(); + if (!prevMeta) { + return false; + } + + return _.isEqual(prevMeta.fieldNames, nextMeta.fieldNames); +} diff --git a/x-pack/plugins/maps/public/layers/util/can_skip_fetch.test.js b/x-pack/plugins/maps/public/layers/util/can_skip_fetch.test.js new file mode 100644 index 0000000000000..2a4843c78635f --- /dev/null +++ b/x-pack/plugins/maps/public/layers/util/can_skip_fetch.test.js @@ -0,0 +1,308 @@ +/* + * 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 { canSkipSourceUpdate, updateDueToExtent } from './can_skip_fetch'; +import { DataRequest } from './data_request'; + +describe('updateDueToExtent', () => { + it('should be false when the source is not extent aware', async () => { + const sourceMock = { + isFilterByMapBounds: () => { + return false; + }, + }; + expect(updateDueToExtent(sourceMock)).toBe(false); + }); + + describe('source is extent aware', () => { + const sourceMock = { + isFilterByMapBounds: () => { + return true; + }, + }; + + it('should be false when buffers are the same', async () => { + const oldBuffer = { + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }; + const newBuffer = { + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }; + expect(updateDueToExtent(sourceMock, { buffer: oldBuffer }, { buffer: newBuffer })).toBe( + false + ); + }); + + it('should be false when the new buffer is contained in the old buffer', async () => { + const oldBuffer = { + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }; + const newBuffer = { + maxLat: 10, + maxLon: 100, + minLat: 5, + minLon: 95, + }; + expect(updateDueToExtent(sourceMock, { buffer: oldBuffer }, { buffer: newBuffer })).toBe( + false + ); + }); + + it('should be true when the new buffer is contained in the old buffer and the past results were truncated', async () => { + const oldBuffer = { + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }; + const newBuffer = { + maxLat: 10, + maxLon: 100, + minLat: 5, + minLon: 95, + }; + expect( + updateDueToExtent( + sourceMock, + { buffer: oldBuffer, areResultsTrimmed: true }, + { buffer: newBuffer } + ) + ).toBe(true); + }); + + it('should be true when meta has no old buffer', async () => { + expect(updateDueToExtent(sourceMock)).toBe(true); + }); + + it('should be true when the new buffer is not contained in the old buffer', async () => { + const oldBuffer = { + maxLat: 12.5, + maxLon: 102.5, + minLat: 2.5, + minLon: 92.5, + }; + const newBuffer = { + maxLat: 7.5, + maxLon: 92.5, + minLat: -2.5, + minLon: 82.5, + }; + expect(updateDueToExtent(sourceMock, { buffer: oldBuffer }, { buffer: newBuffer })).toBe( + true + ); + }); + }); +}); + +describe('canSkipSourceUpdate', () => { + const SOURCE_DATA_REQUEST_ID = 'foo'; + + describe('isQueryAware', () => { + const queryAwareSourceMock = { + isTimeAware: () => { + return false; + }, + isRefreshTimerAware: () => { + return false; + }, + isFilterByMapBounds: () => { + return false; + }, + isFieldAware: () => { + return false; + }, + isQueryAware: () => { + return true; + }, + isGeoGridPrecisionAware: () => { + return false; + }, + }; + const prevFilters = []; + const prevQuery = { + language: 'kuery', + query: 'machine.os.keyword : "win 7"', + queryLastTriggeredAt: '2019-04-25T20:53:22.331Z', + }; + + describe('applyGlobalQuery is false', () => { + const prevApplyGlobalQuery = false; + + const prevDataRequest = new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalQuery: prevApplyGlobalQuery, + filters: prevFilters, + query: prevQuery, + }, + data: {}, + }); + + it('can skip update when filter changes', async () => { + const nextMeta = { + applyGlobalQuery: prevApplyGlobalQuery, + filters: [prevQuery], + query: prevQuery, + }; + + const canSkipUpdate = await canSkipSourceUpdate({ + source: queryAwareSourceMock, + prevDataRequest, + nextMeta, + }); + + expect(canSkipUpdate).toBe(true); + }); + + it('can skip update when query changes', async () => { + const nextMeta = { + applyGlobalQuery: prevApplyGlobalQuery, + filters: prevFilters, + query: { + ...prevQuery, + query: 'a new query string', + }, + }; + + const canSkipUpdate = await canSkipSourceUpdate({ + source: queryAwareSourceMock, + prevDataRequest, + nextMeta, + }); + + expect(canSkipUpdate).toBe(true); + }); + + it('can not skip update when query is refreshed', async () => { + const nextMeta = { + applyGlobalQuery: prevApplyGlobalQuery, + filters: prevFilters, + query: { + ...prevQuery, + queryLastTriggeredAt: 'sometime layer when Refresh button is clicked', + }, + }; + + const canSkipUpdate = await canSkipSourceUpdate({ + source: queryAwareSourceMock, + prevDataRequest, + nextMeta, + }); + + expect(canSkipUpdate).toBe(false); + }); + + it('can not skip update when applyGlobalQuery changes', async () => { + const nextMeta = { + applyGlobalQuery: !prevApplyGlobalQuery, + filters: prevFilters, + query: prevQuery, + }; + + const canSkipUpdate = await canSkipSourceUpdate({ + source: queryAwareSourceMock, + prevDataRequest, + nextMeta, + }); + + expect(canSkipUpdate).toBe(false); + }); + }); + + describe('applyGlobalQuery is true', () => { + const prevApplyGlobalQuery = true; + + const prevDataRequest = new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalQuery: prevApplyGlobalQuery, + filters: prevFilters, + query: prevQuery, + }, + data: {}, + }); + + it('can not skip update when filter changes', async () => { + const nextMeta = { + applyGlobalQuery: prevApplyGlobalQuery, + filters: [prevQuery], + query: prevQuery, + }; + + const canSkipUpdate = await canSkipSourceUpdate({ + source: queryAwareSourceMock, + prevDataRequest, + nextMeta, + }); + + expect(canSkipUpdate).toBe(false); + }); + + it('can not skip update when query changes', async () => { + const nextMeta = { + applyGlobalQuery: prevApplyGlobalQuery, + filters: prevFilters, + query: { + ...prevQuery, + query: 'a new query string', + }, + }; + + const canSkipUpdate = await canSkipSourceUpdate({ + source: queryAwareSourceMock, + prevDataRequest, + nextMeta, + }); + + expect(canSkipUpdate).toBe(false); + }); + + it('can not skip update when query is refreshed', async () => { + const nextMeta = { + applyGlobalQuery: prevApplyGlobalQuery, + filters: prevFilters, + query: { + ...prevQuery, + queryLastTriggeredAt: 'sometime layer when Refresh button is clicked', + }, + }; + + const canSkipUpdate = await canSkipSourceUpdate({ + source: queryAwareSourceMock, + prevDataRequest, + nextMeta, + }); + + expect(canSkipUpdate).toBe(false); + }); + + it('can not skip update when applyGlobalQuery changes', async () => { + const nextMeta = { + applyGlobalQuery: !prevApplyGlobalQuery, + filters: prevFilters, + query: prevQuery, + }; + + const canSkipUpdate = await canSkipSourceUpdate({ + source: queryAwareSourceMock, + prevDataRequest, + nextMeta, + }); + + expect(canSkipUpdate).toBe(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/layers/util/data_request.js b/x-pack/plugins/maps/public/layers/util/data_request.js new file mode 100644 index 0000000000000..3a6c10a9f07a6 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/util/data_request.js @@ -0,0 +1,50 @@ +/* + * 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 _ from 'lodash'; + +export class DataRequest { + constructor(descriptor) { + this._descriptor = { + ...descriptor, + }; + } + + getData() { + return this._descriptor.data; + } + + isLoading() { + return !!this._descriptor.dataRequestToken; + } + + getMeta() { + return this.hasData() + ? _.get(this._descriptor, 'dataMeta', {}) + : _.get(this._descriptor, 'dataMetaAtStart', {}); + } + + hasData() { + return !!this._descriptor.data; + } + + hasDataOrRequestInProgress() { + return this._descriptor.data || this._descriptor.dataRequestToken; + } + + getDataId() { + return this._descriptor.dataId; + } + + getRequestToken() { + return this._descriptor.dataRequestToken; + } +} + +export class DataRequestAbortError extends Error { + constructor() { + super(); + } +} diff --git a/x-pack/plugins/maps/public/layers/util/is_metric_countable.js b/x-pack/plugins/maps/public/layers/util/is_metric_countable.js new file mode 100644 index 0000000000000..54d8794b1e3cf --- /dev/null +++ b/x-pack/plugins/maps/public/layers/util/is_metric_countable.js @@ -0,0 +1,11 @@ +/* + * 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 { METRIC_TYPE } from '../../../common/constants'; + +export function isMetricCountable(aggType) { + return [METRIC_TYPE.COUNT, METRIC_TYPE.SUM, METRIC_TYPE.UNIQUE_COUNT].includes(aggType); +} diff --git a/x-pack/plugins/maps/public/layers/util/is_refresh_only_query.js b/x-pack/plugins/maps/public/layers/util/is_refresh_only_query.js new file mode 100644 index 0000000000000..f3dc08a7a7a58 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/util/is_refresh_only_query.js @@ -0,0 +1,18 @@ +/* + * 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. + */ + +// Refresh only query is query where timestamps are different but query is the same. +// Triggered by clicking "Refresh" button in QueryBar +export function isRefreshOnlyQuery(prevQuery, newQuery) { + if (!prevQuery || !newQuery) { + return false; + } + return ( + prevQuery.queryLastTriggeredAt !== newQuery.queryLastTriggeredAt && + prevQuery.language === newQuery.language && + prevQuery.query === newQuery.query + ); +} diff --git a/x-pack/plugins/maps/public/layers/util/mb_filter_expressions.js b/x-pack/plugins/maps/public/layers/util/mb_filter_expressions.js new file mode 100644 index 0000000000000..36841dc727dd3 --- /dev/null +++ b/x-pack/plugins/maps/public/layers/util/mb_filter_expressions.js @@ -0,0 +1,47 @@ +/* + * 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 { GEO_JSON_TYPE, FEATURE_VISIBLE_PROPERTY_NAME } from '../../../common/constants'; + +const VISIBILITY_FILTER_CLAUSE = ['all', ['==', ['get', FEATURE_VISIBLE_PROPERTY_NAME], true]]; + +const CLOSED_SHAPE_MB_FILTER = [ + 'any', + ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], +]; + +const VISIBLE_CLOSED_SHAPE_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, CLOSED_SHAPE_MB_FILTER]; + +const ALL_SHAPE_MB_FILTER = [ + 'any', + ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], + ['==', ['geometry-type'], GEO_JSON_TYPE.LINE_STRING], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_LINE_STRING], +]; + +const VISIBLE_ALL_SHAPE_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, ALL_SHAPE_MB_FILTER]; + +const POINT_MB_FILTER = [ + 'any', + ['==', ['geometry-type'], GEO_JSON_TYPE.POINT], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POINT], +]; + +const VISIBLE_POINT_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, POINT_MB_FILTER]; + +export function getFillFilterExpression(hasJoins) { + return hasJoins ? VISIBLE_CLOSED_SHAPE_MB_FILTER : CLOSED_SHAPE_MB_FILTER; +} + +export function getLineFilterExpression(hasJoins) { + return hasJoins ? VISIBLE_ALL_SHAPE_MB_FILTER : ALL_SHAPE_MB_FILTER; +} + +export function getPointFilterExpression(hasJoins) { + return hasJoins ? VISIBLE_POINT_MB_FILTER : POINT_MB_FILTER; +} diff --git a/x-pack/plugins/maps/public/layers/vector_layer.js b/x-pack/plugins/maps/public/layers/vector_layer.js new file mode 100644 index 0000000000000..31c3831fb612a --- /dev/null +++ b/x-pack/plugins/maps/public/layers/vector_layer.js @@ -0,0 +1,877 @@ +/* + * 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 turf from 'turf'; +import React from 'react'; +import { AbstractLayer } from './layer'; +import { VectorStyle } from './styles/vector/vector_style'; +import { InnerJoin } from './joins/inner_join'; +import { + FEATURE_ID_PROPERTY_NAME, + SOURCE_DATA_ID_ORIGIN, + SOURCE_META_ID_ORIGIN, + SOURCE_FORMATTERS_ID_ORIGIN, + FEATURE_VISIBLE_PROPERTY_NAME, + EMPTY_FEATURE_COLLECTION, + LAYER_TYPE, + FIELD_ORIGIN, + LAYER_STYLE_TYPE, +} from '../../common/constants'; +import _ from 'lodash'; +import { JoinTooltipProperty } from './tooltips/join_tooltip_property'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DataRequestAbortError } from './util/data_request'; +import { + canSkipSourceUpdate, + canSkipStyleMetaUpdate, + canSkipFormattersUpdate, +} from './util/can_skip_fetch'; +import { assignFeatureIds } from './util/assign_feature_ids'; +import { + getFillFilterExpression, + getLineFilterExpression, + getPointFilterExpression, +} from './util/mb_filter_expressions'; + +export class VectorLayer extends AbstractLayer { + static type = LAYER_TYPE.VECTOR; + + static createDescriptor(options, mapColors) { + const layerDescriptor = super.createDescriptor(options); + layerDescriptor.type = VectorLayer.type; + + if (!options.style) { + const styleProperties = VectorStyle.createDefaultStyleProperties(mapColors); + layerDescriptor.style = VectorStyle.createDescriptor(styleProperties); + } + + return layerDescriptor; + } + + constructor(options) { + super(options); + this._joins = []; + if (options.layerDescriptor.joins) { + options.layerDescriptor.joins.forEach(joinDescriptor => { + const join = new InnerJoin(joinDescriptor, this._source); + this._joins.push(join); + }); + } + this._style = new VectorStyle(this._descriptor.style, this._source, this); + } + + destroy() { + if (this._source) { + this._source.destroy(); + } + this._joins.forEach(joinSource => { + joinSource.destroy(); + }); + } + + getJoins() { + return this._joins.slice(); + } + + getValidJoins() { + return this._joins.filter(join => { + return join.hasCompleteConfig(); + }); + } + + _hasJoins() { + return this.getValidJoins().length > 0; + } + + isDataLoaded() { + const sourceDataRequest = this.getSourceDataRequest(); + if (!sourceDataRequest || !sourceDataRequest.hasData()) { + return false; + } + + const joins = this.getValidJoins(); + for (let i = 0; i < joins.length; i++) { + const joinDataRequest = this.getDataRequest(joins[i].getSourceDataRequestId()); + if (!joinDataRequest || !joinDataRequest.hasData()) { + return false; + } + } + + return true; + } + + getCustomIconAndTooltipContent() { + const featureCollection = this._getSourceFeatureCollection(); + + const noResultsIcon = ; + if (!featureCollection || featureCollection.features.length === 0) { + return { + icon: noResultsIcon, + tooltipContent: i18n.translate('xpack.maps.vectorLayer.noResultsFoundTooltip', { + defaultMessage: `No results found.`, + }), + }; + } + + if ( + this._joins.length && + !featureCollection.features.some(feature => feature.properties[FEATURE_VISIBLE_PROPERTY_NAME]) + ) { + return { + icon: noResultsIcon, + tooltipContent: i18n.translate('xpack.maps.vectorLayer.noResultsFoundInJoinTooltip', { + defaultMessage: `No matching results found in term joins`, + }), + }; + } + + const sourceDataRequest = this.getSourceDataRequest(); + const { tooltipContent, areResultsTrimmed } = this._source.getSourceTooltipContent( + sourceDataRequest + ); + return { + icon: this._style.getIcon(), + tooltipContent: tooltipContent, + areResultsTrimmed: areResultsTrimmed, + }; + } + + getLayerTypeIconName() { + return 'vector'; + } + + async hasLegendDetails() { + return this._style.hasLegendDetails(); + } + + renderLegendDetails() { + return this._style.renderLegendDetails(); + } + + _getBoundsBasedOnData() { + const featureCollection = this._getSourceFeatureCollection(); + if (!featureCollection) { + return null; + } + + const visibleFeatures = featureCollection.features.filter( + feature => feature.properties[FEATURE_VISIBLE_PROPERTY_NAME] + ); + const bbox = turf.bbox({ + type: 'FeatureCollection', + features: visibleFeatures, + }); + return { + min_lon: bbox[0], + min_lat: bbox[1], + max_lon: bbox[2], + max_lat: bbox[3], + }; + } + + async getBounds(dataFilters) { + const isStaticLayer = !this._source.isBoundsAware() || !this._source.isFilterByMapBounds(); + if (isStaticLayer) { + return this._getBoundsBasedOnData(); + } + + const searchFilters = this._getSearchFilters(dataFilters); + return await this._source.getBoundsForFilters(searchFilters); + } + + async getLeftJoinFields() { + return await this._source.getLeftJoinFields(); + } + + async getSourceName() { + return this._source.getDisplayName(); + } + + _getJoinFields() { + const joinFields = []; + this.getValidJoins().forEach(join => { + const fields = join.getJoinFields(); + joinFields.push(...fields); + }); + return joinFields; + } + + async getDateFields() { + return await this._source.getDateFields(); + } + + async getNumberFields() { + const numberFieldOptions = await this._source.getNumberFields(); + return [...numberFieldOptions, ...this._getJoinFields()]; + } + + async getCategoricalFields() { + return await this._source.getCategoricalFields(); + } + + async getFields() { + const sourceFields = await this._source.getFields(); + return [...sourceFields, ...this._getJoinFields()]; + } + + getIndexPatternIds() { + const indexPatternIds = this._source.getIndexPatternIds(); + this.getValidJoins().forEach(join => { + indexPatternIds.push(...join.getIndexPatternIds()); + }); + return indexPatternIds; + } + + getQueryableIndexPatternIds() { + const indexPatternIds = this._source.getQueryableIndexPatternIds(); + this.getValidJoins().forEach(join => { + indexPatternIds.push(...join.getQueryableIndexPatternIds()); + }); + return indexPatternIds; + } + + _findDataRequestById(sourceDataId) { + return this._dataRequests.find(dataRequest => dataRequest.getDataId() === sourceDataId); + } + + async _syncJoin({ + join, + startLoading, + stopLoading, + onLoadError, + registerCancelCallback, + dataFilters, + }) { + const joinSource = join.getRightJoinSource(); + const sourceDataId = join.getSourceDataRequestId(); + const requestToken = Symbol(`layer-join-refresh:${this.getId()} - ${sourceDataId}`); + const searchFilters = { + ...dataFilters, + fieldNames: joinSource.getFieldNames(), + sourceQuery: joinSource.getWhereQuery(), + applyGlobalQuery: joinSource.getApplyGlobalQuery(), + }; + const prevDataRequest = this._findDataRequestById(sourceDataId); + + const canSkipFetch = await canSkipSourceUpdate({ + source: joinSource, + prevDataRequest, + nextMeta: searchFilters, + }); + if (canSkipFetch) { + return { + dataHasChanged: false, + join: join, + propertiesMap: prevDataRequest.getData(), + }; + } + + try { + startLoading(sourceDataId, requestToken, searchFilters); + const leftSourceName = await this.getSourceName(); + const { propertiesMap } = await joinSource.getPropertiesMap( + searchFilters, + leftSourceName, + join.getLeftField().getName(), + registerCancelCallback.bind(null, requestToken) + ); + stopLoading(sourceDataId, requestToken, propertiesMap); + return { + dataHasChanged: true, + join: join, + propertiesMap: propertiesMap, + }; + } catch (e) { + if (!(e instanceof DataRequestAbortError)) { + onLoadError(sourceDataId, requestToken, `Join error: ${e.message}`); + } + return { + dataHasChanged: true, + join: join, + propertiesMap: null, + }; + } + } + + async _syncJoins(syncContext) { + const joinSyncs = this.getValidJoins().map(async join => { + await this._syncJoinStyleMeta(syncContext, join); + await this._syncJoinFormatters(syncContext, join); + return this._syncJoin({ join, ...syncContext }); + }); + + return await Promise.all(joinSyncs); + } + + _getSearchFilters(dataFilters) { + const fieldNames = [ + ...this._source.getFieldNames(), + ...this._style.getSourceFieldNames(), + ...this.getValidJoins().map(join => join.getLeftField().getName()), + ]; + + return { + ...dataFilters, + fieldNames: _.uniq(fieldNames).sort(), + geogridPrecision: this._source.getGeoGridPrecision(dataFilters.zoom), + sourceQuery: this.getQuery(), + applyGlobalQuery: this._source.getApplyGlobalQuery(), + sourceMeta: this._source.getSyncMeta(), + }; + } + + async _performInnerJoins(sourceResult, joinStates, updateSourceData) { + //should update the store if + //-- source result was refreshed + //-- any of the join configurations changed (joinState changed) + //-- visibility of any of the features has changed + + let shouldUpdateStore = + sourceResult.refreshed || joinStates.some(joinState => joinState.dataHasChanged); + + if (!shouldUpdateStore) { + return; + } + + for (let i = 0; i < sourceResult.featureCollection.features.length; i++) { + const feature = sourceResult.featureCollection.features[i]; + const oldVisbility = feature.properties[FEATURE_VISIBLE_PROPERTY_NAME]; + let isFeatureVisible = true; + for (let j = 0; j < joinStates.length; j++) { + const joinState = joinStates[j]; + const innerJoin = joinState.join; + const canJoinOnCurrent = innerJoin.joinPropertiesToFeature( + feature, + joinState.propertiesMap + ); + isFeatureVisible = isFeatureVisible && canJoinOnCurrent; + } + + if (oldVisbility !== isFeatureVisible) { + shouldUpdateStore = true; + } + + feature.properties[FEATURE_VISIBLE_PROPERTY_NAME] = isFeatureVisible; + } + + if (shouldUpdateStore) { + updateSourceData({ ...sourceResult.featureCollection }); + } + } + + async _syncSource({ + startLoading, + stopLoading, + onLoadError, + registerCancelCallback, + dataFilters, + }) { + const requestToken = Symbol(`layer-${this.getId()}-${SOURCE_DATA_ID_ORIGIN}`); + const searchFilters = this._getSearchFilters(dataFilters); + const prevDataRequest = this.getSourceDataRequest(); + const canSkipFetch = await canSkipSourceUpdate({ + source: this._source, + prevDataRequest, + nextMeta: searchFilters, + }); + if (canSkipFetch) { + return { + refreshed: false, + featureCollection: prevDataRequest.getData(), + }; + } + + try { + startLoading(SOURCE_DATA_ID_ORIGIN, requestToken, searchFilters); + const layerName = await this.getDisplayName(); + const { data: sourceFeatureCollection, meta } = await this._source.getGeoJsonWithMeta( + layerName, + searchFilters, + registerCancelCallback.bind(null, requestToken) + ); + const layerFeatureCollection = assignFeatureIds(sourceFeatureCollection); + stopLoading(SOURCE_DATA_ID_ORIGIN, requestToken, layerFeatureCollection, meta); + return { + refreshed: true, + featureCollection: layerFeatureCollection, + }; + } catch (error) { + if (!(error instanceof DataRequestAbortError)) { + onLoadError(SOURCE_DATA_ID_ORIGIN, requestToken, error.message); + } + return { + refreshed: false, + }; + } + } + + async _syncSourceStyleMeta(syncContext) { + if (this._style.constructor.type !== LAYER_STYLE_TYPE.VECTOR) { + return; + } + + return this._syncStyleMeta({ + source: this._source, + sourceQuery: this.getQuery(), + dataRequestId: SOURCE_META_ID_ORIGIN, + dynamicStyleProps: this._style.getDynamicPropertiesArray().filter(dynamicStyleProp => { + return ( + dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.SOURCE && + dynamicStyleProp.isFieldMetaEnabled() + ); + }), + ...syncContext, + }); + } + + async _syncJoinStyleMeta(syncContext, join) { + const joinSource = join.getRightJoinSource(); + return this._syncStyleMeta({ + source: joinSource, + sourceQuery: joinSource.getWhereQuery(), + dataRequestId: join.getSourceMetaDataRequestId(), + dynamicStyleProps: this._style.getDynamicPropertiesArray().filter(dynamicStyleProp => { + const matchingField = joinSource.getMetricFieldForName( + dynamicStyleProp.getField().getName() + ); + return ( + dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.JOIN && + !!matchingField && + dynamicStyleProp.isFieldMetaEnabled() + ); + }), + ...syncContext, + }); + } + + async _syncStyleMeta({ + source, + sourceQuery, + dataRequestId, + dynamicStyleProps, + dataFilters, + startLoading, + stopLoading, + onLoadError, + registerCancelCallback, + }) { + if (!source.isESSource() || dynamicStyleProps.length === 0) { + return; + } + + const dynamicStyleFields = dynamicStyleProps.map(dynamicStyleProp => { + return dynamicStyleProp.getField().getName(); + }); + + const nextMeta = { + dynamicStyleFields: _.uniq(dynamicStyleFields).sort(), + sourceQuery, + isTimeAware: this._style.isTimeAware() && (await source.isTimeAware()), + timeFilters: dataFilters.timeFilters, + }; + const prevDataRequest = this._findDataRequestById(dataRequestId); + const canSkipFetch = canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }); + if (canSkipFetch) { + return; + } + + const requestToken = Symbol(`layer-${this.getId()}-${dataRequestId}`); + try { + startLoading(dataRequestId, requestToken, nextMeta); + const layerName = await this.getDisplayName(); + const styleMeta = await source.loadStylePropsMeta( + layerName, + this._style, + dynamicStyleProps, + registerCancelCallback, + nextMeta + ); + stopLoading(dataRequestId, requestToken, styleMeta, nextMeta); + } catch (error) { + if (!(error instanceof DataRequestAbortError)) { + onLoadError(dataRequestId, requestToken, error.message); + } + } + } + + async _syncSourceFormatters(syncContext) { + if (this._style.constructor.type !== LAYER_STYLE_TYPE.VECTOR) { + return; + } + + return this._syncFormatters({ + source: this._source, + dataRequestId: SOURCE_FORMATTERS_ID_ORIGIN, + fields: this._style + .getDynamicPropertiesArray() + .filter(dynamicStyleProp => { + return dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.SOURCE; + }) + .map(dynamicStyleProp => { + return dynamicStyleProp.getField(); + }), + ...syncContext, + }); + } + + async _syncJoinFormatters(syncContext, join) { + const joinSource = join.getRightJoinSource(); + return this._syncFormatters({ + source: joinSource, + dataRequestId: join.getSourceFormattersDataRequestId(), + fields: this._style + .getDynamicPropertiesArray() + .filter(dynamicStyleProp => { + const matchingField = joinSource.getMetricFieldForName( + dynamicStyleProp.getField().getName() + ); + return dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.JOIN && !!matchingField; + }) + .map(dynamicStyleProp => { + return dynamicStyleProp.getField(); + }), + ...syncContext, + }); + } + + async _syncFormatters({ source, dataRequestId, fields, startLoading, stopLoading, onLoadError }) { + if (fields.length === 0) { + return; + } + + const fieldNames = fields.map(field => { + return field.getName(); + }); + const nextMeta = { + fieldNames: _.uniq(fieldNames).sort(), + }; + const prevDataRequest = this._findDataRequestById(dataRequestId); + const canSkipUpdate = canSkipFormattersUpdate({ prevDataRequest, nextMeta }); + if (canSkipUpdate) { + return; + } + + const requestToken = Symbol(`layer-${this.getId()}-${dataRequestId}`); + try { + startLoading(dataRequestId, requestToken, nextMeta); + + const formatters = {}; + const promises = fields.map(async field => { + const fieldName = field.getName(); + formatters[fieldName] = await source.getFieldFormatter(fieldName); + }); + await Promise.all(promises); + + stopLoading(dataRequestId, requestToken, formatters, nextMeta); + } catch (error) { + onLoadError(dataRequestId, requestToken, error.message); + } + } + + async syncData(syncContext) { + if (!this.isVisible() || !this.showAtZoomLevel(syncContext.dataFilters.zoom)) { + return; + } + + await this._syncSourceStyleMeta(syncContext); + await this._syncSourceFormatters(syncContext); + const sourceResult = await this._syncSource(syncContext); + if ( + !sourceResult.featureCollection || + !sourceResult.featureCollection.features.length || + !this._hasJoins() + ) { + return; + } + + const joinStates = await this._syncJoins(syncContext); + await this._performInnerJoins(sourceResult, joinStates, syncContext.updateSourceData); + } + + _getSourceFeatureCollection() { + const sourceDataRequest = this.getSourceDataRequest(); + return sourceDataRequest ? sourceDataRequest.getData() : null; + } + + _syncFeatureCollectionWithMb(mbMap) { + const mbGeoJSONSource = mbMap.getSource(this.getId()); + const featureCollection = this._getSourceFeatureCollection(); + const featureCollectionOnMap = AbstractLayer.getBoundDataForSource(mbMap, this.getId()); + + if (!featureCollection) { + if (featureCollectionOnMap) { + this._style.clearFeatureState(featureCollectionOnMap, mbMap, this.getId()); + } + mbGeoJSONSource.setData(EMPTY_FEATURE_COLLECTION); + return; + } + + // "feature-state" data expressions are not supported with layout properties. + // To work around this limitation, + // scaled layout properties (like icon-size) must fall back to geojson property values :( + const hasGeoJsonProperties = this._style.setFeatureStateAndStyleProps( + featureCollection, + mbMap, + this.getId() + ); + if (featureCollection !== featureCollectionOnMap || hasGeoJsonProperties) { + mbGeoJSONSource.setData(featureCollection); + } + } + + _setMbPointsProperties(mbMap) { + const pointLayerId = this._getMbPointLayerId(); + const symbolLayerId = this._getMbSymbolLayerId(); + const pointLayer = mbMap.getLayer(pointLayerId); + const symbolLayer = mbMap.getLayer(symbolLayerId); + + // Point layers symbolized as circles require 2 mapbox layers because + // "circle" layers do not support "text" style properties + // Point layers symbolized as icons only contain a single mapbox layer. + let markerLayerId; + let textLayerId; + if (this._style.arePointsSymbolizedAsCircles()) { + markerLayerId = pointLayerId; + textLayerId = this._getMbTextLayerId(); + if (symbolLayer) { + mbMap.setLayoutProperty(symbolLayerId, 'visibility', 'none'); + } + this._setMbCircleProperties(mbMap); + } else { + markerLayerId = symbolLayerId; + textLayerId = symbolLayerId; + if (pointLayer) { + mbMap.setLayoutProperty(pointLayerId, 'visibility', 'none'); + mbMap.setLayoutProperty(this._getMbTextLayerId(), 'visibility', 'none'); + } + this._setMbSymbolProperties(mbMap); + } + + this.syncVisibilityWithMb(mbMap, markerLayerId); + mbMap.setLayerZoomRange(markerLayerId, this._descriptor.minZoom, this._descriptor.maxZoom); + if (markerLayerId !== textLayerId) { + this.syncVisibilityWithMb(mbMap, textLayerId); + mbMap.setLayerZoomRange(textLayerId, this._descriptor.minZoom, this._descriptor.maxZoom); + } + } + + _setMbCircleProperties(mbMap) { + const sourceId = this.getId(); + const pointLayerId = this._getMbPointLayerId(); + const pointLayer = mbMap.getLayer(pointLayerId); + if (!pointLayer) { + mbMap.addLayer({ + id: pointLayerId, + type: 'circle', + source: sourceId, + paint: {}, + }); + } + + const textLayerId = this._getMbTextLayerId(); + const textLayer = mbMap.getLayer(textLayerId); + if (!textLayer) { + mbMap.addLayer({ + id: textLayerId, + type: 'symbol', + source: sourceId, + }); + } + + const filterExpr = getPointFilterExpression(this._hasJoins()); + if (filterExpr !== mbMap.getFilter(pointLayerId)) { + mbMap.setFilter(pointLayerId, filterExpr); + mbMap.setFilter(textLayerId, filterExpr); + } + + this._style.setMBPaintPropertiesForPoints({ + alpha: this.getAlpha(), + mbMap, + pointLayerId, + }); + + this._style.setMBPropertiesForLabelText({ + alpha: this.getAlpha(), + mbMap, + textLayerId, + }); + } + + _setMbSymbolProperties(mbMap) { + const sourceId = this.getId(); + const symbolLayerId = this._getMbSymbolLayerId(); + const symbolLayer = mbMap.getLayer(symbolLayerId); + + if (!symbolLayer) { + mbMap.addLayer({ + id: symbolLayerId, + type: 'symbol', + source: sourceId, + }); + } + + const filterExpr = getPointFilterExpression(this._hasJoins()); + if (filterExpr !== mbMap.getFilter(symbolLayerId)) { + mbMap.setFilter(symbolLayerId, filterExpr); + } + + this._style.setMBSymbolPropertiesForPoints({ + alpha: this.getAlpha(), + mbMap, + symbolLayerId, + }); + + this._style.setMBPropertiesForLabelText({ + alpha: this.getAlpha(), + mbMap, + textLayerId: symbolLayerId, + }); + } + + _setMbLinePolygonProperties(mbMap) { + const sourceId = this.getId(); + const fillLayerId = this._getMbPolygonLayerId(); + const lineLayerId = this._getMbLineLayerId(); + const hasJoins = this._hasJoins(); + if (!mbMap.getLayer(fillLayerId)) { + mbMap.addLayer({ + id: fillLayerId, + type: 'fill', + source: sourceId, + paint: {}, + }); + } + if (!mbMap.getLayer(lineLayerId)) { + mbMap.addLayer({ + id: lineLayerId, + type: 'line', + source: sourceId, + paint: {}, + }); + } + this._style.setMBPaintProperties({ + alpha: this.getAlpha(), + mbMap, + fillLayerId, + lineLayerId, + }); + + this.syncVisibilityWithMb(mbMap, fillLayerId); + mbMap.setLayerZoomRange(fillLayerId, this._descriptor.minZoom, this._descriptor.maxZoom); + const fillFilterExpr = getFillFilterExpression(hasJoins); + if (fillFilterExpr !== mbMap.getFilter(fillLayerId)) { + mbMap.setFilter(fillLayerId, fillFilterExpr); + } + + this.syncVisibilityWithMb(mbMap, lineLayerId); + mbMap.setLayerZoomRange(lineLayerId, this._descriptor.minZoom, this._descriptor.maxZoom); + const lineFilterExpr = getLineFilterExpression(hasJoins); + if (lineFilterExpr !== mbMap.getFilter(lineLayerId)) { + mbMap.setFilter(lineLayerId, lineFilterExpr); + } + } + + _syncStylePropertiesWithMb(mbMap) { + this._setMbPointsProperties(mbMap); + this._setMbLinePolygonProperties(mbMap); + } + + _syncSourceBindingWithMb(mbMap) { + const mbSource = mbMap.getSource(this.getId()); + if (!mbSource) { + mbMap.addSource(this.getId(), { + type: 'geojson', + data: EMPTY_FEATURE_COLLECTION, + }); + } + } + + syncLayerWithMB(mbMap) { + this._syncSourceBindingWithMb(mbMap); + this._syncFeatureCollectionWithMb(mbMap); + this._syncStylePropertiesWithMb(mbMap); + } + + _getMbPointLayerId() { + return this.makeMbLayerId('circle'); + } + + _getMbTextLayerId() { + return this.makeMbLayerId('text'); + } + + _getMbSymbolLayerId() { + return this.makeMbLayerId('symbol'); + } + + _getMbLineLayerId() { + return this.makeMbLayerId('line'); + } + + _getMbPolygonLayerId() { + return this.makeMbLayerId('fill'); + } + + getMbLayerIds() { + return [ + this._getMbPointLayerId(), + this._getMbTextLayerId(), + this._getMbSymbolLayerId(), + this._getMbLineLayerId(), + this._getMbPolygonLayerId(), + ]; + } + + ownsMbLayerId(mbLayerId) { + return this.getMbLayerIds().includes(mbLayerId); + } + + ownsMbSourceId(mbSourceId) { + return this.getId() === mbSourceId; + } + + _addJoinsToSourceTooltips(tooltipsFromSource) { + for (let i = 0; i < tooltipsFromSource.length; i++) { + const tooltipProperty = tooltipsFromSource[i]; + const matchingJoins = []; + for (let j = 0; j < this._joins.length; j++) { + if (this._joins[j].getLeftField().getName() === tooltipProperty.getPropertyKey()) { + matchingJoins.push(this._joins[j]); + } + } + if (matchingJoins.length) { + tooltipsFromSource[i] = new JoinTooltipProperty(tooltipProperty, matchingJoins); + } + } + } + + async getPropertiesForTooltip(properties) { + let allTooltips = await this._source.filterAndFormatPropertiesToHtml(properties); + this._addJoinsToSourceTooltips(allTooltips); + + for (let i = 0; i < this._joins.length; i++) { + const propsFromJoin = await this._joins[i].filterAndFormatPropertiesForTooltip(properties); + allTooltips = [...allTooltips, ...propsFromJoin]; + } + return allTooltips; + } + + canShowTooltip() { + return this.isVisible() && (this._source.canFormatFeatureProperties() || this._joins.length); + } + + getFeatureById(id) { + const featureCollection = this._getSourceFeatureCollection(); + if (!featureCollection) { + return; + } + + return featureCollection.features.find(feature => { + return feature.properties[FEATURE_ID_PROPERTY_NAME] === id; + }); + } +} diff --git a/x-pack/plugins/maps/public/layers/vector_tile_layer.js b/x-pack/plugins/maps/public/layers/vector_tile_layer.js new file mode 100644 index 0000000000000..b09ccdc3af8ba --- /dev/null +++ b/x-pack/plugins/maps/public/layers/vector_tile_layer.js @@ -0,0 +1,295 @@ +/* + * 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 { TileLayer } from './tile_layer'; +import _ from 'lodash'; +import { SOURCE_DATA_ID_ORIGIN, LAYER_TYPE } from '../../common/constants'; +import { isRetina } from '../meta'; +import { + addSpriteSheetToMapFromImageData, + loadSpriteSheetImageData, +} from '../connected_components/map/mb/utils'; //todo move this implementation + +const MB_STYLE_TYPE_TO_OPACITY = { + fill: ['fill-opacity'], + line: ['line-opacity'], + circle: ['circle-opacity'], + background: ['background-opacity'], + symbol: ['icon-opacity', 'text-opacity'], +}; + +export class VectorTileLayer extends TileLayer { + static type = LAYER_TYPE.VECTOR_TILE; + + static createDescriptor(options) { + const tileLayerDescriptor = super.createDescriptor(options); + tileLayerDescriptor.type = VectorTileLayer.type; + tileLayerDescriptor.alpha = _.get(options, 'alpha', 1); + return tileLayerDescriptor; + } + + _canSkipSync({ prevDataRequest, nextMeta }) { + if (!prevDataRequest) { + return false; + } + const prevMeta = prevDataRequest.getMeta(); + if (!prevMeta) { + return false; + } + + return prevMeta.tileLayerId === nextMeta.tileLayerId; + } + + async syncData({ startLoading, stopLoading, onLoadError, dataFilters }) { + if (!this.isVisible() || !this.showAtZoomLevel(dataFilters.zoom)) { + return; + } + + const nextMeta = { tileLayerId: this._source.getTileLayerId() }; + const canSkipSync = this._canSkipSync({ + prevDataRequest: this.getSourceDataRequest(), + nextMeta, + }); + if (canSkipSync) { + return; + } + + const requestToken = Symbol(`layer-source-refresh:${this.getId()} - source`); + try { + startLoading(SOURCE_DATA_ID_ORIGIN, requestToken, dataFilters); + const styleAndSprites = await this._source.getVectorStyleSheetAndSpriteMeta(isRetina()); + const spriteSheetImageData = await loadSpriteSheetImageData(styleAndSprites.spriteMeta.png); + const data = { + ...styleAndSprites, + spriteSheetImageData, + }; + stopLoading(SOURCE_DATA_ID_ORIGIN, requestToken, data, nextMeta); + } catch (error) { + onLoadError(SOURCE_DATA_ID_ORIGIN, requestToken, error.message); + } + } + + _generateMbId(name) { + return `${this.getId()}_${name}`; + } + + _generateMbSourceIdPrefix() { + const DELIMITTER = '___'; + return `${this.getId()}${DELIMITTER}${this._source.getTileLayerId()}${DELIMITTER}`; + } + + _generateMbSourceId(name) { + return `${this._generateMbSourceIdPrefix()}${name}`; + } + + _getVectorStyle() { + const sourceDataRequest = this.getSourceDataRequest(); + if (!sourceDataRequest) { + return null; + } + const vectorStyleAndSprites = sourceDataRequest.getData(); + if (!vectorStyleAndSprites) { + return null; + } + return vectorStyleAndSprites.vectorStyleSheet; + } + + _getSpriteMeta() { + const sourceDataRequest = this.getSourceDataRequest(); + if (!sourceDataRequest) { + return null; + } + const vectorStyleAndSprites = sourceDataRequest.getData(); + return vectorStyleAndSprites.spriteMeta; + } + + _getSpriteImageData() { + const sourceDataRequest = this.getSourceDataRequest(); + if (!sourceDataRequest) { + return null; + } + const vectorStyleAndSprites = sourceDataRequest.getData(); + return vectorStyleAndSprites.spriteSheetImageData; + } + + getMbLayerIds() { + const vectorStyle = this._getVectorStyle(); + if (!vectorStyle) { + return []; + } + return vectorStyle.layers.map(layer => this._generateMbId(layer.id)); + } + + getMbSourceIds() { + const vectorStyle = this._getVectorStyle(); + if (!vectorStyle) { + return []; + } + const sourceIds = Object.keys(vectorStyle.sources); + return sourceIds.map(sourceId => this._generateMbSourceId(sourceId)); + } + + ownsMbLayerId(mbLayerId) { + return mbLayerId.startsWith(this.getId()); + } + + ownsMbSourceId(mbSourceId) { + return mbSourceId.startsWith(this.getId()); + } + + _makeNamespacedImageId(imageId) { + const prefix = this._source.getSpriteNamespacePrefix() + '/'; + return prefix + imageId; + } + + _requiresPrevSourceCleanup(mbMap) { + const sourceIdPrefix = this._generateMbSourceIdPrefix(); + const mbStyle = mbMap.getStyle(); + return Object.keys(mbStyle.sources).some(mbSourceId => { + const doesMbSourceBelongToLayer = this.ownsMbSourceId(mbSourceId); + const doesMbSourceBelongToSource = mbSourceId.startsWith(sourceIdPrefix); + return doesMbSourceBelongToLayer && !doesMbSourceBelongToSource; + }); + } + + syncLayerWithMB(mbMap) { + const vectorStyle = this._getVectorStyle(); + if (!vectorStyle) { + return; + } + + if (this._requiresPrevSourceCleanup(mbMap)) { + const mbStyle = mbMap.getStyle(); + mbStyle.layers.forEach(mbLayer => { + if (this.ownsMbLayerId(mbLayer.id)) { + mbMap.removeLayer(mbLayer.id); + } + }); + Object.keys(mbStyle.sources).some(mbSourceId => { + if (this.ownsMbSourceId(mbSourceId)) { + mbMap.removeSource(mbSourceId); + } + }); + } + + let initialBootstrapCompleted = false; + const sourceIds = Object.keys(vectorStyle.sources); + sourceIds.forEach(sourceId => { + if (initialBootstrapCompleted) { + return; + } + const mbSourceId = this._generateMbSourceId(sourceId); + const mbSource = mbMap.getSource(mbSourceId); + if (mbSource) { + //if a single source is present, the layer already has bootstrapped with the mbMap + initialBootstrapCompleted = true; + return; + } + mbMap.addSource(mbSourceId, vectorStyle.sources[sourceId]); + }); + + if (!initialBootstrapCompleted) { + //sync spritesheet + const spriteMeta = this._getSpriteMeta(); + if (!spriteMeta) { + return; + } + const newJson = {}; + for (const imageId in spriteMeta.json) { + if (spriteMeta.json.hasOwnProperty(imageId)) { + const namespacedImageId = this._makeNamespacedImageId(imageId); + newJson[namespacedImageId] = spriteMeta.json[imageId]; + } + } + + const imageData = this._getSpriteImageData(); + if (!imageData) { + return; + } + addSpriteSheetToMapFromImageData(newJson, imageData, mbMap); + + //sync layers + vectorStyle.layers.forEach(layer => { + const mbLayerId = this._generateMbId(layer.id); + const mbLayer = mbMap.getLayer(mbLayerId); + if (mbLayer) { + return; + } + const newLayerObject = { + ...layer, + source: this._generateMbSourceId(layer.source), + id: mbLayerId, + }; + + if ( + newLayerObject.type === 'symbol' && + newLayerObject.layout && + typeof newLayerObject.layout['icon-image'] === 'string' + ) { + newLayerObject.layout['icon-image'] = this._makeNamespacedImageId( + newLayerObject.layout['icon-image'] + ); + } + + if ( + newLayerObject.type === 'fill' && + newLayerObject.paint && + typeof newLayerObject.paint['fill-pattern'] === 'string' + ) { + newLayerObject.paint['fill-pattern'] = this._makeNamespacedImageId( + newLayerObject.paint['fill-pattern'] + ); + } + + mbMap.addLayer(newLayerObject); + }); + } + + this._setTileLayerProperties(mbMap); + } + + _setOpacityForType(mbMap, mbLayer, mbLayerId) { + const opacityProps = MB_STYLE_TYPE_TO_OPACITY[mbLayer.type]; + if (!opacityProps) { + return; + } + + opacityProps.forEach(opacityProp => { + if (mbLayer.paint && typeof mbLayer.paint[opacityProp] === 'number') { + const newOpacity = mbLayer.paint[opacityProp] * this.getAlpha(); + mbMap.setPaintProperty(mbLayerId, opacityProp, newOpacity); + } else { + mbMap.setPaintProperty(mbLayerId, opacityProp, this.getAlpha()); + } + }); + } + + _setLayerZoomRange(mbMap, mbLayer, mbLayerId) { + let minZoom = this._descriptor.minZoom; + if (typeof mbLayer.minzoom === 'number') { + minZoom = Math.max(minZoom, mbLayer.minzoom); + } + let maxZoom = this._descriptor.maxZoom; + if (typeof mbLayer.maxzoom === 'number') { + maxZoom = Math.min(maxZoom, mbLayer.maxzoom); + } + mbMap.setLayerZoomRange(mbLayerId, minZoom, maxZoom); + } + + _setTileLayerProperties(mbMap) { + const vectorStyle = this._getVectorStyle(); + if (!vectorStyle) { + return; + } + + vectorStyle.layers.forEach(mbLayer => { + const mbLayerId = this._generateMbId(mbLayer.id); + this.syncVisibilityWithMb(mbMap, mbLayerId); + this._setLayerZoomRange(mbMap, mbLayer, mbLayerId); + this._setOpacityForType(mbMap, mbLayer, mbLayerId); + }); + } +} diff --git a/x-pack/plugins/maps/public/meta.js b/x-pack/plugins/maps/public/meta.js new file mode 100644 index 0000000000000..c5cfb582976c1 --- /dev/null +++ b/x-pack/plugins/maps/public/meta.js @@ -0,0 +1,99 @@ +/* + * 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 { + GIS_API_PATH, + EMS_FILES_CATALOGUE_PATH, + EMS_TILES_CATALOGUE_PATH, + EMS_GLYPHS_PATH, +} from '../common/constants'; +import chrome from 'ui/chrome'; +import { i18n } from '@kbn/i18n'; +import { EMSClient } from '@elastic/ems-client'; +import { getLicenseId } from './kibana_services'; +import fetch from 'node-fetch'; + +const GIS_API_RELATIVE = `../${GIS_API_PATH}`; + +export function getKibanaRegionList() { + return chrome.getInjected('regionmapLayers'); +} + +export function getKibanaTileMap() { + return chrome.getInjected('tilemap'); +} + +function relativeToAbsolute(url) { + const a = document.createElement('a'); + a.setAttribute('href', url); + return a.href; +} + +function fetchFunction(...args) { + return fetch(...args); +} + +let emsClient = null; +let latestLicenseId = null; +export function getEMSClient() { + if (!emsClient) { + const isEmsEnabled = chrome.getInjected('isEmsEnabled', true); + if (isEmsEnabled) { + const proxyElasticMapsServiceInMaps = chrome.getInjected( + 'proxyElasticMapsServiceInMaps', + false + ); + const proxyPath = ''; + const tileApiUrl = proxyElasticMapsServiceInMaps + ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_TILES_CATALOGUE_PATH}`) + : chrome.getInjected('emsTileApiUrl'); + const fileApiUrl = proxyElasticMapsServiceInMaps + ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_FILES_CATALOGUE_PATH}`) + : chrome.getInjected('emsFileApiUrl'); + + emsClient = new EMSClient({ + language: i18n.getLocale(), + kbnVersion: chrome.getInjected('kbnPkgVersion'), + tileApiUrl, + fileApiUrl, + landingPageUrl: chrome.getInjected('emsLandingPageUrl'), + fetchFunction: fetchFunction, //import this from client-side, so the right instance is returned (bootstrapped from common/* would not work + proxyPath, + }); + } else { + //EMS is turned off. Mock API. + emsClient = { + async getFileLayers() { + return []; + }, + async getTMSServices() { + return []; + }, + addQueryParams() {}, + }; + } + } + const licenseId = getLicenseId(); + if (latestLicenseId !== licenseId) { + latestLicenseId = licenseId; + emsClient.addQueryParams({ license: licenseId }); + } + return emsClient; +} + +export function getGlyphUrl() { + if (!chrome.getInjected('isEmsEnabled', true)) { + return ''; + } + return chrome.getInjected('proxyElasticMapsServiceInMaps', false) + ? relativeToAbsolute(`../${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}/${EMS_GLYPHS_PATH}`) + + `/{fontstack}/{range}` + : chrome.getInjected('emsFontLibraryUrl', true); +} + +export function isRetina() { + return window.devicePixelRatio === 2; +} diff --git a/x-pack/plugins/maps/public/meta.test.js b/x-pack/plugins/maps/public/meta.test.js new file mode 100644 index 0000000000000..64dd73fe109ff --- /dev/null +++ b/x-pack/plugins/maps/public/meta.test.js @@ -0,0 +1,51 @@ +/* + * 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 { EMSClient } from '@elastic/ems-client'; +import { getEMSClient } from './meta'; + +jest.mock('@elastic/ems-client'); + +jest.mock('ui/chrome', () => ({ + getBasePath: () => { + return ''; + }, + getInjected(key) { + if (key === 'proxyElasticMapsServiceInMaps') { + return false; + } else if (key === 'isEmsEnabled') { + return true; + } else if (key === 'emsFileApiUrl') { + return 'https://file-api'; + } else if (key === 'emsTileApiUrl') { + return 'https://tile-api'; + } + }, + getUiSettingsClient: () => { + return { + get: () => { + return ''; + }, + }; + }, +})); + +jest.mock('./kibana_services', () => { + return { + getLicenseId() { + return 'foobarlicenseid'; + }, + }; +}); + +describe('default use without proxy', () => { + it('should construct EMSClient with absolute file and tile API urls', async () => { + getEMSClient(); + const mockEmsClientCall = EMSClient.mock.calls[0]; + expect(mockEmsClientCall[0].fileApiUrl.startsWith('https://file-api')).toBe(true); + expect(mockEmsClientCall[0].tileApiUrl.startsWith('https://tile-api')).toBe(true); + }); +}); diff --git a/x-pack/plugins/maps/public/reducers/map.js b/x-pack/plugins/maps/public/reducers/map.js new file mode 100644 index 0000000000000..234584d08a311 --- /dev/null +++ b/x-pack/plugins/maps/public/reducers/map.js @@ -0,0 +1,543 @@ +/* + * 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 { + SET_SELECTED_LAYER, + SET_TRANSIENT_LAYER, + UPDATE_LAYER_ORDER, + LAYER_DATA_LOAD_STARTED, + LAYER_DATA_LOAD_ENDED, + LAYER_DATA_LOAD_ERROR, + ADD_LAYER, + SET_LAYER_ERROR_STATUS, + ADD_WAITING_FOR_MAP_READY_LAYER, + CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST, + REMOVE_LAYER, + SET_LAYER_VISIBILITY, + MAP_EXTENT_CHANGED, + MAP_READY, + MAP_DESTROYED, + SET_QUERY, + UPDATE_LAYER_PROP, + UPDATE_LAYER_STYLE, + SET_LAYER_STYLE_META, + SET_JOINS, + TOUCH_LAYER, + UPDATE_SOURCE_PROP, + SET_REFRESH_CONFIG, + TRIGGER_REFRESH_TIMER, + SET_MOUSE_COORDINATES, + CLEAR_MOUSE_COORDINATES, + SET_GOTO, + CLEAR_GOTO, + TRACK_CURRENT_LAYER_STATE, + ROLLBACK_TO_TRACKED_LAYER_STATE, + REMOVE_TRACKED_LAYER_STATE, + UPDATE_SOURCE_DATA_REQUEST, + SET_TOOLTIP_STATE, + SET_SCROLL_ZOOM, + SET_MAP_INIT_ERROR, + UPDATE_DRAW_STATE, + SET_INTERACTIVE, + DISABLE_TOOLTIP_CONTROL, + HIDE_TOOLBAR_OVERLAY, + HIDE_LAYER_CONTROL, + HIDE_VIEW_CONTROL, + SET_WAITING_FOR_READY_HIDDEN_LAYERS, +} from '../actions/map_actions'; + +import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from './util'; +import { SOURCE_DATA_ID_ORIGIN } from '../../common/constants'; + +const getLayerIndex = (list, layerId) => list.findIndex(({ id }) => layerId === id); + +const updateLayerInList = (state, layerId, attribute, newValue) => { + if (!layerId) { + return state; + } + const { layerList } = state; + const layerIdx = getLayerIndex(layerList, layerId); + const updatedLayer = { + ...layerList[layerIdx], + // Update layer w/ new value. If no value provided, toggle boolean value + // allow empty strings, 0-value + [attribute]: + newValue || newValue === '' || newValue === 0 ? newValue : !layerList[layerIdx][attribute], + }; + const updatedList = [ + ...layerList.slice(0, layerIdx), + updatedLayer, + ...layerList.slice(layerIdx + 1), + ]; + return { ...state, layerList: updatedList }; +}; + +const updateLayerSourceDescriptorProp = (state, layerId, propName, value) => { + const { layerList } = state; + const layerIdx = getLayerIndex(layerList, layerId); + const updatedLayer = { + ...layerList[layerIdx], + sourceDescriptor: { + ...layerList[layerIdx].sourceDescriptor, + [propName]: value, + }, + }; + const updatedList = [ + ...layerList.slice(0, layerIdx), + updatedLayer, + ...layerList.slice(layerIdx + 1), + ]; + return { ...state, layerList: updatedList }; +}; + +const INITIAL_STATE = { + ready: false, + mapInitError: null, + goto: null, + tooltipState: null, + mapState: { + zoom: null, // setting this value does not adjust map zoom, read only value used to store current map zoom for persisting between sessions + center: null, // setting this value does not adjust map view, read only value used to store current map center for persisting between sessions + scrollZoom: true, + extent: null, + mouseCoordinates: null, + timeFilters: null, + query: null, + filters: [], + refreshConfig: null, + refreshTimerLastTriggeredAt: null, + drawState: null, + disableInteractive: false, + disableTooltipControl: false, + hideToolbarOverlay: false, + hideLayerControl: false, + hideViewControl: false, + }, + selectedLayerId: null, + __transientLayerId: null, + layerList: [], + waitingForMapReadyLayerList: [], +}; + +export function map(state = INITIAL_STATE, action) { + switch (action.type) { + case UPDATE_DRAW_STATE: + return { + ...state, + mapState: { + ...state.mapState, + drawState: action.drawState, + }, + }; + case REMOVE_TRACKED_LAYER_STATE: + return removeTrackedLayerState(state, action.layerId); + case TRACK_CURRENT_LAYER_STATE: + return trackCurrentLayerState(state, action.layerId); + case ROLLBACK_TO_TRACKED_LAYER_STATE: + return rollbackTrackedLayerState(state, action.layerId); + case SET_TOOLTIP_STATE: + return { + ...state, + tooltipState: action.tooltipState, + }; + case SET_MOUSE_COORDINATES: + return { + ...state, + mapState: { + ...state.mapState, + mouseCoordinates: { + lat: action.lat, + lon: action.lon, + }, + }, + }; + case CLEAR_MOUSE_COORDINATES: + return { + ...state, + mapState: { + ...state.mapState, + mouseCoordinates: null, + }, + }; + case SET_GOTO: + return { + ...state, + goto: { + center: action.center, + bounds: action.bounds, + }, + }; + case CLEAR_GOTO: + return { + ...state, + goto: null, + }; + case SET_LAYER_ERROR_STATUS: + const { layerList } = state; + const layerIdx = getLayerIndex(layerList, action.layerId); + if (layerIdx === -1) { + return state; + } + + return { + ...state, + layerList: [ + ...layerList.slice(0, layerIdx), + { + ...layerList[layerIdx], + __isInErrorState: action.isInErrorState, + __errorMessage: action.errorMessage, + }, + ...layerList.slice(layerIdx + 1), + ], + }; + case UPDATE_SOURCE_DATA_REQUEST: + return updateSourceDataRequest(state, action); + case LAYER_DATA_LOAD_STARTED: + return updateWithDataRequest(state, action); + case LAYER_DATA_LOAD_ERROR: + return updateWithDataResponse(state, action); + case LAYER_DATA_LOAD_ENDED: + return updateWithDataResponse(state, action); + case TOUCH_LAYER: + //action to enforce a reflow of the styles + const layer = state.layerList.find(layer => layer.id === action.layerId); + if (!layer) { + return state; + } + const indexOfLayer = state.layerList.indexOf(layer); + const newLayer = { ...layer }; + const newLayerList = [...state.layerList]; + newLayerList[indexOfLayer] = newLayer; + return { ...state, layerList: newLayerList }; + case MAP_READY: + return { ...state, ready: true }; + case MAP_DESTROYED: + return { ...state, ready: false }; + case MAP_EXTENT_CHANGED: + const newMapState = { + center: action.mapState.center, + zoom: action.mapState.zoom, + extent: action.mapState.extent, + buffer: action.mapState.buffer, + }; + return { ...state, mapState: { ...state.mapState, ...newMapState } }; + case SET_QUERY: + const { query, timeFilters, filters } = action; + return { + ...state, + mapState: { + ...state.mapState, + query, + timeFilters, + filters, + }, + }; + case SET_REFRESH_CONFIG: + const { isPaused, interval } = action; + return { + ...state, + mapState: { + ...state.mapState, + refreshConfig: { + isPaused, + interval, + }, + }, + }; + case TRIGGER_REFRESH_TIMER: + return { + ...state, + mapState: { + ...state.mapState, + refreshTimerLastTriggeredAt: new Date().toISOString(), + }, + }; + case SET_SELECTED_LAYER: + const selectedMatch = state.layerList.find(layer => layer.id === action.selectedLayerId); + return { ...state, selectedLayerId: selectedMatch ? action.selectedLayerId : null }; + case SET_TRANSIENT_LAYER: + const transientMatch = state.layerList.find(layer => layer.id === action.transientLayerId); + return { ...state, __transientLayerId: transientMatch ? action.transientLayerId : null }; + case UPDATE_LAYER_ORDER: + return { + ...state, + layerList: action.newLayerOrder.map(layerNumber => state.layerList[layerNumber]), + }; + case UPDATE_LAYER_PROP: + return updateLayerInList(state, action.id, action.propName, action.newValue); + case UPDATE_SOURCE_PROP: + return updateLayerSourceDescriptorProp(state, action.layerId, action.propName, action.value); + case SET_JOINS: + const layerDescriptor = state.layerList.find( + descriptor => descriptor.id === action.layer.getId() + ); + if (layerDescriptor) { + const newLayerDescriptor = { ...layerDescriptor, joins: action.joins.slice() }; + const index = state.layerList.findIndex( + descriptor => descriptor.id === action.layer.getId() + ); + const newLayerList = state.layerList.slice(); + newLayerList[index] = newLayerDescriptor; + return { ...state, layerList: newLayerList }; + } + return state; + case ADD_LAYER: + return { + ...state, + layerList: [...state.layerList, action.layer], + }; + case REMOVE_LAYER: + return { + ...state, + layerList: [...state.layerList.filter(({ id }) => id !== action.id)], + }; + case ADD_WAITING_FOR_MAP_READY_LAYER: + return { + ...state, + waitingForMapReadyLayerList: [...state.waitingForMapReadyLayerList, action.layer], + }; + case CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST: + return { + ...state, + waitingForMapReadyLayerList: [], + }; + case SET_LAYER_VISIBILITY: + return updateLayerInList(state, action.layerId, 'visible', action.visibility); + case UPDATE_LAYER_STYLE: + const styleLayerId = action.layerId; + return updateLayerInList(state, styleLayerId, 'style', { ...action.style }); + case SET_LAYER_STYLE_META: + const { layerId, styleMeta } = action; + const index = getLayerIndex(state.layerList, layerId); + if (index === -1) { + return state; + } + + return updateLayerInList(state, layerId, 'style', { + ...state.layerList[index].style, + __styleMeta: styleMeta, + }); + case SET_SCROLL_ZOOM: + return { + ...state, + mapState: { + ...state.mapState, + scrollZoom: action.scrollZoom, + }, + }; + case SET_MAP_INIT_ERROR: + return { + ...state, + mapInitError: action.errorMessage, + }; + case SET_INTERACTIVE: + return { + ...state, + mapState: { + ...state.mapState, + disableInteractive: action.disableInteractive, + }, + }; + case DISABLE_TOOLTIP_CONTROL: + return { + ...state, + mapState: { + ...state.mapState, + disableTooltipControl: action.disableTooltipControl, + }, + }; + case HIDE_TOOLBAR_OVERLAY: + return { + ...state, + mapState: { + ...state.mapState, + hideToolbarOverlay: action.hideToolbarOverlay, + }, + }; + case HIDE_LAYER_CONTROL: + return { + ...state, + mapState: { + ...state.mapState, + hideLayerControl: action.hideLayerControl, + }, + }; + case HIDE_VIEW_CONTROL: + return { + ...state, + mapState: { + ...state.mapState, + hideViewControl: action.hideViewControl, + }, + }; + case SET_WAITING_FOR_READY_HIDDEN_LAYERS: + return { + ...state, + waitingForMapReadyLayerList: state.waitingForMapReadyLayerList.map(layer => ({ + ...layer, + visible: !action.hiddenLayerIds.includes(layer.id), + })), + }; + default: + return state; + } +} + +function findDataRequest(layerDescriptor, dataRequestAction) { + if (!layerDescriptor.__dataRequests) { + return; + } + + return layerDescriptor.__dataRequests.find(dataRequest => { + return dataRequest.dataId === dataRequestAction.dataId; + }); +} + +function updateWithDataRequest(state, action) { + let dataRequest = getValidDataRequest(state, action, false); + const layerRequestingData = findLayerById(state, action.layerId); + + if (!dataRequest) { + dataRequest = { + dataId: action.dataId, + }; + layerRequestingData.__dataRequests = [ + ...(layerRequestingData.__dataRequests ? layerRequestingData.__dataRequests : []), + dataRequest, + ]; + } + dataRequest.dataMetaAtStart = action.meta; + dataRequest.dataRequestToken = action.requestToken; + const layerList = [...state.layerList]; + return { ...state, layerList }; +} + +function updateSourceDataRequest(state, action) { + const layerDescriptor = findLayerById(state, action.layerId); + if (!layerDescriptor) { + return state; + } + const dataRequest = layerDescriptor.__dataRequests.find(dataRequest => { + return dataRequest.dataId === SOURCE_DATA_ID_ORIGIN; + }); + if (!dataRequest) { + return state; + } + + dataRequest.data = action.newData; + return resetDataRequest(state, action, dataRequest); +} + +function updateWithDataResponse(state, action) { + const dataRequest = getValidDataRequest(state, action); + if (!dataRequest) { + return state; + } + + dataRequest.data = action.data; + dataRequest.dataMeta = { ...dataRequest.dataMetaAtStart, ...action.meta }; + dataRequest.dataMetaAtStart = null; + return resetDataRequest(state, action, dataRequest); +} + +export function resetDataRequest(state, action, request) { + const dataRequest = request || getValidDataRequest(state, action); + if (!dataRequest) { + return state; + } + + const layer = findLayerById(state, action.layerId); + const dataRequestIndex = layer.__dataRequests.indexOf(dataRequest); + + const newDataRequests = [...layer.__dataRequests]; + newDataRequests[dataRequestIndex] = { + ...dataRequest, + dataRequestToken: null, + }; + + const layerIndex = state.layerList.indexOf(layer); + const newLayerList = [...state.layerList]; + newLayerList[layerIndex] = { + ...layer, + __dataRequests: newDataRequests, + }; + return { ...state, layerList: newLayerList }; +} + +function getValidDataRequest(state, action, checkRequestToken = true) { + const layer = findLayerById(state, action.layerId); + if (!layer) { + return; + } + + const dataRequest = findDataRequest(layer, action); + if (!dataRequest) { + return; + } + + if ( + checkRequestToken && + dataRequest.dataRequestToken && + dataRequest.dataRequestToken !== action.requestToken + ) { + // ignore responses to outdated requests + return; + } + return dataRequest; +} + +function findLayerById(state, id) { + return state.layerList.find(layer => layer.id === id); +} + +function trackCurrentLayerState(state, layerId) { + const layer = findLayerById(state, layerId); + const layerCopy = copyPersistentState(layer); + return updateLayerInList(state, layerId, TRACKED_LAYER_DESCRIPTOR, layerCopy); +} + +function removeTrackedLayerState(state, layerId) { + const layer = findLayerById(state, layerId); + if (!layer) { + return state; + } + + const copyLayer = { ...layer }; + delete copyLayer[TRACKED_LAYER_DESCRIPTOR]; + + return { + ...state, + layerList: replaceInLayerList(state.layerList, layerId, copyLayer), + }; +} + +function rollbackTrackedLayerState(state, layerId) { + const layer = findLayerById(state, layerId); + if (!layer) { + return state; + } + + const trackedLayerDescriptor = layer[TRACKED_LAYER_DESCRIPTOR]; + + //this assumes that any nested temp-state in the layer-descriptor (e.g. of styles), is not relevant and can be recovered easily (e.g. this is not the case for __dataRequests) + //That assumption is true in the context of this app, but not generalizable. + //consider rewriting copyPersistentState to only strip the first level of temp state. + const rolledbackLayer = { ...layer, ...trackedLayerDescriptor }; + delete rolledbackLayer[TRACKED_LAYER_DESCRIPTOR]; + + return { + ...state, + layerList: replaceInLayerList(state.layerList, layerId, rolledbackLayer), + }; +} + +function replaceInLayerList(layerList, layerId, newLayerDescriptor) { + const layerIndex = getLayerIndex(layerList, layerId); + const newLayerList = [...layerList]; + newLayerList[layerIndex] = newLayerDescriptor; + return newLayerList; +} diff --git a/x-pack/plugins/maps/public/reducers/map.test.js b/x-pack/plugins/maps/public/reducers/map.test.js new file mode 100644 index 0000000000000..089dec952fc77 --- /dev/null +++ b/x-pack/plugins/maps/public/reducers/map.test.js @@ -0,0 +1,56 @@ +/* + * 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. + */ + +jest.mock('../actions/map_actions', () => ({})); + +import { resetDataRequest } from './map'; +import _ from 'lodash'; + +describe('reducers/map', () => { + it('Should clear datarequest without mutation store state', async () => { + const layerId = 'foobar'; + const requestToken = 'tokenId'; + const dataId = 'dataId'; + + const preState = { + layerList: [ + { + id: `not_${layerId}`, + }, + { + id: layerId, + __dataRequests: [ + { + dataRequestToken: `not_${requestToken}`, + dataId: `not_${dataId}`, + }, + { + dataRequestToken: requestToken, + dataId: dataId, + }, + ], + }, + ], + }; + + const preStateCopy = _.cloneDeep(preState); + + const action = { + layerId, + requestToken, + dataId, + }; + + const postState = resetDataRequest(preState, action); + + //Ensure previous state is not mutated. + expect(_.isEqual(preState, preStateCopy)).toEqual(true); + + //Ensure new state is set correctly. + expect(postState.layerList[1].__dataRequests[1].dataId).toEqual(dataId); + expect(postState.layerList[1].__dataRequests[1].dataRequestToken).toEqual(null); + }); +}); diff --git a/x-pack/plugins/maps/public/reducers/non_serializable_instances.js b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js new file mode 100644 index 0000000000000..fcd583a91ca8a --- /dev/null +++ b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js @@ -0,0 +1,107 @@ +/* + * 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 { RequestAdapter} from '../../../../../src/plugins/inspector/public/adapters/request'; +import { MapAdapter } from '../inspector/adapters/map_adapter'; + +// TODO +import chrome from 'ui/chrome'; + +const REGISTER_CANCEL_CALLBACK = 'REGISTER_CANCEL_CALLBACK'; +const UNREGISTER_CANCEL_CALLBACK = 'UNREGISTER_CANCEL_CALLBACK'; +const SET_EVENT_HANDLERS = 'SET_EVENT_HANDLERS'; + +function createInspectorAdapters() { + const inspectorAdapters = { + requests: new RequestAdapter(), + }; + if (chrome.getInjected('showMapsInspectorAdapter', false)) { + inspectorAdapters.map = new MapAdapter(); + } + return inspectorAdapters; +} + +// Reducer +export function nonSerializableInstances(state, action = {}) { + if (!state) { + return { + inspectorAdapters: createInspectorAdapters(), + cancelRequestCallbacks: new Map(), // key is request token, value is cancel callback + eventHandlers: {}, + }; + } + + switch (action.type) { + case REGISTER_CANCEL_CALLBACK: + state.cancelRequestCallbacks.set(action.requestToken, action.callback); + return { + ...state, + }; + case UNREGISTER_CANCEL_CALLBACK: + state.cancelRequestCallbacks.delete(action.requestToken); + return { + ...state, + }; + case SET_EVENT_HANDLERS: { + return { + ...state, + eventHandlers: action.eventHandlers, + }; + } + default: + return state; + } +} + +// Selectors +export const getInspectorAdapters = ({ nonSerializableInstances }) => { + return nonSerializableInstances.inspectorAdapters; +}; + +export const getCancelRequestCallbacks = ({ nonSerializableInstances }) => { + return nonSerializableInstances.cancelRequestCallbacks; +}; + +export const getEventHandlers = ({ nonSerializableInstances }) => { + return nonSerializableInstances.eventHandlers; +}; + +// Actions +export const registerCancelCallback = (requestToken, callback) => { + return { + type: REGISTER_CANCEL_CALLBACK, + requestToken, + callback, + }; +}; + +export const unregisterCancelCallback = requestToken => { + return { + type: UNREGISTER_CANCEL_CALLBACK, + requestToken, + }; +}; + +export const cancelRequest = requestToken => { + return (dispatch, getState) => { + if (!requestToken) { + return; + } + + const cancelCallback = getCancelRequestCallbacks(getState()).get(requestToken); + if (cancelCallback) { + cancelCallback(); + dispatch(unregisterCancelCallback(requestToken)); + } + }; +}; + +export const setEventHandlers = (eventHandlers = {}) => { + return { + type: SET_EVENT_HANDLERS, + eventHandlers, + }; +}; diff --git a/x-pack/plugins/maps/public/reducers/store.js b/x-pack/plugins/maps/public/reducers/store.js new file mode 100644 index 0000000000000..40b769f11b529 --- /dev/null +++ b/x-pack/plugins/maps/public/reducers/store.js @@ -0,0 +1,24 @@ +/* + * 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 { combineReducers, applyMiddleware, createStore, compose } from 'redux'; +import thunk from 'redux-thunk'; +import { ui } from './ui'; +import { map } from './map'; +import { nonSerializableInstances } from './non_serializable_instances'; + +const rootReducer = combineReducers({ + map, + ui, + nonSerializableInstances, +}); + +const enhancers = [applyMiddleware(thunk)]; + +export function createMapStore() { + const storeConfig = {}; + return createStore(rootReducer, storeConfig, compose(...enhancers)); +} diff --git a/x-pack/plugins/maps/public/reducers/ui.js b/x-pack/plugins/maps/public/reducers/ui.js new file mode 100644 index 0000000000000..287e1f8dd3dda --- /dev/null +++ b/x-pack/plugins/maps/public/reducers/ui.js @@ -0,0 +1,81 @@ +/* + * 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 { + UPDATE_FLYOUT, + CLOSE_SET_VIEW, + OPEN_SET_VIEW, + SET_IS_LAYER_TOC_OPEN, + SET_FULL_SCREEN, + SET_READ_ONLY, + SET_OPEN_TOC_DETAILS, + SHOW_TOC_DETAILS, + HIDE_TOC_DETAILS, + UPDATE_INDEXING_STAGE, +} from '../actions/ui_actions'; + +export const FLYOUT_STATE = { + NONE: 'NONE', + LAYER_PANEL: 'LAYER_PANEL', + ADD_LAYER_WIZARD: 'ADD_LAYER_WIZARD', +}; + +export const INDEXING_STAGE = { + READY: 'READY', + TRIGGERED: 'TRIGGERED', + SUCCESS: 'SUCCESS', + ERROR: 'ERROR', +}; + +export const DEFAULT_IS_LAYER_TOC_OPEN = true; + +const INITIAL_STATE = { + flyoutDisplay: FLYOUT_STATE.NONE, + isFullScreen: false, + isReadOnly: false, + isLayerTOCOpen: DEFAULT_IS_LAYER_TOC_OPEN, + isSetViewOpen: false, + // storing TOC detail visibility outside of map.layerList because its UI state and not map rendering state. + // This also makes for easy read/write access for embeddables. + openTOCDetails: [], + importIndexingStage: null, +}; + +// Reducer +export function ui(state = INITIAL_STATE, action) { + switch (action.type) { + case UPDATE_FLYOUT: + return { ...state, flyoutDisplay: action.display }; + case CLOSE_SET_VIEW: + return { ...state, isSetViewOpen: false }; + case OPEN_SET_VIEW: + return { ...state, isSetViewOpen: true }; + case SET_IS_LAYER_TOC_OPEN: + return { ...state, isLayerTOCOpen: action.isLayerTOCOpen }; + case SET_FULL_SCREEN: + return { ...state, isFullScreen: action.isFullScreen }; + case SET_READ_ONLY: + return { ...state, isReadOnly: action.isReadOnly }; + case SET_OPEN_TOC_DETAILS: + return { ...state, openTOCDetails: action.layerIds }; + case SHOW_TOC_DETAILS: + return { + ...state, + openTOCDetails: [...state.openTOCDetails, action.layerId], + }; + case HIDE_TOC_DETAILS: + return { + ...state, + openTOCDetails: state.openTOCDetails.filter(layerId => { + return layerId !== action.layerId; + }), + }; + case UPDATE_INDEXING_STAGE: + return { ...state, importIndexingStage: action.stage }; + default: + return state; + } +} diff --git a/x-pack/plugins/maps/public/reducers/util.js b/x-pack/plugins/maps/public/reducers/util.js new file mode 100644 index 0000000000000..e93eedbaebf50 --- /dev/null +++ b/x-pack/plugins/maps/public/reducers/util.js @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export const TRACKED_LAYER_DESCRIPTOR = '__trackedLayerDescriptor'; + +export function copyPersistentState(input) { + if (typeof input !== 'object' || input === null) { + //primitive + return input; + } + const copyInput = Array.isArray(input) ? [] : {}; + for (const key in input) { + if (!key.startsWith('__')) { + copyInput[key] = copyPersistentState(input[key]); + } + } + return copyInput; +} diff --git a/x-pack/plugins/maps/public/reducers/util.test.js b/x-pack/plugins/maps/public/reducers/util.test.js new file mode 100644 index 0000000000000..2829c9410724e --- /dev/null +++ b/x-pack/plugins/maps/public/reducers/util.test.js @@ -0,0 +1,47 @@ +/* + * 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 { copyPersistentState } from './util'; + +describe('reducers/util', () => { + describe('copyPersistentState', () => { + it('should ignore state preceded by double underscores', async () => { + const copy = copyPersistentState({ + foo: 'bar', + nested: { + bar: 'foo', + __bar: 'foo__', + }, + }); + + expect(copy).toEqual({ + foo: 'bar', + nested: { + bar: 'foo', + }, + }); + }); + + it('should copy null value correctly', async () => { + const copy = copyPersistentState({ + foo: 'bar', + nested: { + nullval: null, + bar: 'foo', + __bar: 'foo__', + }, + }); + + expect(copy).toEqual({ + foo: 'bar', + nested: { + nullval: null, + bar: 'foo', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.js b/x-pack/plugins/maps/public/selectors/map_selectors.js new file mode 100644 index 0000000000000..4b3d1355e4264 --- /dev/null +++ b/x-pack/plugins/maps/public/selectors/map_selectors.js @@ -0,0 +1,239 @@ +/* + * 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 { createSelector } from 'reselect'; +import _ from 'lodash'; +import { TileLayer } from '../layers/tile_layer'; +import { VectorTileLayer } from '../layers/vector_tile_layer'; +import { VectorLayer } from '../layers/vector_layer'; +import { HeatmapLayer } from '../layers/heatmap_layer'; +import { ALL_SOURCES } from '../layers/sources/all_sources'; +import { timefilter } from 'ui/timefilter'; +import { getInspectorAdapters } from '../reducers/non_serializable_instances'; +import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from '../reducers/util'; + +function createLayerInstance(layerDescriptor, inspectorAdapters) { + const source = createSourceInstance(layerDescriptor.sourceDescriptor, inspectorAdapters); + + switch (layerDescriptor.type) { + case TileLayer.type: + return new TileLayer({ layerDescriptor, source }); + case VectorLayer.type: + return new VectorLayer({ layerDescriptor, source }); + case VectorTileLayer.type: + return new VectorTileLayer({ layerDescriptor, source }); + case HeatmapLayer.type: + return new HeatmapLayer({ layerDescriptor, source }); + default: + throw new Error(`Unrecognized layerType ${layerDescriptor.type}`); + } +} + +function createSourceInstance(sourceDescriptor, inspectorAdapters) { + const Source = ALL_SOURCES.find(Source => { + return Source.type === sourceDescriptor.type; + }); + if (!Source) { + throw new Error(`Unrecognized sourceType ${sourceDescriptor.type}`); + } + return new Source(sourceDescriptor, inspectorAdapters); +} + +export const getTooltipState = ({ map }) => { + return map.tooltipState; +}; + +export const getMapReady = ({ map }) => map && map.ready; + +export const getMapInitError = ({ map }) => map.mapInitError; + +export const getGoto = ({ map }) => map && map.goto; + +export const getSelectedLayerId = ({ map }) => { + return !map.selectedLayerId || !map.layerList ? null : map.selectedLayerId; +}; + +export const getTransientLayerId = ({ map }) => map.__transientLayerId; + +export const getLayerListRaw = ({ map }) => (map.layerList ? map.layerList : []); + +export const getWaitingForMapReadyLayerListRaw = ({ map }) => + map.waitingForMapReadyLayerList ? map.waitingForMapReadyLayerList : []; + +export const getScrollZoom = ({ map }) => map.mapState.scrollZoom; + +export const isInteractiveDisabled = ({ map }) => map.mapState.disableInteractive; + +export const isTooltipControlDisabled = ({ map }) => map.mapState.disableTooltipControl; + +export const isToolbarOverlayHidden = ({ map }) => map.mapState.hideToolbarOverlay; + +export const isLayerControlHidden = ({ map }) => map.mapState.hideLayerControl; + +export const isViewControlHidden = ({ map }) => map.mapState.hideViewControl; + +export const getMapExtent = ({ map }) => (map.mapState.extent ? map.mapState.extent : {}); + +export const getMapBuffer = ({ map }) => (map.mapState.buffer ? map.mapState.buffer : {}); + +export const getMapZoom = ({ map }) => (map.mapState.zoom ? map.mapState.zoom : 0); + +export const getMapCenter = ({ map }) => + map.mapState.center ? map.mapState.center : { lat: 0, lon: 0 }; + +export const getMouseCoordinates = ({ map }) => map.mapState.mouseCoordinates; + +export const getTimeFilters = ({ map }) => + map.mapState.timeFilters ? map.mapState.timeFilters : timefilter.getTime(); + +export const getQuery = ({ map }) => map.mapState.query; + +export const getFilters = ({ map }) => map.mapState.filters; + +export const isUsingSearch = state => { + const filters = getFilters(state).filter(filter => !filter.meta.disabled); + const queryString = _.get(getQuery(state), 'query', ''); + return filters.length || queryString.length; +}; + +export const getDrawState = ({ map }) => map.mapState.drawState; + +export const isDrawingFilter = ({ map }) => { + return !!map.mapState.drawState; +}; + +export const getRefreshConfig = ({ map }) => { + if (map.mapState.refreshConfig) { + return map.mapState.refreshConfig; + } + + const refreshInterval = timefilter.getRefreshInterval(); + return { + isPaused: refreshInterval.pause, + interval: refreshInterval.value, + }; +}; + +export const getRefreshTimerLastTriggeredAt = ({ map }) => map.mapState.refreshTimerLastTriggeredAt; + +export const getDataFilters = createSelector( + getMapExtent, + getMapBuffer, + getMapZoom, + getTimeFilters, + getRefreshTimerLastTriggeredAt, + getQuery, + getFilters, + (mapExtent, mapBuffer, mapZoom, timeFilters, refreshTimerLastTriggeredAt, query, filters) => { + return { + extent: mapExtent, + buffer: mapBuffer, + zoom: mapZoom, + timeFilters, + refreshTimerLastTriggeredAt, + query, + filters, + }; + } +); + +export const getLayerList = createSelector( + getLayerListRaw, + getInspectorAdapters, + (layerDescriptorList, inspectorAdapters) => { + return layerDescriptorList.map(layerDescriptor => + createLayerInstance(layerDescriptor, inspectorAdapters) + ); + } +); + +export const getHiddenLayerIds = createSelector(getLayerListRaw, layers => + layers.filter(layer => !layer.visible).map(layer => layer.id) +); + +export const getSelectedLayer = createSelector( + getSelectedLayerId, + getLayerList, + (selectedLayerId, layerList) => { + return layerList.find(layer => layer.getId() === selectedLayerId); + } +); + +export const getMapColors = createSelector( + getTransientLayerId, + getLayerListRaw, + (transientLayerId, layerList) => + layerList.reduce((accu, layer) => { + if (layer.id === transientLayerId) { + return accu; + } + const color = _.get(layer, 'style.properties.fillColor.options.color'); + if (color) accu.push(color); + return accu; + }, []) +); + +export const getSelectedLayerJoinDescriptors = createSelector(getSelectedLayer, selectedLayer => { + return selectedLayer.getJoins().map(join => { + return join.toDescriptor(); + }); +}); + +// Get list of unique index patterns used by all layers +export const getUniqueIndexPatternIds = createSelector(getLayerList, layerList => { + const indexPatternIds = []; + layerList.forEach(layer => { + indexPatternIds.push(...layer.getIndexPatternIds()); + }); + return _.uniq(indexPatternIds).sort(); +}); + +// Get list of unique index patterns, excluding index patterns from layers that disable applyGlobalQuery +export const getQueryableUniqueIndexPatternIds = createSelector(getLayerList, layerList => { + const indexPatternIds = []; + layerList.forEach(layer => { + indexPatternIds.push(...layer.getQueryableIndexPatternIds()); + }); + return _.uniq(indexPatternIds); +}); + +export const hasDirtyState = createSelector( + getLayerListRaw, + getTransientLayerId, + (layerListRaw, transientLayerId) => { + if (transientLayerId) { + return true; + } + + return layerListRaw.some(layerDescriptor => { + const trackedState = layerDescriptor[TRACKED_LAYER_DESCRIPTOR]; + if (!trackedState) { + return false; + } + const currentState = copyPersistentState(layerDescriptor); + return !_.isEqual(currentState, trackedState); + }); + } +); + +export const areLayersLoaded = createSelector( + getLayerList, + getWaitingForMapReadyLayerListRaw, + getMapZoom, + (layerList, waitingForMapReadyLayerList, zoom) => { + if (waitingForMapReadyLayerList.length) { + return false; + } + + for (let i = 0; i < layerList.length; i++) { + const layer = layerList[i]; + if (layer.isVisible() && layer.showAtZoomLevel(zoom) && !layer.isDataLoaded()) { + return false; + } + } + return true; + } +); diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.test.js b/x-pack/plugins/maps/public/selectors/map_selectors.test.js new file mode 100644 index 0000000000000..da45047d3a567 --- /dev/null +++ b/x-pack/plugins/maps/public/selectors/map_selectors.test.js @@ -0,0 +1,54 @@ +/* + * 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. + */ + +jest.mock('../layers/vector_layer', () => {}); +jest.mock('../layers/heatmap_layer', () => {}); +jest.mock('../layers/vector_tile_layer', () => {}); +jest.mock('../layers/sources/all_sources', () => {}); +jest.mock('../reducers/non_serializable_instances', () => ({ + getInspectorAdapters: () => { + return {}; + }, +})); +jest.mock('ui/timefilter', () => ({ + timefilter: { + getTime: () => { + return { + to: 'now', + from: 'now-15m', + }; + }, + }, +})); + +import { getTimeFilters } from './map_selectors'; + +describe('getTimeFilters', () => { + it('should return timeFilters when contained in state', () => { + const state = { + map: { + mapState: { + timeFilters: { + to: '2001-01-01', + from: '2001-12-31', + }, + }, + }, + }; + expect(getTimeFilters(state)).toEqual({ to: '2001-01-01', from: '2001-12-31' }); + }); + + it('should return kibana time filters when not contained in state', () => { + const state = { + map: { + mapState: { + timeFilters: null, + }, + }, + }; + expect(getTimeFilters(state)).toEqual({ to: 'now', from: 'now-15m' }); + }); +}); diff --git a/x-pack/plugins/maps/public/selectors/ui_selectors.js b/x-pack/plugins/maps/public/selectors/ui_selectors.js new file mode 100644 index 0000000000000..912ee08396212 --- /dev/null +++ b/x-pack/plugins/maps/public/selectors/ui_selectors.js @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export const getFlyoutDisplay = ({ ui }) => ui.flyoutDisplay; +export const getIsSetViewOpen = ({ ui }) => ui.isSetViewOpen; +export const getIsLayerTOCOpen = ({ ui }) => ui.isLayerTOCOpen; +export const getOpenTOCDetails = ({ ui }) => ui.openTOCDetails; +export const getIsFullScreen = ({ ui }) => ui.isFullScreen; +export const getIsReadOnly = ({ ui }) => ui.isReadOnly; +export const getIndexingStage = ({ ui }) => ui.importIndexingStage;