diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index bcfe11851d1eaa..4ee99eb51f44c8 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -64,6 +64,7 @@ export enum SOURCE_TYPES { EMS_TMS = 'EMS_TMS', EMS_FILE = 'EMS_FILE', ES_GEO_GRID = 'ES_GEO_GRID', + ES_GEO_LINE = 'ES_GEO_LINE', ES_SEARCH = 'ES_SEARCH', ES_PEW_PEW = 'ES_PEW_PEW', ES_TERM_SOURCE = 'ES_TERM_SOURCE', diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index 68fc784182a775..eea201dcc8baa4 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -34,7 +34,16 @@ type ESGeoGridSourceSyncMeta = { requestType: RENDER_AS; }; -export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta | null; +type ESGeoLineSourceSyncMeta = { + splitField: string; + sortField: string; +}; + +export type VectorSourceSyncMeta = + | ESSearchSourceSyncMeta + | ESGeoGridSourceSyncMeta + | ESGeoLineSourceSyncMeta + | null; export type VectorSourceRequestMeta = MapFilters & { applyGlobalQuery: boolean; @@ -66,12 +75,21 @@ export type ESSearchSourceResponseMeta = { totalEntities?: number; }; +export type ESGeoLineSourceResponseMeta = { + areResultsTrimmed: boolean; + areEntitiesTrimmed: boolean; + entityCount: number; + numTrimmedTracks: number; + totalEntities: number; +}; + // Partial because objects are justified downstream in constructors export type DataMeta = Partial< VectorSourceRequestMeta & VectorJoinSourceRequestMeta & VectorStyleRequestMeta & - ESSearchSourceResponseMeta + ESSearchSourceResponseMeta & + ESGeoLineSourceResponseMeta >; type NumericalStyleFieldData = { diff --git a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts index c11ee59768a910..0e35b97a66bbf2 100644 --- a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts @@ -72,6 +72,12 @@ export type ESGeoGridSourceDescriptor = AbstractESAggSourceDescriptor & { resolution: GRID_RESOLUTION; }; +export type ESGeoLineSourceDescriptor = AbstractESAggSourceDescriptor & { + geoField: string; + splitField: string; + sortField: string; +}; + export type ESSearchSourceDescriptor = AbstractESSourceDescriptor & { geoField: string; filterByMapBounds?: boolean; diff --git a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts index 278a3c0388b015..aac8afd4f292d6 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts @@ -28,7 +28,9 @@ export type LayerWizard = { categories: LAYER_WIZARD_CATEGORY[]; checkVisibility?: () => Promise; description: string; + disabledReason?: string; icon: string | FunctionComponent; + getIsDisabled?: () => boolean; prerequisiteSteps?: Array<{ id: string; label: string }>; renderWizard(renderWizardArguments: RenderWizardArguments): ReactElement; title: string; diff --git a/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts b/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts index eaef7931b5e6c2..b0f09651968302 100644 --- a/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts +++ b/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts @@ -10,6 +10,7 @@ import { uploadLayerWizardConfig } from './file_upload_wizard'; import { esDocumentsLayerWizardConfig } from '../sources/es_search_source'; // @ts-ignore import { clustersLayerWizardConfig, heatmapLayerWizardConfig } from '../sources/es_geo_grid_source'; +import { geoLineLayerWizardConfig } from '../sources/es_geo_line_source'; // @ts-ignore import { point2PointLayerWizardConfig } from '../sources/es_pew_pew_source'; // @ts-ignore @@ -45,6 +46,7 @@ export function registerLayerWizards() { registerLayerWizard(clustersLayerWizardConfig); // @ts-ignore registerLayerWizard(heatmapLayerWizardConfig); + registerLayerWizard(geoLineLayerWizardConfig); // @ts-ignore registerLayerWizard(point2PointLayerWizardConfig); // @ts-ignore diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.test.ts new file mode 100644 index 00000000000000..de0f18fa537f6c --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.test.ts @@ -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 { convertToGeoJson } from './convert_to_geojson'; + +const esResponse = { + aggregations: { + tracks: { + buckets: { + ios: { + doc_count: 1, + path: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-95.339639, 41.584389], + [-95.339639, 41.0], + ], + }, + properties: { + complete: true, + }, + }, + }, + osx: { + doc_count: 1, + path: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-97.902775, 48.940572], + [-97.902775, 48.0], + ], + }, + properties: { + complete: false, + }, + }, + }, + }, + }, + }, +}; + +it('Should convert elasticsearch aggregation response into feature collection', () => { + const geoJson = convertToGeoJson(esResponse, 'machine.os.keyword'); + expect(geoJson.numTrimmedTracks).toBe(1); + expect(geoJson.featureCollection.features.length).toBe(2); + expect(geoJson.featureCollection.features[0]).toEqual({ + geometry: { + coordinates: [ + [-95.339639, 41.584389], + [-95.339639, 41.0], + ], + type: 'LineString', + }, + id: 'ios', + properties: { + complete: true, + doc_count: 1, + ['machine.os.keyword']: 'ios', + }, + type: 'Feature', + }); +}); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.ts new file mode 100644 index 00000000000000..a40b13bf07ae7f --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.ts @@ -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 { Feature, FeatureCollection } from 'geojson'; +import { extractPropertiesFromBucket } from '../../../../common/elasticsearch_util'; + +const KEYS_TO_IGNORE = ['key', 'path']; + +export function convertToGeoJson(esResponse: any, entitySplitFieldName: string) { + const features: Feature[] = []; + let numTrimmedTracks = 0; + + const buckets = _.get(esResponse, 'aggregations.tracks.buckets', {}); + const entityKeys = Object.keys(buckets); + for (let i = 0; i < entityKeys.length; i++) { + const entityKey = entityKeys[i]; + const bucket = buckets[entityKey]; + const feature = bucket.path as Feature; + if (!feature.properties!.complete) { + numTrimmedTracks++; + } + feature.id = entityKey; + feature.properties = { + [entitySplitFieldName]: entityKey, + ...feature.properties, + ...extractPropertiesFromBucket(bucket, KEYS_TO_IGNORE), + }; + features.push(feature); + } + + return { + featureCollection: { + type: 'FeatureCollection', + features, + } as FeatureCollection, + numTrimmedTracks, + }; +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/create_source_editor.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/create_source_editor.tsx new file mode 100644 index 00000000000000..209f02bbd27b07 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/create_source_editor.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { IndexPattern } from 'src/plugins/data/public'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiPanel } from '@elastic/eui'; +import { SingleFieldSelect } from '../../../components/single_field_select'; +import { GeoIndexPatternSelect } from '../../../components/geo_index_pattern_select'; + +import { getGeoPointFields } from '../../../index_pattern_util'; +import { GeoLineForm } from './geo_line_form'; + +interface Props { + onSourceConfigChange: ( + sourceConfig: { + indexPatternId: string; + geoField: string; + splitField: string; + sortField: string; + } | null + ) => void; +} + +interface State { + indexPattern: IndexPattern | null; + geoField: string; + splitField: string; + sortField: string; +} + +export class CreateSourceEditor extends Component { + state: State = { + indexPattern: null, + geoField: '', + splitField: '', + sortField: '', + }; + + _onIndexPatternSelect = (indexPattern: IndexPattern) => { + const pointFields = getGeoPointFields(indexPattern.fields); + this.setState( + { + indexPattern, + geoField: pointFields.length ? pointFields[0].name : '', + sortField: indexPattern.timeFieldName ? indexPattern.timeFieldName : '', + }, + this.previewLayer + ); + }; + + _onGeoFieldSelect = (geoField?: string) => { + if (geoField === undefined) { + return; + } + + this.setState( + { + geoField, + }, + this.previewLayer + ); + }; + + _onSplitFieldSelect = (newValue: string) => { + this.setState( + { + splitField: newValue, + }, + this.previewLayer + ); + }; + + _onSortFieldSelect = (newValue: string) => { + this.setState( + { + sortField: newValue, + }, + this.previewLayer + ); + }; + + previewLayer = () => { + const { indexPattern, geoField, splitField, sortField } = this.state; + + const sourceConfig = + indexPattern && indexPattern.id && geoField && splitField && sortField + ? { indexPatternId: indexPattern.id, geoField, splitField, sortField } + : null; + this.props.onSourceConfigChange(sourceConfig); + }; + + _renderGeoSelect() { + if (!this.state.indexPattern) { + return null; + } + + return ( + + + + ); + } + + _renderGeoLineForm() { + if (!this.state.indexPattern || !this.state.geoField) { + return null; + } + + return ( + + ); + } + + render() { + return ( + + + {this._renderGeoSelect()} + {this._renderGeoLineForm()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.test.ts new file mode 100644 index 00000000000000..6a173347f48a87 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESGeoLineSource } from './es_geo_line_source'; +import { DataRequest } from '../../util/data_request'; + +describe('getSourceTooltipContent', () => { + const geoLineSource = new ESGeoLineSource({ + indexPatternId: 'myindex', + geoField: 'myGeoField', + splitField: 'mySplitField', + sortField: 'mySortField', + }); + + it('Should not show results trimmed icon when number of entities is not trimmed and all tracks are complete', () => { + const sourceDataRequest = new DataRequest({ + data: {}, + dataId: 'source', + dataMeta: { + areResultsTrimmed: false, + areEntitiesTrimmed: false, + entityCount: 70, + numTrimmedTracks: 0, + totalEntities: 70, + }, + }); + const { tooltipContent, areResultsTrimmed } = geoLineSource.getSourceTooltipContent( + sourceDataRequest + ); + expect(areResultsTrimmed).toBe(false); + expect(tooltipContent).toBe('Found 70 tracks.'); + }); + + it('Should show results trimmed icon and message when number of entities are trimmed', () => { + const sourceDataRequest = new DataRequest({ + data: {}, + dataId: 'source', + dataMeta: { + areResultsTrimmed: true, + areEntitiesTrimmed: true, + entityCount: 1000, + numTrimmedTracks: 0, + totalEntities: 5000, + }, + }); + const { tooltipContent, areResultsTrimmed } = geoLineSource.getSourceTooltipContent( + sourceDataRequest + ); + expect(areResultsTrimmed).toBe(true); + expect(tooltipContent).toBe('Results limited to first 1000 tracks of ~5000.'); + }); + + it('Should show results trimmed icon and message when tracks are trimmed', () => { + const sourceDataRequest = new DataRequest({ + data: {}, + dataId: 'source', + dataMeta: { + areResultsTrimmed: false, + areEntitiesTrimmed: false, + entityCount: 70, + numTrimmedTracks: 10, + totalEntities: 70, + }, + }); + const { tooltipContent, areResultsTrimmed } = geoLineSource.getSourceTooltipContent( + sourceDataRequest + ); + expect(areResultsTrimmed).toBe(true); + expect(tooltipContent).toBe('Found 70 tracks. 10 of 70 tracks are incomplete.'); + }); + + it('Should show results trimmed icon and message when number of entities are trimmed. and tracks are trimmed', () => { + const sourceDataRequest = new DataRequest({ + data: {}, + dataId: 'source', + dataMeta: { + areResultsTrimmed: true, + areEntitiesTrimmed: true, + entityCount: 1000, + numTrimmedTracks: 10, + totalEntities: 5000, + }, + }); + const { tooltipContent, areResultsTrimmed } = geoLineSource.getSourceTooltipContent( + sourceDataRequest + ); + expect(areResultsTrimmed).toBe(true); + expect(tooltipContent).toBe( + 'Results limited to first 1000 tracks of ~5000. 10 of 1000 tracks are incomplete.' + ); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx new file mode 100644 index 00000000000000..d9b363d69d29c3 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx @@ -0,0 +1,365 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { GeoJsonProperties } from 'geojson'; +import { i18n } from '@kbn/i18n'; +import { FIELD_ORIGIN, SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; +import { getField, addFieldToDSL } from '../../../../common/elasticsearch_util'; +import { + ESGeoLineSourceDescriptor, + ESGeoLineSourceResponseMeta, + VectorSourceRequestMeta, +} from '../../../../common/descriptor_types'; +import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import { AbstractESAggSource } from '../es_agg_source'; +import { DataRequest } from '../../util/data_request'; +import { registerSource } from '../source_registry'; +import { convertToGeoJson } from './convert_to_geojson'; +import { ESDocField } from '../../fields/es_doc_field'; +import { UpdateSourceEditor } from './update_source_editor'; +import { ImmutableSourceProperty, SourceEditorArgs } from '../source'; +import { GeoJsonWithMeta } from '../vector_source'; +import { isValidStringConfig } from '../../util/valid_string_config'; +import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; +import { IField } from '../../fields/field'; +import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; +import { esFilters } from '../../../../../../../src/plugins/data/public'; +import { getIsGoldPlus } from '../../../licensed_features'; + +const MAX_TRACKS = 250; + +export const geoLineTitle = i18n.translate('xpack.maps.source.esGeoLineTitle', { + defaultMessage: 'Tracks', +}); + +export const REQUIRES_GOLD_LICENSE_MSG = i18n.translate( + 'xpack.maps.source.esGeoLineDisabledReason', + { + defaultMessage: '{title} requires a Gold license.', + values: { title: geoLineTitle }, + } +); + +export class ESGeoLineSource extends AbstractESAggSource { + static createDescriptor( + descriptor: Partial + ): ESGeoLineSourceDescriptor { + const normalizedDescriptor = AbstractESAggSource.createDescriptor( + descriptor + ) as ESGeoLineSourceDescriptor; + if (!isValidStringConfig(normalizedDescriptor.geoField)) { + throw new Error('Cannot create an ESGeoLineSource without a geoField'); + } + if (!isValidStringConfig(normalizedDescriptor.splitField)) { + throw new Error('Cannot create an ESGeoLineSource without a splitField'); + } + if (!isValidStringConfig(normalizedDescriptor.sortField)) { + throw new Error('Cannot create an ESGeoLineSource without a sortField'); + } + return { + ...normalizedDescriptor, + type: SOURCE_TYPES.ES_GEO_LINE, + geoField: normalizedDescriptor.geoField!, + splitField: normalizedDescriptor.splitField!, + sortField: normalizedDescriptor.sortField!, + }; + } + + readonly _descriptor: ESGeoLineSourceDescriptor; + + constructor(descriptor: Partial, inspectorAdapters?: Adapters) { + const sourceDescriptor = ESGeoLineSource.createDescriptor(descriptor); + super(sourceDescriptor, inspectorAdapters, true); + this._descriptor = sourceDescriptor; + } + + renderSourceSettingsEditor({ onChange }: SourceEditorArgs) { + return ( + + ); + } + + getSyncMeta() { + return { + splitField: this._descriptor.splitField, + sortField: this._descriptor.sortField, + }; + } + + async getImmutableProperties(): Promise { + let indexPatternTitle = this.getIndexPatternId(); + try { + const indexPattern = await this.getIndexPattern(); + indexPatternTitle = indexPattern.title; + } catch (error) { + // ignore error, title will just default to id + } + + return [ + { + label: getDataSourceLabel(), + value: geoLineTitle, + }, + { + label: i18n.translate('xpack.maps.source.esGeoLine.indexPatternLabel', { + defaultMessage: 'Index pattern', + }), + value: indexPatternTitle, + }, + { + label: i18n.translate('xpack.maps.source.esGeoLine.geospatialFieldLabel', { + defaultMessage: 'Geospatial field', + }), + value: this._descriptor.geoField, + }, + ]; + } + + _createSplitField(): IField { + return new ESDocField({ + fieldName: this._descriptor.splitField, + source: this, + origin: FIELD_ORIGIN.SOURCE, + canReadFromGeoJson: true, + }); + } + + getFieldNames() { + return [ + ...this.getMetricFields().map((esAggMetricField) => esAggMetricField.getName()), + this._descriptor.splitField, + this._descriptor.sortField, + ]; + } + + async getFields(): Promise { + return [...this.getMetricFields(), this._createSplitField()]; + } + + getFieldByName(name: string): IField | null { + return name === this._descriptor.splitField + ? this._createSplitField() + : this.getMetricFieldForName(name); + } + + isGeoGridPrecisionAware() { + return false; + } + + showJoinEditor() { + return false; + } + + async getGeoJsonWithMeta( + layerName: string, + searchFilters: VectorSourceRequestMeta, + registerCancelCallback: (callback: () => void) => void, + isRequestStillActive: () => boolean + ): Promise { + if (!getIsGoldPlus()) { + throw new Error(REQUIRES_GOLD_LICENSE_MSG); + } + + const indexPattern = await this.getIndexPattern(); + + // Request is broken into 2 requests + // 1) fetch entities: filtered by buffer so that top entities in view are returned + // 2) fetch tracks: not filtered by buffer to avoid having invalid tracks + // when the track extends beyond the area of the map buffer. + + // + // Fetch entities + // + const entitySearchSource = await this.makeSearchSource(searchFilters, 0); + const splitField = getField(indexPattern, this._descriptor.splitField); + const cardinalityAgg = { precision_threshold: 1 }; + const termsAgg = { size: MAX_TRACKS }; + entitySearchSource.setField('aggs', { + totalEntities: { + cardinality: addFieldToDSL(cardinalityAgg, splitField), + }, + entitySplit: { + terms: addFieldToDSL(termsAgg, splitField), + }, + }); + const entityResp = await this._runEsQuery({ + requestId: `${this.getId()}_entities`, + requestName: i18n.translate('xpack.maps.source.esGeoLine.entityRequestName', { + defaultMessage: '{layerName} entities', + values: { + layerName, + }, + }), + searchSource: entitySearchSource, + registerCancelCallback, + requestDescription: i18n.translate('xpack.maps.source.esGeoLine.entityRequestDescription', { + defaultMessage: 'Elasticsearch terms request to fetch entities within map buffer.', + }), + }); + const entityBuckets: Array<{ key: string; doc_count: number }> = _.get( + entityResp, + 'aggregations.entitySplit.buckets', + [] + ); + const totalEntities = _.get(entityResp, 'aggregations.totalEntities.value', 0); + const areEntitiesTrimmed = entityBuckets.length >= MAX_TRACKS; + + // + // Fetch tracks + // + const entityFilters: { [key: string]: unknown } = {}; + for (let i = 0; i < entityBuckets.length; i++) { + entityFilters[entityBuckets[i].key] = esFilters.buildPhraseFilter( + splitField, + entityBuckets[i].key, + indexPattern + ).query; + } + const tracksSearchFilters = { ...searchFilters }; + delete tracksSearchFilters.buffer; + const tracksSearchSource = await this.makeSearchSource(tracksSearchFilters, 0); + tracksSearchSource.setField('aggs', { + tracks: { + filters: { + filters: entityFilters, + }, + aggs: { + path: { + geo_line: { + point: { + field: this._descriptor.geoField, + }, + sort: { + field: this._descriptor.sortField, + }, + }, + }, + ...this.getValueAggsDsl(indexPattern), + }, + }, + }); + const tracksResp = await this._runEsQuery({ + requestId: `${this.getId()}_tracks`, + requestName: i18n.translate('xpack.maps.source.esGeoLine.trackRequestName', { + defaultMessage: '{layerName} tracks', + values: { + layerName, + }, + }), + searchSource: tracksSearchSource, + registerCancelCallback, + requestDescription: i18n.translate('xpack.maps.source.esGeoLine.trackRequestDescription', { + defaultMessage: + 'Elasticsearch geo_line request to fetch tracks for entities. Tracks are not filtered by map buffer.', + }), + }); + const { featureCollection, numTrimmedTracks } = convertToGeoJson( + tracksResp, + this._descriptor.splitField + ); + + return { + data: featureCollection, + meta: { + // meta.areResultsTrimmed is used by updateDueToExtent to skip re-fetching results + // when extent changes contained by original extent are not needed + // Only trigger re-fetch when the number of entities are trimmed + // Do not trigger re-fetch when tracks are trimmed since the tracks themselves are not filtered by map view extent. + areResultsTrimmed: areEntitiesTrimmed, + areEntitiesTrimmed, + entityCount: entityBuckets.length, + numTrimmedTracks, + totalEntities, + } as ESGeoLineSourceResponseMeta, + }; + } + + getSourceTooltipContent(sourceDataRequest?: DataRequest) { + const featureCollection = sourceDataRequest ? sourceDataRequest.getData() : null; + const meta = sourceDataRequest + ? (sourceDataRequest.getMeta() as ESGeoLineSourceResponseMeta) + : null; + if (!featureCollection || !meta) { + // no tooltip content needed when there is no feature collection or meta + return { + tooltipContent: null, + areResultsTrimmed: false, + }; + } + + const entitiesFoundMsg = meta.areEntitiesTrimmed + ? i18n.translate('xpack.maps.esGeoLine.areEntitiesTrimmedMsg', { + defaultMessage: `Results limited to first {entityCount} tracks of ~{totalEntities}.`, + values: { + entityCount: meta.entityCount, + totalEntities: meta.totalEntities, + }, + }) + : i18n.translate('xpack.maps.esGeoLine.tracksCountMsg', { + defaultMessage: `Found {entityCount} tracks.`, + values: { entityCount: meta.entityCount }, + }); + const tracksTrimmedMsg = + meta.numTrimmedTracks > 0 + ? i18n.translate('xpack.maps.esGeoLine.tracksTrimmedMsg', { + defaultMessage: `{numTrimmedTracks} of {entityCount} tracks are incomplete.`, + values: { + entityCount: meta.entityCount, + numTrimmedTracks: meta.numTrimmedTracks, + }, + }) + : undefined; + return { + tooltipContent: tracksTrimmedMsg + ? `${entitiesFoundMsg} ${tracksTrimmedMsg}` + : entitiesFoundMsg, + // Used to show trimmed icon in legend. Trimmed icon signals the following + // 1) number of entities are trimmed. + // 2) one or more tracks are incomplete. + areResultsTrimmed: meta.areEntitiesTrimmed || meta.numTrimmedTracks > 0, + }; + } + + isFilterByMapBounds() { + return true; + } + + canFormatFeatureProperties() { + return true; + } + + async getSupportedShapeTypes() { + return [VECTOR_SHAPE_TYPE.LINE]; + } + + async getTooltipProperties(properties: GeoJsonProperties): Promise { + const tooltipProperties = await super.getTooltipProperties(properties); + tooltipProperties.push( + new TooltipProperty( + 'isTrackComplete', + i18n.translate('xpack.maps.source.esGeoLine.isTrackCompleteLabel', { + defaultMessage: 'track is complete', + }), + properties!.complete.toString() + ) + ); + return tooltipProperties; + } +} + +registerSource({ + ConstructorFunction: ESGeoLineSource, + type: SOURCE_TYPES.ES_GEO_LINE, +}); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx new file mode 100644 index 00000000000000..f0ccc72feeb426 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { IndexPattern } from 'src/plugins/data/public'; +import { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SingleFieldSelect } from '../../../components/single_field_select'; +import { getTermsFields } from '../../../index_pattern_util'; +import { indexPatterns } from '../../../../../../../src/plugins/data/public'; + +interface Props { + indexPattern: IndexPattern; + onSortFieldChange: (fieldName: string) => void; + onSplitFieldChange: (fieldName: string) => void; + sortField: string; + splitField: string; +} + +export function GeoLineForm(props: Props) { + function onSortFieldChange(fieldName: string | undefined) { + if (fieldName !== undefined) { + props.onSortFieldChange(fieldName); + } + } + function onSplitFieldChange(fieldName: string | undefined) { + if (fieldName !== undefined) { + props.onSplitFieldChange(fieldName); + } + } + return ( + <> + + + + + + { + const isSplitField = props.splitField ? field.name === props.splitField : false; + return !isSplitField && field.sortable && !indexPatterns.isNestedField(field); + })} + isClearable={false} + /> + + + ); +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/index.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/index.ts new file mode 100644 index 00000000000000..9ba46fabe12b06 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { geoLineLayerWizardConfig } from './layer_wizard'; +export { ESGeoLineSource } from './es_geo_line_source'; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx new file mode 100644 index 00000000000000..0738e8faec1e3d --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx @@ -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 { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CreateSourceEditor } from './create_source_editor'; +import { ESGeoLineSource, geoLineTitle, REQUIRES_GOLD_LICENSE_MSG } from './es_geo_line_source'; +import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; +import { LAYER_WIZARD_CATEGORY, STYLE_TYPE, VECTOR_STYLES } from '../../../../common/constants'; +import { VectorStyle } from '../../styles/vector/vector_style'; +import { VectorLayer } from '../../layers/vector_layer/vector_layer'; +import { getIsGoldPlus } from '../../../licensed_features'; +import { TracksLayerIcon } from '../../layers/icons/tracks_layer_icon'; + +export const geoLineLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], + description: i18n.translate('xpack.maps.source.esGeoLineDescription', { + defaultMessage: 'Connect points into lines', + }), + disabledReason: REQUIRES_GOLD_LICENSE_MSG, + icon: TracksLayerIcon, + getIsDisabled: () => { + return !getIsGoldPlus(); + }, + renderWizard: ({ previewLayers }: RenderWizardArguments) => { + const onSourceConfigChange = ( + sourceConfig: { + indexPatternId: string; + geoField: string; + splitField: string; + sortField: string; + } | null + ) => { + if (!sourceConfig) { + previewLayers([]); + return; + } + + const layerDescriptor = VectorLayer.createDescriptor({ + sourceDescriptor: ESGeoLineSource.createDescriptor(sourceConfig), + style: VectorStyle.createDescriptor({ + [VECTOR_STYLES.LINE_WIDTH]: { + type: STYLE_TYPE.STATIC, + options: { + size: 2, + }, + }, + }), + }); + layerDescriptor.alpha = 1; + previewLayers([layerDescriptor]); + }; + + return ; + }, + title: geoLineTitle, +}; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/update_source_editor.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/update_source_editor.tsx new file mode 100644 index 00000000000000..1130b6d644903b --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/update_source_editor.tsx @@ -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, { Fragment, Component } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { + IFieldType, + IndexPattern, + indexPatterns, +} from '../../../../../../../src/plugins/data/public'; +import { MetricsEditor } from '../../../components/metrics_editor'; +import { getIndexPatternService } from '../../../kibana_services'; +import { GeoLineForm } from './geo_line_form'; +import { AggDescriptor } from '../../../../common/descriptor_types'; +import { OnSourceChangeArgs } from '../../../connected_components/layer_panel/view'; + +interface Props { + indexPatternId: string; + splitField: string; + sortField: string; + metrics: AggDescriptor[]; + onChange: (...args: OnSourceChangeArgs[]) => void; +} + +interface State { + indexPattern: IndexPattern | null; + fields: IFieldType[]; +} + +export class UpdateSourceEditor extends Component { + private _isMounted: boolean = false; + + state: State = { + indexPattern: null, + fields: [], + }; + + componentDidMount() { + this._isMounted = true; + this._loadFields(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadFields() { + let indexPattern; + try { + indexPattern = await getIndexPatternService().get(this.props.indexPatternId); + } catch (err) { + return; + } + + if (!this._isMounted) { + return; + } + + this.setState({ + indexPattern, + fields: indexPattern.fields.filter((field) => !indexPatterns.isNestedField(field)), + }); + } + + _onMetricsChange = (metrics: AggDescriptor[]) => { + this.props.onChange({ propName: 'metrics', value: metrics }); + }; + + _onSplitFieldChange = (fieldName: string) => { + this.props.onChange({ propName: 'splitField', value: fieldName }); + }; + + _onSortFieldChange = (fieldName: string) => { + this.props.onChange({ propName: 'sortField', value: fieldName }); + }; + + render() { + if (!this.state.indexPattern) { + return null; + } + + return ( + + + +
+ +
+
+ + +
+ + + + +
+ +
+
+ + +
+ +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/components/geo_index_pattern_select.tsx b/x-pack/plugins/maps/public/components/geo_index_pattern_select.tsx index 2e750e0648e53f..ba87e2c8691871 100644 --- a/x-pack/plugins/maps/public/components/geo_index_pattern_select.tsx +++ b/x-pack/plugins/maps/public/components/geo_index_pattern_select.tsx @@ -14,11 +14,12 @@ import { getIndexPatternService, getHttp, } from '../kibana_services'; -import { ES_GEO_FIELD_TYPES } from '../../common/constants'; +import { ES_GEO_FIELD_TYPE, ES_GEO_FIELD_TYPES } from '../../common/constants'; interface Props { onChange: (indexPattern: IndexPattern) => void; value: string | null; + isGeoPointsOnly?: boolean; } interface State { @@ -128,7 +129,9 @@ export class GeoIndexPatternSelect extends Component { placeholder={i18n.translate('xpack.maps.indexPatternSelectPlaceholder', { defaultMessage: 'Select index pattern', })} - fieldTypes={ES_GEO_FIELD_TYPES} + fieldTypes={ + this.props?.isGeoPointsOnly ? [ES_GEO_FIELD_TYPE.GEO_POINT] : ES_GEO_FIELD_TYPES + } onNoIndexPatterns={this._onNoIndexPatterns} isClearable={false} /> diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap index f8803d6339d9ca..18e28b715680e1 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap @@ -45,6 +45,7 @@ exports[`LayerWizardSelect Should render layer select after layer wizards are lo } + isDisabled={false} onClick={[Function]} title="wizard 2" titleSize="xs" diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.scss b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.scss new file mode 100644 index 00000000000000..73bbd2be3349c7 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.scss @@ -0,0 +1,4 @@ +.mapMapLayerWizardSelect__tooltip { + display: flex; + flex: 1; +} diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx index 6f3a88ce905ceb..7870f11530634b 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx @@ -14,12 +14,14 @@ import { EuiLoadingContent, EuiFacetGroup, EuiFacetButton, + EuiToolTip, EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getLayerWizards, LayerWizard } from '../../../classes/layers/layer_wizard_registry'; import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import './layer_wizard_select.scss'; interface Props { onSelect: (layerWizard: LayerWizard) => void; @@ -150,16 +152,32 @@ export class LayerWizardSelect extends Component { this.props.onSelect(layerWizard); }; + const isDisabled = layerWizard.getIsDisabled ? layerWizard.getIsDisabled() : false; + const card = ( + + ); + return ( - + {isDisabled && layerWizard.disabledReason ? ( + + {card} + + ) : ( + card + )} ); }); diff --git a/x-pack/plugins/maps/public/index_pattern_util.ts b/x-pack/plugins/maps/public/index_pattern_util.ts index 68fd224dcbb454..79fa8f6eb6ddf3 100644 --- a/x-pack/plugins/maps/public/index_pattern_util.ts +++ b/x-pack/plugins/maps/public/index_pattern_util.ts @@ -69,6 +69,12 @@ export function getGeoFields(fields: IFieldType[]): IFieldType[] { }); } +export function getGeoPointFields(fields: IFieldType[]): IFieldType[] { + return fields.filter((field) => { + return !indexPatterns.isNestedField(field) && ES_GEO_FIELD_TYPE.GEO_POINT === field.type; + }); +} + export function getFieldsWithGeoTileAgg(fields: IFieldType[]): IFieldType[] { return fields.filter(supportsGeoTileAgg); }