[maps] metrics mask (#154983)

Fixes https://github.com/elastic/kibana/issues/55520

PR adds ability to set mask configuration for metric fields to hide
features client side that are outside the range of the mask.

<img width="600" alt="Screen Shot 2023-04-19 at 3 45 15 PM"
src="https://user-images.githubusercontent.com/373691/233207269-854695dd-ea51-45cd-b092-3360092846bb.png">

<img width="600" alt="Screen Shot 2023-04-19 at 3 44 45 PM"
src="https://user-images.githubusercontent.com/373691/233207329-e0c7f20c-e789-446c-8994-87bcdf8e4bbe.png">

For vector layer, I could not use easier solution of filter expression
since filter expressions do not support feature-state. Instead,
implementation sets opacity paint property to 0.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2023-04-21 18:46:57 -06:00 committed by GitHub
parent ef64acf405
commit a108b8c9c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1022 additions and 140 deletions

View file

@ -188,10 +188,6 @@ 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',
});
export const COUNT_PROP_NAME = 'doc_count';
export enum STYLE_TYPE {
@ -341,6 +337,11 @@ export enum WIZARD_ID {
TMS_LAYER = 'tmsLayer',
}
export enum MASK_OPERATOR {
ABOVE = 'ABOVE',
BELOW = 'BELOW',
}
// Maplibre does not provide any feedback when rendering is complete.
// Workaround is hard-coded timeout period.
export const RENDER_TIMEOUT = 1000;

View file

@ -13,6 +13,7 @@ import { SortDirection } from '@kbn/data-plugin/common/search';
import {
AGG_TYPE,
GRID_RESOLUTION,
MASK_OPERATOR,
RENDER_AS,
SCALING_TYPES,
MVT_FIELD_TYPE,
@ -49,6 +50,10 @@ export type AbstractESSourceDescriptor = AbstractSourceDescriptor & {
type AbstractAggDescriptor = {
type: AGG_TYPE;
label?: string;
mask?: {
operator: MASK_OPERATOR;
value: number;
};
};
export type CountAggDescriptor = AbstractAggDescriptor & {

View file

@ -9,14 +9,17 @@ import { DataView } from '@kbn/data-plugin/common';
import { IField } from '../field';
import { IESAggSource } from '../../sources/es_agg_source';
import { FIELD_ORIGIN } from '../../../../common/constants';
import { AggDescriptor } from '../../../../common/descriptor_types';
export interface IESAggField extends IField {
getValueAggDsl(indexPattern: DataView): unknown | null;
getBucketCount(): number;
getMask(): AggDescriptor['mask'] | undefined;
}
export interface CountAggFieldParams {
label?: string;
source: IESAggSource;
origin: FIELD_ORIGIN;
mask?: AggDescriptor['mask'];
}

View file

@ -14,7 +14,7 @@ import { DataView } from '@kbn/data-plugin/common';
import { IESAggSource } from '../../sources/es_agg_source';
import { IVectorSource } from '../../sources/vector_source';
import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
import { TileMetaFeature } from '../../../../common/descriptor_types';
import { AggDescriptor, TileMetaFeature } from '../../../../common/descriptor_types';
import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property';
import { ESAggTooltipProperty } from '../../tooltips/es_agg_tooltip_property';
import { IESAggField, CountAggFieldParams } from './agg_field_types';
@ -25,11 +25,13 @@ export class CountAggField implements IESAggField {
protected readonly _source: IESAggSource;
private readonly _origin: FIELD_ORIGIN;
protected readonly _label?: string;
protected readonly _mask?: AggDescriptor['mask'];
constructor({ label, source, origin }: CountAggFieldParams) {
constructor({ label, source, origin, mask }: CountAggFieldParams) {
this._source = source;
this._origin = origin;
this._label = label;
this._mask = mask;
}
supportsFieldMetaFromEs(): boolean {
@ -131,4 +133,8 @@ export class CountAggField implements IESAggField {
pluckRangeFromTileMetaFeature(metaFeature: TileMetaFeature) {
return getAggRange(metaFeature, '_count');
}
getMask() {
return this._mask;
}
}

View file

@ -26,6 +26,7 @@ export function esAggFieldsFactory(
label: aggDescriptor.label,
source,
origin,
mask: aggDescriptor.mask,
});
} else if (aggDescriptor.type === AGG_TYPE.PERCENTILE) {
aggField = new PercentileAggField({
@ -40,6 +41,7 @@ export function esAggFieldsFactory(
: DEFAULT_PERCENTILE,
source,
origin,
mask: aggDescriptor.mask,
});
} else {
aggField = new AggField({
@ -51,6 +53,7 @@ export function esAggFieldsFactory(
aggType: aggDescriptor.type,
source,
origin,
mask: aggDescriptor.mask,
});
}

View file

@ -105,4 +105,8 @@ export class TopTermPercentageField implements IESAggField {
pluckRangeFromTileMetaFeature(metaFeature: TileMetaFeature) {
return null;
}
getMask() {
return undefined;
}
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { Map as MbMap, VectorTileSource } from '@kbn/mapbox-gl';
import type { FilterSpecification, Map as MbMap, VectorTileSource } from '@kbn/mapbox-gl';
import { AbstractLayer } from '../layer';
import { HeatmapStyle } from '../../styles/heatmap/heatmap_style';
import { LAYER_TYPE } from '../../../../common/constants';
@ -21,6 +21,7 @@ import { DataRequestContext } from '../../../actions';
import { buildVectorRequestMeta } from '../build_vector_request_meta';
import { IMvtVectorSource } from '../../sources/vector_source';
import { getAggsMeta } from '../../util/tile_meta_feature_utils';
import { Mask } from '../vector_layer/mask';
export class HeatmapLayer extends AbstractLayer {
private readonly _style: HeatmapStyle;
@ -186,6 +187,19 @@ export class HeatmapLayer extends AbstractLayer {
this.syncVisibilityWithMb(mbMap, heatmapLayerId);
mbMap.setPaintProperty(heatmapLayerId, 'heatmap-opacity', this.getAlpha());
// heatmap can implement mask with filter expression because
// feature-state support is not needed since heatmap layers do not support joins
const maskDescriptor = metricField.getMask();
if (maskDescriptor) {
const mask = new Mask({
esAggField: metricField,
isGeometrySourceMvt: true,
...maskDescriptor,
});
mbMap.setFilter(heatmapLayerId, mask.getMatchUnmaskedExpression() as FilterSpecification);
}
mbMap.setLayerZoomRange(heatmapLayerId, this.getMinZoom(), this.getMaxZoom());
}

View file

@ -13,7 +13,6 @@ import { getDefaultDynamicProperties } from '../../../styles/vector/vector_style
import { IDynamicStyleProperty } from '../../../styles/vector/properties/dynamic_style_property';
import { IStyleProperty } from '../../../styles/vector/properties/style_property';
import {
COUNT_PROP_LABEL,
COUNT_PROP_NAME,
GRID_RESOLUTION,
LAYER_TYPE,
@ -67,7 +66,6 @@ function getClusterSource(documentSource: IESSource, documentStyle: IVectorStyle
clusterSourceDescriptor.metrics = [
{
type: AGG_TYPE.COUNT,
label: COUNT_PROP_LABEL,
},
...documentStyle.getDynamicPropertiesArray().map((dynamicProperty) => {
return {
@ -267,9 +265,9 @@ export class BlendedVectorLayer extends GeoJsonVectorLayer implements IVectorLay
return [clonedDescriptor];
}
getSource(): IVectorSource {
getSource = () => {
return this._isClustered ? this._clusterSource : this._documentSource;
}
};
getSourceForEditing() {
// Layer is based on this._documentSource

View file

@ -0,0 +1,193 @@
/*
* 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';
import { MapGeoJSONFeature } from '@kbn/mapbox-gl';
import type { IESAggSource } from '../../sources/es_agg_source';
import type { IESAggField } from '../../fields/agg';
import { FIELD_ORIGIN, MASK_OPERATOR, MB_LOOKUP_FUNCTION } from '../../../../common/constants';
export const BELOW = i18n.translate('xpack.maps.mask.belowLabel', {
defaultMessage: 'below',
});
export const ABOVE = i18n.translate('xpack.maps.mask.aboveLabel', {
defaultMessage: 'above',
});
export const BUCKETS = i18n.translate('xpack.maps.mask.genericBucketsName', {
defaultMessage: 'buckets',
});
const FEATURES = i18n.translate('xpack.maps.mask.genericFeaturesName', {
defaultMessage: 'features',
});
const VALUE = i18n.translate('xpack.maps.mask.genericAggLabel', {
defaultMessage: 'value',
});
const WHEN = i18n.translate('xpack.maps.mask.when', {
defaultMessage: 'when',
});
const WHEN_JOIN_METRIC = i18n.translate('xpack.maps.mask.whenJoinMetric', {
defaultMessage: '{whenLabel} join metric',
values: {
whenLabel: WHEN,
},
});
function getOperatorLabel(operator: MASK_OPERATOR): string {
if (operator === MASK_OPERATOR.BELOW) {
return BELOW;
}
if (operator === MASK_OPERATOR.ABOVE) {
return ABOVE;
}
return operator as string;
}
export function getMaskI18nValue(operator: MASK_OPERATOR, value: number): string {
return `${getOperatorLabel(operator)} ${value}`;
}
export function getMaskI18nLabel({
bucketsName,
isJoin,
}: {
bucketsName?: string;
isJoin: boolean;
}): string {
return i18n.translate('xpack.maps.mask.maskLabel', {
defaultMessage: 'Hide {hideNoun}',
values: {
hideNoun: isJoin ? FEATURES : bucketsName ? bucketsName : BUCKETS,
},
});
}
export function getMaskI18nDescription({
aggLabel,
bucketsName,
isJoin,
}: {
aggLabel?: string;
bucketsName?: string;
isJoin: boolean;
}): string {
return i18n.translate('xpack.maps.mask.maskDescription', {
defaultMessage: '{maskAdverb} {aggLabel} is ',
values: {
aggLabel: aggLabel ? aggLabel : VALUE,
maskAdverb: isJoin ? WHEN_JOIN_METRIC : WHEN,
},
});
}
export class Mask {
private readonly _esAggField: IESAggField;
private readonly _isGeometrySourceMvt: boolean;
private readonly _operator: MASK_OPERATOR;
private readonly _value: number;
constructor({
esAggField,
isGeometrySourceMvt,
operator,
value,
}: {
esAggField: IESAggField;
isGeometrySourceMvt: boolean;
operator: MASK_OPERATOR;
value: number;
}) {
this._esAggField = esAggField;
this._isGeometrySourceMvt = isGeometrySourceMvt;
this._operator = operator;
this._value = value;
}
private _isFeatureState() {
if (this._esAggField.getOrigin() === FIELD_ORIGIN.SOURCE) {
// source fields are stored in properties
return false;
}
if (!this._isGeometrySourceMvt) {
// For geojson sources, join fields are stored in properties
return false;
}
// For vector tile sources, it is not possible to add join fields to properties
// so join fields are stored in feature state
return true;
}
/*
* Returns maplibre expression that matches masked features
*/
getMatchMaskedExpression() {
const comparisionOperator = this._operator === MASK_OPERATOR.BELOW ? '<' : '>';
const lookup = this._isFeatureState()
? MB_LOOKUP_FUNCTION.FEATURE_STATE
: MB_LOOKUP_FUNCTION.GET;
return [comparisionOperator, [lookup, this._esAggField.getMbFieldName()], this._value];
}
/*
* Returns maplibre expression that matches unmasked features
*/
getMatchUnmaskedExpression() {
const comparisionOperator = this._operator === MASK_OPERATOR.BELOW ? '>=' : '<=';
const lookup = this._isFeatureState()
? MB_LOOKUP_FUNCTION.FEATURE_STATE
: MB_LOOKUP_FUNCTION.GET;
return [comparisionOperator, [lookup, this._esAggField.getMbFieldName()], this._value];
}
getEsAggField() {
return this._esAggField;
}
getFieldOriginListLabel() {
const source = this._esAggField.getSource();
const isJoin = this._esAggField.getOrigin() === FIELD_ORIGIN.JOIN;
const maskLabel = getMaskI18nLabel({
bucketsName:
'getBucketsName' in (source as IESAggSource)
? (source as IESAggSource).getBucketsName()
: undefined,
isJoin,
});
const adverb = isJoin ? WHEN_JOIN_METRIC : WHEN;
return `${maskLabel} ${adverb}`;
}
getOperator() {
return this._operator;
}
getValue() {
return this._value;
}
isFeatureMasked(feature: MapGeoJSONFeature) {
const featureValue = this._isFeatureState()
? feature?.state[this._esAggField.getMbFieldName()]
: feature?.properties[this._esAggField.getMbFieldName()];
if (typeof featureValue !== 'number') {
return false;
}
return this._operator === MASK_OPERATOR.BELOW
? featureValue < this._value
: featureValue > this._value;
}
}

View file

@ -25,6 +25,7 @@ import {
} from '../../../../../common/descriptor_types';
import { LAYER_TYPE, SOURCE_TYPES } from '../../../../../common/constants';
import { MvtVectorLayer } from './mvt_vector_layer';
import { ITermJoinSource } from '../../../sources/term_join_source';
const defaultConfig = {
urlTemplate: 'https://example.com/{x}/{y}/{z}.pbf',
@ -176,6 +177,9 @@ describe('isLayerLoading', () => {
getSourceDataRequestId: () => {
return 'join_source_a0b0da65-5e1a-4967-9dbe-74f24391afe2';
},
getRightJoinSource: () => {
return {} as unknown as ITermJoinSource;
},
} as unknown as InnerJoin,
],
layerDescriptor: {
@ -212,6 +216,9 @@ describe('isLayerLoading', () => {
getSourceDataRequestId: () => {
return 'join_source_a0b0da65-5e1a-4967-9dbe-74f24391afe2';
},
getRightJoinSource: () => {
return {} as unknown as ITermJoinSource;
},
} as unknown as InnerJoin,
],
layerDescriptor: {

View file

@ -58,12 +58,14 @@ import { ITooltipProperty } from '../../tooltips/tooltip_property';
import { IDynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property';
import { IESSource } from '../../sources/es_source';
import { ITermJoinSource } from '../../sources/term_join_source';
import type { IESAggSource } from '../../sources/es_agg_source';
import { buildVectorRequestMeta } from '../build_vector_request_meta';
import { getJoinAggKey } from '../../../../common/get_agg_key';
import { syncBoundsData } from './bounds_data';
import { JoinState } from './types';
import { canSkipSourceUpdate } from '../../util/can_skip_fetch';
import { PropertiesMap } from '../../../../common/elasticsearch_util';
import { Mask } from './mask';
const SUPPORTS_FEATURE_EDITING_REQUEST_ID = 'SUPPORTS_FEATURE_EDITING_REQUEST_ID';
@ -106,6 +108,7 @@ export interface IVectorLayer extends ILayer {
getLeftJoinFields(): Promise<IField[]>;
addFeature(geometry: Geometry | Position[]): Promise<void>;
deleteFeature(featureId: string): Promise<void>;
getMasks(): Mask[];
}
export const noResultsIcon = <EuiIcon size="m" color="subdued" type="minusInCircle" />;
@ -120,6 +123,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
protected readonly _style: VectorStyle;
private readonly _joins: InnerJoin[];
protected readonly _descriptor: VectorLayerDescriptor;
private readonly _masks: Mask[];
static createDescriptor(
options: Partial<VectorLayerDescriptor>,
@ -163,6 +167,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
customIcons,
chartsPaletteServiceGetColor
);
this._masks = this._createMasks();
}
async cloneDescriptor(): Promise<VectorLayerDescriptor[]> {
@ -692,6 +697,69 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
return undefined;
}
_createMasks() {
const masks: Mask[] = [];
const source = this.getSource();
if ('getMetricFields' in (source as IESAggSource)) {
const metricFields = (source as IESAggSource).getMetricFields();
metricFields.forEach((metricField) => {
const maskDescriptor = metricField.getMask();
if (maskDescriptor) {
masks.push(
new Mask({
esAggField: metricField,
isGeometrySourceMvt: source.isMvt(),
...maskDescriptor,
})
);
}
});
}
this.getValidJoins().forEach((join) => {
const rightSource = join.getRightJoinSource();
if ('getMetricFields' in (rightSource as unknown as IESAggSource)) {
const metricFields = (rightSource as unknown as IESAggSource).getMetricFields();
metricFields.forEach((metricField) => {
const maskDescriptor = metricField.getMask();
if (maskDescriptor) {
masks.push(
new Mask({
esAggField: metricField,
isGeometrySourceMvt: source.isMvt(),
...maskDescriptor,
})
);
}
});
}
});
return masks;
}
getMasks() {
return this._masks;
}
// feature-state is not supported in filter expressions
// https://github.com/mapbox/mapbox-gl-js/issues/8487
// therefore, masking must be accomplished via setting opacity paint property (hack)
_getAlphaExpression() {
const maskCaseExpressions: unknown[] = [];
this.getMasks().forEach((mask) => {
// case expressions require 2 parts
// 1) condition expression
maskCaseExpressions.push(mask.getMatchMaskedExpression());
// 2) output. 0 opacity styling "hides" feature
maskCaseExpressions.push(0);
});
return maskCaseExpressions.length
? ['case', ...maskCaseExpressions, this.getAlpha()]
: this.getAlpha();
}
_setMbPointsProperties(
mbMap: MbMap,
mvtSourceLayer?: string,
@ -759,13 +827,13 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
if (this.getCurrentStyle().arePointsSymbolizedAsCircles()) {
this.getCurrentStyle().setMBPaintPropertiesForPoints({
alpha: this.getAlpha(),
alpha: this._getAlphaExpression(),
mbMap,
pointLayerId: markerLayerId,
});
} else {
this.getCurrentStyle().setMBSymbolPropertiesForPoints({
alpha: this.getAlpha(),
alpha: this._getAlphaExpression(),
mbMap,
symbolLayerId: markerLayerId,
});
@ -811,7 +879,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
}
this.getCurrentStyle().setMBPaintProperties({
alpha: this.getAlpha(),
alpha: this._getAlphaExpression(),
mbMap,
fillLayerId,
lineLayerId,
@ -865,7 +933,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
}
this.getCurrentStyle().setMBPropertiesForLabelText({
alpha: this.getAlpha(),
alpha: this._getAlphaExpression(),
mbMap,
textLayerId: labelLayerId,
});

View file

@ -11,11 +11,13 @@ import { DataView } from '@kbn/data-plugin/common';
import type { IESAggSource } from './types';
import { AbstractESSource } from '../es_source';
import { esAggFieldsFactory, IESAggField } from '../../fields/agg';
import { AGG_TYPE, COUNT_PROP_LABEL, FIELD_ORIGIN } from '../../../../common/constants';
import { AGG_TYPE, FIELD_ORIGIN } from '../../../../common/constants';
import { getSourceAggKey } from '../../../../common/get_agg_key';
import { AbstractESAggSourceDescriptor, AggDescriptor } from '../../../../common/descriptor_types';
import { IField } from '../../fields/field';
import { ITooltipProperty } from '../../tooltips/tooltip_property';
import { getAggDisplayName } from './get_agg_display_name';
import { BUCKETS } from '../../layers/vector_layer/mask';
export const DEFAULT_METRIC = { type: AGG_TYPE.COUNT };
@ -46,6 +48,10 @@ export abstract class AbstractESAggSource extends AbstractESSource implements IE
}
}
getBucketsName() {
return BUCKETS;
}
getFieldByName(fieldName: string): IField | null {
return this.getMetricFieldForName(fieldName);
}
@ -83,14 +89,14 @@ export abstract class AbstractESAggSource extends AbstractESSource implements IE
async getAggLabel(aggType: AGG_TYPE, fieldLabel: string): Promise<string> {
switch (aggType) {
case AGG_TYPE.COUNT:
return COUNT_PROP_LABEL;
return getAggDisplayName(aggType);
case AGG_TYPE.TERMS:
return i18n.translate('xpack.maps.source.esAggSource.topTermLabel', {
defaultMessage: `Top {fieldLabel}`,
defaultMessage: `top {fieldLabel}`,
values: { fieldLabel },
});
default:
return `${aggType} ${fieldLabel}`;
return `${getAggDisplayName(aggType)} ${fieldLabel}`;
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { AGG_TYPE } from '../../../../common/constants';
export function getAggDisplayName(aggType: AGG_TYPE): string {
switch (aggType) {
case AGG_TYPE.AVG:
return i18n.translate('xpack.maps.aggType.averageLabel', {
defaultMessage: 'average',
});
case AGG_TYPE.COUNT:
return i18n.translate('xpack.maps.aggType.countLabel', {
defaultMessage: 'count',
});
case AGG_TYPE.MAX:
return i18n.translate('xpack.maps.aggType.maximumLabel', {
defaultMessage: 'max',
});
case AGG_TYPE.MIN:
return i18n.translate('xpack.maps.aggType.minimumLabel', {
defaultMessage: 'min',
});
case AGG_TYPE.PERCENTILE:
return i18n.translate('xpack.maps.aggType.percentileLabel', {
defaultMessage: 'percentile',
});
case AGG_TYPE.SUM:
return i18n.translate('xpack.maps.aggType.sumLabel', {
defaultMessage: 'sum',
});
case AGG_TYPE.TERMS:
return i18n.translate('xpack.maps.aggType.topTermLabel', {
defaultMessage: 'top term',
});
case AGG_TYPE.UNIQUE_COUNT:
return i18n.translate('xpack.maps.aggType.cardinalityTermLabel', {
defaultMessage: 'unique count',
});
default:
return aggType;
}
}

View file

@ -7,3 +7,4 @@
export type { IESAggSource } from './types';
export { AbstractESAggSource, DEFAULT_METRIC } from './es_agg_source';
export { getAggDisplayName } from './get_agg_display_name';

View file

@ -13,6 +13,12 @@ import { IESAggField } from '../../fields/agg';
export interface IESAggSource extends IESSource {
getAggKey(aggType: AGG_TYPE, fieldName: string): string;
getAggLabel(aggType: AGG_TYPE, fieldLabel: string): Promise<string>;
/*
* Returns human readable name describing buckets, like "clusters" or "grids"
*/
getBucketsName(): string;
getMetricFields(): IESAggField[];
getMetricFieldForName(fieldName: string): IESAggField | null;
getValueAggsDsl(indexPattern: DataView): { [key: string]: unknown };

View file

@ -19,7 +19,9 @@ exports[`source editor geo_grid_source should not allow editing multiple metrics
/>
<MetricsEditor
allowMultipleMetrics={false}
bucketsName="clusters"
fields={Array []}
isJoin={false}
key="12345"
metrics={Array []}
metricsFilter={[Function]}
@ -77,7 +79,9 @@ exports[`source editor geo_grid_source should render editor 1`] = `
/>
<MetricsEditor
allowMultipleMetrics={true}
bucketsName="clusters"
fields={Array []}
isJoin={false}
key="12345"
metrics={Array []}
metricsFilter={[Function]}

View file

@ -98,6 +98,24 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo
this._descriptor = sourceDescriptor;
}
getBucketsName() {
if (this._descriptor.requestType === RENDER_AS.HEX) {
return i18n.translate('xpack.maps.source.esGeoGrid.hex.bucketsName', {
defaultMessage: 'hexagons',
});
}
if (this._descriptor.requestType === RENDER_AS.GRID) {
return i18n.translate('xpack.maps.source.esGeoGrid.grid.bucketsName', {
defaultMessage: 'grid',
});
}
return i18n.translate('xpack.maps.source.esGeoGrid.cluster.bucketsName', {
defaultMessage: 'clusters',
});
}
renderSourceSettingsEditor(sourceEditorArgs: SourceEditorArgs): ReactElement<any> {
async function onChange(...sourceChanges: OnSourceChangeArgs[]) {
sourceEditorArgs.onChange(...sourceChanges);
@ -129,6 +147,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo
}
return (
<UpdateSourceEditor
bucketsName={this.getBucketsName()}
currentLayerType={sourceEditorArgs.currentLayerType}
geoFieldName={this.getGeoFieldName()}
indexPatternId={this.getIndexPatternId()}

View file

@ -16,6 +16,7 @@ jest.mock('uuid', () => ({
}));
const defaultProps = {
bucketsName: 'clusters',
currentLayerType: LAYER_TYPE.GEOJSON_VECTOR,
geoFieldName: 'myLocation',
indexPatternId: 'foobar',

View file

@ -6,7 +6,6 @@
*/
import React, { Fragment, Component } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiPanel, EuiSpacer, EuiComboBoxOptionOption, EuiTitle } from '@elastic/eui';
@ -25,6 +24,7 @@ import { clustersTitle, heatmapTitle } from './es_geo_grid_source';
import { isMvt } from './is_mvt';
interface Props {
bucketsName: string;
currentLayerType?: string;
geoFieldName: string;
indexPatternId: string;
@ -148,6 +148,8 @@ export class UpdateSourceEditor extends Component<Props, State> {
<MetricsEditor
key={this.state.metricsEditorKey}
allowMultipleMetrics={this.props.currentLayerType !== LAYER_TYPE.HEATMAP}
bucketsName={this.props.bucketsName}
isJoin={false}
metricsFilter={this._getMetricsFilter()}
fields={this.state.fields}
metrics={this.props.metrics}

View file

@ -89,9 +89,16 @@ export class ESGeoLineSource extends AbstractESAggSource {
this._descriptor = sourceDescriptor;
}
getBucketsName() {
return i18n.translate('xpack.maps.source.esGeoLine.bucketsName', {
defaultMessage: 'tracks',
});
}
renderSourceSettingsEditor({ onChange }: SourceEditorArgs) {
return (
<UpdateSourceEditor
bucketsName={this.getBucketsName()}
indexPatternId={this.getIndexPatternId()}
onChange={onChange}
metrics={this._descriptor.metrics}

View file

@ -6,7 +6,6 @@
*/
import React, { Fragment, Component } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
import type { DataViewField, DataView } from '@kbn/data-plugin/common';
@ -14,10 +13,11 @@ import { indexPatterns } from '@kbn/data-plugin/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 '../source';
import type { AggDescriptor } from '../../../../common/descriptor_types';
import type { OnSourceChangeArgs } from '../source';
interface Props {
bucketsName: string;
indexPatternId: string;
splitField: string;
sortField: string;
@ -96,6 +96,8 @@ export class UpdateSourceEditor extends Component<Props, State> {
<EuiSpacer size="m" />
<MetricsEditor
allowMultipleMetrics={true}
bucketsName={this.props.bucketsName}
isJoin={false}
fields={this.state.fields}
metrics={this.props.metrics}
onChange={this._onMetricsChange}

View file

@ -67,13 +67,19 @@ export class ESPewPewSource extends AbstractESAggSource {
this._descriptor = descriptor;
}
getBucketsName() {
return i18n.translate('xpack.maps.source.pewPew.bucketsName', {
defaultMessage: 'paths',
});
}
renderSourceSettingsEditor({ onChange }: SourceEditorArgs) {
return (
<UpdateSourceEditor
bucketsName={this.getBucketsName()}
indexPatternId={this.getIndexPatternId()}
onChange={onChange}
metrics={this._descriptor.metrics}
applyGlobalQuery={this._descriptor.applyGlobalQuery}
/>
);
}

View file

@ -6,17 +6,31 @@
*/
import React, { Component, Fragment } from 'react';
import { getDataViewNotFoundMessage } from '../../../../common/i18n_getters';
import { MetricsEditor } from '../../../components/metrics_editor';
import { getIndexPatternService } from '../../../kibana_services';
import type { DataViewField } from '@kbn/data-plugin/common';
import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { indexPatterns } from '@kbn/data-plugin/public';
import { MetricsEditor } from '../../../components/metrics_editor';
import { getIndexPatternService } from '../../../kibana_services';
import type { AggDescriptor } from '../../../../common/descriptor_types';
import type { OnSourceChangeArgs } from '../source';
interface Props {
bucketsName: string;
indexPatternId: string;
metrics: AggDescriptor[];
onChange: (...args: OnSourceChangeArgs[]) => void;
}
interface State {
fields: DataViewField[];
}
export class UpdateSourceEditor extends Component<Props, State> {
private _isMounted: boolean = false;
export class UpdateSourceEditor extends Component {
state = {
fields: null,
fields: [],
};
componentDidMount() {
@ -33,11 +47,6 @@ export class UpdateSourceEditor extends Component {
try {
indexPattern = await getIndexPatternService().get(this.props.indexPatternId);
} catch (err) {
if (this._isMounted) {
this.setState({
loadError: getDataViewNotFoundMessage(this.props.indexPatternId),
});
}
return;
}
@ -50,7 +59,7 @@ export class UpdateSourceEditor extends Component {
});
}
_onMetricsChange = (metrics) => {
_onMetricsChange = (metrics: AggDescriptor[]) => {
this.props.onChange({ propName: 'metrics', value: metrics });
};
@ -69,6 +78,8 @@ export class UpdateSourceEditor extends Component {
<EuiSpacer size="m" />
<MetricsEditor
allowMultipleMetrics={true}
bucketsName={this.props.bucketsName}
isJoin={false}
fields={this.state.fields}
metrics={this.props.metrics}
onChange={this._onMetricsChange}

View file

@ -37,7 +37,7 @@ describe('getMetricFields', () => {
});
const metrics = source.getMetricFields();
expect(metrics[0].getName()).toEqual('__kbnjoin__count__1234');
expect(await metrics[0].getLabel()).toEqual('Count of foobar');
expect(await metrics[0].getLabel()).toEqual('count of foobar');
});
it('should override name and label of sum metric', async () => {
@ -51,7 +51,7 @@ describe('getMetricFields', () => {
expect(metrics[0].getName()).toEqual('__kbnjoin__sum_of_myFieldGettingSummed__1234');
expect(await metrics[0].getLabel()).toEqual('my custom label');
expect(metrics[1].getName()).toEqual('__kbnjoin__count__1234');
expect(await metrics[1].getLabel()).toEqual('Count of foobar');
expect(await metrics[1].getLabel()).toEqual('count of foobar');
});
});

View file

@ -31,6 +31,7 @@ import {
import { PropertiesMap } from '../../../../common/elasticsearch_util';
import { isValidStringConfig } from '../../util/valid_string_config';
import { ITermJoinSource } from '../term_join_source';
import type { IESAggSource } from '../es_agg_source';
import { IField } from '../../fields/field';
import { mergeExecutionContext } from '../execution_context_utils';
@ -52,7 +53,7 @@ export function extractPropertiesMap(rawEsData: any, countPropertyName: string):
return propertiesMap;
}
export class ESTermSource extends AbstractESAggSource implements ITermJoinSource {
export class ESTermSource extends AbstractESAggSource implements ITermJoinSource, IESAggSource {
static type = SOURCE_TYPES.ES_TERM_SOURCE;
static createDescriptor(descriptor: Partial<ESTermSourceDescriptor>): ESTermSourceDescriptor {
@ -115,7 +116,7 @@ export class ESTermSource extends AbstractESAggSource implements ITermJoinSource
}
return aggType === AGG_TYPE.COUNT
? i18n.translate('xpack.maps.source.esJoin.countLabel', {
defaultMessage: `Count of {indexPatternLabel}`,
defaultMessage: `count of {indexPatternLabel}`,
values: { indexPatternLabel },
})
: super.getAggLabel(aggType, fieldLabel);

View file

@ -5,13 +5,15 @@
* 2.0.
*/
import React, { Component } from 'react';
import React, { Component, ReactNode } from 'react';
import { i18n } from '@kbn/i18n';
import { ColorGradient } from './color_gradient';
import { RangedStyleLegendRow } from '../../../components/ranged_style_legend_row';
import { HEATMAP_COLOR_RAMP_LABEL } from '../heatmap_constants';
import { IField } from '../../../../fields/field';
import type { IField } from '../../../../fields/field';
import type { IESAggField } from '../../../../fields/agg';
import { MaskLegend } from '../../../vector/components/legend/mask_legend';
interface Props {
colorRampName: string;
@ -47,7 +49,7 @@ export class HeatmapLegend extends Component<Props, State> {
}
render() {
return (
const metricLegend = (
<RangedStyleLegendRow
header={<ColorGradient colorPaletteId={this.props.colorRampName} />}
minLabel={i18n.translate('xpack.maps.heatmapLegend.coldLabel', {
@ -61,5 +63,28 @@ export class HeatmapLegend extends Component<Props, State> {
invert={false}
/>
);
let maskLegend: ReactNode | undefined;
if ('getMask' in (this.props.field as IESAggField)) {
const mask = (this.props.field as IESAggField).getMask();
if (mask) {
maskLegend = (
<MaskLegend
esAggField={this.props.field as IESAggField}
operator={mask.operator}
value={mask.value}
/>
);
}
}
return maskLegend ? (
<>
{maskLegend}
{metricLegend}
</>
) : (
metricLegend
);
}
}

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Component } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiText } from '@elastic/eui';
import { FIELD_ORIGIN, MASK_OPERATOR } from '../../../../../../common/constants';
import type { IESAggField } from '../../../../fields/agg';
import type { IESAggSource } from '../../../../sources/es_agg_source';
import {
getMaskI18nDescription,
getMaskI18nLabel,
getMaskI18nValue,
} from '../../../../layers/vector_layer/mask';
interface Props {
esAggField: IESAggField;
onlyShowLabelAndValue?: boolean;
operator: MASK_OPERATOR;
value: number;
}
interface State {
aggLabel?: string;
}
export class MaskLegend extends Component<Props, State> {
private _isMounted = false;
state: State = {};
componentDidMount() {
this._isMounted = true;
this._loadAggLabel();
}
componentWillUnmount() {
this._isMounted = false;
}
componentDidUpdate() {
this._loadAggLabel();
}
_loadAggLabel = async () => {
const aggLabel = await this.props.esAggField.getLabel();
if (this._isMounted && aggLabel !== this.state.aggLabel) {
this.setState({ aggLabel });
}
};
_getBucketsName() {
const source = this.props.esAggField.getSource();
return 'getBucketsName' in (source as IESAggSource)
? (source as IESAggSource).getBucketsName()
: undefined;
}
_getPrefix() {
if (this.props.onlyShowLabelAndValue) {
return i18n.translate('xpack.maps.maskLegend.is', {
defaultMessage: '{aggLabel} is',
values: {
aggLabel: this.state.aggLabel,
},
});
}
const isJoin = this.props.esAggField.getOrigin() === FIELD_ORIGIN.JOIN;
const maskLabel = getMaskI18nLabel({
bucketsName: this._getBucketsName(),
isJoin,
});
const maskDescription = getMaskI18nDescription({
aggLabel: this.state.aggLabel,
isJoin,
});
return `${maskLabel} ${maskDescription}`;
}
render() {
return (
<EuiText size="xs" textAlign="left" color="subdued">
<small>
{`${this._getPrefix()} `}
<strong>{getMaskI18nValue(this.props.operator, this.props.value)}</strong>
</small>
</EuiText>
);
}
}

View file

@ -6,17 +6,30 @@
*/
import React from 'react';
import { EuiText } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { FIELD_ORIGIN } from '../../../../../../common/constants';
import { Mask } from '../../../../layers/vector_layer/mask';
import { IStyleProperty } from '../../properties/style_property';
import { MaskLegend } from './mask_legend';
interface Props {
isLinesOnly: boolean;
isPointsOnly: boolean;
masks: Mask[];
styles: Array<IStyleProperty<any>>;
symbolId?: string;
svg?: string;
}
export function VectorStyleLegend({ isLinesOnly, isPointsOnly, styles, symbolId, svg }: Props) {
export function VectorStyleLegend({
isLinesOnly,
isPointsOnly,
masks,
styles,
symbolId,
svg,
}: Props) {
const legendRows = [];
for (let i = 0; i < styles.length; i++) {
@ -34,5 +47,55 @@ export function VectorStyleLegend({ isLinesOnly, isPointsOnly, styles, symbolId,
);
}
return <>{legendRows}</>;
function renderMasksByFieldOrigin(fieldOrigin: FIELD_ORIGIN) {
const masksByFieldOrigin = masks.filter(
(mask) => mask.getEsAggField().getOrigin() === fieldOrigin
);
if (masksByFieldOrigin.length === 0) {
return null;
}
if (masksByFieldOrigin.length === 1) {
const mask = masksByFieldOrigin[0];
return (
<MaskLegend
key={mask.getEsAggField().getMbFieldName()}
esAggField={mask.getEsAggField()}
operator={mask.getOperator()}
value={mask.getValue()}
/>
);
}
return (
<>
<EuiText size="xs" textAlign="left" color="subdued">
<small>{masksByFieldOrigin[0].getFieldOriginListLabel()}</small>
</EuiText>
<ul>
{masksByFieldOrigin.map((mask) => (
<li
key={mask.getEsAggField().getMbFieldName()}
style={{ marginLeft: euiThemeVars.euiSizeS }}
>
<MaskLegend
esAggField={mask.getEsAggField()}
onlyShowLabelAndValue={true}
operator={mask.getOperator()}
value={mask.getValue()}
/>
</li>
))}
</ul>
</>
);
}
return (
<>
{renderMasksByFieldOrigin(FIELD_ORIGIN.SOURCE)}
{renderMasksByFieldOrigin(FIELD_ORIGIN.JOIN)}
{legendRows}
</>
);
}

View file

@ -51,7 +51,7 @@ export class DynamicColorProperty extends DynamicStyleProperty<ColorDynamicOptio
this._chartsPaletteServiceGetColor = chartsPaletteServiceGetColor;
}
syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: unknown) {
const color = this._getMbColor();
mbMap.setPaintProperty(mbLayerId, 'circle-color', color);
mbMap.setPaintProperty(mbLayerId, 'circle-opacity', alpha);
@ -67,25 +67,25 @@ export class DynamicColorProperty extends DynamicStyleProperty<ColorDynamicOptio
mbMap.setPaintProperty(mbLayerId, 'icon-halo-color', color);
}
syncCircleStrokeWithMb(pointLayerId: string, mbMap: MbMap, alpha: number) {
syncCircleStrokeWithMb(pointLayerId: string, mbMap: MbMap, alpha: unknown) {
const color = this._getMbColor();
mbMap.setPaintProperty(pointLayerId, 'circle-stroke-color', color);
mbMap.setPaintProperty(pointLayerId, 'circle-stroke-opacity', alpha);
}
syncFillColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
syncFillColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: unknown) {
const color = this._getMbColor();
mbMap.setPaintProperty(mbLayerId, 'fill-color', color);
mbMap.setPaintProperty(mbLayerId, 'fill-opacity', alpha);
}
syncLineColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
syncLineColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: unknown) {
const color = this._getMbColor();
mbMap.setPaintProperty(mbLayerId, 'line-color', color);
mbMap.setPaintProperty(mbLayerId, 'line-opacity', alpha);
}
syncLabelColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
syncLabelColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: unknown) {
const color = this._getMbColor();
mbMap.setPaintProperty(mbLayerId, 'text-color', color);
mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha);

View file

@ -10,12 +10,12 @@ import { StaticStyleProperty } from './static_style_property';
import { ColorStaticOptions } from '../../../../../common/descriptor_types';
export class StaticColorProperty extends StaticStyleProperty<ColorStaticOptions> {
syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: unknown) {
mbMap.setPaintProperty(mbLayerId, 'circle-color', this._options.color);
mbMap.setPaintProperty(mbLayerId, 'circle-opacity', alpha);
}
syncFillColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
syncFillColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: unknown) {
mbMap.setPaintProperty(mbLayerId, 'fill-color', this._options.color);
mbMap.setPaintProperty(mbLayerId, 'fill-opacity', alpha);
}
@ -28,17 +28,17 @@ export class StaticColorProperty extends StaticStyleProperty<ColorStaticOptions>
mbMap.setPaintProperty(mbLayerId, 'icon-halo-color', this._options.color);
}
syncLineColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
syncLineColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: unknown) {
mbMap.setPaintProperty(mbLayerId, 'line-color', this._options.color);
mbMap.setPaintProperty(mbLayerId, 'line-opacity', alpha);
}
syncCircleStrokeWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
syncCircleStrokeWithMb(mbLayerId: string, mbMap: MbMap, alpha: unknown) {
mbMap.setPaintProperty(mbLayerId, 'circle-stroke-color', this._options.color);
mbMap.setPaintProperty(mbLayerId, 'circle-stroke-opacity', alpha);
}
syncLabelColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) {
syncLabelColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: unknown) {
mbMap.setPaintProperty(mbLayerId, 'text-color', this._options.color);
mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha);
}

View file

@ -130,7 +130,7 @@ export interface IVectorStyle extends IStyle {
fillLayerId,
lineLayerId,
}: {
alpha: number;
alpha: unknown;
mbMap: MbMap;
fillLayerId: string;
lineLayerId: string;
@ -140,7 +140,7 @@ export interface IVectorStyle extends IStyle {
mbMap,
pointLayerId,
}: {
alpha: number;
alpha: unknown;
mbMap: MbMap;
pointLayerId: string;
}) => void;
@ -149,7 +149,7 @@ export interface IVectorStyle extends IStyle {
mbMap,
textLayerId,
}: {
alpha: number;
alpha: unknown;
mbMap: MbMap;
textLayerId: string;
}) => void;
@ -158,7 +158,7 @@ export interface IVectorStyle extends IStyle {
symbolLayerId,
alpha,
}: {
alpha: number;
alpha: unknown;
mbMap: MbMap;
symbolLayerId: string;
}) => void;
@ -730,6 +730,7 @@ export class VectorStyle implements IVectorStyle {
return (
<VectorStyleLegend
masks={this._layer.getMasks()}
styles={this._getLegendDetailStyleProperties()}
isPointsOnly={this.getIsPointsOnly()}
isLinesOnly={this._getIsLinesOnly()}
@ -800,7 +801,7 @@ export class VectorStyle implements IVectorStyle {
fillLayerId,
lineLayerId,
}: {
alpha: number;
alpha: unknown;
mbMap: MbMap;
fillLayerId: string;
lineLayerId: string;
@ -815,7 +816,7 @@ export class VectorStyle implements IVectorStyle {
mbMap,
pointLayerId,
}: {
alpha: number;
alpha: unknown;
mbMap: MbMap;
pointLayerId: string;
}) {
@ -832,7 +833,7 @@ export class VectorStyle implements IVectorStyle {
mbMap,
textLayerId,
}: {
alpha: number;
alpha: unknown;
mbMap: MbMap;
textLayerId: string;
}) {
@ -850,7 +851,7 @@ export class VectorStyle implements IVectorStyle {
symbolLayerId,
alpha,
}: {
alpha: number;
alpha: unknown;
mbMap: MbMap;
symbolLayerId: string;
}) {

View file

@ -11,6 +11,7 @@ exports[`should add default count metric when metrics is empty array 1`] = `
>
<MetricEditor
fields={Array []}
isJoin={false}
metric={
Object {
"type": "count",
@ -54,6 +55,7 @@ exports[`should render metrics editor 1`] = `
>
<MetricEditor
fields={Array []}
isJoin={false}
metric={
Object {
"field": "myField",

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { MaskExpression } from './mask_expression';

View file

@ -0,0 +1,183 @@
/*
* 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, { ChangeEvent, Component } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiButtonEmpty,
EuiFieldNumber,
EuiFlexItem,
EuiFlexGroup,
EuiFormRow,
EuiPopoverFooter,
EuiSelect,
EuiSpacer,
} from '@elastic/eui';
import { MASK_OPERATOR } from '../../../../common/constants';
import { AggDescriptor } from '../../../../common/descriptor_types';
import { panelStrings } from '../../../connected_components/panel_strings';
import { ABOVE, BELOW } from '../../../classes/layers/vector_layer/mask';
const operatorOptions = [
{
value: MASK_OPERATOR.BELOW,
text: BELOW,
},
{
value: MASK_OPERATOR.ABOVE,
text: ABOVE,
},
];
interface Props {
metric: AggDescriptor;
onChange: (metric: AggDescriptor) => void;
onClose: () => void;
}
interface State {
operator: MASK_OPERATOR;
value: number | string;
}
export class MaskEditor extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
operator:
this.props.metric.mask !== undefined
? this.props.metric.mask.operator
: MASK_OPERATOR.BELOW,
value: this.props.metric.mask !== undefined ? this.props.metric.mask.value : '',
};
}
_onSet = () => {
if (this._isValueInValid()) {
return;
}
this.props.onChange({
...this.props.metric,
mask: {
operator: this.state.operator,
value: this.state.value as number,
},
});
this.props.onClose();
};
_onClear = () => {
const newMetric = {
...this.props.metric,
};
delete newMetric.mask;
this.props.onChange(newMetric);
this.props.onClose();
};
_onOperatorChange = (e: ChangeEvent<HTMLSelectElement>) => {
this.setState({
operator: e.target.value as MASK_OPERATOR,
});
};
_onValueChange = (evt: ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = parseFloat(evt.target.value);
this.setState({
value: isNaN(sanitizedValue) ? evt.target.value : sanitizedValue,
});
};
_hasChanges() {
return (
this.props.metric.mask === undefined ||
this.props.metric.mask.operator !== this.state.operator ||
this.props.metric.mask.value !== this.state.value
);
}
_isValueInValid() {
return typeof this.state.value === 'string';
}
_renderForm() {
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFormRow>
<EuiSelect
id="maskOperatorSelect"
options={operatorOptions}
value={this.state.operator}
onChange={this._onOperatorChange}
aria-label={i18n.translate('xpack.maps.maskEditor.operatorSelectLabel', {
defaultMessage: 'Mask operator select',
})}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow>
<EuiFieldNumber
value={this.state.value}
onChange={this._onValueChange}
aria-label={i18n.translate('xpack.maps.maskEditor.valueInputLabel', {
defaultMessage: 'Mask value input',
})}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
);
}
_renderFooter() {
return (
<EuiPopoverFooter paddingSize="s">
<EuiFlexGroup>
<EuiFlexItem>
<EuiButtonEmpty onClick={this.props.onClose} size="s">
{panelStrings.close}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty
color="danger"
isDisabled={this.props.metric.mask === undefined}
onClick={this._onClear}
size="s"
>
{panelStrings.clear}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
fill
isDisabled={this._isValueInValid() || !this._hasChanges()}
onClick={this._onSet}
size="s"
>
{panelStrings.apply}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverFooter>
);
}
render() {
return (
<>
{this._renderForm()}
<EuiSpacer size="xs" />
{this._renderFooter()}
</>
);
}
}

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Component } from 'react';
import { EuiExpression, EuiPopover } from '@elastic/eui';
import { DataViewField } from '@kbn/data-views-plugin/public';
import { AGG_TYPE } from '../../../../common/constants';
import { AggDescriptor, FieldedAggDescriptor } from '../../../../common/descriptor_types';
import { MaskEditor } from './mask_editor';
import { getAggDisplayName } from '../../../classes/sources/es_agg_source';
import {
getMaskI18nDescription,
getMaskI18nValue,
} from '../../../classes/layers/vector_layer/mask';
interface Props {
fields: DataViewField[];
isJoin: boolean;
metric: AggDescriptor;
onChange: (metric: AggDescriptor) => void;
}
interface State {
isPopoverOpen: boolean;
}
export class MaskExpression extends Component<Props, State> {
state: State = {
isPopoverOpen: false,
};
_togglePopover = () => {
this.setState((prevState) => ({
isPopoverOpen: !prevState.isPopoverOpen,
}));
};
_closePopover = () => {
this.setState({
isPopoverOpen: false,
});
};
_getMaskExpressionValue() {
return this.props.metric.mask === undefined
? '...'
: getMaskI18nValue(this.props.metric.mask.operator, this.props.metric.mask.value);
}
_getAggLabel() {
const aggDisplayName = getAggDisplayName(this.props.metric.type);
if (this.props.metric.type === AGG_TYPE.COUNT || this.props.metric.field === undefined) {
return aggDisplayName;
}
const targetField = this.props.fields.find(
(field) => field.name === (this.props.metric as FieldedAggDescriptor).field
);
const fieldDisplayName = targetField?.displayName
? targetField?.displayName
: this.props.metric.field;
return `${aggDisplayName} ${fieldDisplayName}`;
}
render() {
// masks only supported for numerical metrics
if (this.props.metric.type === AGG_TYPE.TERMS) {
return null;
}
return (
<EuiPopover
id="mask"
button={
<EuiExpression
color="subdued"
description={getMaskI18nDescription({
aggLabel: this._getAggLabel(),
isJoin: this.props.isJoin,
})}
value={this._getMaskExpressionValue()}
onClick={this._togglePopover}
uppercase={false}
/>
}
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
panelPaddingSize="s"
anchorPosition="downCenter"
repositionOnScroll={true}
>
<MaskEditor
metric={this.props.metric}
onChange={this.props.onChange}
onClose={this._closePopover}
/>
</EuiPopover>
);
}
}

View file

@ -18,6 +18,8 @@ import { AggDescriptor } from '../../../common/descriptor_types';
import { AGG_TYPE, DEFAULT_PERCENTILE } from '../../../common/constants';
import { getTermsFields } from '../../index_pattern_util';
import { ValidatedNumberInput } from '../validated_number_input';
import { getMaskI18nLabel } from '../../classes/layers/vector_layer/mask';
import { MaskExpression } from './mask_expression';
function filterFieldsForAgg(fields: DataViewField[], aggType: AGG_TYPE) {
if (!fields) {
@ -43,6 +45,8 @@ function filterFieldsForAgg(fields: DataViewField[], aggType: AGG_TYPE) {
}
interface Props {
bucketsName?: string;
isJoin: boolean;
metric: AggDescriptor;
fields: DataViewField[];
onChange: (metric: AggDescriptor) => void;
@ -52,7 +56,9 @@ interface Props {
}
export function MetricEditor({
bucketsName,
fields,
isJoin,
metricsFilter,
metric,
onChange,
@ -64,6 +70,8 @@ export function MetricEditor({
return;
}
// Intentionally not adding mask.
// Changing aggregation likely changes value range so keeping old mask does not seem relevent
const descriptor = {
type: metricAggregationType,
label: metric.label,
@ -93,6 +101,8 @@ export function MetricEditor({
if (!fieldName || metric.type === AGG_TYPE.COUNT) {
return;
}
// Intentionally not adding mask.
// Changing field likely changes value range so keeping old mask does not seem relevent
onChange({
label: metric.label,
type: metric.type,
@ -223,6 +233,11 @@ export function MetricEditor({
{fieldSelect}
{percentileSelect}
{labelInput}
<EuiFormRow label={getMaskI18nLabel({ bucketsName, isJoin })} display="columnCompressed">
<MaskExpression fields={fields} isJoin={isJoin} metric={metric} onChange={onChange} />
</EuiFormRow>
{removeButton}
</Fragment>
);

View file

@ -9,54 +9,39 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui';
import { AGG_TYPE } from '../../../common/constants';
import { getAggDisplayName } from '../../classes/sources/es_agg_source';
const AGG_OPTIONS = [
{
label: i18n.translate('xpack.maps.metricSelect.averageDropDownOptionLabel', {
defaultMessage: 'Average',
}),
label: getAggDisplayName(AGG_TYPE.AVG),
value: AGG_TYPE.AVG,
},
{
label: i18n.translate('xpack.maps.metricSelect.countDropDownOptionLabel', {
defaultMessage: 'Count',
}),
label: getAggDisplayName(AGG_TYPE.COUNT),
value: AGG_TYPE.COUNT,
},
{
label: i18n.translate('xpack.maps.metricSelect.maxDropDownOptionLabel', {
defaultMessage: 'Max',
}),
label: getAggDisplayName(AGG_TYPE.MAX),
value: AGG_TYPE.MAX,
},
{
label: i18n.translate('xpack.maps.metricSelect.minDropDownOptionLabel', {
defaultMessage: 'Min',
}),
label: getAggDisplayName(AGG_TYPE.MIN),
value: AGG_TYPE.MIN,
},
{
label: i18n.translate('xpack.maps.metricSelect.percentileDropDownOptionLabel', {
defaultMessage: 'Percentile',
}),
label: getAggDisplayName(AGG_TYPE.PERCENTILE),
value: AGG_TYPE.PERCENTILE,
},
{
label: i18n.translate('xpack.maps.metricSelect.sumDropDownOptionLabel', {
defaultMessage: 'Sum',
}),
label: getAggDisplayName(AGG_TYPE.SUM),
value: AGG_TYPE.SUM,
},
{
label: i18n.translate('xpack.maps.metricSelect.termsDropDownOptionLabel', {
defaultMessage: 'Top term',
}),
label: getAggDisplayName(AGG_TYPE.TERMS),
value: AGG_TYPE.TERMS,
},
{
label: i18n.translate('xpack.maps.metricSelect.cardinalityDropDownOptionLabel', {
defaultMessage: 'Unique count',
}),
label: getAggDisplayName(AGG_TYPE.UNIQUE_COUNT),
value: AGG_TYPE.UNIQUE_COUNT,
},
];

View file

@ -20,6 +20,7 @@ const defaultProps = {
fields: [],
onChange: () => {},
allowMultipleMetrics: true,
isJoin: false,
};
test('should render metrics editor', () => {

View file

@ -22,6 +22,8 @@ export function isMetricValid(aggDescriptor: AggDescriptor) {
interface Props {
allowMultipleMetrics: boolean;
bucketsName?: string;
isJoin: boolean;
metrics: AggDescriptor[];
fields: DataViewField[];
onChange: (metrics: AggDescriptor[]) => void;
@ -81,6 +83,8 @@ export class MetricsEditor extends Component<Props, State> {
return (
<div key={index} className="mapMetricEditorPanel__metricEditor">
<MetricEditor
bucketsName={this.props.bucketsName}
isJoin={this.props.isJoin}
onChange={onMetricChange}
metric={metric}
fields={this.props.fields}

View file

@ -46,6 +46,7 @@ exports[`Should render default props 1`] = `
<MetricsEditor
allowMultipleMetrics={true}
fields={Array []}
isJoin={true}
metrics={
Array [
Object {
@ -105,6 +106,7 @@ exports[`Should render metrics expression for metrics 1`] = `
<MetricsEditor
allowMultipleMetrics={true}
fields={Array []}
isJoin={true}
metrics={
Array [
Object {

View file

@ -66,6 +66,7 @@ export class MetricsExpression extends Component<Props, State> {
metrics={this.props.metrics}
onChange={this.props.onChange}
allowMultipleMetrics={true}
isJoin={true}
/>
);
};

View file

@ -76,11 +76,7 @@ exports[`Should render edit form row when attribution not provided 1`] = `
onClick={[Function]}
size="xs"
>
<FormattedMessage
defaultMessage="Clear"
id="xpack.maps.attribution.clearBtnLabel"
values={Object {}}
/>
Clear
</EuiButtonEmpty>
</div>
</div>

View file

@ -8,10 +8,10 @@
import React from 'react';
import { EuiButtonEmpty, EuiLink, EuiPanel } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { Attribution } from '../../../../common/descriptor_types';
import { ILayer } from '../../../classes/layers/layer';
import { AttributionPopover } from './attribution_popover';
import { panelStrings } from '../../panel_strings';
interface Props {
layer: ILayer;
@ -65,9 +65,7 @@ export function AttributionFormRow(props: Props) {
defaultMessage: 'Edit attribution',
}
)}
popoverButtonLabel={i18n.translate('xpack.maps.attribution.editBtnLabel', {
defaultMessage: 'Edit',
})}
popoverButtonLabel={panelStrings.edit}
label={layerDescriptor.attribution.label}
url={layerDescriptor.attribution.url}
/>
@ -83,10 +81,7 @@ export function AttributionFormRow(props: Props) {
defaultMessage: 'Clear attribution',
})}
>
<FormattedMessage
id="xpack.maps.attribution.clearBtnLabel"
defaultMessage="Clear"
/>
{panelStrings.clear}
</EuiButtonEmpty>
</div>
</div>

View file

@ -20,6 +20,7 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { Attribution } from '../../../../common/descriptor_types';
import { panelStrings } from '../../panel_strings';
interface Props {
onChange: (attribution: Attribution) => void;
@ -128,7 +129,7 @@ export class AttributionPopover extends Component<Props, State> {
onClick={this._onApply}
size="s"
>
<FormattedMessage id="xpack.maps.attribution.applyBtnLabel" defaultMessage="Apply" />
{panelStrings.apply}
</EuiButton>
</EuiTextAlign>
</EuiPopoverFooter>

View file

@ -35,7 +35,7 @@ export function StyleSettings({ layer, updateStyleDescriptor, updateCustomIcons
<h5>
<FormattedMessage
id="xpack.maps.layerPanel.styleSettingsTitle"
defaultMessage="Layer Style"
defaultMessage="Layer style"
/>
</h5>
</EuiTitle>

View file

@ -70,6 +70,9 @@ const mockLayer = {
},
} as unknown as IVectorSource;
},
getMasks: () => {
return [];
},
} as unknown as IVectorLayer;
const mockMbMapHandlers: { [key: string]: (event?: MapMouseEvent) => void } = {};

View file

@ -184,6 +184,15 @@ export class TooltipControl extends Component<Props, {}> {
continue;
}
// masking must use paint property "opacity" to hide features in order to support feature state
// therefore, there is no way to remove masked features with queryRenderedFeatures
// masked features must be removed via manual filtering
const masks = layer.getMasks();
const maskHiddingFeature = masks.find((mask) => mask.isFeatureMasked(mbFeature));
if (maskHiddingFeature) {
continue;
}
const featureId = layer.getFeatureId(mbFeature);
if (featureId === undefined) {
continue;

View file

@ -8,12 +8,21 @@
import { i18n } from '@kbn/i18n';
export const panelStrings = {
apply: i18n.translate('xpack.maps.panel.applyLabel', {
defaultMessage: 'Apply',
}),
clear: i18n.translate('xpack.maps.panel.clearLabel', {
defaultMessage: 'Clear',
}),
close: i18n.translate('xpack.maps.panel.closeLabel', {
defaultMessage: 'Close',
}),
discardChanges: i18n.translate('xpack.maps.panel.discardChangesLabel', {
defaultMessage: 'Discard changes',
}),
edit: i18n.translate('xpack.maps.panel.editLabel', {
defaultMessage: 'Edit',
}),
keepChanges: i18n.translate('xpack.maps.panel.keepChangesLabel', {
defaultMessage: 'Keep changes',
}),

View file

@ -20315,15 +20315,11 @@
"xpack.maps.addLayerPanel.addLayer": "Ajouter un calque",
"xpack.maps.addLayerPanel.changeDataSourceButtonLabel": "Changer de calque",
"xpack.maps.addLayerPanel.footer.cancelButtonLabel": "Annuler",
"xpack.maps.aggs.defaultCountLabel": "compte",
"xpack.maps.attribution.addBtnAriaLabel": "Ajouter une attribution",
"xpack.maps.attribution.addBtnLabel": "Ajouter une attribution",
"xpack.maps.attribution.applyBtnLabel": "Appliquer",
"xpack.maps.attribution.attributionFormLabel": "Attribution",
"xpack.maps.attribution.clearBtnAriaLabel": "Effacer l'attribution",
"xpack.maps.attribution.clearBtnLabel": "Effacer",
"xpack.maps.attribution.editBtnAriaLabel": "Modifier l'attribution",
"xpack.maps.attribution.editBtnLabel": "Modifier",
"xpack.maps.attribution.labelFieldLabel": "Étiquette",
"xpack.maps.attribution.urlLabel": "Lien",
"xpack.maps.badge.readOnly.text": "Lecture seule",
@ -20595,15 +20591,7 @@
"xpack.maps.metricsEditor.selectFieldLabel": "Champ",
"xpack.maps.metricsEditor.selectFieldPlaceholder": "Sélectionner un champ",
"xpack.maps.metricsEditor.selectPercentileLabel": "Centile",
"xpack.maps.metricSelect.averageDropDownOptionLabel": "Moyenne",
"xpack.maps.metricSelect.cardinalityDropDownOptionLabel": "Compte unique",
"xpack.maps.metricSelect.countDropDownOptionLabel": "Décompte",
"xpack.maps.metricSelect.maxDropDownOptionLabel": "Max.",
"xpack.maps.metricSelect.minDropDownOptionLabel": "Min.",
"xpack.maps.metricSelect.percentileDropDownOptionLabel": "Centile",
"xpack.maps.metricSelect.selectAggregationPlaceholder": "Sélectionner une agrégation",
"xpack.maps.metricSelect.sumDropDownOptionLabel": "Somme",
"xpack.maps.metricSelect.termsDropDownOptionLabel": "Premier terme",
"xpack.maps.mvtSource.addFieldLabel": "Ajouter",
"xpack.maps.mvtSource.fieldPlaceholderText": "Nom du champ",
"xpack.maps.mvtSource.numberFieldLabel": "numéro",

View file

@ -20315,15 +20315,11 @@
"xpack.maps.addLayerPanel.addLayer": "レイヤーを追加",
"xpack.maps.addLayerPanel.changeDataSourceButtonLabel": "レイヤーを変更",
"xpack.maps.addLayerPanel.footer.cancelButtonLabel": "キャンセル",
"xpack.maps.aggs.defaultCountLabel": "カウント",
"xpack.maps.attribution.addBtnAriaLabel": "属性を追加",
"xpack.maps.attribution.addBtnLabel": "属性を追加",
"xpack.maps.attribution.applyBtnLabel": "適用",
"xpack.maps.attribution.attributionFormLabel": "属性",
"xpack.maps.attribution.clearBtnAriaLabel": "属性を消去",
"xpack.maps.attribution.clearBtnLabel": "クリア",
"xpack.maps.attribution.editBtnAriaLabel": "属性を編集",
"xpack.maps.attribution.editBtnLabel": "編集",
"xpack.maps.attribution.labelFieldLabel": "ラベル",
"xpack.maps.attribution.urlLabel": "リンク",
"xpack.maps.badge.readOnly.text": "読み取り専用",
@ -20595,15 +20591,7 @@
"xpack.maps.metricsEditor.selectFieldLabel": "フィールド",
"xpack.maps.metricsEditor.selectFieldPlaceholder": "フィールドを選択",
"xpack.maps.metricsEditor.selectPercentileLabel": "パーセンタイル",
"xpack.maps.metricSelect.averageDropDownOptionLabel": "平均",
"xpack.maps.metricSelect.cardinalityDropDownOptionLabel": "ユニークカウント",
"xpack.maps.metricSelect.countDropDownOptionLabel": "カウント",
"xpack.maps.metricSelect.maxDropDownOptionLabel": "最高",
"xpack.maps.metricSelect.minDropDownOptionLabel": "最低",
"xpack.maps.metricSelect.percentileDropDownOptionLabel": "パーセンタイル",
"xpack.maps.metricSelect.selectAggregationPlaceholder": "集約を選択",
"xpack.maps.metricSelect.sumDropDownOptionLabel": "合計",
"xpack.maps.metricSelect.termsDropDownOptionLabel": "トップ用語",
"xpack.maps.mvtSource.addFieldLabel": "追加",
"xpack.maps.mvtSource.fieldPlaceholderText": "フィールド名",
"xpack.maps.mvtSource.numberFieldLabel": "数字",
@ -23391,7 +23379,7 @@
"xpack.ml.ruleEditor.scopeSection.noPermissionToViewFilterListsTitle": "フィルターリストを表示するパーミッションがありません",
"xpack.ml.ruleEditor.scopeSection.scopeTitle": "範囲",
"xpack.ml.ruleEditor.selectRuleAction.createRuleLinkText": "ルールを作成",
"xpack.ml.ruleEditor.selectRuleAction.orText": "OR ",
"xpack.ml.ruleEditor.selectRuleAction.orText": "OR ",
"xpack.ml.ruleEditor.typicalAppliesTypeText": "通常",
"xpack.ml.sampleDataLinkLabel": "ML ジョブ",
"xpack.ml.selectDataViewLabel": "データビューを選択",

View file

@ -20315,15 +20315,11 @@
"xpack.maps.addLayerPanel.addLayer": "添加图层",
"xpack.maps.addLayerPanel.changeDataSourceButtonLabel": "更改图层",
"xpack.maps.addLayerPanel.footer.cancelButtonLabel": "取消",
"xpack.maps.aggs.defaultCountLabel": "计数",
"xpack.maps.attribution.addBtnAriaLabel": "添加归因",
"xpack.maps.attribution.addBtnLabel": "添加归因",
"xpack.maps.attribution.applyBtnLabel": "应用",
"xpack.maps.attribution.attributionFormLabel": "归因",
"xpack.maps.attribution.clearBtnAriaLabel": "清除归因",
"xpack.maps.attribution.clearBtnLabel": "清除",
"xpack.maps.attribution.editBtnAriaLabel": "编辑归因",
"xpack.maps.attribution.editBtnLabel": "编辑",
"xpack.maps.attribution.labelFieldLabel": "标签",
"xpack.maps.attribution.urlLabel": "链接",
"xpack.maps.badge.readOnly.text": "只读",
@ -20595,15 +20591,7 @@
"xpack.maps.metricsEditor.selectFieldLabel": "字段",
"xpack.maps.metricsEditor.selectFieldPlaceholder": "选择字段",
"xpack.maps.metricsEditor.selectPercentileLabel": "百分位数",
"xpack.maps.metricSelect.averageDropDownOptionLabel": "平均值",
"xpack.maps.metricSelect.cardinalityDropDownOptionLabel": "唯一计数",
"xpack.maps.metricSelect.countDropDownOptionLabel": "计数",
"xpack.maps.metricSelect.maxDropDownOptionLabel": "最大值",
"xpack.maps.metricSelect.minDropDownOptionLabel": "最小值",
"xpack.maps.metricSelect.percentileDropDownOptionLabel": "百分位数",
"xpack.maps.metricSelect.selectAggregationPlaceholder": "选择聚合",
"xpack.maps.metricSelect.sumDropDownOptionLabel": "求和",
"xpack.maps.metricSelect.termsDropDownOptionLabel": "热门词",
"xpack.maps.mvtSource.addFieldLabel": "添加",
"xpack.maps.mvtSource.fieldPlaceholderText": "字段名称",
"xpack.maps.mvtSource.numberFieldLabel": "数字",
@ -23403,7 +23391,7 @@
"xpack.ml.ruleEditor.scopeSection.noPermissionToViewFilterListsTitle": "您无权查看筛选列表",
"xpack.ml.ruleEditor.scopeSection.scopeTitle": "范围",
"xpack.ml.ruleEditor.selectRuleAction.createRuleLinkText": "创建规则",
"xpack.ml.ruleEditor.selectRuleAction.orText": "或 ",
"xpack.ml.ruleEditor.selectRuleAction.orText": "或 ",
"xpack.ml.ruleEditor.typicalAppliesTypeText": "典型",
"xpack.ml.sampleDataLinkLabel": "ML 作业",
"xpack.ml.selectDataViewLabel": "选择数据视图",