[Maps] Add percentile (#85367)

This commit is contained in:
Thomas Neirynck 2020-12-15 15:40:21 -05:00 committed by GitHub
parent 89bd0fbf1e
commit 47e2663473
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 528 additions and 42 deletions

View file

@ -150,6 +150,7 @@ export enum AGG_TYPE {
MIN = 'min',
SUM = 'sum',
TERMS = 'terms',
PERCENTILE = 'percentile',
UNIQUE_COUNT = 'cardinality',
}
@ -171,6 +172,7 @@ export const GEOTILE_GRID_AGG_NAME = 'gridSplit';
export const GEOCENTROID_AGG_NAME = 'gridCentroid';
export const TOP_TERM_PERCENTAGE_SUFFIX = '__percentage';
export const DEFAULT_PERCENTILE = 50;
export const COUNT_PROP_LABEL = i18n.translate('xpack.maps.aggs.defaultCountLabel', {
defaultMessage: 'count',

View file

@ -60,7 +60,13 @@ export type FieldedAggDescriptor = AbstractAggDescriptor & {
field?: string;
};
export type AggDescriptor = CountAggDescriptor | FieldedAggDescriptor;
export type PercentileAggDescriptor = AbstractAggDescriptor & {
type: AGG_TYPE.PERCENTILE;
field?: string;
percentile?: number;
};
export type AggDescriptor = CountAggDescriptor | FieldedAggDescriptor | PercentileAggDescriptor;
export type AbstractESAggSourceDescriptor = AbstractESSourceDescriptor & {
metrics: AggDescriptor[];

View file

@ -6,7 +6,7 @@
import { i18n } from '@kbn/i18n';
import _ from 'lodash';
import { IndexPattern, IFieldType } from '../../../../../src/plugins/data/common';
import { TOP_TERM_PERCENTAGE_SUFFIX } from '../constants';
import { AGG_TYPE, JOIN_FIELD_NAME_PREFIX, TOP_TERM_PERCENTAGE_SUFFIX } from '../constants';
export type BucketProperties = Record<string | number, unknown>;
export type PropertiesMap = Map<string, BucketProperties>;
@ -46,6 +46,7 @@ export function extractPropertiesFromBucket(
continue;
}
// todo: push these implementations in the IAggFields
if (_.has(bucket[key], 'value')) {
properties[key] = bucket[key].value;
} else if (_.has(bucket[key], 'buckets')) {
@ -63,7 +64,20 @@ export function extractPropertiesFromBucket(
);
}
} else {
properties[key] = bucket[key];
if (
key.startsWith(AGG_TYPE.PERCENTILE) ||
key.startsWith(JOIN_FIELD_NAME_PREFIX + AGG_TYPE.PERCENTILE)
) {
const values = bucket[key].values;
for (const k in values) {
if (values.hasOwnProperty(k)) {
properties[key] = values[k];
break;
}
}
} else {
properties[key] = bucket[key];
}
}
}
return properties;

View file

@ -8,7 +8,11 @@ import { esAggFieldsFactory } from './es_agg_factory';
import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
import { IESAggSource } from '../../sources/es_agg_source';
const mockEsAggSource = ({} as unknown) as IESAggSource;
const mockEsAggSource = ({
getAggKey() {
return 'foobar';
},
} as unknown) as IESAggSource;
describe('esAggFieldsFactory', () => {
test('Should only create top terms field when term field is not provided', () => {
@ -28,4 +32,26 @@ describe('esAggFieldsFactory', () => {
);
expect(fields.length).toBe(2);
});
describe('percentile-fields', () => {
test('Should create percentile agg fields with default', () => {
const fields = esAggFieldsFactory(
{ type: AGG_TYPE.PERCENTILE, field: 'myField' },
mockEsAggSource,
FIELD_ORIGIN.SOURCE
);
expect(fields.length).toBe(1);
expect(fields[0].getName()).toBe('foobar_50');
});
test('Should create percentile agg fields with param', () => {
const fields = esAggFieldsFactory(
{ type: AGG_TYPE.PERCENTILE, field: 'myField', percentile: 90 },
mockEsAggSource,
FIELD_ORIGIN.SOURCE
);
expect(fields.length).toBe(1);
expect(fields[0].getName()).toBe('foobar_90');
});
});
});

View file

@ -6,12 +6,13 @@
import { AggDescriptor } from '../../../../common/descriptor_types';
import { IESAggSource } from '../../sources/es_agg_source';
import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
import { AGG_TYPE, DEFAULT_PERCENTILE, FIELD_ORIGIN } from '../../../../common/constants';
import { ESDocField } from '../es_doc_field';
import { TopTermPercentageField } from './top_term_percentage_field';
import { CountAggField } from './count_agg_field';
import { IESAggField } from './agg_field_types';
import { AggField } from './agg_field';
import { PercentileAggField } from './percentile_agg_field';
export function esAggFieldsFactory(
aggDescriptor: AggDescriptor,
@ -27,6 +28,21 @@ export function esAggFieldsFactory(
origin,
canReadFromGeoJson,
});
} else if (aggDescriptor.type === AGG_TYPE.PERCENTILE) {
aggField = new PercentileAggField({
label: aggDescriptor.label,
esDocField:
'field' in aggDescriptor && aggDescriptor.field
? new ESDocField({ fieldName: aggDescriptor.field, source, origin })
: undefined,
percentile:
'percentile' in aggDescriptor && typeof aggDescriptor.percentile === 'number'
? aggDescriptor.percentile
: DEFAULT_PERCENTILE,
source,
origin,
canReadFromGeoJson,
});
} else {
aggField = new AggField({
label: aggDescriptor.label,

View file

@ -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 { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
import { IESAggSource } from '../../sources/es_agg_source';
import { IndexPattern } from 'src/plugins/data/public';
import { PercentileAggField } from './percentile_agg_field';
import { ESDocField } from '../es_doc_field';
const mockFields = [
{
name: 'foo*',
},
];
// @ts-expect-error
mockFields.getByName = (name: string) => {
return {
name,
};
};
const mockIndexPattern = {
title: 'wildIndex',
fields: mockFields,
};
const mockEsAggSource = {
getAggKey: (aggType: AGG_TYPE, fieldName: string) => {
return 'agg_key';
},
getAggLabel: (aggType: AGG_TYPE, fieldName: string) => {
return 'agg_label';
},
getIndexPattern: async () => {
return mockIndexPattern;
},
} as IESAggSource;
const mockEsDocField = {
getName() {
return 'foobar';
},
};
const defaultParams = {
source: mockEsAggSource,
origin: FIELD_ORIGIN.SOURCE,
};
describe('percentile agg field', () => {
test('should include percentile in name', () => {
const field = new PercentileAggField({
...defaultParams,
esDocField: mockEsDocField as ESDocField,
percentile: 80,
});
expect(field.getName()).toEqual('agg_key_80');
});
test('should create percentile dsl', () => {
const field = new PercentileAggField({
...defaultParams,
esDocField: mockEsDocField as ESDocField,
percentile: 80,
});
expect(field.getValueAggDsl(mockIndexPattern as IndexPattern)).toEqual({
percentiles: { field: 'foobar', percents: [80] },
});
});
test('label', async () => {
const field = new PercentileAggField({
...defaultParams,
esDocField: mockEsDocField as ESDocField,
percentile: 80,
});
expect(await field.getLabel()).toEqual('80th agg_label');
});
test('label (median)', async () => {
const field = new PercentileAggField({
...defaultParams,
label: '',
esDocField: mockEsDocField as ESDocField,
percentile: 50,
});
expect(await field.getLabel()).toEqual('median foobar');
});
});

View file

@ -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 { IndexPattern } from 'src/plugins/data/common/index_patterns/index_patterns';
import { i18n } from '@kbn/i18n';
import { AGG_TYPE } from '../../../../common/constants';
import { IESAggField, CountAggFieldParams } from './agg_field_types';
import { addFieldToDSL, getField } from '../../../../common/elasticsearch_util';
import { ESDocField } from '../es_doc_field';
import { getOrdinalSuffix } from '../../util/ordinal_suffix';
import { AggField } from './agg_field';
interface PercentileAggParams extends CountAggFieldParams {
esDocField?: ESDocField;
percentile: number;
}
export class PercentileAggField extends AggField implements IESAggField {
private readonly _percentile: number;
constructor(params: PercentileAggParams) {
super({
...params,
...{
aggType: AGG_TYPE.PERCENTILE,
},
});
this._percentile = params.percentile;
}
supportsFieldMeta(): boolean {
return true;
}
canValueBeFormatted(): boolean {
return true;
}
async getLabel(): Promise<string> {
if (this._label) {
return this._label;
}
if (this._percentile === 50) {
const median = i18n.translate('xpack.maps.fields.percentileMedianLabek', {
defaultMessage: 'median',
});
return `${median} ${this.getRootName()}`;
}
const suffix = getOrdinalSuffix(this._percentile);
return `${this._percentile}${suffix} ${this._source.getAggLabel(
this._getAggType(),
this.getRootName()
)}`;
}
getName() {
return `${super.getName()}_${this._percentile}`;
}
getValueAggDsl(indexPattern: IndexPattern): unknown {
const field = getField(indexPattern, this.getRootName());
const dsl: Record<string, unknown> = addFieldToDSL({}, field);
dsl.percents = [this._percentile];
return {
percentiles: dsl,
};
}
}

View file

@ -48,7 +48,9 @@ interface CountData {
isSyncClustered: boolean;
}
function getAggType(dynamicProperty: IDynamicStyleProperty<DynamicStylePropertyOptions>): AGG_TYPE {
function getAggType(
dynamicProperty: IDynamicStyleProperty<DynamicStylePropertyOptions>
): AGG_TYPE.AVG | AGG_TYPE.TERMS {
return dynamicProperty.isOrdinal() ? AGG_TYPE.AVG : AGG_TYPE.TERMS;
}

View file

@ -21,7 +21,6 @@ import {
import { VectorStyle } from '../styles/vector/vector_style';
import { EMSFileSource } from '../sources/ems_file_source';
// @ts-ignore
import { ESGeoGridSource } from '../sources/es_geo_grid_source';
import { VectorLayer } from './vector_layer/vector_layer';
import { getDefaultDynamicProperties } from '../styles/vector/vector_style_defaults';
import { NUMERICAL_COLOR_PALETTES } from '../styles/color_palettes';
@ -35,9 +34,13 @@ export function createAggDescriptor(metricAgg: string, metricFieldName?: string)
});
const aggType = aggTypeKey ? AGG_TYPE[aggTypeKey as keyof typeof AGG_TYPE] : undefined;
return aggType && metricFieldName
? { type: aggType, field: metricFieldName }
: { type: AGG_TYPE.COUNT };
if (!aggType || aggType === AGG_TYPE.COUNT || !metricFieldName) {
return { type: AGG_TYPE.COUNT };
} else if (aggType === AGG_TYPE.PERCENTILE) {
return { type: aggType, field: metricFieldName, percentile: 50 };
} else {
return { type: aggType, field: metricFieldName };
}
}
export function createRegionMapLayerDescriptor({

View file

@ -14,6 +14,7 @@ import {
import {
AGG_TYPE,
COLOR_MAP_TYPE,
DEFAULT_PERCENTILE,
FIELD_ORIGIN,
GRID_RESOLUTION,
RENDER_AS,
@ -59,9 +60,18 @@ export function createAggDescriptor(
});
const aggType = aggTypeKey ? AGG_TYPE[aggTypeKey as keyof typeof AGG_TYPE] : undefined;
return aggType && metricFieldName && (!isHeatmap(mapType) || isMetricCountable(aggType))
? { type: aggType, field: metricFieldName }
: { type: AGG_TYPE.COUNT };
if (
!aggType ||
aggType === AGG_TYPE.COUNT ||
!metricFieldName ||
(isHeatmap(mapType) && !isMetricCountable(aggType))
) {
return { type: AGG_TYPE.COUNT };
}
return aggType === AGG_TYPE.PERCENTILE
? { type: aggType, field: metricFieldName, percentile: DEFAULT_PERCENTILE }
: { type: aggType, field: metricFieldName };
}
export function createTileMapLayerDescriptor({

View file

@ -5,7 +5,6 @@
*/
import { Map as MbMap } from 'mapbox-gl';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiTextColor } from '@elastic/eui';
import { DynamicStyleProperty } from './dynamic_style_property';
@ -25,35 +24,11 @@ import {
import { Break, BreakedLegend } from '../components/legend/breaked_legend';
import { ColorDynamicOptions, OrdinalColorStop } from '../../../../../common/descriptor_types';
import { LegendProps } from './style_property';
import { getOrdinalSuffix } from '../../../util/ordinal_suffix';
const EMPTY_STOPS = { stops: [], defaultColor: null };
const RGBA_0000 = 'rgba(0,0,0,0)';
function getOrdinalSuffix(value: number) {
const lastDigit = value % 10;
if (lastDigit === 1 && value !== 11) {
return i18n.translate('xpack.maps.styles.firstOrdinalSuffix', {
defaultMessage: 'st',
});
}
if (lastDigit === 2 && value !== 12) {
return i18n.translate('xpack.maps.styles.secondOrdinalSuffix', {
defaultMessage: 'nd',
});
}
if (lastDigit === 3 && value !== 13) {
return i18n.translate('xpack.maps.styles.thirdOrdinalSuffix', {
defaultMessage: 'rd',
});
}
return i18n.translate('xpack.maps.styles.ordinalSuffix', {
defaultMessage: 'th',
});
}
export class DynamicColorProperty extends DynamicStyleProperty<ColorDynamicOptions> {
syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
const color = this._getMbColor();

View file

@ -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 { i18n } from '@kbn/i18n';
export function getOrdinalSuffix(value: number) {
const lastDigit = value % 10;
if (lastDigit === 1 && value !== 11) {
return i18n.translate('xpack.maps.styles.firstOrdinalSuffix', {
defaultMessage: 'st',
});
}
if (lastDigit === 2 && value !== 12) {
return i18n.translate('xpack.maps.styles.secondOrdinalSuffix', {
defaultMessage: 'nd',
});
}
if (lastDigit === 3 && value !== 13) {
return i18n.translate('xpack.maps.styles.thirdOrdinalSuffix', {
defaultMessage: 'rd',
});
}
return i18n.translate('xpack.maps.styles.ordinalSuffix', {
defaultMessage: 'th',
});
}

View file

@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should render with error 1`] = `
<EuiFormRow
describedByIds={Array []}
display="columnCompressed"
error={
Array [
"Must be between 0 and 20",
]
}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={true}
label="foobar"
labelType="label"
>
<EuiFieldNumber
aria-label="foobar number input"
isInvalid={true}
max={20}
min={0}
onChange={[Function]}
value={30}
/>
</EuiFormRow>
`;
exports[`should render without error 1`] = `
<EuiFormRow
describedByIds={Array []}
display="columnCompressed"
error={Array []}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label="foobar"
labelType="label"
>
<EuiFieldNumber
aria-label="foobar number input"
isInvalid={false}
max={20}
min={0}
onChange={[Function]}
value={10}
/>
</EuiFormRow>
`;

View file

@ -13,9 +13,10 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { MetricSelect } from './metric_select';
import { SingleFieldSelect } from '../single_field_select';
import { AggDescriptor } from '../../../common/descriptor_types';
import { AGG_TYPE } from '../../../common/constants';
import { AGG_TYPE, DEFAULT_PERCENTILE } from '../../../common/constants';
import { getTermsFields } from '../../index_pattern_util';
import { IFieldType } from '../../../../../../src/plugins/data/public';
import { ValidatedNumberInput } from '../validated_number_input';
function filterFieldsForAgg(fields: IFieldType[], aggType: AGG_TYPE) {
if (!fields) {
@ -70,10 +71,18 @@ export function MetricEditor({
const fieldsForNewAggType = filterFieldsForAgg(fields, metricAggregationType);
const found = fieldsForNewAggType.find((field) => field.name === metric.field);
onChange({
const newDescriptor = {
...descriptor,
field: found ? metric.field : undefined,
});
};
if (metricAggregationType === AGG_TYPE.PERCENTILE) {
onChange({
...newDescriptor,
percentile: 'percentile' in metric ? metric.percentile : DEFAULT_PERCENTILE,
});
} else {
onChange(newDescriptor);
}
};
const onFieldChange = (fieldName?: string) => {
if (!fieldName || metric.type === AGG_TYPE.COUNT) {
@ -85,6 +94,16 @@ export function MetricEditor({
field: fieldName,
});
};
const onPercentileChange = (percentile: number) => {
if (metric.type !== AGG_TYPE.PERCENTILE) {
return;
}
onChange({
...metric,
percentile,
});
};
const onLabelChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange({
...metric,
@ -121,6 +140,24 @@ export function MetricEditor({
);
}
let percentileSelect;
if (metric.type === AGG_TYPE.PERCENTILE) {
const label = i18n.translate('xpack.maps.metricsEditor.selectPercentileLabel', {
defaultMessage: 'Percentile',
});
percentileSelect = (
<ValidatedNumberInput
min={0}
max={100}
onChange={onPercentileChange}
label={label}
initialValue={
typeof metric.percentile === 'number' ? metric.percentile : DEFAULT_PERCENTILE
}
/>
);
}
let labelInput;
if (metric.type) {
labelInput = (
@ -178,6 +215,7 @@ export function MetricEditor({
</EuiFormRow>
{fieldSelect}
{percentileSelect}
{labelInput}
{removeButton}
</Fragment>

View file

@ -34,6 +34,12 @@ const AGG_OPTIONS = [
}),
value: AGG_TYPE.MIN,
},
{
label: i18n.translate('xpack.maps.metricSelect.percentileDropDownOptionLabel', {
defaultMessage: 'Percentile',
}),
value: AGG_TYPE.PERCENTILE,
},
{
label: i18n.translate('xpack.maps.metricSelect.sumDropDownOptionLabel', {
defaultMessage: 'Sum',

View file

@ -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 React from 'react';
import { shallow } from 'enzyme';
import { ValidatedNumberInput } from './validated_number_input';
test('should render without error', async () => {
const component = shallow(
<ValidatedNumberInput onChange={() => {}} initialValue={10} min={0} max={20} label={'foobar'} />
);
expect(component).toMatchSnapshot();
});
test('should render with error', async () => {
const component = shallow(
<ValidatedNumberInput onChange={() => {}} initialValue={30} min={0} max={20} label={'foobar'} />
);
expect(component).toMatchSnapshot();
});

View file

@ -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, { Component, ChangeEvent } from 'react';
import { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import _ from 'lodash';
interface State {
value: number | string;
errorMessage: string;
isValid: boolean;
}
interface Props {
initialValue: number;
min: number;
max: number;
onChange: (value: number) => void;
label: string;
}
function getErrorMessage(min: number, max: number): string {
return i18n.translate('xpack.maps.validatedNumberInput.invalidClampErrorMessage', {
defaultMessage: 'Must be between {min} and {max}',
values: {
min,
max,
},
});
}
function isNumberValid(value: number | string, min: number, max: number) {
const parsedValue = parseFloat(value.toString());
if (isNaN(parsedValue)) {
return {
isValid: false,
errorMessage: getErrorMessage(min, max),
};
}
const isValid = parsedValue >= min && parsedValue <= max;
return {
parsedValue,
isValid,
errorMessage: isValid ? '' : getErrorMessage(min, max),
};
}
export class ValidatedNumberInput extends Component<Props, State> {
constructor(props: Props) {
super(props);
const { isValid, errorMessage } = isNumberValid(
props.initialValue,
this.props.min,
this.props.max
);
this.state = {
value: props.initialValue,
errorMessage,
isValid,
};
}
_submit = _.debounce((value) => {
this.props.onChange(value);
}, 250);
_onChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const { isValid, errorMessage, parsedValue } = isNumberValid(
value,
this.props.min,
this.props.max
);
this.setState({
value,
errorMessage,
isValid,
});
if (isValid) {
this._submit(parsedValue);
}
};
render() {
return (
<EuiFormRow
label={this.props.label}
isInvalid={!this.state.isValid}
error={this.state.errorMessage ? [this.state.errorMessage] : []}
display="columnCompressed"
>
<EuiFieldNumber
isInvalid={!this.state.isValid}
min={this.props.min}
max={this.props.max}
value={this.state.value}
onChange={this._onChange}
aria-label={`${this.props.label} number input`}
/>
</EuiFormRow>
);
}
}