[maps] time series geo line (#159267)

Part of https://github.com/elastic/kibana/issues/141978

PR updates tracks layer with "group by time series" logic. When true,
geo_line metric aggregation is proceeded by `time_series` bucket
aggregation instead of `filters` bucket aggregation (used by existing
terms split).

### UI when creating tracks layer with time series data view

<img width="481" alt="Screen Shot 2023-06-22 at 12 35 46 PM"
src="ccfeb6ef-c714-49a3-a6d6-f6b52cce80be">

<img width="469" alt="Screen Shot 2023-06-22 at 12 35 55 PM"
src="55cba2dc-6326-4141-bde5-7a6cc0f0b333">

<img width="542" alt="Screen Shot 2023-06-22 at 12 49 22 PM"
src="694ce621-2b6e-4a20-ba20-b9f9d20da8ef">

### UI when editing tracks layer with time series data view

<img width="447" alt="Screen Shot 2023-06-22 at 12 36 17 PM"
src="96cbb3f3-4ca5-430f-91b3-71b5013ca6e9">

<img width="457" alt="Screen Shot 2023-06-22 at 12 36 24 PM"
src="4d603809-7e6a-4b72-98d7-d3a516b2c809">

### Test instructions
* clone https://github.com/thomasneirynck/faketracks
* cd into `faketracks`
* run `npm install`
* run `node ./generate_tracks.js --isTimeSeries`
* In Kibana, create `tracks` data view
* In Maps, create new map and add `Tracks` layer. Select `Tracks` data
view.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2023-06-29 11:15:20 -06:00 committed by GitHub
parent bfb07386b2
commit 7340007718
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 630 additions and 173 deletions

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import type { SavedObject } from '@kbn/core/server';
import type { ErrorToastOptions, ToastInputFields } from '@kbn/core-notifications-browser';
@ -437,7 +438,7 @@ export type FieldSpec = DataViewFieldBase & {
/**
* set if field is a TSDB metric field
*/
timeSeriesMetric?: 'histogram' | 'summary' | 'gauge' | 'counter';
timeSeriesMetric?: estypes.MappingTimeSeriesMetricType;
// not persisted

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ElasticsearchClient } from '@kbn/core/server';
import { keyBy } from 'lodash';
import type { QueryDslQueryContainer } from '../../common/types';
@ -27,7 +28,7 @@ export interface FieldDescriptor {
metadata_field?: boolean;
fixedInterval?: string[];
timeZone?: string[];
timeSeriesMetric?: 'histogram' | 'summary' | 'counter' | 'gauge';
timeSeriesMetric?: estypes.MappingTimeSeriesMetricType;
timeSeriesDimension?: boolean;
}

View file

@ -122,13 +122,17 @@ export function readFieldCapsResponse(
return agg;
}
let timeSeriesMetricType: 'gauge' | 'counter' | undefined;
let timeSeriesMetricType: 'gauge' | 'counter' | 'position' | undefined;
if (timeSeriesMetricProp.length === 1 && timeSeriesMetricProp[0] === 'gauge') {
timeSeriesMetricType = 'gauge';
}
if (timeSeriesMetricProp.length === 1 && timeSeriesMetricProp[0] === 'counter') {
timeSeriesMetricType = 'counter';
}
// @ts-expect-error MappingTimeSeriesMetricType does not contain 'position'
if (timeSeriesMetricProp.length === 1 && timeSeriesMetricProp[0] === 'position') {
timeSeriesMetricType = 'position';
}
const esType = types[0];
const field = {
name: fieldName,
@ -144,7 +148,9 @@ export function readFieldCapsResponse(
timeSeriesDimension: capsByType[types[0]].time_series_dimension,
};
// This is intentionally using a "hash" and a "push" to be highly optimized with very large indexes
// @ts-expect-error MappingTimeSeriesMetricType does not contain 'position'
agg.array.push(field);
// @ts-expect-error MappingTimeSeriesMetricType does not contain 'position'
agg.hash[fieldName] = field;
return agg;
},

View file

@ -91,8 +91,10 @@ export type ESGeoGridSourceDescriptor = AbstractESAggSourceDescriptor & {
export type ESGeoLineSourceDescriptor = AbstractESAggSourceDescriptor & {
geoField: string;
splitField: string;
sortField: string;
groupByTimeseries: boolean;
lineSimplificationSize: number;
splitField?: string;
sortField?: string;
};
export type ESSearchSourceDescriptor = AbstractESSourceDescriptor & {

View file

@ -19,7 +19,7 @@ export function ShowAsLabel(props: Props) {
return (
<EuiToolTip
content={
<EuiText>
<EuiText size="s">
<dl>
<dt>{CLUSTER_LABEL}</dt>
<dd>

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// geo_line aggregation without time series buckets uses lots of resources
// limit resource consumption by limiting number of tracks to smaller amount
export const MAX_TERMS_TRACKS = 250;
// Constant is used to identify time series id field in UIs, tooltips, and styling.
// Constant is not passed to Elasticsearch APIs and is not related to '_tsid' document metadata field.
// Constant value of '_tsid' is arbitrary.
export const TIME_SERIES_ID_FIELD_NAME = '_tsid';
export const DEFAULT_LINE_SIMPLIFICATION_SIZE = 500;

View file

@ -20,7 +20,9 @@ export function convertToGeoJson(esResponse: any, entitySplitFieldName: string)
for (let i = 0; i < entityKeys.length; i++) {
const entityKey = entityKeys[i];
const bucket = buckets[entityKey];
const feature = bucket.path as Feature;
const feature = {
...(bucket.path as Feature),
};
if (!feature.properties!.complete) {
numTrimmedTracks++;
}

View file

@ -7,39 +7,38 @@
import React, { Component } from 'react';
import { DataView } from '@kbn/data-plugin/common';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiPanel } from '@elastic/eui';
import { SingleFieldSelect } from '../../../components/single_field_select';
import type { DataView, DataViewField } from '@kbn/data-plugin/common';
import { EuiPanel } from '@elastic/eui';
import { GeoIndexPatternSelect } from '../../../components/geo_index_pattern_select';
import { getGeoPointFields } from '../../../index_pattern_util';
import { GeoFieldSelect } from '../../../components/geo_field_select';
import { ESGeoLineSourceDescriptor } from '../../../../common/descriptor_types';
import { getGeoPointFields, getIsTimeseries } from '../../../index_pattern_util';
import { GeoLineForm } from './geo_line_form';
import { DEFAULT_LINE_SIMPLIFICATION_SIZE } from './constants';
interface Props {
onSourceConfigChange: (
sourceConfig: {
indexPatternId: string;
geoField: string;
splitField: string;
sortField: string;
} | null
) => void;
onSourceConfigChange: (sourceConfig: Partial<ESGeoLineSourceDescriptor> | null) => void;
}
interface State {
indexPattern: DataView | null;
pointFields: DataViewField[];
geoField: string;
groupByTimeseries: boolean;
splitField: string;
sortField: string;
lineSimplificationSize: number;
}
export class CreateSourceEditor extends Component<Props, State> {
state: State = {
indexPattern: null,
pointFields: [],
geoField: '',
groupByTimeseries: false,
splitField: '',
sortField: '',
lineSimplificationSize: DEFAULT_LINE_SIMPLIFICATION_SIZE,
};
_onIndexPatternSelect = (indexPattern: DataView) => {
@ -47,6 +46,8 @@ export class CreateSourceEditor extends Component<Props, State> {
this.setState(
{
indexPattern,
pointFields,
groupByTimeseries: getIsTimeseries(indexPattern),
geoField: pointFields.length ? pointFields[0].name : '',
sortField: indexPattern.timeFieldName ? indexPattern.timeFieldName : '',
},
@ -67,6 +68,24 @@ export class CreateSourceEditor extends Component<Props, State> {
);
};
_onGroupByTimeseriesChange = (groupByTimeseries: boolean) => {
this.setState(
{
groupByTimeseries,
},
this.previewLayer
);
};
_onLineSimplificationSizeChange = (lineSimplificationSize: number) => {
this.setState(
{
lineSimplificationSize,
},
this.previewLayer
);
};
_onSplitFieldSelect = (newValue: string) => {
this.setState(
{
@ -86,11 +105,28 @@ export class CreateSourceEditor extends Component<Props, State> {
};
previewLayer = () => {
const { indexPattern, geoField, splitField, sortField } = this.state;
const {
indexPattern,
geoField,
groupByTimeseries,
splitField,
sortField,
lineSimplificationSize,
} = this.state;
const sourceConfig =
indexPattern && indexPattern.id && geoField && splitField && sortField
? { indexPatternId: indexPattern.id, geoField, splitField, sortField }
indexPattern &&
indexPattern.id &&
geoField &&
(groupByTimeseries || (splitField && sortField))
? {
indexPatternId: indexPattern.id,
geoField,
groupByTimeseries,
lineSimplificationSize,
splitField,
sortField,
}
: null;
this.props.onSourceConfigChange(sourceConfig);
};
@ -101,20 +137,12 @@ export class CreateSourceEditor extends Component<Props, State> {
}
return (
<EuiFormRow
label={i18n.translate('xpack.maps.source.esGeoLine.geofieldLabel', {
defaultMessage: 'Geospatial field',
})}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.maps.source.esGeoLine.geofieldPlaceholder', {
defaultMessage: 'Select geo field',
})}
value={this.state.geoField}
onChange={this._onGeoFieldSelect}
fields={getGeoPointFields(this.state.indexPattern.fields)}
/>
</EuiFormRow>
<GeoFieldSelect
value={this.state.geoField}
onChange={this._onGeoFieldSelect}
geoFields={this.state.pointFields}
isClearable={false}
/>
);
}
@ -125,9 +153,14 @@ export class CreateSourceEditor extends Component<Props, State> {
return (
<GeoLineForm
isColumnCompressed={false}
indexPattern={this.state.indexPattern}
onGroupByTimeseriesChange={this._onGroupByTimeseriesChange}
onLineSimplificationSizeChange={this._onLineSimplificationSizeChange}
onSortFieldChange={this._onSortFieldSelect}
onSplitFieldChange={this._onSplitFieldSelect}
groupByTimeseries={this.state.groupByTimeseries}
lineSimplificationSize={this.state.lineSimplificationSize}
sortField={this.state.sortField}
splitField={this.state.splitField}
/>

View file

@ -30,6 +30,7 @@ import { AbstractESAggSource, ESAggsSourceSyncMeta } from '../es_agg_source';
import { DataRequest } from '../../util/data_request';
import { convertToGeoJson } from './convert_to_geojson';
import { ESDocField } from '../../fields/es_doc_field';
import { InlineField } from '../../fields/inline_field';
import { UpdateSourceEditor } from './update_source_editor';
import { ImmutableSourceProperty, SourceEditorArgs } from '../source';
import { GeoJsonWithMeta } from '../vector_source';
@ -39,11 +40,18 @@ import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_proper
import { getIsGoldPlus } from '../../../licensed_features';
import { LICENSED_FEATURES } from '../../../licensed_features';
import { mergeExecutionContext } from '../execution_context_utils';
import { ENTITY_INPUT_LABEL, SORT_INPUT_LABEL } from './geo_line_form';
import {
DEFAULT_LINE_SIMPLIFICATION_SIZE,
MAX_TERMS_TRACKS,
TIME_SERIES_ID_FIELD_NAME,
} from './constants';
type ESGeoLineSourceSyncMeta = ESAggsSourceSyncMeta &
Pick<ESGeoLineSourceDescriptor, 'splitField' | 'sortField'>;
const MAX_TRACKS = 250;
Pick<
ESGeoLineSourceDescriptor,
'groupByTimeseries' | 'lineSimplificationSize' | 'splitField' | 'sortField'
>;
export const geoLineTitle = i18n.translate('xpack.maps.source.esGeoLineTitle', {
defaultMessage: 'Tracks',
@ -64,21 +72,27 @@ export class ESGeoLineSource extends AbstractESAggSource {
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');
}
const groupByTimeseries =
typeof normalizedDescriptor.groupByTimeseries === 'boolean'
? normalizedDescriptor.groupByTimeseries
: false;
return {
...normalizedDescriptor,
type: SOURCE_TYPES.ES_GEO_LINE,
groupByTimeseries,
lineSimplificationSize:
typeof normalizedDescriptor.lineSimplificationSize === 'number'
? normalizedDescriptor.lineSimplificationSize
: DEFAULT_LINE_SIMPLIFICATION_SIZE,
geoField: normalizedDescriptor.geoField!,
splitField: normalizedDescriptor.splitField!,
sortField: normalizedDescriptor.sortField!,
splitField: normalizedDescriptor.splitField,
sortField: normalizedDescriptor.sortField,
};
}
@ -103,8 +117,10 @@ export class ESGeoLineSource extends AbstractESAggSource {
indexPatternId={this.getIndexPatternId()}
onChange={onChange}
metrics={this._descriptor.metrics}
splitField={this._descriptor.splitField}
sortField={this._descriptor.sortField}
groupByTimeseries={this._descriptor.groupByTimeseries}
lineSimplificationSize={this._descriptor.lineSimplificationSize}
splitField={this._descriptor.splitField ?? ''}
sortField={this._descriptor.sortField ?? ''}
/>
);
}
@ -112,6 +128,8 @@ export class ESGeoLineSource extends AbstractESAggSource {
getSyncMeta(dataFilters: DataFilters): ESGeoLineSourceSyncMeta {
return {
...super.getSyncMeta(dataFilters),
groupByTimeseries: this._descriptor.groupByTimeseries,
lineSimplificationSize: this._descriptor.lineSimplificationSize,
splitField: this._descriptor.splitField,
sortField: this._descriptor.sortField,
};
@ -136,22 +154,43 @@ export class ESGeoLineSource extends AbstractESAggSource {
];
}
_createSplitField(): IField {
return new ESDocField({
fieldName: this._descriptor.splitField,
_createSplitField(): IField | null {
return this._descriptor.splitField
? new ESDocField({
fieldName: this._descriptor.splitField,
source: this,
origin: FIELD_ORIGIN.SOURCE,
})
: null;
}
_createTsidField(): IField | null {
return new InlineField<ESGeoLineSource>({
fieldName: TIME_SERIES_ID_FIELD_NAME,
label: TIME_SERIES_ID_FIELD_NAME,
source: this,
origin: FIELD_ORIGIN.SOURCE,
dataType: 'string',
});
}
async getFields(): Promise<IField[]> {
return [...this.getMetricFields(), this._createSplitField()];
const groupByField = this._descriptor.groupByTimeseries
? this._createTsidField()
: this._createSplitField();
return groupByField ? [...this.getMetricFields(), groupByField] : this.getMetricFields();
}
getFieldByName(name: string): IField | null {
return name === this._descriptor.splitField
? this._createSplitField()
: this.getMetricFieldForName(name);
if (name === this._descriptor.splitField) {
return this._createSplitField();
}
if (name === TIME_SERIES_ID_FIELD_NAME) {
return this._createTsidField();
}
return this.getMetricFieldForName(name);
}
isGeoGridPrecisionAware() {
@ -173,6 +212,126 @@ export class ESGeoLineSource extends AbstractESAggSource {
throw new Error(REQUIRES_GOLD_LICENSE_MSG);
}
return this._descriptor.groupByTimeseries
? this._getGeoLineByTimeseries(
layerName,
requestMeta,
registerCancelCallback,
isRequestStillActive,
inspectorAdapters
)
: this._getGeoLineByTerms(
layerName,
requestMeta,
registerCancelCallback,
isRequestStillActive,
inspectorAdapters
);
}
async _getGeoLineByTimeseries(
layerName: string,
requestMeta: VectorSourceRequestMeta,
registerCancelCallback: (callback: () => void) => void,
isRequestStillActive: () => boolean,
inspectorAdapters: Adapters
): Promise<GeoJsonWithMeta> {
const indexPattern = await this.getIndexPattern();
const searchSource = await this.makeSearchSource(requestMeta, 0);
searchSource.setField('trackTotalHits', false);
searchSource.setField('aggs', {
totalEntities: {
cardinality: {
field: '_tsid',
},
},
tracks: {
time_series: {},
aggs: {
path: {
geo_line: {
point: {
field: this._descriptor.geoField,
},
size: this._descriptor.lineSimplificationSize,
},
},
...this.getValueAggsDsl(indexPattern),
},
},
});
const resp = await this._runEsQuery({
requestId: `${this.getId()}_tracks`,
requestName: i18n.translate('xpack.maps.source.esGeoLine.timeSeriesTrackRequestName', {
defaultMessage: `'{layerName}' tracks request (time series)`,
values: {
layerName,
},
}),
searchSource,
registerCancelCallback,
requestDescription: i18n.translate(
'xpack.maps.source.esGeoLine.timeSeriesTrackRequestDescription',
{
defaultMessage:
'Get tracks from data view: {dataViewName}, geospatial field: {geoFieldName}',
values: {
dataViewName: indexPattern.getName(),
geoFieldName: this._descriptor.geoField,
},
}
),
searchSessionId: requestMeta.searchSessionId,
executionContext: mergeExecutionContext(
{ description: 'es_geo_line:time_series_tracks' },
requestMeta.executionContext
),
requestsAdapter: inspectorAdapters.requests,
});
const { featureCollection } = convertToGeoJson(resp, TIME_SERIES_ID_FIELD_NAME);
const entityCount = featureCollection.features.length;
const areEntitiesTrimmed = entityCount >= 10000; // 10000 is max buckets created by time_series aggregation
return {
data: featureCollection,
meta: {
areResultsTrimmed: areEntitiesTrimmed,
areEntitiesTrimmed,
entityCount,
numTrimmedTracks: 0, // geo_line by time series never truncates tracks and instead simplifies tracks
totalEntities: resp?.aggregations?.totalEntities?.value ?? 0,
} as ESGeoLineSourceResponseMeta,
};
}
async _getGeoLineByTerms(
layerName: string,
requestMeta: VectorSourceRequestMeta,
registerCancelCallback: (callback: () => void) => void,
isRequestStillActive: () => boolean,
inspectorAdapters: Adapters
): Promise<GeoJsonWithMeta> {
if (!this._descriptor.splitField) {
throw new Error(
i18n.translate('xpack.maps.source.esGeoLine.missingConfigurationError', {
defaultMessage: `Unable to create tracks. Provide a value for required configuration '{inputLabel}'`,
values: { inputLabel: ENTITY_INPUT_LABEL },
})
);
}
if (!this._descriptor.sortField) {
throw new Error(
i18n.translate('xpack.maps.source.esGeoLine.missingConfigurationError', {
defaultMessage: `Unable to create tracks. Provide a value for required configuration '{inputLabel}'`,
values: { inputLabel: SORT_INPUT_LABEL },
})
);
}
const indexPattern = await this.getIndexPattern();
// Request is broken into 2 requests
@ -186,8 +345,8 @@ export class ESGeoLineSource extends AbstractESAggSource {
const entitySearchSource = await this.makeSearchSource(requestMeta, 0);
entitySearchSource.setField('trackTotalHits', false);
const splitField = getField(indexPattern, this._descriptor.splitField);
const cardinalityAgg = { precision_threshold: 1 };
const termsAgg = { size: MAX_TRACKS };
const cardinalityAgg = { precision_threshold: MAX_TERMS_TRACKS };
const termsAgg = { size: MAX_TERMS_TRACKS };
entitySearchSource.setField('aggs', {
totalEntities: {
cardinality: addFieldToDSL(cardinalityAgg, splitField),
@ -208,7 +367,7 @@ export class ESGeoLineSource extends AbstractESAggSource {
const entityResp = await this._runEsQuery({
requestId: `${this.getId()}_entities`,
requestName: i18n.translate('xpack.maps.source.esGeoLine.entityRequestName', {
defaultMessage: '{layerName} entities request',
defaultMessage: `'{layerName}' entities request`,
values: {
layerName,
},
@ -236,7 +395,7 @@ export class ESGeoLineSource extends AbstractESAggSource {
[]
);
const totalEntities = _.get(entityResp, 'aggregations.totalEntities.value', 0);
const areEntitiesTrimmed = entityBuckets.length >= MAX_TRACKS;
const areEntitiesTrimmed = entityBuckets.length >= MAX_TERMS_TRACKS;
if (totalEntities === 0) {
return {
data: EMPTY_FEATURE_COLLECTION,
@ -288,7 +447,7 @@ export class ESGeoLineSource extends AbstractESAggSource {
const tracksResp = await this._runEsQuery({
requestId: `${this.getId()}_tracks`,
requestName: i18n.translate('xpack.maps.source.esGeoLine.trackRequestName', {
defaultMessage: '{layerName} tracks request',
defaultMessage: `'{layerName}' tracks request (terms)`,
values: {
layerName,
},
@ -306,7 +465,7 @@ export class ESGeoLineSource extends AbstractESAggSource {
}),
searchSessionId: requestMeta.searchSessionId,
executionContext: mergeExecutionContext(
{ description: 'es_geo_line:tracks' },
{ description: 'es_geo_line:terms_tracks' },
requestMeta.executionContext
),
requestsAdapter: inspectorAdapters.requests,
@ -392,15 +551,21 @@ export class ESGeoLineSource extends AbstractESAggSource {
async getTooltipProperties(properties: GeoJsonProperties): Promise<ITooltipProperty[]> {
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()
)
);
if (properties && typeof properties!.complete === 'boolean') {
tooltipProperties.push(
new TooltipProperty(
'__kbn__track__complete',
this._descriptor.groupByTimeseries
? i18n.translate('xpack.maps.source.esGeoLine.isTrackSimplifiedLabel', {
defaultMessage: 'track is simplified',
})
: i18n.translate('xpack.maps.source.esGeoLine.isTrackTruncatedLabel', {
defaultMessage: 'track is truncated',
}),
(!properties.complete).toString()
)
);
}
return tooltipProperties;
}

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { DataView } from '@kbn/data-plugin/common';
import { EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { indexPatterns } from '@kbn/data-plugin/public';
import { SingleFieldSelect } from '../../../components/single_field_select';
import { getTermsFields } from '../../../index_pattern_util';
interface Props {
indexPattern: DataView;
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 (
<>
<EuiFormRow
label={i18n.translate('xpack.maps.source.esGeoLine.splitFieldLabel', {
defaultMessage: 'Entity',
})}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.maps.source.esGeoLine.splitFieldPlaceholder', {
defaultMessage: 'Select entity field',
})}
value={props.splitField}
onChange={onSplitFieldChange}
fields={getTermsFields(props.indexPattern.fields)}
isClearable={false}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.source.esGeoLine.sortFieldLabel', {
defaultMessage: 'Sort',
})}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.maps.source.esGeoLine.sortFieldPlaceholder', {
defaultMessage: 'Select sort field',
})}
value={props.sortField}
onChange={onSortFieldChange}
fields={props.indexPattern.fields.filter((field) => {
const isSplitField = props.splitField ? field.name === props.splitField : false;
return (
!isSplitField &&
field.sortable &&
!indexPatterns.isNestedField(field) &&
['number', 'date'].includes(field.type)
);
})}
isClearable={false}
/>
</EuiFormRow>
</>
);
}

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { DataView } from '@kbn/data-plugin/common';
import { EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { indexPatterns } from '@kbn/data-plugin/public';
import { SingleFieldSelect } from '../../../../components/single_field_select';
import { getTermsFields, getIsTimeseries } from '../../../../index_pattern_util';
import { ENTITY_INPUT_LABEL, SORT_INPUT_LABEL } from './i18n_constants';
import { GroupByButtonGroup } from './group_by_button_group';
import { GroupByLabel } from './group_by_label';
import { SizeSlider } from './size_slider';
interface Props {
isColumnCompressed: boolean;
indexPattern: DataView;
groupByTimeseries: boolean;
lineSimplificationSize: number;
onGroupByTimeseriesChange: (groupByTimeseries: boolean) => void;
onLineSimplificationSizeChange: (lineSimplificationSize: number) => void;
onSortFieldChange: (fieldName: string) => void;
onSplitFieldChange: (fieldName: string) => void;
sortField: string;
splitField: string;
}
export function GeoLineForm(props: Props) {
const isTimeseries = useMemo(() => {
return getIsTimeseries(props.indexPattern);
}, [props.indexPattern]);
function onSortFieldChange(fieldName: string | undefined) {
if (fieldName !== undefined) {
props.onSortFieldChange(fieldName);
}
}
function onSplitFieldChange(fieldName: string | undefined) {
if (fieldName !== undefined) {
props.onSplitFieldChange(fieldName);
}
}
return (
<>
{isTimeseries && (
<EuiFormRow
label={<GroupByLabel />}
display={props.isColumnCompressed ? 'columnCompressed' : 'row'}
>
<GroupByButtonGroup
groupByTimeseries={props.groupByTimeseries}
onGroupByTimeseriesChange={props.onGroupByTimeseriesChange}
/>
</EuiFormRow>
)}
{props.groupByTimeseries ? (
<EuiFormRow
label={i18n.translate('xpack.maps.esGeoLine.lineSImplificationSizeLabel', {
defaultMessage: 'Simplification threshold',
})}
helpText={i18n.translate('xpack.maps.esGeoLine.lineSImplificationSizeHelpText', {
defaultMessage:
'The maximum number of points for each track. Track is simplifed when threshold is exceeded. Use smaller values for better performance.',
})}
display={props.isColumnCompressed ? 'columnCompressed' : 'row'}
>
<SizeSlider
value={props.lineSimplificationSize}
onChange={props.onLineSimplificationSizeChange}
/>
</EuiFormRow>
) : (
<>
<EuiFormRow
label={ENTITY_INPUT_LABEL}
display={props.isColumnCompressed ? 'columnCompressed' : 'row'}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.maps.source.esGeoLine.splitFieldPlaceholder', {
defaultMessage: 'Select entity field',
})}
value={props.splitField}
onChange={onSplitFieldChange}
fields={getTermsFields(props.indexPattern.fields)}
isClearable={false}
compressed={props.isColumnCompressed}
/>
</EuiFormRow>
<EuiFormRow
label={SORT_INPUT_LABEL}
display={props.isColumnCompressed ? 'columnCompressed' : 'row'}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.maps.source.esGeoLine.sortFieldPlaceholder', {
defaultMessage: 'Select sort field',
})}
value={props.sortField}
onChange={onSortFieldChange}
fields={props.indexPattern.fields.filter((field) => {
const isSplitField = props.splitField ? field.name === props.splitField : false;
return (
!isSplitField &&
field.sortable &&
!indexPatterns.isNestedField(field) &&
['number', 'date'].includes(field.type)
);
})}
isClearable={false}
compressed={props.isColumnCompressed}
/>
</EuiFormRow>
</>
)}
</>
);
}

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButtonGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TIME_SERIES_LABEL, TERMS_LABEL } from './i18n_constants';
const GROUP_BY_TIME_SERIES = 'timeseries';
const GROUP_BY_TERM = 'terms';
const GROUP_BY_OPTIONS = [
{
id: GROUP_BY_TIME_SERIES,
label: TIME_SERIES_LABEL,
},
{
id: GROUP_BY_TERM,
label: TERMS_LABEL,
},
];
interface Props {
groupByTimeseries: boolean;
onGroupByTimeseriesChange: (groupByTimeseries: boolean) => void;
}
export function GroupByButtonGroup({ groupByTimeseries, onGroupByTimeseriesChange }: Props) {
return (
<EuiButtonGroup
type="single"
legend={i18n.translate('xpack.maps.source.esGeoLine.groupByButtonGroupLegend', {
defaultMessage: 'Choose group by method',
})}
options={GROUP_BY_OPTIONS}
idSelected={groupByTimeseries ? GROUP_BY_TIME_SERIES : GROUP_BY_TERM}
onChange={(id: string) => {
onGroupByTimeseriesChange(id === GROUP_BY_TIME_SERIES);
}}
isFullWidth={true}
buttonSize="compressed"
/>
);
}

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { TIME_SERIES_LABEL, TERMS_LABEL } from './i18n_constants';
import { MAX_TERMS_TRACKS } from '../constants';
export function GroupByLabel() {
return (
<EuiToolTip
content={
<EuiText size="s">
<dl>
<dt>{TIME_SERIES_LABEL}</dt>
<dd>
<p>
<FormattedMessage
id="xpack.maps.source.esGeoLine.groupBy.timeseriesDescription"
defaultMessage="Create a track for each unique time series. Track is simplifed when number of points exceeds limit."
/>
</p>
</dd>
<dt>{TERMS_LABEL}</dt>
<dd>
<p>
<FormattedMessage
id="xpack.maps.source.esGeoGrid.groupBy.termsDescription"
defaultMessage="Create a track for top {maxTermsTracks} terms. Track is truncated when number of points exceeds limit."
values={{ maxTermsTracks: MAX_TERMS_TRACKS }}
/>
</p>
</dd>
</dl>
</EuiText>
}
>
<span>
<FormattedMessage id="xpack.maps.source.esGeoGrid.groupByLabel" defaultMessage="Group by" />{' '}
<EuiIcon type="questionInCircle" color="subdued" />
</span>
</EuiToolTip>
);
}

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const TIME_SERIES_LABEL = i18n.translate(
'xpack.maps.source.esGeoLine.groupBy.timeseriesLabel',
{
defaultMessage: 'Time series',
}
);
export const TERMS_LABEL = i18n.translate('xpack.maps.source.esGeoLine.groupBy.termsLabel', {
defaultMessage: 'Top terms',
});
export const ENTITY_INPUT_LABEL = i18n.translate('xpack.maps.source.esGeoLine.splitFieldLabel', {
defaultMessage: 'Entity',
});
export const SORT_INPUT_LABEL = i18n.translate('xpack.maps.source.esGeoLine.sortFieldLabel', {
defaultMessage: 'Sort',
});

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { ENTITY_INPUT_LABEL, SORT_INPUT_LABEL } from './i18n_constants';
export { GeoLineForm } from './geo_line_form';

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { EuiRange } from '@elastic/eui';
interface Props {
value: number;
onChange: (size: number) => void;
}
export function SizeSlider({ onChange, value }: Props) {
const [size, setSize] = useState(value);
const [, cancel] = useDebounce(
() => {
onChange(size);
},
150,
[size]
);
useEffect(() => {
return () => {
cancel();
};
}, [cancel]);
return (
<EuiRange
value={size}
onChange={(event) => {
setSize(parseInt(event.currentTarget.value, 10));
}}
min={100}
max={10000}
showLabels
showValue
step={100}
/>
);
}

View file

@ -16,6 +16,7 @@ import {
VECTOR_STYLES,
WIZARD_ID,
} from '../../../../common/constants';
import { ESGeoLineSourceDescriptor } from '../../../../common/descriptor_types';
import { VectorStyle } from '../../styles/vector/vector_style';
import { GeoJsonVectorLayer } from '../../layers/vector_layer';
import { getIsGoldPlus } from '../../../licensed_features';
@ -34,14 +35,7 @@ export const geoLineLayerWizardConfig: LayerWizard = {
return !getIsGoldPlus();
},
renderWizard: ({ previewLayers }: RenderWizardArguments) => {
const onSourceConfigChange = (
sourceConfig: {
indexPatternId: string;
geoField: string;
splitField: string;
sortField: string;
} | null
) => {
const onSourceConfigChange = (sourceConfig: Partial<ESGeoLineSourceDescriptor> | null) => {
if (!sourceConfig) {
previewLayers([]);
return;

View file

@ -19,6 +19,8 @@ import type { OnSourceChangeArgs } from '../source';
interface Props {
bucketsName: string;
indexPatternId: string;
groupByTimeseries: boolean;
lineSimplificationSize: number;
splitField: string;
sortField: string;
metrics: AggDescriptor[];
@ -69,12 +71,20 @@ export class UpdateSourceEditor extends Component<Props, State> {
this.props.onChange({ propName: 'metrics', value: metrics });
};
_onSplitFieldChange = (fieldName: string) => {
this.props.onChange({ propName: 'splitField', value: fieldName });
_onGroupByTimeseriesChange = (value: boolean) => {
this.props.onChange({ propName: 'groupByTimeseries', value });
};
_onSortFieldChange = (fieldName: string) => {
this.props.onChange({ propName: 'sortField', value: fieldName });
_onLineSimplificationSizeChange = (value: number) => {
this.props.onChange({ propName: 'lineSimplificationSize', value });
};
_onSplitFieldChange = (value: string) => {
this.props.onChange({ propName: 'splitField', value });
};
_onSortFieldChange = (value: string) => {
this.props.onChange({ propName: 'sortField', value });
};
render() {
@ -116,9 +126,14 @@ export class UpdateSourceEditor extends Component<Props, State> {
</EuiTitle>
<EuiSpacer size="m" />
<GeoLineForm
isColumnCompressed={true}
indexPattern={this.state.indexPattern}
onGroupByTimeseriesChange={this._onGroupByTimeseriesChange}
onLineSimplificationSizeChange={this._onLineSimplificationSizeChange}
onSortFieldChange={this._onSortFieldChange}
onSplitFieldChange={this._onSplitFieldChange}
groupByTimeseries={this.props.groupByTimeseries}
lineSimplificationSize={this.props.lineSimplificationSize}
sortField={this.props.sortField}
splitField={this.props.splitField}
/>

View file

@ -13,6 +13,12 @@ import { getIndexPatternService } from './kibana_services';
import { ES_GEO_FIELD_TYPE, ES_GEO_FIELD_TYPES } from '../common/constants';
import { getIsGoldPlus } from './licensed_features';
export function getIsTimeseries(dataView: DataView): boolean {
return dataView.fields.some((field) => {
return field.timeSeriesDimension || field.timeSeriesMetric;
});
}
export function getGeoTileAggNotSupportedReason(field: DataViewField): string | null {
if (!field.aggregatable) {
return i18n.translate('xpack.maps.geoTileAgg.disabled.docValues', {

View file

@ -21810,10 +21810,7 @@
"xpack.maps.source.esGeoGrid.showAsLabel": "Afficher en tant que",
"xpack.maps.source.esGeoGrid.showAsSelector": "Sélectionner la méthode daffichage",
"xpack.maps.source.esGeoLine.bucketsName": "pistes",
"xpack.maps.source.esGeoLine.geofieldLabel": "Champ géospatial",
"xpack.maps.source.esGeoLine.geofieldPlaceholder": "Sélectionner un champ géographique",
"xpack.maps.source.esGeoLine.geospatialFieldLabel": "Champ géospatial",
"xpack.maps.source.esGeoLine.isTrackCompleteLabel": "la piste est complète",
"xpack.maps.source.esGeoLine.metricsLabel": "Indicateurs de piste",
"xpack.maps.source.esGeoLine.sortFieldLabel": "Trier",
"xpack.maps.source.esGeoLine.sortFieldPlaceholder": "Sélectionner le champ de tri",

View file

@ -21810,10 +21810,7 @@
"xpack.maps.source.esGeoGrid.showAsLabel": "表示形式",
"xpack.maps.source.esGeoGrid.showAsSelector": "表示方法を選択",
"xpack.maps.source.esGeoLine.bucketsName": "追跡",
"xpack.maps.source.esGeoLine.geofieldLabel": "地理空間フィールド",
"xpack.maps.source.esGeoLine.geofieldPlaceholder": "ジオフィールドを選択",
"xpack.maps.source.esGeoLine.geospatialFieldLabel": "地理空間フィールド",
"xpack.maps.source.esGeoLine.isTrackCompleteLabel": "トラックは完了しました",
"xpack.maps.source.esGeoLine.metricsLabel": "トラックメトリック",
"xpack.maps.source.esGeoLine.sortFieldLabel": "並べ替え",
"xpack.maps.source.esGeoLine.sortFieldPlaceholder": "ソートフィールドを選択",

View file

@ -21810,10 +21810,7 @@
"xpack.maps.source.esGeoGrid.showAsLabel": "显示为",
"xpack.maps.source.esGeoGrid.showAsSelector": "选择显示方法",
"xpack.maps.source.esGeoLine.bucketsName": "轨迹",
"xpack.maps.source.esGeoLine.geofieldLabel": "地理空间字段",
"xpack.maps.source.esGeoLine.geofieldPlaceholder": "选择地理字段",
"xpack.maps.source.esGeoLine.geospatialFieldLabel": "地理空间字段",
"xpack.maps.source.esGeoLine.isTrackCompleteLabel": "轨迹完整",
"xpack.maps.source.esGeoLine.metricsLabel": "轨迹指标",
"xpack.maps.source.esGeoLine.sortFieldLabel": "排序",
"xpack.maps.source.esGeoLine.sortFieldPlaceholder": "选择排序字段",