[Maps] Add percentile (#85367)
This commit is contained in:
parent
89bd0fbf1e
commit
47e2663473
|
@ -150,6 +150,7 @@ export enum AGG_TYPE {
|
||||||
MIN = 'min',
|
MIN = 'min',
|
||||||
SUM = 'sum',
|
SUM = 'sum',
|
||||||
TERMS = 'terms',
|
TERMS = 'terms',
|
||||||
|
PERCENTILE = 'percentile',
|
||||||
UNIQUE_COUNT = 'cardinality',
|
UNIQUE_COUNT = 'cardinality',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,6 +172,7 @@ export const GEOTILE_GRID_AGG_NAME = 'gridSplit';
|
||||||
export const GEOCENTROID_AGG_NAME = 'gridCentroid';
|
export const GEOCENTROID_AGG_NAME = 'gridCentroid';
|
||||||
|
|
||||||
export const TOP_TERM_PERCENTAGE_SUFFIX = '__percentage';
|
export const TOP_TERM_PERCENTAGE_SUFFIX = '__percentage';
|
||||||
|
export const DEFAULT_PERCENTILE = 50;
|
||||||
|
|
||||||
export const COUNT_PROP_LABEL = i18n.translate('xpack.maps.aggs.defaultCountLabel', {
|
export const COUNT_PROP_LABEL = i18n.translate('xpack.maps.aggs.defaultCountLabel', {
|
||||||
defaultMessage: 'count',
|
defaultMessage: 'count',
|
||||||
|
|
|
@ -60,7 +60,13 @@ export type FieldedAggDescriptor = AbstractAggDescriptor & {
|
||||||
field?: string;
|
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 & {
|
export type AbstractESAggSourceDescriptor = AbstractESSourceDescriptor & {
|
||||||
metrics: AggDescriptor[];
|
metrics: AggDescriptor[];
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { IndexPattern, IFieldType } from '../../../../../src/plugins/data/common';
|
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 BucketProperties = Record<string | number, unknown>;
|
||||||
export type PropertiesMap = Map<string, BucketProperties>;
|
export type PropertiesMap = Map<string, BucketProperties>;
|
||||||
|
@ -46,6 +46,7 @@ export function extractPropertiesFromBucket(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo: push these implementations in the IAggFields
|
||||||
if (_.has(bucket[key], 'value')) {
|
if (_.has(bucket[key], 'value')) {
|
||||||
properties[key] = bucket[key].value;
|
properties[key] = bucket[key].value;
|
||||||
} else if (_.has(bucket[key], 'buckets')) {
|
} else if (_.has(bucket[key], 'buckets')) {
|
||||||
|
@ -63,7 +64,20 @@ export function extractPropertiesFromBucket(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} 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;
|
return properties;
|
||||||
|
|
|
@ -8,7 +8,11 @@ import { esAggFieldsFactory } from './es_agg_factory';
|
||||||
import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
|
import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
|
||||||
import { IESAggSource } from '../../sources/es_agg_source';
|
import { IESAggSource } from '../../sources/es_agg_source';
|
||||||
|
|
||||||
const mockEsAggSource = ({} as unknown) as IESAggSource;
|
const mockEsAggSource = ({
|
||||||
|
getAggKey() {
|
||||||
|
return 'foobar';
|
||||||
|
},
|
||||||
|
} as unknown) as IESAggSource;
|
||||||
|
|
||||||
describe('esAggFieldsFactory', () => {
|
describe('esAggFieldsFactory', () => {
|
||||||
test('Should only create top terms field when term field is not provided', () => {
|
test('Should only create top terms field when term field is not provided', () => {
|
||||||
|
@ -28,4 +32,26 @@ describe('esAggFieldsFactory', () => {
|
||||||
);
|
);
|
||||||
expect(fields.length).toBe(2);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,12 +6,13 @@
|
||||||
|
|
||||||
import { AggDescriptor } from '../../../../common/descriptor_types';
|
import { AggDescriptor } from '../../../../common/descriptor_types';
|
||||||
import { IESAggSource } from '../../sources/es_agg_source';
|
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 { ESDocField } from '../es_doc_field';
|
||||||
import { TopTermPercentageField } from './top_term_percentage_field';
|
import { TopTermPercentageField } from './top_term_percentage_field';
|
||||||
import { CountAggField } from './count_agg_field';
|
import { CountAggField } from './count_agg_field';
|
||||||
import { IESAggField } from './agg_field_types';
|
import { IESAggField } from './agg_field_types';
|
||||||
import { AggField } from './agg_field';
|
import { AggField } from './agg_field';
|
||||||
|
import { PercentileAggField } from './percentile_agg_field';
|
||||||
|
|
||||||
export function esAggFieldsFactory(
|
export function esAggFieldsFactory(
|
||||||
aggDescriptor: AggDescriptor,
|
aggDescriptor: AggDescriptor,
|
||||||
|
@ -27,6 +28,21 @@ export function esAggFieldsFactory(
|
||||||
origin,
|
origin,
|
||||||
canReadFromGeoJson,
|
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 {
|
} else {
|
||||||
aggField = new AggField({
|
aggField = new AggField({
|
||||||
label: aggDescriptor.label,
|
label: aggDescriptor.label,
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -48,7 +48,9 @@ interface CountData {
|
||||||
isSyncClustered: boolean;
|
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;
|
return dynamicProperty.isOrdinal() ? AGG_TYPE.AVG : AGG_TYPE.TERMS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,6 @@ import {
|
||||||
import { VectorStyle } from '../styles/vector/vector_style';
|
import { VectorStyle } from '../styles/vector/vector_style';
|
||||||
import { EMSFileSource } from '../sources/ems_file_source';
|
import { EMSFileSource } from '../sources/ems_file_source';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { ESGeoGridSource } from '../sources/es_geo_grid_source';
|
|
||||||
import { VectorLayer } from './vector_layer/vector_layer';
|
import { VectorLayer } from './vector_layer/vector_layer';
|
||||||
import { getDefaultDynamicProperties } from '../styles/vector/vector_style_defaults';
|
import { getDefaultDynamicProperties } from '../styles/vector/vector_style_defaults';
|
||||||
import { NUMERICAL_COLOR_PALETTES } from '../styles/color_palettes';
|
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;
|
const aggType = aggTypeKey ? AGG_TYPE[aggTypeKey as keyof typeof AGG_TYPE] : undefined;
|
||||||
|
|
||||||
return aggType && metricFieldName
|
if (!aggType || aggType === AGG_TYPE.COUNT || !metricFieldName) {
|
||||||
? { type: aggType, field: metricFieldName }
|
return { type: AGG_TYPE.COUNT };
|
||||||
: { 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({
|
export function createRegionMapLayerDescriptor({
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import {
|
import {
|
||||||
AGG_TYPE,
|
AGG_TYPE,
|
||||||
COLOR_MAP_TYPE,
|
COLOR_MAP_TYPE,
|
||||||
|
DEFAULT_PERCENTILE,
|
||||||
FIELD_ORIGIN,
|
FIELD_ORIGIN,
|
||||||
GRID_RESOLUTION,
|
GRID_RESOLUTION,
|
||||||
RENDER_AS,
|
RENDER_AS,
|
||||||
|
@ -59,9 +60,18 @@ export function createAggDescriptor(
|
||||||
});
|
});
|
||||||
const aggType = aggTypeKey ? AGG_TYPE[aggTypeKey as keyof typeof AGG_TYPE] : undefined;
|
const aggType = aggTypeKey ? AGG_TYPE[aggTypeKey as keyof typeof AGG_TYPE] : undefined;
|
||||||
|
|
||||||
return aggType && metricFieldName && (!isHeatmap(mapType) || isMetricCountable(aggType))
|
if (
|
||||||
? { type: aggType, field: metricFieldName }
|
!aggType ||
|
||||||
: { type: AGG_TYPE.COUNT };
|
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({
|
export function createTileMapLayerDescriptor({
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Map as MbMap } from 'mapbox-gl';
|
import { Map as MbMap } from 'mapbox-gl';
|
||||||
import { i18n } from '@kbn/i18n';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { EuiTextColor } from '@elastic/eui';
|
import { EuiTextColor } from '@elastic/eui';
|
||||||
import { DynamicStyleProperty } from './dynamic_style_property';
|
import { DynamicStyleProperty } from './dynamic_style_property';
|
||||||
|
@ -25,35 +24,11 @@ import {
|
||||||
import { Break, BreakedLegend } from '../components/legend/breaked_legend';
|
import { Break, BreakedLegend } from '../components/legend/breaked_legend';
|
||||||
import { ColorDynamicOptions, OrdinalColorStop } from '../../../../../common/descriptor_types';
|
import { ColorDynamicOptions, OrdinalColorStop } from '../../../../../common/descriptor_types';
|
||||||
import { LegendProps } from './style_property';
|
import { LegendProps } from './style_property';
|
||||||
|
import { getOrdinalSuffix } from '../../../util/ordinal_suffix';
|
||||||
|
|
||||||
const EMPTY_STOPS = { stops: [], defaultColor: null };
|
const EMPTY_STOPS = { stops: [], defaultColor: null };
|
||||||
const RGBA_0000 = 'rgba(0,0,0,0)';
|
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> {
|
export class DynamicColorProperty extends DynamicStyleProperty<ColorDynamicOptions> {
|
||||||
syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
|
syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
|
||||||
const color = this._getMbColor();
|
const color = this._getMbColor();
|
||||||
|
|
32
x-pack/plugins/maps/public/classes/util/ordinal_suffix.ts
Normal file
32
x-pack/plugins/maps/public/classes/util/ordinal_suffix.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
51
x-pack/plugins/maps/public/components/__snapshots__/validated_number_input.test.tsx.snap
generated
Normal file
51
x-pack/plugins/maps/public/components/__snapshots__/validated_number_input.test.tsx.snap
generated
Normal 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>
|
||||||
|
`;
|
|
@ -13,9 +13,10 @@ import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
import { MetricSelect } from './metric_select';
|
import { MetricSelect } from './metric_select';
|
||||||
import { SingleFieldSelect } from '../single_field_select';
|
import { SingleFieldSelect } from '../single_field_select';
|
||||||
import { AggDescriptor } from '../../../common/descriptor_types';
|
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 { getTermsFields } from '../../index_pattern_util';
|
||||||
import { IFieldType } from '../../../../../../src/plugins/data/public';
|
import { IFieldType } from '../../../../../../src/plugins/data/public';
|
||||||
|
import { ValidatedNumberInput } from '../validated_number_input';
|
||||||
|
|
||||||
function filterFieldsForAgg(fields: IFieldType[], aggType: AGG_TYPE) {
|
function filterFieldsForAgg(fields: IFieldType[], aggType: AGG_TYPE) {
|
||||||
if (!fields) {
|
if (!fields) {
|
||||||
|
@ -70,10 +71,18 @@ export function MetricEditor({
|
||||||
|
|
||||||
const fieldsForNewAggType = filterFieldsForAgg(fields, metricAggregationType);
|
const fieldsForNewAggType = filterFieldsForAgg(fields, metricAggregationType);
|
||||||
const found = fieldsForNewAggType.find((field) => field.name === metric.field);
|
const found = fieldsForNewAggType.find((field) => field.name === metric.field);
|
||||||
onChange({
|
const newDescriptor = {
|
||||||
...descriptor,
|
...descriptor,
|
||||||
field: found ? metric.field : undefined,
|
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) => {
|
const onFieldChange = (fieldName?: string) => {
|
||||||
if (!fieldName || metric.type === AGG_TYPE.COUNT) {
|
if (!fieldName || metric.type === AGG_TYPE.COUNT) {
|
||||||
|
@ -85,6 +94,16 @@ export function MetricEditor({
|
||||||
field: fieldName,
|
field: fieldName,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPercentileChange = (percentile: number) => {
|
||||||
|
if (metric.type !== AGG_TYPE.PERCENTILE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({
|
||||||
|
...metric,
|
||||||
|
percentile,
|
||||||
|
});
|
||||||
|
};
|
||||||
const onLabelChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const onLabelChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange({
|
onChange({
|
||||||
...metric,
|
...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;
|
let labelInput;
|
||||||
if (metric.type) {
|
if (metric.type) {
|
||||||
labelInput = (
|
labelInput = (
|
||||||
|
@ -178,6 +215,7 @@ export function MetricEditor({
|
||||||
</EuiFormRow>
|
</EuiFormRow>
|
||||||
|
|
||||||
{fieldSelect}
|
{fieldSelect}
|
||||||
|
{percentileSelect}
|
||||||
{labelInput}
|
{labelInput}
|
||||||
{removeButton}
|
{removeButton}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
|
@ -34,6 +34,12 @@ const AGG_OPTIONS = [
|
||||||
}),
|
}),
|
||||||
value: AGG_TYPE.MIN,
|
value: AGG_TYPE.MIN,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: i18n.translate('xpack.maps.metricSelect.percentileDropDownOptionLabel', {
|
||||||
|
defaultMessage: 'Percentile',
|
||||||
|
}),
|
||||||
|
value: AGG_TYPE.PERCENTILE,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: i18n.translate('xpack.maps.metricSelect.sumDropDownOptionLabel', {
|
label: i18n.translate('xpack.maps.metricSelect.sumDropDownOptionLabel', {
|
||||||
defaultMessage: 'Sum',
|
defaultMessage: 'Sum',
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
113
x-pack/plugins/maps/public/components/validated_number_input.tsx
Normal file
113
x-pack/plugins/maps/public/components/validated_number_input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue