[XY] xyVis and layeredXyVis. (#128255)

* Added extended layers expressions.

* Added support of tables at layers.

* Added annotations to layeredXyVIs.

* Refactored the implementation to be reusable.

* Fixed undefined layers.

* Fixed empty arrays problems.

* Fixed input translations and removed not used arguments.

* Fixed missing required args error, and added required to arguments.

* Simplified expression configuration.

* Added strict to all the expressions.

* Moved dataLayer to the separate component.

* Refactored dataLayers helpers and xy_chart.

* fillOpacity usage validation is added.

* Fixed valueLabels argument options. Removed not used. Added validation for usage.

* Added validation to the layeredXyVis.

* Fixed extent validation.

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Marta Bondyra <marta.bondyra@gmail.com>
Co-authored-by: Marta Bondyra <marta.bondyra@elastic.co>
This commit is contained in:
Yaroslav Kuznietsov 2022-05-03 17:29:01 +03:00 committed by GitHub
parent c1d44151e2
commit b29b468961
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
118 changed files with 7394 additions and 3800 deletions

View file

@ -124,7 +124,7 @@ pageLoadAssetSize:
visTypeGauge: 24113
unifiedSearch: 71059
data: 454087
expressionXY: 26500
eventAnnotation: 19334
screenshotting: 22870
synthetics: 40958
expressionXY: 29000

View file

@ -10,7 +10,7 @@ import { Position } from '@elastic/charts';
import type { PaletteOutput } from '@kbn/coloring';
import { Datatable, DatatableRow } from '@kbn/expressions-plugin';
import { LayerTypes } from '../constants';
import { DataLayerConfigResult, LensMultiTable, XYArgs } from '../types';
import { DataLayerConfig, XYProps } from '../types';
export const mockPaletteOutput: PaletteOutput = {
type: 'palette',
@ -46,9 +46,9 @@ export const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable =
rows,
});
export const sampleLayer: DataLayerConfigResult = {
type: 'dataLayer',
export const sampleLayer: DataLayerConfig = {
layerId: 'first',
type: 'dataLayer',
layerType: LayerTypes.DATA,
seriesType: 'line',
xAccessor: 'c',
@ -59,9 +59,12 @@ export const sampleLayer: DataLayerConfigResult = {
yScaleType: 'linear',
isHistogram: false,
palette: mockPaletteOutput,
table: createSampleDatatableWithRows([]),
};
export const createArgsWithLayers = (layers: DataLayerConfigResult[] = [sampleLayer]): XYArgs => ({
export const createArgsWithLayers = (
layers: DataLayerConfig | DataLayerConfig[] = sampleLayer
): XYProps => ({
xTitle: '',
yTitle: '',
yRightTitle: '',
@ -104,25 +107,17 @@ export const createArgsWithLayers = (layers: DataLayerConfigResult[] = [sampleLa
mode: 'full',
type: 'axisExtentConfig',
},
layers,
layers: Array.isArray(layers) ? layers : [layers],
});
export function sampleArgs() {
const data: LensMultiTable = {
type: 'lens_multitable',
tables: {
first: createSampleDatatableWithRows([
{ a: 1, b: 2, c: 'I', d: 'Foo' },
{ a: 1, b: 5, c: 'J', d: 'Bar' },
]),
},
dateRange: {
fromDate: new Date('2019-01-02T05:00:00.000Z'),
toDate: new Date('2019-01-03T05:00:00.000Z'),
},
const data = createSampleDatatableWithRows([
{ a: 1, b: 2, c: 'I', d: 'Foo' },
{ a: 1, b: 5, c: 'J', d: 'Bar' },
]);
return {
data,
args: createArgsWithLayers({ ...sampleLayer, table: data }),
};
const args: XYArgs = createArgsWithLayers();
return { data, args };
}

View file

@ -7,16 +7,21 @@
*/
export const XY_VIS = 'xyVis';
export const LAYERED_XY_VIS = 'layeredXyVis';
export const Y_CONFIG = 'yConfig';
export const EXTENDED_Y_CONFIG = 'extendedYConfig';
export const MULTITABLE = 'lens_multitable';
export const DATA_LAYER = 'dataLayer';
export const EXTENDED_DATA_LAYER = 'extendedDataLayer';
export const LEGEND_CONFIG = 'legendConfig';
export const XY_VIS_RENDERER = 'xyVis';
export const GRID_LINES_CONFIG = 'gridlinesConfig';
export const ANNOTATION_LAYER = 'annotationLayer';
export const EXTENDED_ANNOTATION_LAYER = 'extendedAnnotationLayer';
export const TICK_LABELS_CONFIG = 'tickLabelsConfig';
export const AXIS_EXTENT_CONFIG = 'axisExtentConfig';
export const REFERENCE_LINE_LAYER = 'referenceLineLayer';
export const EXTENDED_REFERENCE_LINE_LAYER = 'extendedReferenceLineLayer';
export const LABELS_ORIENTATION_CONFIG = 'labelsOrientationConfig';
export const AXIS_TITLES_VISIBILITY_CONFIG = 'axisTitlesVisibilityConfig';
@ -106,6 +111,23 @@ export const XYCurveTypes = {
export const ValueLabelModes = {
HIDE: 'hide',
INSIDE: 'inside',
OUTSIDE: 'outside',
SHOW: 'show',
} as const;
export const AvailableReferenceLineIcons = {
EMPTY: 'empty',
ASTERISK: 'asterisk',
ALERT: 'alert',
BELL: 'bell',
BOLT: 'bolt',
BUG: 'bug',
CIRCLE: 'circle',
EDITOR_COMMENT: 'editorComment',
FLAG: 'flag',
HEART: 'heart',
MAP_MARKER: 'mapMarker',
PIN_FILLED: 'pinFilled',
STAR_EMPTY: 'starEmpty',
TAG: 'tag',
TRIANGLE: 'triangle',
} as const;

View file

@ -6,13 +6,14 @@
* Side Public License, v 1.
*/
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import type { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { LayerTypes, ANNOTATION_LAYER } from '../constants';
import { AnnotationLayerArgs, AnnotationLayerConfigResult } from '../types';
import { strings } from '../i18n';
export function annotationLayerConfigFunction(): ExpressionFunctionDefinition<
export function annotationLayerFunction(): ExpressionFunctionDefinition<
typeof ANNOTATION_LAYER,
null,
Datatable,
AnnotationLayerArgs,
AnnotationLayerConfigResult
> {
@ -20,21 +21,17 @@ export function annotationLayerConfigFunction(): ExpressionFunctionDefinition<
name: ANNOTATION_LAYER,
aliases: [],
type: ANNOTATION_LAYER,
inputTypes: ['null'],
help: 'Annotation layer in lens',
inputTypes: ['datatable'],
help: strings.getAnnotationLayerFnHelp(),
args: {
layerId: {
types: ['string'],
help: '',
},
hide: {
types: ['boolean'],
default: false,
help: 'Show details',
help: strings.getAnnotationLayerHideHelp(),
},
annotations: {
types: ['manual_point_event_annotation', 'manual_range_event_annotation'],
help: '',
help: strings.getAnnotationLayerAnnotationsHelp(),
multi: true,
},
},
@ -42,6 +39,7 @@ export function annotationLayerConfigFunction(): ExpressionFunctionDefinition<
return {
type: ANNOTATION_LAYER,
...args,
annotations: args.annotations ?? [],
layerType: LayerTypes.ANNOTATIONS,
};
},

View file

@ -11,6 +11,13 @@ import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/commo
import { AxisExtentConfig, AxisExtentConfigResult } from '../types';
import { AxisExtentModes, AXIS_EXTENT_CONFIG } from '../constants';
const errors = {
upperBoundLowerOrEqualToLowerBoundError: () =>
i18n.translate('expressionXY.reusable.function.axisExtentConfig.errors.emptyUpperBound', {
defaultMessage: 'Upper bound should be greater than lower bound, if custom mode is enabled.',
}),
};
export const axisExtentConfigFunction: ExpressionFunctionDefinition<
typeof AXIS_EXTENT_CONFIG,
null,
@ -27,10 +34,12 @@ export const axisExtentConfigFunction: ExpressionFunctionDefinition<
args: {
mode: {
types: ['string'],
options: [...Object.values(AxisExtentModes)],
help: i18n.translate('expressionXY.axisExtentConfig.extentMode.help', {
defaultMessage: 'The extent mode',
}),
options: [...Object.values(AxisExtentModes)],
strict: true,
default: AxisExtentModes.FULL,
},
lowerBound: {
types: ['number'],
@ -46,6 +55,16 @@ export const axisExtentConfigFunction: ExpressionFunctionDefinition<
},
},
fn(input, args) {
if (args.mode === AxisExtentModes.CUSTOM) {
if (
args.lowerBound !== undefined &&
args.upperBound !== undefined &&
args.lowerBound >= args.upperBound
) {
throw new Error(errors.upperBoundLowerOrEqualToLowerBoundError());
}
}
return {
type: AXIS_EXTENT_CONFIG,
...args,

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { SeriesTypes, XScaleTypes, YScaleTypes, Y_CONFIG } from '../constants';
import { strings } from '../i18n';
import { DataLayerFn, ExtendedDataLayerFn } from '../types';
type CommonDataLayerFn = DataLayerFn | ExtendedDataLayerFn;
export const commonDataLayerArgs: CommonDataLayerFn['args'] = {
hide: {
types: ['boolean'],
default: false,
help: strings.getHideHelp(),
},
xAccessor: {
types: ['string'],
help: strings.getXAccessorHelp(),
},
seriesType: {
types: ['string'],
options: [...Object.values(SeriesTypes)],
help: strings.getSeriesTypeHelp(),
required: true,
strict: true,
},
xScaleType: {
options: [...Object.values(XScaleTypes)],
help: strings.getXScaleTypeHelp(),
default: XScaleTypes.ORDINAL,
strict: true,
},
isHistogram: {
types: ['boolean'],
default: false,
help: strings.getIsHistogramHelp(),
},
yScaleType: {
options: [...Object.values(YScaleTypes)],
help: strings.getYScaleTypeHelp(),
default: YScaleTypes.LINEAR,
strict: true,
},
splitAccessor: {
types: ['string'],
help: strings.getSplitAccessorHelp(),
},
accessors: {
types: ['string'],
help: strings.getAccessorsHelp(),
multi: true,
},
yConfig: {
types: [Y_CONFIG],
help: strings.getYConfigHelp(),
multi: true,
},
columnToLabel: {
types: ['string'],
help: strings.getColumnToLabelHelp(),
},
palette: {
types: ['palette', 'system_palette'],
help: strings.getPaletteHelp(),
default: '{palette}',
},
};

View file

@ -0,0 +1,30 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EXTENDED_Y_CONFIG } from '../constants';
import { strings } from '../i18n';
import { ReferenceLineLayerFn, ExtendedReferenceLineLayerFn } from '../types';
type CommonReferenceLineLayerFn = ReferenceLineLayerFn | ExtendedReferenceLineLayerFn;
export const commonReferenceLineLayerArgs: CommonReferenceLineLayerFn['args'] = {
accessors: {
types: ['string'],
help: strings.getRLAccessorsHelp(),
multi: true,
},
yConfig: {
types: [EXTENDED_Y_CONFIG],
help: strings.getRLYConfigHelp(),
multi: true,
},
columnToLabel: {
types: ['string'],
help: strings.getColumnToLabelHelp(),
},
};

View file

@ -0,0 +1,118 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
AXIS_EXTENT_CONFIG,
AXIS_TITLES_VISIBILITY_CONFIG,
EndValues,
FittingFunctions,
GRID_LINES_CONFIG,
LABELS_ORIENTATION_CONFIG,
LEGEND_CONFIG,
TICK_LABELS_CONFIG,
ValueLabelModes,
XYCurveTypes,
} from '../constants';
import { strings } from '../i18n';
import { LayeredXyVisFn, XyVisFn } from '../types';
type CommonXYFn = XyVisFn | LayeredXyVisFn;
export const commonXYArgs: CommonXYFn['args'] = {
xTitle: {
types: ['string'],
help: strings.getXTitleHelp(),
},
yTitle: {
types: ['string'],
help: strings.getYTitleHelp(),
},
yRightTitle: {
types: ['string'],
help: strings.getYRightTitleHelp(),
},
yLeftExtent: {
types: [AXIS_EXTENT_CONFIG],
help: strings.getYLeftExtentHelp(),
default: `{${AXIS_EXTENT_CONFIG}}`,
},
yRightExtent: {
types: [AXIS_EXTENT_CONFIG],
help: strings.getYRightExtentHelp(),
default: `{${AXIS_EXTENT_CONFIG}}`,
},
legend: {
types: [LEGEND_CONFIG],
help: strings.getLegendHelp(),
default: `{${LEGEND_CONFIG}}`,
},
fittingFunction: {
types: ['string'],
options: [...Object.values(FittingFunctions)],
help: strings.getFittingFunctionHelp(),
strict: true,
},
endValue: {
types: ['string'],
options: [...Object.values(EndValues)],
help: strings.getEndValueHelp(),
strict: true,
},
emphasizeFitting: {
types: ['boolean'],
default: false,
help: '',
},
valueLabels: {
types: ['string'],
options: [...Object.values(ValueLabelModes)],
help: strings.getValueLabelsHelp(),
strict: true,
default: ValueLabelModes.HIDE,
},
tickLabelsVisibilitySettings: {
types: [TICK_LABELS_CONFIG],
help: strings.getTickLabelsVisibilitySettingsHelp(),
},
labelsOrientation: {
types: [LABELS_ORIENTATION_CONFIG],
help: strings.getLabelsOrientationHelp(),
},
gridlinesVisibilitySettings: {
types: [GRID_LINES_CONFIG],
help: strings.getGridlinesVisibilitySettingsHelp(),
},
axisTitlesVisibilitySettings: {
types: [AXIS_TITLES_VISIBILITY_CONFIG],
help: strings.getAxisTitlesVisibilitySettingsHelp(),
},
curveType: {
types: ['string'],
options: [...Object.values(XYCurveTypes)],
help: strings.getCurveTypeHelp(),
strict: true,
},
fillOpacity: {
types: ['number'],
help: strings.getFillOpacityHelp(),
},
hideEndzones: {
types: ['boolean'],
default: false,
help: strings.getHideEndzonesHelp(),
},
valuesInLegend: {
types: ['boolean'],
default: false,
help: strings.getValuesInLegendHelp(),
},
ariaLabel: {
types: ['string'],
help: strings.getAriaLabelHelp(),
},
};

View file

@ -0,0 +1,30 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { YAxisModes } from '../constants';
import { strings } from '../i18n';
import { YConfigFn, ExtendedYConfigFn } from '../types';
type CommonYConfigFn = YConfigFn | ExtendedYConfigFn;
export const commonYConfigArgs: CommonYConfigFn['args'] = {
forAccessor: {
types: ['string'],
help: strings.getForAccessorHelp(),
},
axisMode: {
types: ['string'],
options: [...Object.values(YAxisModes)],
help: strings.getAxisModeHelp(),
strict: true,
},
color: {
types: ['string'],
help: strings.getColorHelp(),
},
};

View file

@ -7,15 +7,15 @@
*/
import { DataLayerArgs } from '../types';
import { dataLayerConfigFunction } from '.';
import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks';
import { mockPaletteOutput } from '../__mocks__';
import { mockPaletteOutput, sampleArgs } from '../__mocks__';
import { LayerTypes } from '../constants';
import { dataLayerFunction } from './data_layer';
describe('dataLayerConfig', () => {
test('produces the correct arguments', () => {
const { data } = sampleArgs();
const args: DataLayerArgs = {
layerId: 'first',
seriesType: 'line',
xAccessor: 'c',
accessors: ['a', 'b'],
@ -26,8 +26,13 @@ describe('dataLayerConfig', () => {
palette: mockPaletteOutput,
};
const result = dataLayerConfigFunction.fn(null, args, createMockExecutionContext());
const result = dataLayerFunction.fn(data, args, createMockExecutionContext());
expect(result).toEqual({ type: 'dataLayer', layerType: LayerTypes.DATA, ...args });
expect(result).toEqual({
type: 'dataLayer',
layerType: LayerTypes.DATA,
...args,
table: data,
});
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataLayerFn } from '../types';
import { DATA_LAYER, LayerTypes } from '../constants';
import { strings } from '../i18n';
import { commonDataLayerArgs } from './common_data_layer_args';
export const dataLayerFunction: DataLayerFn = {
name: DATA_LAYER,
aliases: [],
type: DATA_LAYER,
help: strings.getDataLayerFnHelp(),
inputTypes: ['datatable'],
args: { ...commonDataLayerArgs },
fn(table, args) {
return {
type: DATA_LAYER,
...args,
accessors: args.accessors ?? [],
layerType: LayerTypes.DATA,
table,
};
},
};

View file

@ -1,123 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { DataLayerArgs, DataLayerConfigResult } from '../types';
import {
DATA_LAYER,
LayerTypes,
SeriesTypes,
XScaleTypes,
YScaleTypes,
Y_CONFIG,
} from '../constants';
export const dataLayerConfigFunction: ExpressionFunctionDefinition<
typeof DATA_LAYER,
null,
DataLayerArgs,
DataLayerConfigResult
> = {
name: DATA_LAYER,
aliases: [],
type: DATA_LAYER,
help: i18n.translate('expressionXY.dataLayer.help', {
defaultMessage: `Configure a layer in the xy chart`,
}),
inputTypes: ['null'],
args: {
hide: {
types: ['boolean'],
default: false,
help: i18n.translate('expressionXY.dataLayer.hide.help', {
defaultMessage: 'Show / hide axis',
}),
},
layerId: {
types: ['string'],
help: i18n.translate('expressionXY.dataLayer.layerId.help', {
defaultMessage: 'Layer ID',
}),
},
xAccessor: {
types: ['string'],
help: i18n.translate('expressionXY.dataLayer.xAccessor.help', {
defaultMessage: 'X-axis',
}),
},
seriesType: {
types: ['string'],
options: [...Object.values(SeriesTypes)],
help: i18n.translate('expressionXY.dataLayer.seriesType.help', {
defaultMessage: 'The type of chart to display.',
}),
},
xScaleType: {
options: [...Object.values(XScaleTypes)],
help: i18n.translate('expressionXY.dataLayer.xScaleType.help', {
defaultMessage: 'The scale type of the x axis',
}),
default: XScaleTypes.ORDINAL,
},
isHistogram: {
types: ['boolean'],
default: false,
help: i18n.translate('expressionXY.dataLayer.isHistogram.help', {
defaultMessage: 'Whether to layout the chart as a histogram',
}),
},
yScaleType: {
options: [...Object.values(YScaleTypes)],
help: i18n.translate('expressionXY.dataLayer.yScaleType.help', {
defaultMessage: 'The scale type of the y axes',
}),
default: YScaleTypes.LINEAR,
},
splitAccessor: {
types: ['string'],
help: i18n.translate('expressionXY.dataLayer.splitAccessor.help', {
defaultMessage: 'The column to split by',
}),
},
accessors: {
types: ['string'],
help: i18n.translate('expressionXY.dataLayer.accessors.help', {
defaultMessage: 'The columns to display on the y axis.',
}),
multi: true,
},
yConfig: {
types: [Y_CONFIG],
help: i18n.translate('expressionXY.dataLayer.yConfig.help', {
defaultMessage: 'Additional configuration for y axes',
}),
multi: true,
},
columnToLabel: {
types: ['string'],
help: i18n.translate('expressionXY.dataLayer.columnToLabel.help', {
defaultMessage: 'JSON key-value pairs of column ID to label',
}),
},
palette: {
types: ['palette', 'system_palette'],
help: i18n.translate('expressionXY.dataLayer.palette.help', {
defaultMessage: 'Palette',
}),
default: '{palette}',
},
},
fn(input, args) {
return {
type: DATA_LAYER,
...args,
layerType: LayerTypes.DATA,
};
},
};

View file

@ -0,0 +1,51 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { LayerTypes, EXTENDED_ANNOTATION_LAYER } from '../constants';
import { ExtendedAnnotationLayerConfigResult, ExtendedAnnotationLayerArgs } from '../types';
import { strings } from '../i18n';
export function extendedAnnotationLayerFunction(): ExpressionFunctionDefinition<
typeof EXTENDED_ANNOTATION_LAYER,
Datatable,
ExtendedAnnotationLayerArgs,
ExtendedAnnotationLayerConfigResult
> {
return {
name: EXTENDED_ANNOTATION_LAYER,
aliases: [],
type: EXTENDED_ANNOTATION_LAYER,
inputTypes: ['datatable'],
help: strings.getAnnotationLayerFnHelp(),
args: {
hide: {
types: ['boolean'],
default: false,
help: strings.getAnnotationLayerHideHelp(),
},
annotations: {
types: ['manual_point_event_annotation', 'manual_range_event_annotation'],
help: strings.getAnnotationLayerAnnotationsHelp(),
multi: true,
},
layerId: {
types: ['string'],
help: strings.getLayerIdHelp(),
},
},
fn: (input, args) => {
return {
type: EXTENDED_ANNOTATION_LAYER,
...args,
annotations: args.annotations ?? [],
layerType: LayerTypes.ANNOTATIONS,
};
},
};
}

View file

@ -0,0 +1,40 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ExtendedDataLayerFn } from '../types';
import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants';
import { strings } from '../i18n';
import { commonDataLayerArgs } from './common_data_layer_args';
export const extendedDataLayerFunction: ExtendedDataLayerFn = {
name: EXTENDED_DATA_LAYER,
aliases: [],
type: EXTENDED_DATA_LAYER,
help: strings.getDataLayerFnHelp(),
inputTypes: ['datatable'],
args: {
...commonDataLayerArgs,
table: {
types: ['datatable'],
help: strings.getTableHelp(),
},
layerId: {
types: ['string'],
help: strings.getLayerIdHelp(),
},
},
fn(input, args) {
return {
type: EXTENDED_DATA_LAYER,
...args,
accessors: args.accessors ?? [],
layerType: LayerTypes.DATA,
table: args.table ?? input,
};
},
};

View file

@ -0,0 +1,40 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { LayerTypes, EXTENDED_REFERENCE_LINE_LAYER } from '../constants';
import { ExtendedReferenceLineLayerFn } from '../types';
import { strings } from '../i18n';
import { commonReferenceLineLayerArgs } from './common_reference_line_layer_args';
export const extendedReferenceLineLayerFunction: ExtendedReferenceLineLayerFn = {
name: EXTENDED_REFERENCE_LINE_LAYER,
aliases: [],
type: EXTENDED_REFERENCE_LINE_LAYER,
help: strings.getRLHelp(),
inputTypes: ['datatable'],
args: {
...commonReferenceLineLayerArgs,
table: {
types: ['datatable'],
help: strings.getTableHelp(),
},
layerId: {
types: ['string'],
help: strings.getLayerIdHelp(),
},
},
fn(input, args) {
return {
type: EXTENDED_REFERENCE_LINE_LAYER,
...args,
accessors: args.accessors ?? [],
layerType: LayerTypes.REFERENCELINE,
table: args.table ?? input,
};
},
};

View file

@ -0,0 +1,80 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import {
AvailableReferenceLineIcons,
EXTENDED_Y_CONFIG,
FillStyles,
IconPositions,
LineStyles,
} from '../constants';
import { strings } from '../i18n';
import { ExtendedYConfigFn } from '../types';
import { commonYConfigArgs } from './common_y_config_args';
export const extendedYAxisConfigFunction: ExtendedYConfigFn = {
name: EXTENDED_Y_CONFIG,
aliases: [],
type: EXTENDED_Y_CONFIG,
help: strings.getYConfigFnHelp(),
inputTypes: ['null'],
args: {
...commonYConfigArgs,
lineStyle: {
types: ['string'],
options: [...Object.values(LineStyles)],
help: i18n.translate('expressionXY.yConfig.lineStyle.help', {
defaultMessage: 'The style of the reference line',
}),
strict: true,
},
lineWidth: {
types: ['number'],
help: i18n.translate('expressionXY.yConfig.lineWidth.help', {
defaultMessage: 'The width of the reference line',
}),
},
icon: {
types: ['string'],
help: i18n.translate('expressionXY.yConfig.icon.help', {
defaultMessage: 'An optional icon used for reference lines',
}),
options: [...Object.values(AvailableReferenceLineIcons)],
strict: true,
},
iconPosition: {
types: ['string'],
options: [...Object.values(IconPositions)],
help: i18n.translate('expressionXY.yConfig.iconPosition.help', {
defaultMessage: 'The placement of the icon for the reference line',
}),
strict: true,
},
textVisibility: {
types: ['boolean'],
help: i18n.translate('expressionXY.yConfig.textVisibility.help', {
defaultMessage: 'Visibility of the label on the reference line',
}),
},
fill: {
types: ['string'],
options: [...Object.values(FillStyles)],
help: i18n.translate('expressionXY.yConfig.fill.help', {
defaultMessage: 'Fill',
}),
strict: true,
},
},
fn(input, args) {
return {
type: EXTENDED_Y_CONFIG,
...args,
};
},
};

View file

@ -7,13 +7,18 @@
*/
export * from './xy_vis';
export * from './layered_xy_vis';
export * from './legend_config';
export * from './annotation_layer_config';
export * from './annotation_layer';
export * from './extended_annotation_layer';
export * from './y_axis_config';
export * from './data_layer_config';
export * from './extended_y_axis_config';
export * from './data_layer';
export * from './extended_data_layer';
export * from './grid_lines_config';
export * from './axis_extent_config';
export * from './tick_labels_config';
export * from './labels_orientation_config';
export * from './reference_line_layer_config';
export * from './reference_line_layer';
export * from './extended_reference_line_layer';
export * from './axis_titles_visibility_config';

View file

@ -0,0 +1,39 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { LayeredXyVisFn } from '../types';
import {
EXTENDED_DATA_LAYER,
EXTENDED_REFERENCE_LINE_LAYER,
LAYERED_XY_VIS,
EXTENDED_ANNOTATION_LAYER,
} from '../constants';
import { commonXYArgs } from './common_xy_args';
import { strings } from '../i18n';
export const layeredXyVisFunction: LayeredXyVisFn = {
name: LAYERED_XY_VIS,
type: 'render',
inputTypes: ['datatable'],
help: strings.getXYHelp(),
args: {
...commonXYArgs,
layers: {
types: [EXTENDED_DATA_LAYER, EXTENDED_REFERENCE_LINE_LAYER, EXTENDED_ANNOTATION_LAYER],
help: i18n.translate('expressionXY.layeredXyVis.layers.help', {
defaultMessage: 'Layers of visual series',
}),
multi: true,
},
},
async fn(data, args, handlers) {
const { layeredXyVisFn } = await import('./layered_xy_vis_fn');
return await layeredXyVisFn(data, args, handlers);
},
};

View file

@ -0,0 +1,33 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { XY_VIS_RENDERER } from '../constants';
import { appendLayerIds } from '../helpers';
import { LayeredXyVisFn } from '../types';
import { logDatatables } from '../utils';
export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) => {
const layers = appendLayerIds(args.layers ?? [], 'layers');
logDatatables(layers, handlers);
return {
type: 'render',
as: XY_VIS_RENDERER,
value: {
args: {
...args,
layers,
ariaLabel:
args.ariaLabel ??
(handlers.variables?.embeddableTitle as string) ??
handlers.getExecutionContext?.()?.description,
},
},
};
};

View file

@ -12,9 +12,9 @@ import { LegendConfig } from '../types';
import { legendConfigFunction } from './legend_config';
describe('legendConfigFunction', () => {
test('produces the correct arguments', () => {
test('produces the correct arguments', async () => {
const args: LegendConfig = { isVisible: true, position: Position.Left };
const result = legendConfigFunction.fn(null, args, createMockExecutionContext());
const result = await legendConfigFunction.fn(null, args, createMockExecutionContext());
expect(result).toEqual({ type: 'legendConfig', ...args });
});

View file

@ -8,16 +8,10 @@
import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { LEGEND_CONFIG } from '../constants';
import { LegendConfig, LegendConfigResult } from '../types';
import { LegendConfigFn } from '../types';
export const legendConfigFunction: ExpressionFunctionDefinition<
typeof LEGEND_CONFIG,
null,
LegendConfig,
LegendConfigResult
> = {
export const legendConfigFunction: LegendConfigFn = {
name: LEGEND_CONFIG,
aliases: [],
type: LEGEND_CONFIG,
@ -31,6 +25,7 @@ export const legendConfigFunction: ExpressionFunctionDefinition<
help: i18n.translate('expressionXY.legendConfig.isVisible.help', {
defaultMessage: 'Specifies whether or not the legend is visible.',
}),
default: true,
},
position: {
types: ['string'],
@ -38,6 +33,7 @@ export const legendConfigFunction: ExpressionFunctionDefinition<
help: i18n.translate('expressionXY.legendConfig.position.help', {
defaultMessage: 'Specifies the legend position.',
}),
strict: true,
},
showSingleSeries: {
types: ['boolean'],
@ -58,6 +54,7 @@ export const legendConfigFunction: ExpressionFunctionDefinition<
defaultMessage:
'Specifies the horizontal alignment of the legend when it is displayed inside chart.',
}),
strict: true,
},
verticalAlignment: {
types: ['string'],
@ -66,6 +63,7 @@ export const legendConfigFunction: ExpressionFunctionDefinition<
defaultMessage:
'Specifies the vertical alignment of the legend when it is displayed inside chart.',
}),
strict: true,
},
floatingColumns: {
types: ['number'],
@ -93,10 +91,8 @@ export const legendConfigFunction: ExpressionFunctionDefinition<
}),
},
},
fn(input, args) {
return {
type: LEGEND_CONFIG,
...args,
};
async fn(input, args, handlers) {
const { legendConfigFn } = await import('./legend_config_fn');
return await legendConfigFn(input, args, handlers);
},
};

View file

@ -0,0 +1,68 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { LEGEND_CONFIG } from '../constants';
import { LegendConfigFn } from '../types';
const errors = {
positionUsageWithIsInsideError: () =>
i18n.translate(
'expressionXY.reusable.function.legendConfig.errors.positionUsageWithIsInsideError',
{
defaultMessage:
'`position` argument is not applied if `isInside = true`. Please, use `horizontalAlignment` and `verticalAlignment` arguments instead.',
}
),
alignmentUsageWithFalsyIsInsideError: () =>
i18n.translate(
'expressionXY.reusable.function.legendConfig.errors.alignmentUsageWithFalsyIsInsideError',
{
defaultMessage:
'`horizontalAlignment` and `verticalAlignment` arguments are not applied if `isInside = false`. Please, use the `position` argument instead.',
}
),
floatingColumnsWithFalsyIsInsideError: () =>
i18n.translate(
'expressionXY.reusable.function.legendConfig.errors.floatingColumnsWithFalsyIsInsideError',
{
defaultMessage: '`floatingColumns` arguments are not applied if `isInside = false`.',
}
),
legendSizeWithFalsyIsInsideError: () =>
i18n.translate(
'expressionXY.reusable.function.legendConfig.errors.legendSizeWithFalsyIsInsideError',
{
defaultMessage: '`legendSize` argument is not applied if `isInside = false`.',
}
),
};
export const legendConfigFn: LegendConfigFn['fn'] = async (data, args) => {
if (args.isInside) {
if (args.position) {
throw new Error(errors.positionUsageWithIsInsideError());
}
if (args.legendSize !== undefined) {
throw new Error(errors.legendSizeWithFalsyIsInsideError());
}
}
if (!args.isInside) {
if (args.verticalAlignment || args.horizontalAlignment) {
throw new Error(errors.alignmentUsageWithFalsyIsInsideError());
}
if (args.floatingColumns !== undefined) {
throw new Error(errors.floatingColumnsWithFalsyIsInsideError());
}
}
return { type: LEGEND_CONFIG, ...args };
};

View file

@ -0,0 +1,30 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { LayerTypes, REFERENCE_LINE_LAYER } from '../constants';
import { ReferenceLineLayerFn } from '../types';
import { strings } from '../i18n';
import { commonReferenceLineLayerArgs } from './common_reference_line_layer_args';
export const referenceLineLayerFunction: ReferenceLineLayerFn = {
name: REFERENCE_LINE_LAYER,
aliases: [],
type: REFERENCE_LINE_LAYER,
help: strings.getRLHelp(),
inputTypes: ['datatable'],
args: { ...commonReferenceLineLayerArgs },
fn(table, args) {
return {
type: REFERENCE_LINE_LAYER,
...args,
accessors: args.accessors ?? [],
layerType: LayerTypes.REFERENCELINE,
table,
};
},
};

View file

@ -1,62 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { LayerTypes, REFERENCE_LINE_LAYER, Y_CONFIG } from '../constants';
import { ReferenceLineLayerArgs, ReferenceLineLayerConfigResult } from '../types';
export const referenceLineLayerConfigFunction: ExpressionFunctionDefinition<
typeof REFERENCE_LINE_LAYER,
null,
ReferenceLineLayerArgs,
ReferenceLineLayerConfigResult
> = {
name: REFERENCE_LINE_LAYER,
aliases: [],
type: REFERENCE_LINE_LAYER,
help: i18n.translate('expressionXY.referenceLineLayer.help', {
defaultMessage: `Configure a reference line in the xy chart`,
}),
inputTypes: ['null'],
args: {
layerId: {
types: ['string'],
help: i18n.translate('expressionXY.referenceLineLayer.layerId.help', {
defaultMessage: `Layer ID`,
}),
},
accessors: {
types: ['string'],
help: i18n.translate('expressionXY.referenceLineLayer.accessors.help', {
defaultMessage: 'The columns to display on the y axis.',
}),
multi: true,
},
yConfig: {
types: [Y_CONFIG],
help: i18n.translate('expressionXY.referenceLineLayer.yConfig.help', {
defaultMessage: 'Additional configuration for y axes',
}),
multi: true,
},
columnToLabel: {
types: ['string'],
help: i18n.translate('expressionXY.referenceLineLayer.columnToLabel.help', {
defaultMessage: 'JSON key-value pairs of column ID to label',
}),
},
},
fn(input, args) {
return {
type: REFERENCE_LINE_LAYER,
...args,
layerType: LayerTypes.REFERENCELINE,
};
},
};

View file

@ -0,0 +1,103 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { AxisExtentModes, ValueLabelModes } from '../constants';
import {
AxisExtentConfigResult,
DataLayerConfigResult,
ValueLabelMode,
CommonXYDataLayerConfig,
} from '../types';
const errors = {
extendBoundsAreInvalidError: () =>
i18n.translate('expressionXY.reusable.function.xyVis.errors.extendBoundsAreInvalidError', {
defaultMessage:
'For area and bar modes, and custom extent mode, the lower bound should be less or greater than 0 and the upper bound - be greater or equal than 0',
}),
notUsedFillOpacityError: () =>
i18n.translate('expressionXY.reusable.function.xyVis.errors.notUsedFillOpacityError', {
defaultMessage: '`fillOpacity` argument is applicable only for area charts.',
}),
valueLabelsForNotBarsOrHistogramBarsChartsError: () =>
i18n.translate(
'expressionXY.reusable.function.xyVis.errors.valueLabelsForNotBarsOrHistogramBarsChartsError',
{
defaultMessage:
'`valueLabels` argument is applicable only for bar charts, which are not histograms.',
}
),
dataBoundsForNotLineChartError: () =>
i18n.translate('expressionXY.reusable.function.xyVis.errors.dataBoundsForNotLineChartError', {
defaultMessage: 'Only line charts can be fit to the data bounds',
}),
};
export const hasBarLayer = (layers: Array<DataLayerConfigResult | CommonXYDataLayerConfig>) =>
layers.filter(({ seriesType }) => seriesType.includes('bar')).length > 0;
export const hasAreaLayer = (layers: Array<DataLayerConfigResult | CommonXYDataLayerConfig>) =>
layers.filter(({ seriesType }) => seriesType.includes('area')).length > 0;
export const hasHistogramBarLayer = (
layers: Array<DataLayerConfigResult | CommonXYDataLayerConfig>
) =>
layers.filter(({ seriesType, isHistogram }) => seriesType.includes('bar') && isHistogram).length >
0;
export const isValidExtentWithCustomMode = (extent: AxisExtentConfigResult) => {
const isValidLowerBound =
extent.lowerBound === undefined || (extent.lowerBound !== undefined && extent.lowerBound <= 0);
const isValidUpperBound =
extent.upperBound === undefined || (extent.upperBound !== undefined && extent.upperBound >= 0);
return isValidLowerBound && isValidUpperBound;
};
export const validateExtentForDataBounds = (
extent: AxisExtentConfigResult,
layers: Array<DataLayerConfigResult | CommonXYDataLayerConfig>
) => {
const lineSeries = layers.filter(({ seriesType }) => seriesType.includes('line'));
if (!lineSeries.length && extent.mode === AxisExtentModes.DATA_BOUNDS) {
throw new Error(errors.dataBoundsForNotLineChartError());
}
};
export const validateExtent = (
extent: AxisExtentConfigResult,
hasBarOrArea: boolean,
dataLayers: Array<DataLayerConfigResult | CommonXYDataLayerConfig>
) => {
if (
extent.mode === AxisExtentModes.CUSTOM &&
hasBarOrArea &&
!isValidExtentWithCustomMode(extent)
) {
throw new Error(errors.extendBoundsAreInvalidError());
}
validateExtentForDataBounds(extent, dataLayers);
};
export const validateFillOpacity = (fillOpacity: number | undefined, hasArea: boolean) => {
if (fillOpacity !== undefined && !hasArea) {
throw new Error(errors.notUsedFillOpacityError());
}
};
export const validateValueLabels = (
valueLabels: ValueLabelMode,
hasBar: boolean,
hasNotHistogramBars: boolean
) => {
if ((!hasBar || !hasNotHistogramBars) && valueLabels !== ValueLabelModes.HIDE) {
throw new Error(errors.valueLabelsForNotBarsOrHistogramBarsChartsError());
}
};

View file

@ -8,14 +8,23 @@
import { xyVisFunction } from '.';
import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks';
import { sampleArgs } from '../__mocks__';
import { sampleArgs, sampleLayer } from '../__mocks__';
import { XY_VIS } from '../constants';
describe('xyVis', () => {
test('it renders with the specified data and args', () => {
test('it renders with the specified data and args', async () => {
const { data, args } = sampleArgs();
const result = xyVisFunction.fn(data, args, createMockExecutionContext());
const { layers, ...rest } = args;
const result = await xyVisFunction.fn(
data,
{ ...rest, dataLayers: [sampleLayer], referenceLineLayers: [], annotationLayers: [] },
createMockExecutionContext()
);
expect(result).toEqual({ type: 'render', as: XY_VIS, value: { data, args } });
expect(result).toEqual({
type: 'render',
as: XY_VIS,
value: { args: { ...rest, layers: [sampleLayer] } },
});
});
});

View file

@ -6,243 +6,36 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin';
import { prepareLogTable } from '@kbn/visualizations-plugin/common/utils';
import { LensMultiTable, XYArgs, XYRender } from '../types';
import {
XY_VIS,
DATA_LAYER,
MULTITABLE,
XYCurveTypes,
LEGEND_CONFIG,
ValueLabelModes,
FittingFunctions,
GRID_LINES_CONFIG,
XY_VIS_RENDERER,
AXIS_EXTENT_CONFIG,
TICK_LABELS_CONFIG,
REFERENCE_LINE_LAYER,
LABELS_ORIENTATION_CONFIG,
AXIS_TITLES_VISIBILITY_CONFIG,
EndValues,
ANNOTATION_LAYER,
LayerTypes,
} from '../constants';
import { XyVisFn } from '../types';
import { XY_VIS, DATA_LAYER, REFERENCE_LINE_LAYER, ANNOTATION_LAYER } from '../constants';
import { strings } from '../i18n';
import { commonXYArgs } from './common_xy_args';
const strings = {
getMetricHelp: () =>
i18n.translate('expressionXY.xyVis.logDatatable.metric', {
defaultMessage: 'Vertical axis',
}),
getXAxisHelp: () =>
i18n.translate('expressionXY.xyVis.logDatatable.x', {
defaultMessage: 'Horizontal axis',
}),
getBreakdownHelp: () =>
i18n.translate('expressionXY.xyVis.logDatatable.breakDown', {
defaultMessage: 'Break down by',
}),
getReferenceLineHelp: () =>
i18n.translate('expressionXY.xyVis.logDatatable.breakDown', {
defaultMessage: 'Break down by',
}),
};
export const xyVisFunction: ExpressionFunctionDefinition<
typeof XY_VIS,
LensMultiTable,
XYArgs,
XYRender
> = {
export const xyVisFunction: XyVisFn = {
name: XY_VIS,
type: 'render',
inputTypes: [MULTITABLE],
help: i18n.translate('expressionXY.xyVis.help', {
defaultMessage: 'An X/Y chart',
}),
inputTypes: ['datatable'],
help: strings.getXYHelp(),
args: {
title: {
types: ['string'],
help: 'The chart title.',
},
description: {
types: ['string'],
help: '',
},
xTitle: {
types: ['string'],
help: i18n.translate('expressionXY.xyVis.xTitle.help', {
defaultMessage: 'X axis title',
}),
},
yTitle: {
types: ['string'],
help: i18n.translate('expressionXY.xyVis.yLeftTitle.help', {
defaultMessage: 'Y left axis title',
}),
},
yRightTitle: {
types: ['string'],
help: i18n.translate('expressionXY.xyVis.yRightTitle.help', {
defaultMessage: 'Y right axis title',
}),
},
yLeftExtent: {
types: [AXIS_EXTENT_CONFIG],
help: i18n.translate('expressionXY.xyVis.yLeftExtent.help', {
defaultMessage: 'Y left axis extents',
}),
},
yRightExtent: {
types: [AXIS_EXTENT_CONFIG],
help: i18n.translate('expressionXY.xyVis.yRightExtent.help', {
defaultMessage: 'Y right axis extents',
}),
},
legend: {
types: [LEGEND_CONFIG],
help: i18n.translate('expressionXY.xyVis.legend.help', {
defaultMessage: 'Configure the chart legend.',
}),
},
fittingFunction: {
types: ['string'],
options: [...Object.values(FittingFunctions)],
help: i18n.translate('expressionXY.xyVis.fittingFunction.help', {
defaultMessage: 'Define how missing values are treated',
}),
},
endValue: {
types: ['string'],
options: [...Object.values(EndValues)],
help: i18n.translate('expressionXY.xyVis.endValue.help', {
defaultMessage: 'End value',
}),
},
emphasizeFitting: {
types: ['boolean'],
default: false,
help: '',
},
valueLabels: {
types: ['string'],
options: [...Object.values(ValueLabelModes)],
help: i18n.translate('expressionXY.xyVis.valueLabels.help', {
defaultMessage: 'Value labels mode',
}),
},
tickLabelsVisibilitySettings: {
types: [TICK_LABELS_CONFIG],
help: i18n.translate('expressionXY.xyVis.tickLabelsVisibilitySettings.help', {
defaultMessage: 'Show x and y axes tick labels',
}),
},
labelsOrientation: {
types: [LABELS_ORIENTATION_CONFIG],
help: i18n.translate('expressionXY.xyVis.labelsOrientation.help', {
defaultMessage: 'Defines the rotation of the axis labels',
}),
},
gridlinesVisibilitySettings: {
types: [GRID_LINES_CONFIG],
help: i18n.translate('expressionXY.xyVis.gridlinesVisibilitySettings.help', {
defaultMessage: 'Show x and y axes gridlines',
}),
},
axisTitlesVisibilitySettings: {
types: [AXIS_TITLES_VISIBILITY_CONFIG],
help: i18n.translate('expressionXY.xyVis.axisTitlesVisibilitySettings.help', {
defaultMessage: 'Show x and y axes titles',
}),
},
layers: {
types: [DATA_LAYER, REFERENCE_LINE_LAYER, ANNOTATION_LAYER],
help: i18n.translate('expressionXY.xyVis.layers.help', {
defaultMessage: 'Layers of visual series',
}),
...commonXYArgs,
dataLayers: {
types: [DATA_LAYER],
help: strings.getDataLayerHelp(),
multi: true,
},
curveType: {
types: ['string'],
options: [...Object.values(XYCurveTypes)],
help: i18n.translate('expressionXY.xyVis.curveType.help', {
defaultMessage: 'Define how curve type is rendered for a line chart',
}),
referenceLineLayers: {
types: [REFERENCE_LINE_LAYER],
help: strings.getReferenceLineLayerHelp(),
multi: true,
},
fillOpacity: {
types: ['number'],
help: i18n.translate('expressionXY.xyVis.fillOpacity.help', {
defaultMessage: 'Define the area chart fill opacity',
}),
},
hideEndzones: {
types: ['boolean'],
default: false,
help: i18n.translate('expressionXY.xyVis.hideEndzones.help', {
defaultMessage: 'Hide endzone markers for partial data',
}),
},
valuesInLegend: {
types: ['boolean'],
default: false,
help: i18n.translate('expressionXY.xyVis.valuesInLegend.help', {
defaultMessage: 'Show values in legend',
}),
},
ariaLabel: {
types: ['string'],
help: i18n.translate('expressionXY.xyVis.ariaLabel.help', {
defaultMessage: 'Specifies the aria label of the xy chart',
}),
required: false,
annotationLayers: {
types: [ANNOTATION_LAYER],
help: strings.getAnnotationLayerHelp(),
multi: true,
},
},
fn(data, args, handlers) {
if (handlers?.inspectorAdapters?.tables) {
args.layers.forEach((layer) => {
if (layer.layerType === LayerTypes.ANNOTATIONS) {
return;
}
let xAccessor;
let splitAccessor;
if (layer.layerType === LayerTypes.DATA) {
xAccessor = layer.xAccessor;
splitAccessor = layer.splitAccessor;
}
const { layerId, accessors, layerType } = layer;
const logTable = prepareLogTable(
data.tables[layerId],
[
[
accessors ? accessors : undefined,
layerType === 'data' ? strings.getMetricHelp() : strings.getReferenceLineHelp(),
],
[xAccessor ? [xAccessor] : undefined, strings.getXAxisHelp()],
[splitAccessor ? [splitAccessor] : undefined, strings.getBreakdownHelp()],
],
true
);
handlers.inspectorAdapters.tables.logDatatable(layerId, logTable);
});
}
return {
type: 'render',
as: XY_VIS_RENDERER,
value: {
data,
args: {
...args,
ariaLabel:
args.ariaLabel ??
(handlers.variables?.embeddableTitle as string) ??
handlers.getExecutionContext?.()?.description,
},
},
};
async fn(data, args, handlers) {
const { xyVisFn } = await import('./xy_vis_fn');
return await xyVisFn(data, args, handlers);
},
};

View file

@ -0,0 +1,69 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Dimension, prepareLogTable } from '@kbn/visualizations-plugin/common/utils';
import { LayerTypes, XY_VIS_RENDERER } from '../constants';
import { appendLayerIds } from '../helpers';
import { XYLayerConfig, XyVisFn } from '../types';
import { getLayerDimensions } from '../utils';
import {
hasAreaLayer,
hasBarLayer,
hasHistogramBarLayer,
validateExtent,
validateFillOpacity,
validateValueLabels,
} from './validate';
export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => {
const { dataLayers = [], referenceLineLayers = [], annotationLayers = [], ...restArgs } = args;
const layers: XYLayerConfig[] = [
...appendLayerIds(dataLayers, 'dataLayers'),
...appendLayerIds(referenceLineLayers, 'referenceLineLayers'),
...appendLayerIds(annotationLayers, 'annotationLayers'),
];
if (handlers.inspectorAdapters.tables) {
const layerDimensions = layers.reduce<Dimension[]>((dimensions, layer) => {
if (layer.layerType === LayerTypes.ANNOTATIONS) {
return dimensions;
}
return [...dimensions, ...getLayerDimensions(layer)];
}, []);
const logTable = prepareLogTable(data, layerDimensions, true);
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
}
const hasBar = hasBarLayer(dataLayers);
const hasArea = hasAreaLayer(dataLayers);
validateExtent(args.yLeftExtent, hasBar || hasArea, dataLayers);
validateExtent(args.yRightExtent, hasBar || hasArea, dataLayers);
validateFillOpacity(args.fillOpacity, hasArea);
const hasNotHistogramBars = !hasHistogramBarLayer(dataLayers);
validateValueLabels(args.valueLabels, hasBar, hasNotHistogramBars);
return {
type: 'render',
as: XY_VIS_RENDERER,
value: {
args: {
...restArgs,
layers,
ariaLabel:
args.ariaLabel ??
(handlers.variables?.embeddableTitle as string) ??
handlers.getExecutionContext?.()?.description,
},
},
};
};

View file

@ -6,84 +6,18 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { FillStyles, IconPositions, LineStyles, YAxisModes, Y_CONFIG } from '../constants';
import { YConfig, YConfigResult } from '../types';
import { Y_CONFIG } from '../constants';
import { YConfigFn } from '../types';
import { strings } from '../i18n';
import { commonYConfigArgs } from './common_y_config_args';
export const yAxisConfigFunction: ExpressionFunctionDefinition<
typeof Y_CONFIG,
null,
YConfig,
YConfigResult
> = {
export const yAxisConfigFunction: YConfigFn = {
name: Y_CONFIG,
aliases: [],
type: Y_CONFIG,
help: i18n.translate('expressionXY.yConfig.help', {
defaultMessage: `Configure the behavior of a xy chart's y axis metric`,
}),
help: strings.getYConfigFnHelp(),
inputTypes: ['null'],
args: {
forAccessor: {
types: ['string'],
help: i18n.translate('expressionXY.yConfig.forAccessor.help', {
defaultMessage: 'The accessor this configuration is for',
}),
},
axisMode: {
types: ['string'],
options: [...Object.values(YAxisModes)],
help: i18n.translate('expressionXY.yConfig.axisMode.help', {
defaultMessage: 'The axis mode of the metric',
}),
},
color: {
types: ['string'],
help: i18n.translate('expressionXY.yConfig.color.help', {
defaultMessage: 'The color of the series',
}),
},
lineStyle: {
types: ['string'],
options: [...Object.values(LineStyles)],
help: i18n.translate('expressionXY.yConfig.lineStyle.help', {
defaultMessage: 'The style of the reference line',
}),
},
lineWidth: {
types: ['number'],
help: i18n.translate('expressionXY.yConfig.lineWidth.help', {
defaultMessage: 'The width of the reference line',
}),
},
icon: {
types: ['string'],
help: i18n.translate('expressionXY.yConfig.icon.help', {
defaultMessage: 'An optional icon used for reference lines',
}),
},
iconPosition: {
types: ['string'],
options: [...Object.values(IconPositions)],
help: i18n.translate('expressionXY.yConfig.iconPosition.help', {
defaultMessage: 'The placement of the icon for the reference line',
}),
},
textVisibility: {
types: ['boolean'],
help: i18n.translate('expressionXY.yConfig.textVisibility.help', {
defaultMessage: 'Visibility of the label on the reference line',
}),
},
fill: {
types: ['string'],
options: [...Object.values(FillStyles)],
help: i18n.translate('expressionXY.yConfig.fill.help', {
defaultMessage: 'Fill',
}),
},
},
args: { ...commonYConfigArgs },
fn(input, args) {
return {
type: Y_CONFIG,

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { appendLayerIds } from './layers';

View file

@ -0,0 +1,49 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { generateLayerId, appendLayerIds } from './layers';
describe('#generateLayerId', () => {
it('should return the combination of keyword and index', () => {
const key = 'some-key';
const index = 10;
const id = generateLayerId(key, index);
expect(id).toBe(`${key}-${index}`);
});
});
describe('#appendLayerIds', () => {
it('should add layerId to each layer', () => {
const layers = [{ name: 'someName' }, { name: 'someName2' }, { name: 'someName3' }];
const keyword = 'keyword';
const expectedLayerIds = [
{ ...layers[0], layerId: `${keyword}-0` },
{ ...layers[1], layerId: `${keyword}-1` },
{ ...layers[2], layerId: `${keyword}-2` },
];
const layersWithIds = appendLayerIds(layers, keyword);
expect(layersWithIds).toStrictEqual(expectedLayerIds);
});
it('should filter out undefined layers', () => {
const layers = [undefined, undefined, undefined];
const result = appendLayerIds(layers, 'some-key');
expect(result).toStrictEqual([]);
const layers2 = [{ name: 'someName' }, undefined, { name: 'someName3' }];
const keyword = 'keyword';
const expectedLayerIds = [
{ ...layers2[0], layerId: `${keyword}-0` },
{ ...layers2[2], layerId: `${keyword}-1` },
];
const layersWithIds = appendLayerIds(layers2, keyword);
expect(layersWithIds).toStrictEqual(expectedLayerIds);
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { WithLayerId } from '../types';
function isWithLayerId<T>(layer: T): layer is T & WithLayerId {
return (layer as T & WithLayerId).layerId ? true : false;
}
export const generateLayerId = (keyword: string, index: number) => `${keyword}-${index}`;
export function appendLayerIds<T>(
layers: Array<T | undefined>,
keyword: string
): Array<T & WithLayerId> {
return layers
.filter((l): l is T => l !== undefined)
.map((l, index) => ({
...l,
layerId: isWithLayerId(l) ? l.layerId : generateLayerId(keyword, index),
}));
}

View file

@ -0,0 +1,212 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const strings = {
getXYHelp: () =>
i18n.translate('expressionXY.xyVis.help', {
defaultMessage: 'An X/Y chart',
}),
getMetricHelp: () =>
i18n.translate('expressionXY.xyVis.logDatatable.metric', {
defaultMessage: 'Vertical axis',
}),
getXAxisHelp: () =>
i18n.translate('expressionXY.xyVis.logDatatable.x', {
defaultMessage: 'Horizontal axis',
}),
getBreakdownHelp: () =>
i18n.translate('expressionXY.xyVis.logDatatable.breakDown', {
defaultMessage: 'Break down by',
}),
getReferenceLineHelp: () =>
i18n.translate('expressionXY.xyVis.logDatatable.breakDown', {
defaultMessage: 'Break down by',
}),
getXTitleHelp: () =>
i18n.translate('expressionXY.xyVis.xTitle.help', {
defaultMessage: 'X axis title',
}),
getYTitleHelp: () =>
i18n.translate('expressionXY.xyVis.yLeftTitle.help', {
defaultMessage: 'Y left axis title',
}),
getYRightTitleHelp: () =>
i18n.translate('expressionXY.xyVis.yRightTitle.help', {
defaultMessage: 'Y right axis title',
}),
getYLeftExtentHelp: () =>
i18n.translate('expressionXY.xyVis.yLeftExtent.help', {
defaultMessage: 'Y left axis extents',
}),
getYRightExtentHelp: () =>
i18n.translate('expressionXY.xyVis.yRightExtent.help', {
defaultMessage: 'Y right axis extents',
}),
getLegendHelp: () =>
i18n.translate('expressionXY.xyVis.legend.help', {
defaultMessage: 'Configure the chart legend.',
}),
getFittingFunctionHelp: () =>
i18n.translate('expressionXY.xyVis.fittingFunction.help', {
defaultMessage: 'Define how missing values are treated',
}),
getEndValueHelp: () =>
i18n.translate('expressionXY.xyVis.endValue.help', {
defaultMessage: 'End value',
}),
getValueLabelsHelp: () =>
i18n.translate('expressionXY.xyVis.valueLabels.help', {
defaultMessage: 'Value labels mode',
}),
getTickLabelsVisibilitySettingsHelp: () =>
i18n.translate('expressionXY.xyVis.tickLabelsVisibilitySettings.help', {
defaultMessage: 'Show x and y axes tick labels',
}),
getLabelsOrientationHelp: () =>
i18n.translate('expressionXY.xyVis.labelsOrientation.help', {
defaultMessage: 'Defines the rotation of the axis labels',
}),
getGridlinesVisibilitySettingsHelp: () =>
i18n.translate('expressionXY.xyVis.gridlinesVisibilitySettings.help', {
defaultMessage: 'Show x and y axes gridlines',
}),
getAxisTitlesVisibilitySettingsHelp: () =>
i18n.translate('expressionXY.xyVis.axisTitlesVisibilitySettings.help', {
defaultMessage: 'Show x and y axes titles',
}),
getDataLayerHelp: () =>
i18n.translate('expressionXY.xyVis.dataLayer.help', {
defaultMessage: 'Data layer of visual series',
}),
getReferenceLineLayerHelp: () =>
i18n.translate('expressionXY.xyVis.referenceLineLayer.help', {
defaultMessage: 'Reference line layer',
}),
getAnnotationLayerHelp: () =>
i18n.translate('expressionXY.xyVis.annotationLayer.help', {
defaultMessage: 'Annotation layer',
}),
getCurveTypeHelp: () =>
i18n.translate('expressionXY.xyVis.curveType.help', {
defaultMessage: 'Define how curve type is rendered for a line chart',
}),
getFillOpacityHelp: () =>
i18n.translate('expressionXY.xyVis.fillOpacity.help', {
defaultMessage: 'Define the area chart fill opacity',
}),
getHideEndzonesHelp: () =>
i18n.translate('expressionXY.xyVis.hideEndzones.help', {
defaultMessage: 'Hide endzone markers for partial data',
}),
getValuesInLegendHelp: () =>
i18n.translate('expressionXY.xyVis.valuesInLegend.help', {
defaultMessage: 'Show values in legend',
}),
getAriaLabelHelp: () =>
i18n.translate('expressionXY.xyVis.ariaLabel.help', {
defaultMessage: 'Specifies the aria label of the xy chart',
}),
getDataLayerFnHelp: () =>
i18n.translate('expressionXY.dataLayer.help', {
defaultMessage: `Configure a layer in the xy chart`,
}),
getHideHelp: () =>
i18n.translate('expressionXY.dataLayer.hide.help', {
defaultMessage: 'Show / hide axis',
}),
getXAccessorHelp: () =>
i18n.translate('expressionXY.dataLayer.xAccessor.help', {
defaultMessage: 'X-axis',
}),
getSeriesTypeHelp: () =>
i18n.translate('expressionXY.dataLayer.seriesType.help', {
defaultMessage: 'The type of chart to display.',
}),
getXScaleTypeHelp: () =>
i18n.translate('expressionXY.dataLayer.xScaleType.help', {
defaultMessage: 'The scale type of the x axis',
}),
getIsHistogramHelp: () =>
i18n.translate('expressionXY.dataLayer.isHistogram.help', {
defaultMessage: 'Whether to layout the chart as a histogram',
}),
getYScaleTypeHelp: () =>
i18n.translate('expressionXY.dataLayer.yScaleType.help', {
defaultMessage: 'The scale type of the y axes',
}),
getSplitAccessorHelp: () =>
i18n.translate('expressionXY.dataLayer.splitAccessor.help', {
defaultMessage: 'The column to split by',
}),
getAccessorsHelp: () =>
i18n.translate('expressionXY.dataLayer.accessors.help', {
defaultMessage: 'The columns to display on the y axis.',
}),
getYConfigHelp: () =>
i18n.translate('expressionXY.dataLayer.yConfig.help', {
defaultMessage: 'Additional configuration for y axes',
}),
getColumnToLabelHelp: () =>
i18n.translate('expressionXY.layer.columnToLabel.help', {
defaultMessage: 'JSON key-value pairs of column ID to label',
}),
getPaletteHelp: () =>
i18n.translate('expressionXY.dataLayer.palette.help', {
defaultMessage: 'Palette',
}),
getTableHelp: () =>
i18n.translate('expressionXY.layers.table.help', {
defaultMessage: 'Table',
}),
getLayerIdHelp: () =>
i18n.translate('expressionXY.layers.layerId.help', {
defaultMessage: 'Layer ID',
}),
getRLAccessorsHelp: () =>
i18n.translate('expressionXY.referenceLineLayer.accessors.help', {
defaultMessage: 'The columns to display on the y axis.',
}),
getRLYConfigHelp: () =>
i18n.translate('expressionXY.referenceLineLayer.yConfig.help', {
defaultMessage: 'Additional configuration for y axes',
}),
getRLHelp: () =>
i18n.translate('expressionXY.referenceLineLayer.help', {
defaultMessage: `Configure a reference line in the xy chart`,
}),
getYConfigFnHelp: () =>
i18n.translate('expressionXY.yConfig.help', {
defaultMessage: `Configure the behavior of a xy chart's y axis metric`,
}),
getForAccessorHelp: () =>
i18n.translate('expressionXY.yConfig.forAccessor.help', {
defaultMessage: 'The accessor this configuration is for',
}),
getAxisModeHelp: () =>
i18n.translate('expressionXY.yConfig.axisMode.help', {
defaultMessage: 'The axis mode of the metric',
}),
getColorHelp: () =>
i18n.translate('expressionXY.yConfig.color.help', {
defaultMessage: 'The color of the series',
}),
getAnnotationLayerFnHelp: () =>
i18n.translate('expressionXY.annotationLayer.help', {
defaultMessage: `Configure an annotation layer in the xy chart`,
}),
getAnnotationLayerHideHelp: () =>
i18n.translate('expressionXY.annotationLayer.hide.help', {
defaultMessage: 'Show / hide details',
}),
getAnnotationLayerAnnotationsHelp: () =>
i18n.translate('expressionXY.annotationLayer.annotations.help', {
defaultMessage: 'Annotations',
}),
};

View file

@ -9,20 +9,6 @@
export const PLUGIN_ID = 'expressionXy';
export const PLUGIN_NAME = 'expressionXy';
export {
xyVisFunction,
yAxisConfigFunction,
legendConfigFunction,
gridlinesConfigFunction,
dataLayerConfigFunction,
axisExtentConfigFunction,
tickLabelsConfigFunction,
annotationLayerConfigFunction,
labelsOrientationConfigFunction,
referenceLineLayerConfigFunction,
axisTitlesVisibilityConfigFunction,
} from './expression_functions';
export type {
XYArgs,
YConfig,
@ -42,25 +28,37 @@ export type {
XYChartProps,
LegendConfig,
IconPosition,
YConfigResult,
DataLayerArgs,
LensMultiTable,
ValueLabelMode,
AxisExtentMode,
DataLayerConfig,
FittingFunction,
ExtendedYConfig,
AxisExtentConfig,
CollectiveConfig,
LegendConfigResult,
AxesSettingsConfig,
CommonXYLayerConfig,
AnnotationLayerArgs,
XYLayerConfigResult,
ExtendedYConfigResult,
GridlinesConfigResult,
DataLayerConfigResult,
TickLabelsConfigResult,
AxisExtentConfigResult,
ReferenceLineLayerArgs,
CommonXYDataLayerConfig,
LabelsOrientationConfig,
AnnotationLayerConfigResult,
ReferenceLineLayerConfig,
AvailableReferenceLineIcon,
XYExtendedLayerConfigResult,
CommonXYAnnotationLayerConfig,
ExtendedDataLayerConfigResult,
LabelsOrientationConfigResult,
CommonXYDataLayerConfigResult,
ReferenceLineLayerConfigResult,
CommonXYReferenceLineLayerConfig,
AxisTitlesVisibilityConfigResult,
ExtendedReferenceLineLayerConfigResult,
CommonXYReferenceLineLayerConfigResult,
} from './types';

View file

@ -9,7 +9,7 @@
import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/charts';
import { $Values } from '@kbn/utility-types';
import type { PaletteOutput } from '@kbn/coloring';
import { Datatable } from '@kbn/expressions-plugin';
import { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin';
import { EventAnnotationOutput } from '@kbn/event-annotation-plugin/common';
import {
AxisExtentModes,
@ -34,9 +34,17 @@ import {
LEGEND_CONFIG,
DATA_LAYER,
AXIS_EXTENT_CONFIG,
EXTENDED_DATA_LAYER,
EXTENDED_REFERENCE_LINE_LAYER,
ANNOTATION_LAYER,
EndValues,
EXTENDED_Y_CONFIG,
AvailableReferenceLineIcons,
XY_VIS,
LAYERED_XY_VIS,
EXTENDED_ANNOTATION_LAYER,
} from '../constants';
import { XYRender } from './expression_renderers';
export type EndValue = $Values<typeof EndValues>;
export type LayerType = $Values<typeof LayerTypes>;
@ -51,6 +59,7 @@ export type IconPosition = $Values<typeof IconPositions>;
export type ValueLabelMode = $Values<typeof ValueLabelModes>;
export type AxisExtentMode = $Values<typeof AxisExtentModes>;
export type FittingFunction = $Values<typeof FittingFunctions>;
export type AvailableReferenceLineIcon = $Values<typeof AvailableReferenceLineIcons>;
export interface AxesSettingsConfig {
x: boolean;
@ -69,11 +78,8 @@ export interface AxisConfig {
hide?: boolean;
}
export interface YConfig {
forAccessor: string;
axisMode?: YAxisMode;
color?: string;
icon?: string;
export interface ExtendedYConfig extends YConfig {
icon?: AvailableReferenceLineIcon;
lineWidth?: number;
lineStyle?: LineStyle;
fill?: FillStyle;
@ -81,12 +87,13 @@ export interface YConfig {
textVisibility?: boolean;
}
export interface ValidLayer extends DataLayerConfigResult {
xAccessor: NonNullable<DataLayerConfigResult['xAccessor']>;
export interface YConfig {
forAccessor: string;
axisMode?: YAxisMode;
color?: string;
}
export interface DataLayerArgs {
layerId: string;
accessors: string[];
seriesType: SeriesType;
xAccessor?: string;
@ -96,11 +103,30 @@ export interface DataLayerArgs {
yScaleType: YScaleType;
xScaleType: XScaleType;
isHistogram: boolean;
// palette will always be set on the expression
palette: PaletteOutput;
yConfig?: YConfigResult[];
}
export interface ValidLayer extends DataLayerConfigResult {
xAccessor: NonNullable<DataLayerConfigResult['xAccessor']>;
}
export interface ExtendedDataLayerArgs {
layerId?: string;
accessors: string[];
seriesType: SeriesType;
xAccessor?: string;
hide?: boolean;
splitAccessor?: string;
columnToLabel?: string; // Actually a JSON key-value pair
yScaleType: YScaleType;
xScaleType: XScaleType;
isHistogram: boolean;
palette: PaletteOutput;
yConfig?: YConfigResult[];
table?: Datatable;
}
export interface LegendConfig {
/**
* Flag whether the legend should be shown. If there is just a single series, it will be hidden
@ -121,11 +147,11 @@ export interface LegendConfig {
/**
* Horizontal Alignment of the legend when it is set inside chart
*/
horizontalAlignment?: HorizontalAlignment;
horizontalAlignment?: typeof HorizontalAlignment.Right | typeof HorizontalAlignment.Left;
/**
* Vertical Alignment of the legend when it is set inside chart
*/
verticalAlignment?: VerticalAlignment;
verticalAlignment?: typeof VerticalAlignment.Top | typeof VerticalAlignment.Bottom;
/**
* Number of columns when legend is set inside chart
*/
@ -155,8 +181,54 @@ export interface LabelsOrientationConfig {
// Arguments to XY chart expression, with computed properties
export interface XYArgs {
title?: string;
description?: string;
xTitle: string;
yTitle: string;
yRightTitle: string;
yLeftExtent: AxisExtentConfigResult;
yRightExtent: AxisExtentConfigResult;
legend: LegendConfigResult;
endValue?: EndValue;
emphasizeFitting?: boolean;
valueLabels: ValueLabelMode;
dataLayers: DataLayerConfigResult[];
referenceLineLayers: ReferenceLineLayerConfigResult[];
annotationLayers: AnnotationLayerConfigResult[];
fittingFunction?: FittingFunction;
axisTitlesVisibilitySettings?: AxisTitlesVisibilityConfigResult;
tickLabelsVisibilitySettings?: TickLabelsConfigResult;
gridlinesVisibilitySettings?: GridlinesConfigResult;
labelsOrientation?: LabelsOrientationConfigResult;
curveType?: XYCurveType;
fillOpacity?: number;
hideEndzones?: boolean;
valuesInLegend?: boolean;
ariaLabel?: string;
}
export interface LayeredXYArgs {
xTitle: string;
yTitle: string;
yRightTitle: string;
yLeftExtent: AxisExtentConfigResult;
yRightExtent: AxisExtentConfigResult;
legend: LegendConfigResult;
endValue?: EndValue;
emphasizeFitting?: boolean;
valueLabels: ValueLabelMode;
layers?: XYExtendedLayerConfigResult[];
fittingFunction?: FittingFunction;
axisTitlesVisibilitySettings?: AxisTitlesVisibilityConfigResult;
tickLabelsVisibilitySettings?: TickLabelsConfigResult;
gridlinesVisibilitySettings?: GridlinesConfigResult;
labelsOrientation?: LabelsOrientationConfigResult;
curveType?: XYCurveType;
fillOpacity?: number;
hideEndzones?: boolean;
valuesInLegend?: boolean;
ariaLabel?: string;
}
export interface XYProps {
xTitle: string;
yTitle: string;
yRightTitle: string;
@ -164,7 +236,7 @@ export interface XYArgs {
yRightExtent: AxisExtentConfigResult;
legend: LegendConfigResult;
valueLabels: ValueLabelMode;
layers: XYLayerConfigResult[];
layers: CommonXYLayerConfig[];
endValue?: EndValue;
emphasizeFitting?: boolean;
fittingFunction?: FittingFunction;
@ -181,28 +253,48 @@ export interface XYArgs {
export interface AnnotationLayerArgs {
annotations: EventAnnotationOutput[];
layerId: string;
hide?: boolean;
}
export type ExtendedAnnotationLayerArgs = AnnotationLayerArgs & {
layerId?: string;
};
export type AnnotationLayerConfigResult = AnnotationLayerArgs & {
type: typeof ANNOTATION_LAYER;
layerType: typeof LayerTypes.ANNOTATIONS;
};
export type ExtendedAnnotationLayerConfigResult = ExtendedAnnotationLayerArgs & {
type: typeof EXTENDED_ANNOTATION_LAYER;
layerType: typeof LayerTypes.ANNOTATIONS;
};
export interface ReferenceLineLayerArgs {
layerId: string;
accessors: string[];
columnToLabel?: string;
yConfig?: YConfigResult[];
yConfig?: ExtendedYConfigResult[];
}
export interface ExtendedReferenceLineLayerArgs {
layerId?: string;
accessors: string[];
columnToLabel?: string;
yConfig?: ExtendedYConfigResult[];
table?: Datatable;
}
export type XYLayerArgs = DataLayerArgs | ReferenceLineLayerArgs | AnnotationLayerArgs;
export type XYLayerConfig = DataLayerConfig | ReferenceLineLayerConfig | AnnotationLayerConfig;
export type XYExtendedLayerConfig =
| ExtendedDataLayerConfig
| ExtendedReferenceLineLayerConfig
| ExtendedAnnotationLayerConfig;
export type XYLayerConfigResult =
| DataLayerConfigResult
| ReferenceLineLayerConfigResult
| AnnotationLayerConfigResult;
export type XYExtendedLayerConfigResult =
| ExtendedDataLayerConfigResult
| ExtendedReferenceLineLayerConfigResult
| ExtendedAnnotationLayerConfigResult;
export interface LensMultiTable {
type: typeof MULTITABLE;
@ -216,14 +308,43 @@ export interface LensMultiTable {
export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & {
type: typeof REFERENCE_LINE_LAYER;
layerType: typeof LayerTypes.REFERENCELINE;
table: Datatable;
};
export type DataLayerConfigResult = DataLayerArgs & {
export type ExtendedReferenceLineLayerConfigResult = ExtendedReferenceLineLayerArgs & {
type: typeof EXTENDED_REFERENCE_LINE_LAYER;
layerType: typeof LayerTypes.REFERENCELINE;
table: Datatable;
};
export type DataLayerConfigResult = Omit<DataLayerArgs, 'palette'> & {
type: typeof DATA_LAYER;
layerType: typeof LayerTypes.DATA;
palette: PaletteOutput;
table: Datatable;
};
export interface WithLayerId {
layerId: string;
}
export type DataLayerConfig = DataLayerConfigResult & WithLayerId;
export type ReferenceLineLayerConfig = ReferenceLineLayerConfigResult & WithLayerId;
export type AnnotationLayerConfig = AnnotationLayerConfigResult & WithLayerId;
export type ExtendedDataLayerConfig = ExtendedDataLayerConfigResult & WithLayerId;
export type ExtendedReferenceLineLayerConfig = ExtendedReferenceLineLayerConfigResult & WithLayerId;
export type ExtendedAnnotationLayerConfig = ExtendedAnnotationLayerConfigResult & WithLayerId;
export type ExtendedDataLayerConfigResult = Omit<ExtendedDataLayerArgs, 'palette'> & {
type: typeof EXTENDED_DATA_LAYER;
layerType: typeof LayerTypes.DATA;
palette: PaletteOutput;
table: Datatable;
};
export type YConfigResult = YConfig & { type: typeof Y_CONFIG };
export type ExtendedYConfigResult = ExtendedYConfig & { type: typeof EXTENDED_Y_CONFIG };
export type AxisTitlesVisibilityConfigResult = AxesSettingsConfig & {
type: typeof AXIS_TITLES_VISIBILITY_CONFIG;
@ -237,3 +358,70 @@ export type LegendConfigResult = LegendConfig & { type: typeof LEGEND_CONFIG };
export type AxisExtentConfigResult = AxisExtentConfig & { type: typeof AXIS_EXTENT_CONFIG };
export type GridlinesConfigResult = AxesSettingsConfig & { type: typeof GRID_LINES_CONFIG };
export type TickLabelsConfigResult = AxesSettingsConfig & { type: typeof TICK_LABELS_CONFIG };
export type CommonXYLayerConfig = XYLayerConfig | XYExtendedLayerConfig;
export type CommonXYDataLayerConfigResult = DataLayerConfigResult | ExtendedDataLayerConfigResult;
export type CommonXYReferenceLineLayerConfigResult =
| ReferenceLineLayerConfigResult
| ExtendedReferenceLineLayerConfigResult;
export type CommonXYDataLayerConfig = DataLayerConfig | ExtendedDataLayerConfig;
export type CommonXYReferenceLineLayerConfig =
| ReferenceLineLayerConfig
| ExtendedReferenceLineLayerConfig;
export type CommonXYAnnotationLayerConfig = AnnotationLayerConfig | ExtendedAnnotationLayerConfig;
export type XyVisFn = ExpressionFunctionDefinition<
typeof XY_VIS,
Datatable,
XYArgs,
Promise<XYRender>
>;
export type LayeredXyVisFn = ExpressionFunctionDefinition<
typeof LAYERED_XY_VIS,
Datatable,
LayeredXYArgs,
Promise<XYRender>
>;
export type DataLayerFn = ExpressionFunctionDefinition<
typeof DATA_LAYER,
Datatable,
DataLayerArgs,
DataLayerConfigResult
>;
export type ExtendedDataLayerFn = ExpressionFunctionDefinition<
typeof EXTENDED_DATA_LAYER,
Datatable,
ExtendedDataLayerArgs,
ExtendedDataLayerConfigResult
>;
export type ReferenceLineLayerFn = ExpressionFunctionDefinition<
typeof REFERENCE_LINE_LAYER,
Datatable,
ReferenceLineLayerArgs,
ReferenceLineLayerConfigResult
>;
export type ExtendedReferenceLineLayerFn = ExpressionFunctionDefinition<
typeof EXTENDED_REFERENCE_LINE_LAYER,
Datatable,
ExtendedReferenceLineLayerArgs,
ExtendedReferenceLineLayerConfigResult
>;
export type YConfigFn = ExpressionFunctionDefinition<typeof Y_CONFIG, null, YConfig, YConfigResult>;
export type ExtendedYConfigFn = ExpressionFunctionDefinition<
typeof EXTENDED_Y_CONFIG,
null,
ExtendedYConfig,
ExtendedYConfigResult
>;
export type LegendConfigFn = ExpressionFunctionDefinition<
typeof LEGEND_CONFIG,
null,
LegendConfig,
Promise<LegendConfigResult>
>;

View file

@ -6,12 +6,16 @@
* Side Public License, v 1.
*/
import { AnnotationTooltipFormatter } from '@elastic/charts';
import {
AvailableAnnotationIcon,
ManualPointEventAnnotationArgs,
} from '@kbn/event-annotation-plugin/common';
import { XY_VIS_RENDERER } from '../constants';
import { LensMultiTable, XYArgs } from './expression_functions';
import { XYProps } from './expression_functions';
export interface XYChartProps {
data: LensMultiTable;
args: XYArgs;
args: XYProps;
}
export interface XYRender {
@ -19,3 +23,10 @@ export interface XYRender {
as: typeof XY_VIS_RENDERER;
value: XYChartProps;
}
export interface CollectiveConfig extends Omit<ManualPointEventAnnotationArgs, 'icon'> {
roundedTimestamp: number;
axisMode: 'bottom';
icon?: AvailableAnnotationIcon | string;
customTooltipDetails?: AnnotationTooltipFormatter | undefined;
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { logDatatables, getLayerDimensions } from './log_datatables';

View file

@ -0,0 +1,55 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ExecutionContext } from '@kbn/expressions-plugin';
import { Dimension, prepareLogTable } from '@kbn/visualizations-plugin/common/utils';
import { LayerTypes } from '../constants';
import { strings } from '../i18n';
import {
CommonXYDataLayerConfig,
CommonXYLayerConfig,
CommonXYReferenceLineLayerConfig,
} from '../types';
export const logDatatables = (layers: CommonXYLayerConfig[], handlers: ExecutionContext) => {
if (!handlers?.inspectorAdapters?.tables) {
return;
}
handlers.inspectorAdapters.tables.reset();
handlers.inspectorAdapters.tables.allowCsvExport = true;
layers.forEach((layer) => {
if (layer.layerType === LayerTypes.ANNOTATIONS) {
return;
}
const logTable = prepareLogTable(layer.table, getLayerDimensions(layer), true);
handlers.inspectorAdapters.tables.logDatatable(layer.layerId, logTable);
});
};
export const getLayerDimensions = (
layer: CommonXYDataLayerConfig | CommonXYReferenceLineLayerConfig
): Dimension[] => {
let xAccessor;
let splitAccessor;
if (layer.layerType === LayerTypes.DATA) {
xAccessor = layer.xAccessor;
splitAccessor = layer.splitAccessor;
}
const { accessors, layerType } = layer;
return [
[
accessors ? accessors : undefined,
layerType === LayerTypes.DATA ? strings.getMetricHelp() : strings.getReferenceLineHelp(),
],
[xAccessor ? [xAccessor] : undefined, strings.getXAxisHelp()],
[splitAccessor ? [splitAccessor] : undefined, strings.getBreakdownHelp()],
];
};

View file

@ -6,9 +6,11 @@
* Side Public License, v 1.
*/
import { Datatable } from '@kbn/expressions-plugin/common';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { DataLayerConfigResult, LensMultiTable, XYArgs } from '../../common';
import { LensMultiTable } from '../../common';
import { LayerTypes } from '../../common/constants';
import { DataLayerConfig, XYProps } from '../../common/types';
import { mockPaletteOutput, sampleArgs } from '../../common/__mocks__';
const chartSetupContract = chartPluginMock.createSetupContract();
@ -166,9 +168,9 @@ export const dateHistogramData: LensMultiTable = {
},
};
export const dateHistogramLayer: DataLayerConfigResult = {
export const dateHistogramLayer: DataLayerConfig = {
layerId: 'dateHistogramLayer',
type: 'dataLayer',
layerId: 'timeLayer',
layerType: LayerTypes.DATA,
hide: false,
xAccessor: 'xAccessorId',
@ -179,46 +181,37 @@ export const dateHistogramLayer: DataLayerConfigResult = {
seriesType: 'bar_stacked',
accessors: ['yAccessorId'],
palette: mockPaletteOutput,
table: dateHistogramData.tables.timeLayer,
};
export function sampleArgsWithReferenceLine(value: number = 150) {
const { data, args } = sampleArgs();
return {
data: {
...data,
tables: {
...data.tables,
referenceLine: {
type: 'datatable',
columns: [
{
id: 'referenceLine-a',
meta: { params: { id: 'number' }, type: 'number' },
name: 'Static value',
},
],
rows: [{ 'referenceLine-a': value }],
},
const { args: sArgs } = sampleArgs();
const data: Datatable = {
type: 'datatable',
columns: [
{
id: 'referenceLine-a',
meta: { params: { id: 'number' }, type: 'number' },
name: 'Static value',
},
} as LensMultiTable,
args: {
...args,
layers: [
...args.layers,
{
layerType: LayerTypes.REFERENCELINE,
accessors: ['referenceLine-a'],
layerId: 'referenceLine',
seriesType: 'line',
xScaleType: 'linear',
yScaleType: 'linear',
palette: mockPaletteOutput,
isHistogram: false,
hide: true,
yConfig: [{ axisMode: 'left', forAccessor: 'referenceLine-a', type: 'yConfig' }],
},
],
} as XYArgs,
],
rows: [{ 'referenceLine-a': value }],
};
const args: XYProps = {
...sArgs,
layers: [
...sArgs.layers,
{
layerId: 'referenceLine-a',
type: 'referenceLineLayer',
layerType: LayerTypes.REFERENCELINE,
accessors: ['referenceLine-a'],
yConfig: [{ axisMode: 'left', forAccessor: 'referenceLine-a', type: 'extendedYConfig' }],
table: data,
},
],
};
return { data, args };
}

View file

@ -29,7 +29,12 @@ import {
defaultAnnotationColor,
defaultAnnotationRangeColor,
} from '@kbn/event-annotation-plugin/public';
import type { AnnotationLayerArgs, AnnotationLayerConfigResult } from '../../common/types';
import type {
AnnotationLayerArgs,
CommonXYAnnotationLayerConfig,
CollectiveConfig,
} from '../../common';
import { AnnotationIcon, hasIcon, Marker, MarkerBody } from '../helpers';
import { mapVerticalToHorizontalPlacement, LINES_MARKER_SIZE } from '../helpers';
@ -52,12 +57,6 @@ export interface AnnotationsProps {
outsideDimension: number;
}
interface CollectiveConfig extends ManualPointEventAnnotationArgs {
roundedTimestamp: number;
axisMode: 'bottom';
customTooltipDetails?: AnnotationTooltipFormatter | undefined;
}
const groupVisibleConfigsByInterval = (
layers: AnnotationLayerArgs[],
minInterval?: number,
@ -131,7 +130,7 @@ const getCommonStyles = (configArr: ManualPointEventAnnotationArgs[]) => {
};
};
export const getRangeAnnotations = (layers: AnnotationLayerConfigResult[]) => {
export const getRangeAnnotations = (layers: CommonXYAnnotationLayerConfig[]) => {
return layers
.flatMap(({ annotations }) =>
annotations.filter(
@ -146,7 +145,7 @@ export const OUTSIDE_RECT_ANNOTATION_WIDTH = 8;
export const OUTSIDE_RECT_ANNOTATION_WIDTH_SUGGESTION = 2;
export const getAnnotationsGroupedByInterval = (
layers: AnnotationLayerConfigResult[],
layers: CommonXYAnnotationLayerConfig[],
minInterval?: number,
firstTimestamp?: number,
formatter?: FieldFormat

View file

@ -0,0 +1,166 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
AreaSeries,
BarSeries,
CurveType,
LabelOverflowConstraint,
LineSeries,
} from '@elastic/charts';
import React, { FC } from 'react';
import { PaletteRegistry } from '@kbn/coloring';
import { FormatFactory } from '@kbn/field-formats-plugin/common';
import {
CommonXYDataLayerConfig,
EndValue,
FittingFunction,
ValueLabelMode,
XYCurveType,
} from '../../common';
import { SeriesTypes, ValueLabelModes } from '../../common/constants';
import {
getColorAssignments,
getFitOptions,
GroupsConfiguration,
getSeriesProps,
DatatablesWithFormatInfo,
} from '../helpers';
interface Props {
layers: CommonXYDataLayerConfig[];
formatFactory: FormatFactory;
chartHasMoreThanOneBarSeries?: boolean;
yAxesConfiguration: GroupsConfiguration;
curveType?: XYCurveType;
fittingFunction?: FittingFunction;
endValue?: EndValue | undefined;
paletteService: PaletteRegistry;
formattedDatatables: DatatablesWithFormatInfo;
syncColors?: boolean;
timeZone?: string;
emphasizeFitting?: boolean;
fillOpacity?: number;
shouldShowValueLabels?: boolean;
valueLabels: ValueLabelMode;
}
export const DataLayers: FC<Props> = ({
layers,
endValue,
timeZone,
curveType,
syncColors,
valueLabels,
fillOpacity,
formatFactory,
paletteService,
fittingFunction,
emphasizeFitting,
yAxesConfiguration,
shouldShowValueLabels,
formattedDatatables,
chartHasMoreThanOneBarSeries,
}) => {
const colorAssignments = getColorAssignments(layers, formatFactory);
return (
<>
{layers.flatMap((layer) =>
layer.accessors.map((accessor, accessorIndex) => {
const { seriesType, columnToLabel, layerId } = layer;
const columnToLabelMap: Record<string, string> = columnToLabel
? JSON.parse(columnToLabel)
: {};
// what if row values are not primitive? That is the case of, for instance, Ranges
// remaps them to their serialized version with the formatHint metadata
// In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on
const formattedDatatableInfo = formattedDatatables[layerId];
const isPercentage = seriesType.includes('percentage');
const yAxis = yAxesConfiguration.find((axisConfiguration) =>
axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor)
);
const seriesProps = getSeriesProps({
layer,
accessor,
chartHasMoreThanOneBarSeries,
colorAssignments,
formatFactory,
columnToLabelMap,
paletteService,
formattedDatatableInfo,
syncColors,
yAxis,
timeZone,
emphasizeFitting,
fillOpacity,
});
const index = `${layer.layerId}-${accessorIndex}`;
const curve = curveType ? CurveType[curveType] : undefined;
switch (seriesType) {
case SeriesTypes.LINE:
return (
<LineSeries
key={index}
{...seriesProps}
fit={getFitOptions(fittingFunction, endValue)}
curve={curve}
/>
);
case SeriesTypes.BAR:
case SeriesTypes.BAR_STACKED:
case SeriesTypes.BAR_PERCENTAGE_STACKED:
case SeriesTypes.BAR_HORIZONTAL:
case SeriesTypes.BAR_HORIZONTAL_STACKED:
case SeriesTypes.BAR_HORIZONTAL_PERCENTAGE_STACKED:
const valueLabelsSettings = {
displayValueSettings: {
// This format double fixes two issues in elastic-chart
// * when rotating the chart, the formatter is not correctly picked
// * in some scenarios value labels are not strings, and this breaks the elastic-chart lib
valueFormatter: (d: unknown) => yAxis?.formatter?.convert(d) || '',
showValueLabel: shouldShowValueLabels && valueLabels !== ValueLabelModes.HIDE,
isValueContainedInElement: false,
isAlternatingValueLabel: false,
overflowConstraints: [
LabelOverflowConstraint.ChartEdges,
LabelOverflowConstraint.BarGeometry,
],
},
};
return <BarSeries key={index} {...seriesProps} {...valueLabelsSettings} />;
case SeriesTypes.AREA_STACKED:
case SeriesTypes.AREA_PERCENTAGE_STACKED:
return (
<AreaSeries
key={index}
{...seriesProps}
fit={isPercentage ? 'zero' : getFitOptions(fittingFunction, endValue)}
curve={curve}
/>
);
case SeriesTypes.AREA:
return (
<AreaSeries
key={index}
{...seriesProps}
fit={getFitOptions(fittingFunction, endValue)}
curve={curve}
/>
);
}
})
)}
</>
);
};

View file

@ -11,27 +11,12 @@ import { LegendActionProps, SeriesIdentifier } from '@elastic/charts';
import { EuiPopover } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { ComponentType, ReactWrapper } from 'enzyme';
import type { LensMultiTable } from '../../common';
import type { DataLayerConfig, LensMultiTable } from '../../common';
import { LayerTypes } from '../../common/constants';
import type { DataLayerArgs } from '../../common';
import { getLegendAction } from './legend_action';
import { LegendActionPopover } from './legend_action_popover';
import { mockPaletteOutput } from '../../common/__mocks__';
const sampleLayer = {
layerId: 'first',
layerType: LayerTypes.DATA,
seriesType: 'line',
xAccessor: 'c',
accessors: ['a', 'b'],
splitAccessor: 'splitAccessorId',
columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}',
xScaleType: 'ordinal',
yScaleType: 'linear',
isHistogram: false,
palette: mockPaletteOutput,
} as DataLayerArgs;
const tables = {
first: {
type: 'datatable',
@ -168,11 +153,26 @@ const tables = {
},
} as LensMultiTable['tables'];
const sampleLayer: DataLayerConfig = {
layerId: 'first',
type: 'dataLayer',
layerType: LayerTypes.DATA,
seriesType: 'line',
xAccessor: 'c',
accessors: ['a', 'b'],
splitAccessor: 'splitAccessorId',
columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}',
xScaleType: 'ordinal',
yScaleType: 'linear',
isHistogram: false,
palette: mockPaletteOutput,
table: tables.first,
};
describe('getLegendAction', function () {
let wrapperProps: LegendActionProps;
const Component: ComponentType<LegendActionProps> = getLegendAction(
[sampleLayer],
tables,
jest.fn(),
jest.fn(),
{}

View file

@ -9,23 +9,28 @@
import React from 'react';
import type { LegendAction, XYChartSeriesIdentifier } from '@elastic/charts';
import type { FilterEvent } from '../types';
import type { LensMultiTable, DataLayerArgs } from '../../common';
import type { CommonXYDataLayerConfig } from '../../common';
import type { FormatFactory } from '../types';
import { LegendActionPopover } from './legend_action_popover';
import { DatatablesWithFormatInfo } from '../helpers';
export const getLegendAction = (
filteredLayers: DataLayerArgs[],
tables: LensMultiTable['tables'],
dataLayers: CommonXYDataLayerConfig[],
onFilter: (data: FilterEvent['data']) => void,
formatFactory: FormatFactory,
layersAlreadyFormatted: Record<string, boolean>
formattedDatatables: DatatablesWithFormatInfo
): LegendAction =>
React.memo(({ series: [xySeries] }) => {
const series = xySeries as XYChartSeriesIdentifier;
const layer = filteredLayers.find((l) =>
const layerIndex = dataLayers.findIndex((l) =>
series.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString()))
);
if (layerIndex === -1) {
return null;
}
const layer = dataLayers[layerIndex];
if (!layer || !layer.splitAccessor) {
return null;
}
@ -33,12 +38,12 @@ export const getLegendAction = (
const splitLabel = series.seriesKeys[0] as string;
const accessor = layer.splitAccessor;
const table = tables[layer.layerId];
const { table } = layer;
const splitColumn = table.columns.find(({ id }) => id === layer.splitAccessor);
const formatter = formatFactory(splitColumn && splitColumn.meta?.params);
const rowIndex = table.rows.findIndex((row) => {
if (layersAlreadyFormatted[accessor]) {
if (formattedDatatables[layer.layerId]?.formattedColumns[accessor]) {
// stringify the value to compare with the chart value
return formatter.convert(row[accessor]) === splitLabel;
}
@ -63,7 +68,7 @@ export const getLegendAction = (
return (
<LegendActionPopover
label={
!layersAlreadyFormatted[accessor] && formatter
!formattedDatatables[layer.layerId]?.formattedColumns[accessor] && formatter
? formatter.convert(splitLabel)
: splitLabel
}

View file

@ -9,9 +9,14 @@
import { LineAnnotation, RectAnnotation } from '@elastic/charts';
import { shallow } from 'enzyme';
import React from 'react';
import { Datatable } from '@kbn/expressions-plugin/common';
import { FieldFormat } from '@kbn/field-formats-plugin/common';
import { LensMultiTable } from '../../common';
import { ReferenceLineLayerArgs, YConfig } from '../../common/types';
import { LayerTypes } from '../../common/constants';
import {
ReferenceLineLayerArgs,
ReferenceLineLayerConfig,
ExtendedYConfig,
} from '../../common/types';
import { ReferenceLineAnnotations, ReferenceLineAnnotationsProps } from './reference_lines';
const row: Record<string, number> = {
@ -23,34 +28,28 @@ const row: Record<string, number> = {
yAccessorRightSecondId: 10,
};
const histogramData: LensMultiTable = {
type: 'lens_multitable',
tables: {
firstLayer: {
type: 'datatable',
rows: [row],
columns: Object.keys(row).map((id) => ({
id,
name: `Static value: ${row[id]}`,
meta: {
type: 'number',
params: { id: 'number' },
},
})),
const data: Datatable = {
type: 'datatable',
rows: [row],
columns: Object.keys(row).map((id) => ({
id,
name: `Static value: ${row[id]}`,
meta: {
type: 'number',
params: { id: 'number' },
},
},
dateRange: {
fromDate: new Date('2020-04-01T16:14:16.246Z'),
toDate: new Date('2020-04-01T17:15:41.263Z'),
},
})),
};
function createLayers(yConfigs: ReferenceLineLayerArgs['yConfig']): ReferenceLineLayerArgs[] {
function createLayers(yConfigs: ReferenceLineLayerArgs['yConfig']): ReferenceLineLayerConfig[] {
return [
{
layerId: 'firstLayer',
layerId: 'first',
accessors: (yConfigs || []).map(({ forAccessor }) => forAccessor),
yConfig: yConfigs,
type: 'referenceLineLayer',
layerType: LayerTypes.REFERENCELINE,
table: data,
},
];
}
@ -64,7 +63,7 @@ interface XCoords {
x1: number | undefined;
}
function getAxisFromId(layerPrefix: string): YConfig['axisMode'] {
function getAxisFromId(layerPrefix: string): ExtendedYConfig['axisMode'] {
return /left/i.test(layerPrefix) ? 'left' : /right/i.test(layerPrefix) ? 'right' : 'bottom';
}
@ -95,21 +94,20 @@ describe('ReferenceLineAnnotations', () => {
['yAccessorLeft', 'below'],
['yAccessorRight', 'above'],
['yAccessorRight', 'below'],
] as Array<[string, YConfig['fill']]>)(
] as Array<[string, ExtendedYConfig['fill']]>)(
'should render a RectAnnotation for a reference line with fill set: %s %s',
(layerPrefix, fill) => {
const axisMode = getAxisFromId(layerPrefix);
const wrapper = shallow(
<ReferenceLineAnnotations
{...defaultProps}
data={histogramData}
layers={createLayers([
{
forAccessor: `${layerPrefix}FirstId`,
axisMode,
lineStyle: 'solid',
fill,
type: 'yConfig',
type: 'extendedYConfig',
},
])}
/>
@ -135,19 +133,18 @@ describe('ReferenceLineAnnotations', () => {
it.each([
['xAccessor', 'above'],
['xAccessor', 'below'],
] as Array<[string, YConfig['fill']]>)(
] as Array<[string, ExtendedYConfig['fill']]>)(
'should render a RectAnnotation for a reference line with fill set: %s %s',
(layerPrefix, fill) => {
const wrapper = shallow(
<ReferenceLineAnnotations
{...defaultProps}
data={histogramData}
layers={createLayers([
{
forAccessor: `${layerPrefix}FirstId`,
axisMode: 'bottom',
lineStyle: 'solid',
type: 'yConfig',
type: 'extendedYConfig',
fill,
},
])}
@ -176,27 +173,26 @@ describe('ReferenceLineAnnotations', () => {
['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }],
['yAccessorRight', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }],
['yAccessorRight', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }],
] as Array<[string, YConfig['fill'], YCoords, YCoords]>)(
] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)(
'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s',
(layerPrefix, fill, coordsA, coordsB) => {
const axisMode = getAxisFromId(layerPrefix);
const wrapper = shallow(
<ReferenceLineAnnotations
{...defaultProps}
data={histogramData}
layers={createLayers([
{
forAccessor: `${layerPrefix}FirstId`,
axisMode,
lineStyle: 'solid',
type: 'yConfig',
type: 'extendedYConfig',
fill,
},
{
forAccessor: `${layerPrefix}SecondId`,
axisMode,
lineStyle: 'solid',
type: 'yConfig',
type: 'extendedYConfig',
fill,
},
])}
@ -227,26 +223,25 @@ describe('ReferenceLineAnnotations', () => {
it.each([
['xAccessor', 'above', { x0: 1, x1: 2 }, { x0: 2, x1: undefined }],
['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: 1, x1: 2 }],
] as Array<[string, YConfig['fill'], XCoords, XCoords]>)(
] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)(
'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s',
(layerPrefix, fill, coordsA, coordsB) => {
const wrapper = shallow(
<ReferenceLineAnnotations
{...defaultProps}
data={histogramData}
layers={createLayers([
{
forAccessor: `${layerPrefix}FirstId`,
axisMode: 'bottom',
lineStyle: 'solid',
type: 'yConfig',
type: 'extendedYConfig',
fill,
},
{
forAccessor: `${layerPrefix}SecondId`,
axisMode: 'bottom',
lineStyle: 'solid',
type: 'yConfig',
type: 'extendedYConfig',
fill,
},
])}
@ -282,21 +277,20 @@ describe('ReferenceLineAnnotations', () => {
const wrapper = shallow(
<ReferenceLineAnnotations
{...defaultProps}
data={histogramData}
layers={createLayers([
{
forAccessor: `${layerPrefix}FirstId`,
axisMode,
lineStyle: 'solid',
fill: 'above',
type: 'yConfig',
type: 'extendedYConfig',
},
{
forAccessor: `${layerPrefix}SecondId`,
axisMode,
lineStyle: 'solid',
fill: 'below',
type: 'yConfig',
type: 'extendedYConfig',
},
])}
/>
@ -326,27 +320,26 @@ describe('ReferenceLineAnnotations', () => {
it.each([
['above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }],
['below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }],
] as Array<[YConfig['fill'], YCoords, YCoords]>)(
] as Array<[ExtendedYConfig['fill'], YCoords, YCoords]>)(
'should be robust and works also for different axes when on same direction: 1x Left + 1x Right both %s',
(fill, coordsA, coordsB) => {
const wrapper = shallow(
<ReferenceLineAnnotations
{...defaultProps}
data={histogramData}
layers={createLayers([
{
forAccessor: `yAccessorLeftFirstId`,
axisMode: 'left',
lineStyle: 'solid',
fill,
type: 'yConfig',
type: 'extendedYConfig',
},
{
forAccessor: `yAccessorRightSecondId`,
axisMode: 'right',
lineStyle: 'solid',
fill,
type: 'yConfig',
type: 'extendedYConfig',
},
])}
/>

View file

@ -13,8 +13,7 @@ import { groupBy } from 'lodash';
import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts';
import { euiLightVars } from '@kbn/ui-theme';
import type { FieldFormat } from '@kbn/field-formats-plugin/common';
import type { IconPosition, ReferenceLineLayerArgs, YAxisMode } from '../../common/types';
import type { LensMultiTable } from '../../common/types';
import type { CommonXYReferenceLineLayerConfig, IconPosition, YAxisMode } from '../../common/types';
import {
LINES_MARKER_SIZE,
mapVerticalToHorizontalPlacement,
@ -89,8 +88,7 @@ export function getBaseIconPlacement(
}
export interface ReferenceLineAnnotationsProps {
layers: ReferenceLineLayerArgs[];
data: LensMultiTable;
layers: CommonXYReferenceLineLayerConfig[];
formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>;
axesMap: Record<'left' | 'right', boolean>;
isHorizontal: boolean;
@ -99,7 +97,6 @@ export interface ReferenceLineAnnotationsProps {
export const ReferenceLineAnnotations = ({
layers,
data,
formatters,
axesMap,
isHorizontal,
@ -111,11 +108,10 @@ export const ReferenceLineAnnotations = ({
if (!layer.yConfig) {
return [];
}
const { columnToLabel, yConfig: yConfigs, layerId } = layer;
const { columnToLabel, yConfig: yConfigs, table } = layer;
const columnToLabelMap: Record<string, string> = columnToLabel
? JSON.parse(columnToLabel)
: {};
const table = data.tables[layerId];
const row = table.rows[0];
@ -194,8 +190,8 @@ export const ReferenceLineAnnotations = ({
annotations.push(
<LineAnnotation
{...props}
id={`${layerId}-${yConfig.forAccessor}-line`}
key={`${layerId}-${yConfig.forAccessor}-line`}
id={`${layer.layerId}-${yConfig.forAccessor}-line`}
key={`${layer.layerId}-${yConfig.forAccessor}-line`}
dataValues={table.rows.map(() => ({
dataValue: row[yConfig.forAccessor],
header: columnToLabelMap[yConfig.forAccessor],
@ -225,8 +221,8 @@ export const ReferenceLineAnnotations = ({
annotations.push(
<RectAnnotation
{...props}
id={`${layerId}-${yConfig.forAccessor}-rect`}
key={`${layerId}-${yConfig.forAccessor}-rect`}
id={`${layer.layerId}-${yConfig.forAccessor}-rect`}
key={`${layer.layerId}-${yConfig.forAccessor}-rect`}
dataValues={table.rows.map(() => {
const nextValue = shouldCheckNextReferenceLine
? row[groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor]

View file

@ -11,7 +11,7 @@ import React from 'react';
import moment from 'moment';
import { Endzones } from '@kbn/charts-plugin/public';
import { search } from '@kbn/data-plugin/public';
import type { LensMultiTable, DataLayerArgs } from '../../common';
import type { CommonXYDataLayerConfig } from '../../common';
export interface XDomain {
min?: number;
@ -19,17 +19,16 @@ export interface XDomain {
minInterval?: number;
}
export const getAppliedTimeRange = (layers: DataLayerArgs[], data: LensMultiTable) => {
return Object.entries(data.tables)
.map(([tableId, table]) => {
const layer = layers.find((l) => l.layerId === tableId);
const xColumn = table.columns.find((col) => col.id === layer?.xAccessor);
export const getAppliedTimeRange = (layers: CommonXYDataLayerConfig[]) => {
return layers
.map(({ xAccessor, table }) => {
const xColumn = table.columns.find((col) => col.id === xAccessor);
const timeRange =
xColumn && search.aggs.getDateHistogramMetaDataByDatatableColumn(xColumn)?.timeRange;
if (timeRange) {
return {
timeRange,
field: xColumn.meta.field,
field: xColumn?.meta.field,
};
}
})
@ -37,13 +36,12 @@ export const getAppliedTimeRange = (layers: DataLayerArgs[], data: LensMultiTabl
};
export const getXDomain = (
layers: DataLayerArgs[],
data: LensMultiTable,
layers: CommonXYDataLayerConfig[],
minInterval: number | undefined,
isTimeViz: boolean,
isHistogram: boolean
) => {
const appliedTimeRange = getAppliedTimeRange(layers, data)?.timeRange;
const appliedTimeRange = getAppliedTimeRange(layers)?.timeRange;
const from = appliedTimeRange?.from;
const to = appliedTimeRange?.to;
const baseDomain = isTimeViz
@ -59,8 +57,8 @@ export const getXDomain = (
if (isHistogram && isFullyQualified(baseDomain)) {
const xValues = uniq(
layers
.flatMap((layer) =>
data.tables[layer.layerId].rows.map((row) => row[layer.xAccessor!].valueOf() as number)
.flatMap<number>(({ table, xAccessor }) =>
table.rows.map((row) => row[xAccessor!].valueOf())
)
.sort()
);

View file

@ -6,66 +6,56 @@
* Side Public License, v 1.
*/
import React, { useRef } from 'react';
import React, { useMemo, useRef } from 'react';
import {
Chart,
Settings,
Axis,
LineSeries,
AreaSeries,
BarSeries,
Position,
GeometryValue,
XYChartSeriesIdentifier,
StackMode,
VerticalAlignment,
HorizontalAlignment,
LayoutDirection,
ElementClickListener,
BrushEndListener,
XYBrushEvent,
CurveType,
LegendPositionConfig,
LabelOverflowConstraint,
DisplayValueStyle,
RecursivePartial,
AxisStyle,
ScaleType,
AreaSeriesProps,
BarSeriesProps,
LineSeriesProps,
ColorVariant,
Placement,
} from '@elastic/charts';
import { IconType } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { PaletteRegistry, SeriesLayer } from '@kbn/coloring';
import type { Datatable, DatatableRow, DatatableColumn } from '@kbn/expressions-plugin/public';
import { PaletteRegistry } from '@kbn/coloring';
import { RenderMode } from '@kbn/expressions-plugin/common';
import { FieldFormat } from '@kbn/field-formats-plugin/common';
import { EmptyPlaceholder } from '@kbn/charts-plugin/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { ChartsPluginSetup, ChartsPluginStart, useActiveCursor } from '@kbn/charts-plugin/public';
import { MULTILAYER_TIME_AXIS_STYLE } from '@kbn/charts-plugin/common';
import type { FilterEvent, BrushEvent, FormatFactory } from '../types';
import type { SeriesType, XYChartProps } from '../../common/types';
import { isHorizontalChart, getSeriesColor, getAnnotationsLayers, getDataLayers } from '../helpers';
import type { CommonXYDataLayerConfig, SeriesType, XYChartProps } from '../../common/types';
import {
isHorizontalChart,
getAnnotationsLayers,
getDataLayers,
Series,
getFormattedTablesByLayers,
validateExtent,
} from '../helpers';
import {
getFilteredLayers,
getReferenceLayers,
isDataLayer,
getFitOptions,
getAxesConfiguration,
GroupsConfiguration,
validateExtent,
getColorAssignments,
getLinesCausedPaddings,
} from '../helpers';
import { getXDomain, XyEndzones } from './x_domain';
import { getLegendAction } from './legend_action';
import { ReferenceLineAnnotations, computeChartMargins } from './reference_lines';
import { visualizationDefinitions } from '../definitions';
import { XYLayerConfigResult } from '../../common/types';
import { CommonXYLayerConfig } from '../../common/types';
import {
Annotations,
getAnnotationsGroupedByInterval,
@ -73,7 +63,8 @@ import {
OUTSIDE_RECT_ANNOTATION_WIDTH,
OUTSIDE_RECT_ANNOTATION_WIDTH_SUGGESTION,
} from './annotations';
import { AxisExtentModes, SeriesTypes, ValueLabelModes } from '../../common/constants';
import { DataLayers } from './data_layers';
import './xy_chart.scss';
declare global {
@ -85,8 +76,6 @@ declare global {
}
}
type SeriesSpec = LineSeriesProps & BarSeriesProps & AreaSeriesProps;
export type XYChartRenderProps = XYChartProps & {
chartsThemeService: ChartsPluginSetup['theme'];
chartsActiveCursorService: ChartsPluginStart['activeCursor'];
@ -104,8 +93,6 @@ export type XYChartRenderProps = XYChartProps & {
eventAnnotationService: EventAnnotationServiceType;
};
const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object';
function getValueLabelsStyling(isHorizontal: boolean): {
displayValue: RecursivePartial<DisplayValueStyle>;
} {
@ -134,7 +121,6 @@ function getIconForSeriesType(seriesType: SeriesType): IconType {
export const XYChartReportable = React.memo(XYChart);
export function XYChart({
data,
args,
formatFactory,
timeZone,
@ -166,50 +152,47 @@ export function XYChart({
const chartTheme = chartsThemeService.useChartsTheme();
const chartBaseTheme = chartsThemeService.useChartsBaseTheme();
const darkMode = chartsThemeService.useDarkMode();
const filteredLayers = getFilteredLayers(layers, data);
const layersById = filteredLayers.reduce<Record<string, XYLayerConfigResult>>(
(hashMap, layer) => {
hashMap[layer.layerId] = layer;
return hashMap;
},
const filteredLayers = getFilteredLayers(layers);
const layersById = filteredLayers.reduce<Record<string, CommonXYLayerConfig>>(
(hashMap, layer) => ({ ...hashMap, [layer.layerId]: layer }),
{}
);
const handleCursorUpdate = useActiveCursor(chartsActiveCursorService, chartRef, {
datatables: Object.values(data.tables),
datatables: filteredLayers.map(({ table }) => table),
});
const dataLayers: CommonXYDataLayerConfig[] = filteredLayers.filter(isDataLayer);
const formattedDatatables = useMemo(
() => getFormattedTablesByLayers(dataLayers, formatFactory),
[dataLayers, formatFactory]
);
if (filteredLayers.length === 0) {
const icon: IconType = getIconForSeriesType(getDataLayers(layers)?.[0]?.seriesType || 'bar');
const icon: IconType = getIconForSeriesType(
getDataLayers(layers)?.[0]?.seriesType || SeriesTypes.BAR
);
return <EmptyPlaceholder className="xyChart__empty" icon={icon} />;
}
// use formatting hint of first x axis column to format ticks
const xAxisColumn = data.tables[filteredLayers[0].layerId].columns.find(
({ id }) => id === filteredLayers[0].xAccessor
);
const xAxisColumn = dataLayers[0]?.table.columns.find(({ id }) => id === dataLayers[0].xAccessor);
const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.meta?.params);
const layersAlreadyFormatted: Record<string, boolean> = {};
// This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers
const safeXAccessorLabelRenderer = (value: unknown): string =>
xAxisColumn && layersAlreadyFormatted[xAxisColumn.id]
xAxisColumn && formattedDatatables[dataLayers[0]?.layerId]?.formattedColumns[xAxisColumn.id]
? String(value)
: String(xAxisFormatter.convert(value));
const chartHasMoreThanOneSeries =
filteredLayers.length > 1 ||
filteredLayers.some((layer) => layer.accessors.length > 1) ||
filteredLayers.some((layer) => layer.splitAccessor);
const shouldRotate = isHorizontalChart(filteredLayers);
filteredLayers.some((layer) => isDataLayer(layer) && layer.splitAccessor);
const shouldRotate = isHorizontalChart(dataLayers);
const yAxesConfiguration = getAxesConfiguration(
filteredLayers,
shouldRotate,
data.tables,
formatFactory
);
const yAxesConfiguration = getAxesConfiguration(dataLayers, shouldRotate, formatFactory);
const xTitle = args.xTitle || (xAxisColumn && xAxisColumn.name);
const axisTitlesVisibilitySettings = args.axisTitlesVisibilitySettings || {
@ -223,25 +206,20 @@ export function XYChart({
yRight: true,
};
const labelsOrientation = args.labelsOrientation || {
x: 0,
yLeft: 0,
yRight: 0,
};
const labelsOrientation = args.labelsOrientation || { x: 0, yLeft: 0, yRight: 0 };
const filteredBarLayers = filteredLayers.filter((layer) => layer.seriesType.includes('bar'));
const filteredBarLayers = dataLayers.filter((layer) => layer.seriesType.includes('bar'));
const chartHasMoreThanOneBarSeries =
filteredBarLayers.length > 1 ||
filteredBarLayers.some((layer) => layer.accessors.length > 1) ||
filteredBarLayers.some((layer) => layer.splitAccessor);
filteredBarLayers.some((layer) => isDataLayer(layer) && layer.splitAccessor);
const isTimeViz = Boolean(filteredLayers.every((l) => l.xScaleType === 'time'));
const isHistogramViz = filteredLayers.every((l) => l.isHistogram);
const isTimeViz = Boolean(dataLayers.every((l) => l.xScaleType === 'time'));
const isHistogramViz = dataLayers.every((l) => l.isHistogram);
const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain(
filteredLayers,
data,
dataLayers,
minInterval,
isTimeViz,
isHistogramViz
@ -252,17 +230,16 @@ export function XYChart({
right: yAxesConfiguration.find(({ groupId }) => groupId === 'right'),
};
const getYAxesTitles = (
axisSeries: Array<{ layer: string; accessor: string }>,
groupId: string
) => {
const getYAxesTitles = (axisSeries: Series[], groupId: 'right' | 'left') => {
const yTitle = groupId === 'right' ? args.yRightTitle : args.yTitle;
return (
yTitle ||
axisSeries
.map(
(series) =>
data.tables[series.layer].columns.find((column) => column.id === series.accessor)?.name
filteredLayers
.find(({ layerId }) => series.layer === layerId)
?.table.columns.find((column) => column.id === series.accessor)?.name
)
.filter((name) => Boolean(name))[0]
);
@ -270,9 +247,9 @@ export function XYChart({
const referenceLineLayers = getReferenceLayers(layers);
const annotationsLayers = getAnnotationsLayers(layers);
const firstTable = data.tables[filteredLayers[0].layerId];
const firstTable = dataLayers[0]?.table;
const xColumnId = firstTable.columns.find((col) => col.id === filteredLayers[0].xAccessor)?.id;
const xColumnId = firstTable.columns.find((col) => col.id === dataLayers[0]?.xAccessor)?.id;
const groupedLineAnnotations = getAnnotationsGroupedByInterval(
annotationsLayers,
@ -339,10 +316,11 @@ export function XYChart({
return layer.seriesType.includes('bar') || layer.seriesType.includes('area');
})
);
const fit = !hasBarOrArea && extent.mode === 'dataBounds';
const fit = !hasBarOrArea && extent.mode === AxisExtentModes.DATA_BOUNDS;
let min: number = NaN;
let max: number = NaN;
if (extent.mode === 'custom') {
const { inclusiveZeroError, boundaryError } = validateExtent(hasBarOrArea, extent);
if (!inclusiveZeroError && !boundaryError) {
@ -369,38 +347,42 @@ export function XYChart({
const shouldShowValueLabels =
// No stacked bar charts
filteredLayers.every((layer) => !layer.seriesType.includes('stacked')) &&
dataLayers.every((layer) => !layer.seriesType.includes('stacked')) &&
// No histogram charts
!isHistogramViz;
const valueLabelsStyling =
shouldShowValueLabels && valueLabels !== 'hide' && getValueLabelsStyling(shouldRotate);
const colorAssignments = getColorAssignments(getDataLayers(args.layers), data, formatFactory);
shouldShowValueLabels &&
valueLabels !== ValueLabelModes.HIDE &&
getValueLabelsStyling(shouldRotate);
const clickHandler: ElementClickListener = ([[geometry, series]]) => {
// for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue
const xySeries = series as XYChartSeriesIdentifier;
const xyGeometry = geometry as GeometryValue;
const layer = filteredLayers.find((l) =>
const layerIndex = dataLayers.findIndex((l) =>
xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString()))
);
if (!layer) {
if (layerIndex === -1) {
return;
}
const table = data.tables[layer.layerId];
const layer = dataLayers[layerIndex];
const { table } = layer;
const xColumn = table.columns.find((col) => col.id === layer.xAccessor);
const currentXFormatter =
layer.xAccessor && layersAlreadyFormatted[layer.xAccessor] && xColumn
layer.xAccessor &&
formattedDatatables[layer.layerId]?.formattedColumns[layer.xAccessor] &&
xColumn
? formatFactory(xColumn.meta.params)
: xAxisFormatter;
const rowIndex = table.rows.findIndex((row) => {
if (layer.xAccessor) {
if (layersAlreadyFormatted[layer.xAccessor]) {
if (formattedDatatables[layer.layerId]?.formattedColumns[layer.xAccessor]) {
// stringify the value to compare with the chart value
return currentXFormatter.convert(row[layer.xAccessor]) === xyGeometry.x;
}
@ -425,7 +407,7 @@ export function XYChart({
points.push({
row: table.rows.findIndex((row) => {
if (layer.splitAccessor) {
if (layersAlreadyFormatted[layer.splitAccessor]) {
if (formattedDatatables[layer.layerId]?.formattedColumns[layer.splitAccessor]) {
return splitFormatter.convert(row[layer.splitAccessor]) === pointValue;
}
return row[layer.splitAccessor] === pointValue;
@ -436,12 +418,7 @@ export function XYChart({
});
}
const context: FilterEvent['data'] = {
data: points.map((point) => ({
row: point.row,
column: point.column,
value: point.value,
table,
})),
data: points.map(({ row, column, value }) => ({ row, column, value, table })),
};
onClickValue(context);
};
@ -455,27 +432,23 @@ export function XYChart({
return;
}
const table = data.tables[filteredLayers[0].layerId];
const { table } = dataLayers[0];
const xAxisColumnIndex = table.columns.findIndex((el) => el.id === filteredLayers[0].xAccessor);
const xAxisColumnIndex = table.columns.findIndex((el) => el.id === dataLayers[0].xAccessor);
const context: BrushEvent['data'] = {
range: [min, max],
table,
column: xAxisColumnIndex,
};
const context: BrushEvent['data'] = { range: [min, max], table, column: xAxisColumnIndex };
onSelectRange(context);
};
const legendInsideParams = {
const legendInsideParams: LegendPositionConfig = {
vAlign: legend.verticalAlignment ?? VerticalAlignment.Top,
hAlign: legend?.horizontalAlignment ?? HorizontalAlignment.Right,
direction: LayoutDirection.Vertical,
floating: true,
floatingColumns: legend?.floatingColumns ?? 1,
} as LegendPositionConfig;
};
const isHistogramModeEnabled = filteredLayers.some(
const isHistogramModeEnabled = dataLayers.some(
({ isHistogram, seriesType }) =>
isHistogram &&
(seriesType.includes('stacked') ||
@ -570,13 +543,7 @@ export function XYChart({
onElementClick={interactive ? clickHandler : undefined}
legendAction={
interactive
? getLegendAction(
filteredLayers,
data.tables,
onClickValue,
formatFactory,
layersAlreadyFormatted
)
? getLegendAction(dataLayers, onClickValue, formatFactory, formattedDatatables)
: undefined
}
showLegendExtra={isHistogramViz && valuesInLegend}
@ -589,7 +556,7 @@ export function XYChart({
position={shouldRotate ? Position.Left : Position.Bottom}
title={xTitle}
gridLine={gridLineStyle}
hide={filteredLayers[0].hide || !filteredLayers[0].xAccessor}
hide={dataLayers[0]?.hide || !dataLayers[0]?.xAccessor}
tickFormat={(d) => safeXAccessorLabelRenderer(d)}
style={xAxisStyle}
timeAxisLayerCount={shouldUseNewTimeAxis ? 3 : 0}
@ -609,9 +576,9 @@ export function XYChart({
? gridlinesVisibilitySettings?.yRight
: gridlinesVisibilitySettings?.yLeft,
}}
hide={filteredLayers[0].hide}
hide={dataLayers[0]?.hide}
tickFormat={(d) => axis.formatter?.convert(d) || ''}
style={getYAxesStyle(axis.groupId as 'left' | 'right')}
style={getYAxesStyle(axis.groupId)}
domain={getYAxisDomain(axis)}
ticks={5}
/>
@ -623,7 +590,7 @@ export function XYChart({
baseDomain={rawXDomain}
extendedDomain={xDomain}
darkMode={darkMode}
histogramMode={filteredLayers.every(
histogramMode={dataLayers.every(
(layer) =>
layer.isHistogram &&
(layer.seriesType.includes('stacked') || !layer.splitAccessor) &&
@ -634,282 +601,28 @@ export function XYChart({
/>
)}
{filteredLayers.flatMap((layer, layerIndex) =>
layer.accessors.map((accessor, accessorIndex) => {
const {
splitAccessor,
seriesType,
accessors,
xAccessor,
layerId,
columnToLabel,
yScaleType,
xScaleType,
isHistogram,
palette,
} = layer;
const columnToLabelMap: Record<string, string> = columnToLabel
? JSON.parse(columnToLabel)
: {};
const table = data.tables[layerId];
const formatterPerColumn = new Map<DatatableColumn, FieldFormat>();
for (const column of table.columns) {
formatterPerColumn.set(column, formatFactory(column.meta.params));
}
// what if row values are not primitive? That is the case of, for instance, Ranges
// remaps them to their serialized version with the formatHint metadata
// In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on
const tableConverted: Datatable = {
...table,
rows: table.rows.map((row: DatatableRow) => {
const newRow = { ...row };
for (const column of table.columns) {
const record = newRow[column.id];
if (
record != null &&
// pre-format values for ordinal x axes because there can only be a single x axis formatter on chart level
(!isPrimitive(record) || (column.id === xAccessor && xScaleType === 'ordinal'))
) {
newRow[column.id] = formatterPerColumn.get(column)!.convert(record);
}
}
return newRow;
}),
};
// save the id of the layer with the custom table
table.columns.reduce<Record<string, boolean>>(
(alreadyFormatted: Record<string, boolean>, { id }) => {
if (alreadyFormatted[id]) {
return alreadyFormatted;
}
alreadyFormatted[id] = table.rows.some(
(row, i) => row[id] !== tableConverted.rows[i][id]
);
return alreadyFormatted;
},
layersAlreadyFormatted
);
const isStacked = seriesType.includes('stacked');
const isPercentage = seriesType.includes('percentage');
const isBarChart = seriesType.includes('bar');
const enableHistogramMode =
isHistogram &&
(isStacked || !splitAccessor) &&
(isStacked || !isBarChart || !chartHasMoreThanOneBarSeries);
// For date histogram chart type, we're getting the rows that represent intervals without data.
// To not display them in the legend, they need to be filtered out.
const rows = tableConverted.rows.filter(
(row) =>
!(xAccessor && typeof row[xAccessor] === 'undefined') &&
!(
splitAccessor &&
typeof row[splitAccessor] === 'undefined' &&
typeof row[accessor] === 'undefined'
)
);
if (!xAccessor) {
rows.forEach((row) => {
row.unifiedX = i18n.translate('expressionXY.xyChart.emptyXLabel', {
defaultMessage: '(empty)',
});
});
}
const yAxis = yAxesConfiguration.find((axisConfiguration) =>
axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor)
);
const formatter = table?.columns.find((column) => column.id === accessor)?.meta?.params;
const splitHint = table.columns.find((col) => col.id === splitAccessor)?.meta?.params;
const splitFormatter = formatFactory(splitHint);
const seriesProps: SeriesSpec = {
splitSeriesAccessors: splitAccessor ? [splitAccessor] : [],
stackAccessors: isStacked ? [xAccessor as string] : [],
id: `${splitAccessor}-${accessor}`,
xAccessor: xAccessor || 'unifiedX',
yAccessors: [accessor],
data: rows,
xScaleType: xAccessor ? xScaleType : 'ordinal',
yScaleType:
formatter?.id === 'bytes' && yScaleType === ScaleType.Linear
? ScaleType.LinearBinary
: yScaleType,
color: ({ yAccessor, seriesKeys }) => {
const overwriteColor = getSeriesColor(layer, accessor);
if (overwriteColor !== null) {
return overwriteColor;
}
const colorAssignment = colorAssignments[palette.name];
const seriesLayers: SeriesLayer[] = [
{
name: splitAccessor ? String(seriesKeys[0]) : columnToLabelMap[seriesKeys[0]],
totalSeriesAtDepth: colorAssignment.totalSeriesCount,
rankAtDepth: colorAssignment.getRank(
layer,
String(seriesKeys[0]),
String(yAccessor)
),
},
];
return paletteService.get(palette.name).getCategoricalColor(
seriesLayers,
{
maxDepth: 1,
behindText: false,
totalSeries: colorAssignment.totalSeriesCount,
syncColors,
},
palette.params
);
},
groupId: yAxis?.groupId,
enableHistogramMode,
stackMode: isPercentage ? StackMode.Percentage : undefined,
timeZone,
areaSeriesStyle: {
point: {
visible: !xAccessor,
radius: xAccessor && !emphasizeFitting ? 5 : 0,
},
...(args.fillOpacity && { area: { opacity: args.fillOpacity } }),
...(emphasizeFitting && {
fit: {
area: {
opacity: args.fillOpacity || 0.5,
},
line: {
visible: true,
stroke: ColorVariant.Series,
opacity: 1,
dash: [],
},
},
}),
},
lineSeriesStyle: {
point: {
visible: !xAccessor,
radius: xAccessor && !emphasizeFitting ? 5 : 0,
},
...(emphasizeFitting && {
fit: {
line: {
visible: true,
stroke: ColorVariant.Series,
opacity: 1,
dash: [],
},
},
}),
},
name(d) {
// For multiple y series, the name of the operation is used on each, either:
// * Key - Y name
// * Formatted value - Y name
if (accessors.length > 1) {
const result = d.seriesKeys
.map((key: string | number, i) => {
if (
i === 0 &&
splitHint &&
splitAccessor &&
!layersAlreadyFormatted[splitAccessor]
) {
return splitFormatter.convert(key);
}
return splitAccessor && i === 0 ? key : columnToLabelMap[key] ?? '';
})
.join(' - ');
return result;
}
// For formatted split series, format the key
// This handles splitting by dates, for example
if (splitHint) {
if (splitAccessor && layersAlreadyFormatted[splitAccessor]) {
return d.seriesKeys[0];
}
return splitFormatter.convert(d.seriesKeys[0]);
}
// This handles both split and single-y cases:
// * If split series without formatting, show the value literally
// * If single Y, the seriesKey will be the accessor, so we show the human-readable name
return splitAccessor ? d.seriesKeys[0] : columnToLabelMap[d.seriesKeys[0]] ?? '';
},
};
const index = `${layerIndex}-${accessorIndex}`;
const curveType = args.curveType ? CurveType[args.curveType] : undefined;
switch (seriesType) {
case 'line':
return (
<LineSeries
key={index}
{...seriesProps}
fit={getFitOptions(fittingFunction, endValue)}
curve={curveType}
/>
);
case 'bar':
case 'bar_stacked':
case 'bar_percentage_stacked':
case 'bar_horizontal':
case 'bar_horizontal_stacked':
case 'bar_horizontal_percentage_stacked':
const valueLabelsSettings = {
displayValueSettings: {
// This format double fixes two issues in elastic-chart
// * when rotating the chart, the formatter is not correctly picked
// * in some scenarios value labels are not strings, and this breaks the elastic-chart lib
valueFormatter: (d: unknown) => yAxis?.formatter?.convert(d) || '',
showValueLabel: shouldShowValueLabels && valueLabels !== 'hide',
isValueContainedInElement: false,
isAlternatingValueLabel: false,
overflowConstraints: [
LabelOverflowConstraint.ChartEdges,
LabelOverflowConstraint.BarGeometry,
],
},
};
return <BarSeries key={index} {...seriesProps} {...valueLabelsSettings} />;
case 'area_stacked':
case 'area_percentage_stacked':
return (
<AreaSeries
key={index}
{...seriesProps}
fit={isPercentage ? 'zero' : getFitOptions(fittingFunction, endValue)}
curve={curveType}
/>
);
case 'area':
return (
<AreaSeries
key={index}
{...seriesProps}
fit={getFitOptions(fittingFunction, endValue)}
curve={curveType}
/>
);
default:
return assertNever(seriesType);
}
})
{dataLayers.length && (
<DataLayers
layers={dataLayers}
endValue={endValue}
timeZone={timeZone}
curveType={args.curveType}
syncColors={syncColors}
valueLabels={valueLabels}
fillOpacity={args.fillOpacity}
formatFactory={formatFactory}
paletteService={paletteService}
fittingFunction={fittingFunction}
emphasizeFitting={emphasizeFitting}
yAxesConfiguration={yAxesConfiguration}
shouldShowValueLabels={shouldShowValueLabels}
formattedDatatables={formattedDatatables}
chartHasMoreThanOneBarSeries={chartHasMoreThanOneBarSeries}
/>
)}
{referenceLineLayers.length ? (
<ReferenceLineAnnotations
layers={referenceLineLayers}
data={data}
formatters={{
left: yAxesMap.left?.formatter,
right: yAxesMap.right?.formatter,
@ -946,7 +659,3 @@ export function XYChart({
</Chart>
);
}
function assertNever(x: never): never {
throw new Error('Unexpected series type: ' + x);
}

View file

@ -18,7 +18,6 @@ import { ExpressionRenderDefinition } from '@kbn/expressions-plugin';
import { FormatFactory } from '@kbn/field-formats-plugin/common';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import type { XYChartProps } from '../../common';
import { calculateMinInterval } from '../helpers/interval';
import type { BrushEvent, FilterEvent } from '../types';
export type GetStartDepsFn = () => Promise<{
@ -56,7 +55,10 @@ export const getXyChartRenderer = ({
};
const deps = await getStartDeps();
const { XYChartReportable } = await import('../components/xy_chart');
const [{ XYChartReportable }, { calculateMinInterval }] = await Promise.all([
import('../components/xy_chart'),
import('../helpers/interval'),
]);
ReactDOM.render(
<KibanaThemeProvider theme$={deps.kibanaTheme.theme$}>

View file

@ -9,19 +9,32 @@ import React from 'react';
import { Position } from '@elastic/charts';
import { EuiFlexGroup, EuiIcon, EuiIconProps, EuiText } from '@elastic/eui';
import classnames from 'classnames';
import type { IconPosition, YAxisMode, YConfig } from '../../common/types';
import type {
IconPosition,
YAxisMode,
ExtendedYConfig,
CollectiveConfig,
} from '../../common/types';
import { getBaseIconPlacement } from '../components';
import { hasIcon } from './icon';
import { annotationsIconSet } from './annotations_icon_set';
import { hasIcon, iconSet } from './icon';
export const LINES_MARKER_SIZE = 20;
// Note: it does not take into consideration whether the reference line is in view or not
type PartialExtendedYConfig = Pick<
ExtendedYConfig,
'axisMode' | 'icon' | 'iconPosition' | 'textVisibility'
>;
type PartialCollectiveConfig = Pick<CollectiveConfig, 'axisMode' | 'icon' | 'textVisibility'>;
const isExtendedYConfig = (
config: PartialExtendedYConfig | PartialCollectiveConfig | undefined
): config is PartialExtendedYConfig =>
(config as PartialExtendedYConfig)?.iconPosition ? true : false;
// Note: it does not take into consideration whether the reference line is in view or not
export const getLinesCausedPaddings = (
visualConfigs: Array<
Pick<YConfig, 'axisMode' | 'icon' | 'iconPosition' | 'textVisibility'> | undefined
>,
visualConfigs: Array<PartialExtendedYConfig | PartialCollectiveConfig | undefined>,
axesMap: Record<'left' | 'right', unknown>
) => {
// collect all paddings for the 4 axis: if any text is detected double it.
@ -31,7 +44,9 @@ export const getLinesCausedPaddings = (
if (!config) {
return;
}
const { axisMode, icon, iconPosition, textVisibility } = config;
const { axisMode, icon, textVisibility } = config;
const iconPosition = isExtendedYConfig(config) ? config.iconPosition : undefined;
if (axisMode && (hasIcon(icon) || textVisibility)) {
const placement = getBaseIconPlacement(iconPosition, axesMap, axisMode);
paddings[placement] = Math.max(
@ -48,6 +63,7 @@ export const getLinesCausedPaddings = (
paddings[placement] = LINES_MARKER_SIZE;
}
});
return paddings;
};
@ -138,7 +154,7 @@ export const AnnotationIcon = ({
if (isNumericalString(type)) {
return <NumberIcon number={Number(type)} />;
}
const iconConfig = annotationsIconSet.find((i) => i.value === type);
const iconConfig = iconSet.find((i) => i.value === type);
if (!iconConfig) {
return null;
}

View file

@ -1,101 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { TriangleIcon, CircleIcon } from '../icons';
export const annotationsIconSet = [
{
value: 'asterisk',
label: i18n.translate('expressionXY.xyChart.iconSelect.asteriskIconLabel', {
defaultMessage: 'Asterisk',
}),
},
{
value: 'alert',
label: i18n.translate('expressionXY.xyChart.iconSelect.alertIconLabel', {
defaultMessage: 'Alert',
}),
},
{
value: 'bell',
label: i18n.translate('expressionXY.xyChart.iconSelect.bellIconLabel', {
defaultMessage: 'Bell',
}),
},
{
value: 'bolt',
label: i18n.translate('expressionXY.xyChart.iconSelect.boltIconLabel', {
defaultMessage: 'Bolt',
}),
},
{
value: 'bug',
label: i18n.translate('expressionXY.xyChart.iconSelect.bugIconLabel', {
defaultMessage: 'Bug',
}),
},
{
value: 'circle',
label: i18n.translate('expressionXY.xyChart.iconSelect.circleIconLabel', {
defaultMessage: 'Circle',
}),
icon: CircleIcon,
canFill: true,
},
{
value: 'editorComment',
label: i18n.translate('expressionXY.xyChart.iconSelect.commentIconLabel', {
defaultMessage: 'Comment',
}),
},
{
value: 'flag',
label: i18n.translate('expressionXY.xyChart.iconSelect.flagIconLabel', {
defaultMessage: 'Flag',
}),
},
{
value: 'heart',
label: i18n.translate('expressionXY.xyChart.iconSelect.heartLabel', {
defaultMessage: 'Heart',
}),
},
{
value: 'mapMarker',
label: i18n.translate('expressionXY.xyChart.iconSelect.mapMarkerLabel', {
defaultMessage: 'Map Marker',
}),
},
{
value: 'pinFilled',
label: i18n.translate('expressionXY.xyChart.iconSelect.mapPinLabel', {
defaultMessage: 'Map Pin',
}),
},
{
value: 'starEmpty',
label: i18n.translate('expressionXY.xyChart.iconSelect.starLabel', { defaultMessage: 'Star' }),
},
{
value: 'tag',
label: i18n.translate('expressionXY.xyChart.iconSelect.tagIconLabel', {
defaultMessage: 'Tag',
}),
},
{
value: 'triangle',
label: i18n.translate('expressionXY.xyChart.iconSelect.triangleIconLabel', {
defaultMessage: 'Triangle',
}),
icon: TriangleIcon,
shouldRotate: true,
canFill: true,
},
];

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { DataLayerConfigResult } from '../../common';
import { DataLayerConfig } from '../../common';
import { LayerTypes } from '../../common/constants';
import { Datatable } from '@kbn/expressions-plugin/public';
import { getAxesConfiguration } from './axes_configuration';
@ -220,9 +220,9 @@ describe('axes_configuration', () => {
},
};
const sampleLayer: DataLayerConfigResult = {
type: 'dataLayer',
const sampleLayer: DataLayerConfig = {
layerId: 'first',
type: 'dataLayer',
layerType: LayerTypes.DATA,
seriesType: 'line',
xAccessor: 'c',
@ -233,11 +233,12 @@ describe('axes_configuration', () => {
yScaleType: 'linear',
isHistogram: false,
palette: { type: 'palette', name: 'default' },
table: tables.first,
};
it('should map auto series to left axis', () => {
const formatFactory = jest.fn();
const groups = getAxesConfiguration([sampleLayer], false, tables, formatFactory);
const groups = getAxesConfiguration([sampleLayer], false, formatFactory);
expect(groups.length).toEqual(1);
expect(groups[0].position).toEqual('left');
expect(groups[0].series[0].accessor).toEqual('yAccessorId');
@ -247,7 +248,7 @@ describe('axes_configuration', () => {
it('should map auto series to right axis if formatters do not match', () => {
const formatFactory = jest.fn();
const twoSeriesLayer = { ...sampleLayer, accessors: ['yAccessorId', 'yAccessorId2'] };
const groups = getAxesConfiguration([twoSeriesLayer], false, tables, formatFactory);
const groups = getAxesConfiguration([twoSeriesLayer], false, formatFactory);
expect(groups.length).toEqual(2);
expect(groups[0].position).toEqual('left');
expect(groups[1].position).toEqual('right');
@ -261,7 +262,7 @@ describe('axes_configuration', () => {
...sampleLayer,
accessors: ['yAccessorId', 'yAccessorId2', 'yAccessorId3'],
};
const groups = getAxesConfiguration([threeSeriesLayer], false, tables, formatFactory);
const groups = getAxesConfiguration([threeSeriesLayer], false, formatFactory);
expect(groups.length).toEqual(2);
expect(groups[0].position).toEqual('left');
expect(groups[1].position).toEqual('right');
@ -280,7 +281,6 @@ describe('axes_configuration', () => {
},
],
false,
tables,
formatFactory
);
expect(groups.length).toEqual(1);
@ -300,7 +300,6 @@ describe('axes_configuration', () => {
},
],
false,
tables,
formatFactory
);
expect(groups.length).toEqual(2);
@ -324,7 +323,6 @@ describe('axes_configuration', () => {
},
],
false,
tables,
formatFactory
);
expect(formatFactory).toHaveBeenCalledTimes(2);

View file

@ -6,22 +6,25 @@
* Side Public License, v 1.
*/
import { Datatable } from '@kbn/expressions-plugin/public';
import type { IFieldFormat, SerializedFieldFormat } from '@kbn/field-formats-plugin/common';
import { FormatFactory } from '../types';
import { AxisExtentConfig, DataLayerConfigResult } from '../../common';
import { AxisExtentConfig, CommonXYDataLayerConfig, ExtendedYConfig, YConfig } from '../../common';
import { isDataLayer } from './visualization';
interface FormattedMetric {
export interface Series {
layer: string;
accessor: string;
}
interface FormattedMetric extends Series {
fieldFormat: SerializedFieldFormat;
}
export type GroupsConfiguration = Array<{
groupId: string;
groupId: 'left' | 'right';
position: 'left' | 'right' | 'bottom' | 'top';
formatter?: IFieldFormat;
series: Array<{ layer: string; accessor: string }>;
series: Series[];
}>;
export function isFormatterCompatible(
@ -31,10 +34,7 @@ export function isFormatterCompatible(
return formatter1.id === formatter2.id;
}
export function groupAxesByType(
layers: DataLayerConfigResult[],
tables?: Record<string, Datatable>
) {
export function groupAxesByType(layers: CommonXYDataLayerConfig[]) {
const series: {
auto: FormattedMetric[];
left: FormattedMetric[];
@ -47,15 +47,19 @@ export function groupAxesByType(
bottom: [],
};
layers?.forEach((layer) => {
const table = tables?.[layer.layerId];
layers.forEach((layer) => {
const { table } = layer;
layer.accessors.forEach((accessor) => {
const yConfig: Array<YConfig | ExtendedYConfig> | undefined = layer.yConfig;
const mode =
layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode ||
'auto';
let formatter: SerializedFieldFormat = table?.columns.find((column) => column.id === accessor)
yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode || 'auto';
let formatter: SerializedFieldFormat = table.columns?.find((column) => column.id === accessor)
?.meta?.params || { id: 'number' };
if (layer.seriesType.includes('percentage') && formatter.id !== 'percent') {
if (
isDataLayer(layer) &&
layer.seriesType.includes('percentage') &&
formatter.id !== 'percent'
) {
formatter = {
id: 'percent',
params: {
@ -71,10 +75,12 @@ export function groupAxesByType(
});
});
const tablesExist = layers.filter(({ table }) => Boolean(table)).length > 0;
series.auto.forEach((currentSeries) => {
if (
series.left.length === 0 ||
(tables &&
(tablesExist &&
series.left.every((leftSeries) =>
isFormatterCompatible(leftSeries.fieldFormat, currentSeries.fieldFormat)
))
@ -82,7 +88,7 @@ export function groupAxesByType(
series.left.push(currentSeries);
} else if (
series.right.length === 0 ||
(tables &&
(tablesExist &&
series.left.every((leftSeries) =>
isFormatterCompatible(leftSeries.fieldFormat, currentSeries.fieldFormat)
))
@ -98,12 +104,11 @@ export function groupAxesByType(
}
export function getAxesConfiguration(
layers: DataLayerConfigResult[],
layers: CommonXYDataLayerConfig[],
shouldRotate: boolean,
tables?: Record<string, Datatable>,
formatFactory?: FormatFactory
): GroupsConfiguration {
const series = groupAxesByType(layers, tables);
const series = groupAxesByType(layers);
const axisGroups: GroupsConfiguration = [];

View file

@ -7,76 +7,76 @@
*/
import { getColorAssignments } from './color_assignment';
import type { DataLayerConfigResult, LensMultiTable } from '../../common';
import type { DataLayerConfig } from '../../common';
import type { FormatFactory } from '../types';
import { LayerTypes } from '../../common/constants';
import { Datatable } from '@kbn/expressions-plugin';
describe('color_assignment', () => {
const layers: DataLayerConfigResult[] = [
const tables: Record<string, Datatable> = {
'1': {
type: 'datatable',
columns: [
{ id: 'split1', name: '', meta: { type: 'number' } },
{ id: 'y1', name: '', meta: { type: 'number' } },
{ id: 'y2', name: '', meta: { type: 'number' } },
],
rows: [
{ split1: 1 },
{ split1: 2 },
{ split1: 3 },
{ split1: 1 },
{ split1: 2 },
{ split1: 3 },
],
},
'2': {
type: 'datatable',
columns: [
{ id: 'split2', name: '', meta: { type: 'number' } },
{ id: 'y1', name: '', meta: { type: 'number' } },
{ id: 'y2', name: '', meta: { type: 'number' } },
],
rows: [
{ split2: 1 },
{ split2: 2 },
{ split2: 3 },
{ split2: 1 },
{ split2: 2 },
{ split2: 3 },
],
},
};
const layers: DataLayerConfig[] = [
{
layerId: 'first',
type: 'dataLayer',
yScaleType: 'linear',
xScaleType: 'linear',
isHistogram: true,
seriesType: 'bar',
palette: { type: 'palette', name: 'palette1' },
layerId: '1',
layerType: LayerTypes.DATA,
splitAccessor: 'split1',
accessors: ['y1', 'y2'],
table: tables['1'],
},
{
layerId: 'second',
type: 'dataLayer',
yScaleType: 'linear',
xScaleType: 'linear',
isHistogram: true,
seriesType: 'bar',
palette: { type: 'palette', name: 'palette2' },
layerId: '2',
layerType: LayerTypes.DATA,
splitAccessor: 'split2',
accessors: ['y3', 'y4'],
table: tables['2'],
},
];
const data: LensMultiTable = {
type: 'lens_multitable',
tables: {
'1': {
type: 'datatable',
columns: [
{ id: 'split1', name: '', meta: { type: 'number' } },
{ id: 'y1', name: '', meta: { type: 'number' } },
{ id: 'y2', name: '', meta: { type: 'number' } },
],
rows: [
{ split1: 1 },
{ split1: 2 },
{ split1: 3 },
{ split1: 1 },
{ split1: 2 },
{ split1: 3 },
],
},
'2': {
type: 'datatable',
columns: [
{ id: 'split2', name: '', meta: { type: 'number' } },
{ id: 'y1', name: '', meta: { type: 'number' } },
{ id: 'y2', name: '', meta: { type: 'number' } },
],
rows: [
{ split2: 1 },
{ split2: 2 },
{ split2: 3 },
{ split2: 1 },
{ split2: 2 },
{ split2: 3 },
],
},
},
};
const formatFactory = (() =>
({
convert(x: unknown) {
@ -86,7 +86,7 @@ describe('color_assignment', () => {
describe('totalSeriesCount', () => {
it('should calculate total number of series per palette', () => {
const assignments = getColorAssignments(layers, data, formatFactory);
const assignments = getColorAssignments(layers, formatFactory);
// two y accessors, with 3 splitted series
expect(assignments.palette1.totalSeriesCount).toEqual(2 * 3);
expect(assignments.palette2.totalSeriesCount).toEqual(2 * 3);
@ -95,7 +95,6 @@ describe('color_assignment', () => {
it('should calculate total number of series spanning multible layers', () => {
const assignments = getColorAssignments(
[layers[0], { ...layers[1], palette: layers[0].palette }],
data,
formatFactory
);
// two y accessors, with 3 splitted series, two times
@ -106,7 +105,6 @@ describe('color_assignment', () => {
it('should calculate total number of series for non split series', () => {
const assignments = getColorAssignments(
[layers[0], { ...layers[1], palette: layers[0].palette, splitAccessor: undefined }],
data,
formatFactory
);
// two y accessors, with 3 splitted series for the first layer, 2 non splitted y accessors for the second layer
@ -117,15 +115,16 @@ describe('color_assignment', () => {
it('should format non-primitive values and count them correctly', () => {
const complexObject = { aProp: 123 };
const formatMock = jest.fn((x) => 'formatted');
const assignments = getColorAssignments(
layers,
const newLayers = [
{
...data,
tables: {
...data.tables,
'1': { ...data.tables['1'], rows: [{ split1: complexObject }, { split1: 'abc' }] },
},
...layers[0],
table: { ...tables['1'], rows: [{ split1: complexObject }, { split1: 'abc' }] },
},
layers[1],
];
const assignments = getColorAssignments(
newLayers,
(() =>
({
convert: formatMock,
@ -137,26 +136,18 @@ describe('color_assignment', () => {
});
it('should handle missing tables', () => {
const assignments = getColorAssignments(layers, { ...data, tables: {} }, formatFactory);
const assignments = getColorAssignments(
layers.map((l) => ({ ...l, table: {} as any })),
formatFactory
);
// if there is no data, just assume a single split
expect(assignments.palette1.totalSeriesCount).toEqual(2);
});
it('should handle missing columns', () => {
const assignments = getColorAssignments(
layers,
{
...data,
tables: {
...data.tables,
'1': {
...data.tables['1'],
columns: [],
},
},
},
formatFactory
);
const newLayers = [{ ...layers[0], table: { ...tables['1'], columns: [] } }, layers[1]];
const assignments = getColorAssignments(newLayers, formatFactory);
// if the split column is missing, just assume a single split
expect(assignments.palette1.totalSeriesCount).toEqual(2);
});
@ -164,7 +155,7 @@ describe('color_assignment', () => {
describe('getRank', () => {
it('should return the correct rank for a series key', () => {
const assignments = getColorAssignments(layers, data, formatFactory);
const assignments = getColorAssignments(layers, formatFactory);
// 3 series in front of 2/y2 - 1/y1, 1/y2 and 2/y1
expect(assignments.palette1.getRank(layers[0], '2', 'y2')).toEqual(3);
// 1 series in front of 1/y4 - 1/y3
@ -173,7 +164,7 @@ describe('color_assignment', () => {
it('should return the correct rank for a series key spanning multiple layers', () => {
const newLayers = [layers[0], { ...layers[1], palette: layers[0].palette }];
const assignments = getColorAssignments(newLayers, data, formatFactory);
const assignments = getColorAssignments(newLayers, formatFactory);
// 3 series in front of 2/y2 - 1/y1, 1/y2 and 2/y1
expect(assignments.palette1.getRank(newLayers[0], '2', 'y2')).toEqual(3);
// 2 series in front for the current layer (1/y3, 1/y4), plus all 6 series from the first layer
@ -185,7 +176,7 @@ describe('color_assignment', () => {
layers[0],
{ ...layers[1], palette: layers[0].palette, splitAccessor: undefined },
];
const assignments = getColorAssignments(newLayers, data, formatFactory);
const assignments = getColorAssignments(newLayers, formatFactory);
// 3 series in front of 2/y2 - 1/y1, 1/y2 and 2/y1
expect(assignments.palette1.getRank(newLayers[0], '2', 'y2')).toEqual(3);
// 1 series in front for the current layer (y3), plus all 6 series from the first layer
@ -193,15 +184,16 @@ describe('color_assignment', () => {
});
it('should return the correct rank for a series with a non-primitive value', () => {
const assignments = getColorAssignments(
layers,
const newLayers = [
{
...data,
tables: {
...data.tables,
'1': { ...data.tables['1'], rows: [{ split1: 'abc' }, { split1: { aProp: 123 } }] },
},
...layers[0],
table: { ...tables['1'], rows: [{ split1: 'abc' }, { split1: { aProp: 123 } }] },
},
layers[1],
];
const assignments = getColorAssignments(
newLayers,
(() =>
({
convert: () => 'formatted',
@ -212,26 +204,19 @@ describe('color_assignment', () => {
});
it('should handle missing tables', () => {
const assignments = getColorAssignments(layers, { ...data, tables: {} }, formatFactory);
const assignments = getColorAssignments(
layers.map((l) => ({ ...l, table: {} as any })),
formatFactory
);
// if there is no data, assume it is the first splitted series. One series in front - 0/y1
expect(assignments.palette1.getRank(layers[0], '2', 'y2')).toEqual(1);
});
it('should handle missing columns', () => {
const assignments = getColorAssignments(
layers,
{
...data,
tables: {
...data.tables,
'1': {
...data.tables['1'],
columns: [],
},
},
},
formatFactory
);
const newLayers = [{ ...layers[0], table: { ...tables['1'], columns: [] } }, layers[1]];
const assignments = getColorAssignments(newLayers, formatFactory);
// if the split column is missing, assume it is the first splitted series. One series in front - 0/y1
expect(assignments.palette1.getRank(layers[0], '2', 'y2')).toEqual(1);
});

View file

@ -8,10 +8,9 @@
import { uniq, mapValues } from 'lodash';
import { euiLightVars } from '@kbn/ui-theme';
import type { Datatable } from '@kbn/expressions-plugin';
import { FormatFactory } from '../types';
import { isDataLayer } from './visualization';
import { DataLayerConfigResult, XYLayerConfigResult } from '../../common';
import { CommonXYDataLayerConfig, CommonXYLayerConfig } from '../../common';
const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object';
@ -21,40 +20,41 @@ export type ColorAssignments = Record<
string,
{
totalSeriesCount: number;
getRank(sortedLayer: DataLayerConfigResult, seriesKey: string, yAccessor: string): number;
getRank(sortedLayer: CommonXYDataLayerConfig, seriesKey: string, yAccessor: string): number;
}
>;
export function getColorAssignments(
layers: XYLayerConfigResult[],
data: { tables: Record<string, Datatable> },
layers: CommonXYLayerConfig[],
formatFactory: FormatFactory
): ColorAssignments {
const layersPerPalette: Record<string, DataLayerConfigResult[]> = {};
const layersPerPalette: Record<string, CommonXYDataLayerConfig[]> = {};
layers
.filter((layer): layer is DataLayerConfigResult => isDataLayer(layer))
.forEach((layer) => {
const palette = layer.palette?.name || 'default';
if (!layersPerPalette[palette]) {
layersPerPalette[palette] = [];
}
layersPerPalette[palette].push(layer);
});
layers.forEach((layer) => {
if (!isDataLayer(layer)) {
return;
}
const palette = layer.palette?.name || 'default';
if (!layersPerPalette[palette]) {
layersPerPalette[palette] = [];
}
layersPerPalette[palette].push(layer);
});
return mapValues(layersPerPalette, (paletteLayers) => {
const seriesPerLayer = paletteLayers.map((layer, layerIndex) => {
const seriesPerLayer = paletteLayers.map((layer) => {
if (!layer.splitAccessor) {
return { numberOfSeries: layer.accessors.length, splits: [] };
}
const splitAccessor = layer.splitAccessor;
const column = data.tables[layer.layerId]?.columns.find(({ id }) => id === splitAccessor);
const column = layer.table.columns?.find(({ id }) => id === splitAccessor);
const columnFormatter = column && formatFactory(column.meta.params);
const splits =
!column || !data.tables[layer.layerId]
!column || !layer.table
? []
: uniq(
data.tables[layer.layerId].rows.map((row) => {
layer.table.rows.map((row) => {
let value = row[splitAccessor];
if (value && !isPrimitive(value)) {
value = columnFormatter?.convert(value) ?? value;
@ -72,8 +72,10 @@ export function getColorAssignments(
);
return {
totalSeriesCount,
getRank(sortedLayer: DataLayerConfigResult, seriesKey: string, yAccessor: string) {
const layerIndex = paletteLayers.findIndex((l) => sortedLayer.layerId === l.layerId);
getRank(sortedLayer: CommonXYDataLayerConfig, seriesKey: string, yAccessor: string) {
const layerIndex = paletteLayers.findIndex(
(layer) => sortedLayer.layerId === layer.layerId
);
const currentSeriesPerLayer = seriesPerLayer[layerIndex];
const splitRank = currentSeriesPerLayer.splits.indexOf(seriesKey);
return (

View file

@ -0,0 +1,332 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
AreaSeriesProps,
BarSeriesProps,
ColorVariant,
LineSeriesProps,
ScaleType,
SeriesName,
StackMode,
XYChartSeriesIdentifier,
} from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import {
FieldFormat,
FieldFormatParams,
IFieldFormat,
SerializedFieldFormat,
} from '@kbn/field-formats-plugin/common';
import { Datatable } from '@kbn/expressions-plugin';
import { PaletteRegistry, SeriesLayer } from '@kbn/coloring';
import { CommonXYDataLayerConfig, XScaleType } from '../../common';
import { FormatFactory } from '../types';
import { getSeriesColor } from './state';
import { ColorAssignments } from './color_assignment';
import { GroupsConfiguration } from './axes_configuration';
type SeriesSpec = LineSeriesProps & BarSeriesProps & AreaSeriesProps;
type GetSeriesPropsFn = (config: {
layer: CommonXYDataLayerConfig;
accessor: string;
chartHasMoreThanOneBarSeries?: boolean;
formatFactory: FormatFactory;
colorAssignments: ColorAssignments;
columnToLabelMap: Record<string, string>;
paletteService: PaletteRegistry;
syncColors?: boolean;
yAxis?: GroupsConfiguration[number];
timeZone?: string;
emphasizeFitting?: boolean;
fillOpacity?: number;
formattedDatatableInfo: DatatableWithFormatInfo;
}) => SeriesSpec;
type GetSeriesNameFn = (
data: XYChartSeriesIdentifier,
config: {
layer: CommonXYDataLayerConfig;
splitHint: SerializedFieldFormat<FieldFormatParams> | undefined;
splitFormatter: FieldFormat;
alreadyFormattedColumns: Record<string, boolean>;
columnToLabelMap: Record<string, string>;
}
) => SeriesName;
type GetColorFn = (
seriesIdentifier: XYChartSeriesIdentifier,
config: {
layer: CommonXYDataLayerConfig;
accessor: string;
colorAssignments: ColorAssignments;
columnToLabelMap: Record<string, string>;
paletteService: PaletteRegistry;
syncColors?: boolean;
}
) => string | null;
export interface DatatableWithFormatInfo {
table: Datatable;
formattedColumns: Record<string, true>;
}
export type DatatablesWithFormatInfo = Record<string, DatatableWithFormatInfo>;
export type FormattedDatatables = Record<string, Datatable>;
const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object';
export const getFormattedRow = (
row: Datatable['rows'][number],
columns: Datatable['columns'],
columnsFormatters: Record<string, IFieldFormat>,
xAccessor: string | undefined,
xScaleType: XScaleType
): { row: Datatable['rows'][number]; formattedColumns: Record<string, true> } =>
columns.reduce(
(formattedInfo, { id }) => {
const record = formattedInfo.row[id];
if (
record != null &&
// pre-format values for ordinal x axes because there can only be a single x axis formatter on chart level
(!isPrimitive(record) || (id === xAccessor && xScaleType === 'ordinal'))
) {
return {
row: { ...formattedInfo.row, [id]: columnsFormatters[id]!.convert(record) },
formattedColumns: { ...formattedInfo.formattedColumns, [id]: true },
};
}
return formattedInfo;
},
{ row, formattedColumns: {} }
);
export const getFormattedTable = (
table: Datatable,
formatFactory: FormatFactory,
xAccessor: string | undefined,
xScaleType: XScaleType
): { table: Datatable; formattedColumns: Record<string, true> } => {
const columnsFormatters = table.columns.reduce<Record<string, IFieldFormat>>(
(formatters, { id, meta }) => ({ ...formatters, [id]: formatFactory(meta.params) }),
{}
);
const formattedTableInfo = table.rows.reduce<{
rows: Datatable['rows'];
formattedColumns: Record<string, true>;
}>(
({ rows: formattedRows, formattedColumns }, row) => {
const formattedRowInfo = getFormattedRow(
row,
table.columns,
columnsFormatters,
xAccessor,
xScaleType
);
return {
rows: [...formattedRows, formattedRowInfo.row],
formattedColumns: { ...formattedColumns, ...formattedRowInfo.formattedColumns },
};
},
{
rows: [],
formattedColumns: {},
}
);
return {
table: { ...table, rows: formattedTableInfo.rows },
formattedColumns: formattedTableInfo.formattedColumns,
};
};
export const getFormattedTablesByLayers = (
layers: CommonXYDataLayerConfig[],
formatFactory: FormatFactory
): DatatablesWithFormatInfo =>
layers.reduce(
(formattedDatatables, { layerId, table, xAccessor, xScaleType }) => ({
...formattedDatatables,
[layerId]: getFormattedTable(table, formatFactory, xAccessor, xScaleType),
}),
{}
);
const getSeriesName: GetSeriesNameFn = (
data,
{ layer, splitHint, splitFormatter, alreadyFormattedColumns, columnToLabelMap }
) => {
// For multiple y series, the name of the operation is used on each, either:
// * Key - Y name
// * Formatted value - Y name
if (layer.splitAccessor && layer.accessors.length > 1) {
const formatted = alreadyFormattedColumns[layer.splitAccessor];
const result = data.seriesKeys
.map((key: string | number, i) => {
if (i === 0 && splitHint && layer.splitAccessor && !formatted) {
return splitFormatter.convert(key);
}
return layer.splitAccessor && i === 0 ? key : columnToLabelMap[key] ?? null;
})
.join(' - ');
return result;
}
// For formatted split series, format the key
// This handles splitting by dates, for example
if (splitHint) {
if (layer.splitAccessor && alreadyFormattedColumns[layer.splitAccessor]) {
return data.seriesKeys[0];
}
return splitFormatter.convert(data.seriesKeys[0]);
}
// This handles both split and single-y cases:
// * If split series without formatting, show the value literally
// * If single Y, the seriesKey will be the accessor, so we show the human-readable name
return layer.splitAccessor ? data.seriesKeys[0] : columnToLabelMap[data.seriesKeys[0]] ?? null;
};
const getPointConfig = (xAccessor?: string, emphasizeFitting?: boolean) => ({
visible: !xAccessor,
radius: xAccessor && !emphasizeFitting ? 5 : 0,
});
const getLineConfig = () => ({ visible: true, stroke: ColorVariant.Series, opacity: 1, dash: [] });
const getColor: GetColorFn = (
{ yAccessor, seriesKeys },
{ layer, accessor, colorAssignments, columnToLabelMap, paletteService, syncColors }
) => {
const overwriteColor = getSeriesColor(layer, accessor);
if (overwriteColor !== null) {
return overwriteColor;
}
const colorAssignment = colorAssignments[layer.palette.name];
const seriesLayers: SeriesLayer[] = [
{
name: layer.splitAccessor ? String(seriesKeys[0]) : columnToLabelMap[seriesKeys[0]],
totalSeriesAtDepth: colorAssignment.totalSeriesCount,
rankAtDepth: colorAssignment.getRank(layer, String(seriesKeys[0]), String(yAccessor)),
},
];
return paletteService.get(layer.palette.name).getCategoricalColor(
seriesLayers,
{
maxDepth: 1,
behindText: false,
totalSeries: colorAssignment.totalSeriesCount,
syncColors,
},
layer.palette.params
);
};
export const getSeriesProps: GetSeriesPropsFn = ({
layer,
accessor,
chartHasMoreThanOneBarSeries,
colorAssignments,
formatFactory,
columnToLabelMap,
paletteService,
syncColors,
yAxis,
timeZone,
emphasizeFitting,
fillOpacity,
formattedDatatableInfo,
}): SeriesSpec => {
const { table } = layer;
const isStacked = layer.seriesType.includes('stacked');
const isPercentage = layer.seriesType.includes('percentage');
const isBarChart = layer.seriesType.includes('bar');
const enableHistogramMode =
layer.isHistogram &&
(isStacked || !layer.splitAccessor) &&
(isStacked || !isBarChart || !chartHasMoreThanOneBarSeries);
const formatter = table?.columns.find((column) => column.id === accessor)?.meta?.params;
const splitHint = table?.columns.find((col) => col.id === layer.splitAccessor)?.meta?.params;
const splitFormatter = formatFactory(splitHint);
// what if row values are not primitive? That is the case of, for instance, Ranges
// remaps them to their serialized version with the formatHint metadata
// In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on
const { table: formattedTable, formattedColumns } = formattedDatatableInfo;
// For date histogram chart type, we're getting the rows that represent intervals without data.
// To not display them in the legend, they need to be filtered out.
let rows = formattedTable.rows.filter(
(row) =>
!(layer.xAccessor && typeof row[layer.xAccessor] === 'undefined') &&
!(
layer.splitAccessor &&
typeof row[layer.splitAccessor] === 'undefined' &&
typeof row[accessor] === 'undefined'
)
);
if (!layer.xAccessor) {
rows = rows.map((row) => ({
...row,
unifiedX: i18n.translate('expressionXY.xyChart.emptyXLabel', {
defaultMessage: '(empty)',
}),
}));
}
return {
splitSeriesAccessors: layer.splitAccessor ? [layer.splitAccessor] : [],
stackAccessors: isStacked ? [layer.xAccessor as string] : [],
id: layer.splitAccessor ? `${layer.splitAccessor}-${accessor}` : `${accessor}`,
xAccessor: layer.xAccessor || 'unifiedX',
yAccessors: [accessor],
data: rows,
xScaleType: layer.xAccessor ? layer.xScaleType : 'ordinal',
yScaleType:
formatter?.id === 'bytes' && layer.yScaleType === ScaleType.Linear
? ScaleType.LinearBinary
: layer.yScaleType,
color: (series) =>
getColor(series, {
layer,
accessor,
colorAssignments,
columnToLabelMap,
paletteService,
syncColors,
}),
groupId: yAxis?.groupId,
enableHistogramMode,
stackMode: isPercentage ? StackMode.Percentage : undefined,
timeZone,
areaSeriesStyle: {
point: getPointConfig(layer.xAccessor, emphasizeFitting),
...(fillOpacity && { area: { opacity: fillOpacity } }),
...(emphasizeFitting && {
fit: { area: { opacity: fillOpacity || 0.5 }, line: getLineConfig() },
}),
},
lineSeriesStyle: {
point: getPointConfig(layer.xAccessor, emphasizeFitting),
...(emphasizeFitting && { fit: { line: getLineConfig() } }),
},
name(d) {
return getSeriesName(d, {
layer,
splitHint,
splitFormatter,
alreadyFormattedColumns: formattedColumns,
columnToLabelMap,
});
},
};
};

View file

@ -8,6 +8,7 @@
import { Fit } from '@elastic/charts';
import { EndValue, FittingFunction } from '../../common';
import { EndValues } from '../../common/constants';
export function getFitEnum(fittingFunction?: FittingFunction | EndValue) {
if (fittingFunction) {
@ -17,10 +18,10 @@ export function getFitEnum(fittingFunction?: FittingFunction | EndValue) {
}
export function getEndValue(endValue?: EndValue) {
if (endValue === 'Nearest') {
if (endValue === EndValues.NEAREST) {
return Fit[endValue];
}
if (endValue === 'Zero') {
if (endValue === EndValues.ZERO) {
return 0;
}
return undefined;

View file

@ -6,6 +6,107 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { TriangleIcon, CircleIcon } from '../icons';
import { AvailableReferenceLineIcons } from '../../common/constants';
export function hasIcon(icon: string | undefined): icon is string {
return icon != null && icon !== 'empty';
}
export const iconSet = [
{
value: AvailableReferenceLineIcons.EMPTY,
label: i18n.translate('expressionXY.xyChart.iconSelect.noIconLabel', {
defaultMessage: 'None',
}),
},
{
value: AvailableReferenceLineIcons.ASTERISK,
label: i18n.translate('expressionXY.xyChart.iconSelect.asteriskIconLabel', {
defaultMessage: 'Asterisk',
}),
},
{
value: AvailableReferenceLineIcons.ALERT,
label: i18n.translate('expressionXY.xyChart.iconSelect.alertIconLabel', {
defaultMessage: 'Alert',
}),
},
{
value: AvailableReferenceLineIcons.BELL,
label: i18n.translate('expressionXY.xyChart.iconSelect.bellIconLabel', {
defaultMessage: 'Bell',
}),
},
{
value: AvailableReferenceLineIcons.BOLT,
label: i18n.translate('expressionXY.xyChart.iconSelect.boltIconLabel', {
defaultMessage: 'Bolt',
}),
},
{
value: AvailableReferenceLineIcons.BUG,
label: i18n.translate('expressionXY.xyChart.iconSelect.bugIconLabel', {
defaultMessage: 'Bug',
}),
},
{
value: AvailableReferenceLineIcons.CIRCLE,
label: i18n.translate('expressionXY.xyChart.iconSelect.circleIconLabel', {
defaultMessage: 'Circle',
}),
icon: CircleIcon,
canFill: true,
},
{
value: AvailableReferenceLineIcons.EDITOR_COMMENT,
label: i18n.translate('expressionXY.xyChart.iconSelect.commentIconLabel', {
defaultMessage: 'Comment',
}),
},
{
value: AvailableReferenceLineIcons.FLAG,
label: i18n.translate('expressionXY.xyChart.iconSelect.flagIconLabel', {
defaultMessage: 'Flag',
}),
},
{
value: AvailableReferenceLineIcons.HEART,
label: i18n.translate('expressionXY.xyChart.iconSelect.heartLabel', {
defaultMessage: 'Heart',
}),
},
{
value: AvailableReferenceLineIcons.MAP_MARKER,
label: i18n.translate('expressionXY.xyChart.iconSelect.mapMarkerLabel', {
defaultMessage: 'Map Marker',
}),
},
{
value: AvailableReferenceLineIcons.PIN_FILLED,
label: i18n.translate('expressionXY.xyChart.iconSelect.mapPinLabel', {
defaultMessage: 'Map Pin',
}),
},
{
value: AvailableReferenceLineIcons.STAR_EMPTY,
label: i18n.translate('expressionXY.xyChart.iconSelect.starLabel', { defaultMessage: 'Star' }),
},
{
value: AvailableReferenceLineIcons.TAG,
label: i18n.translate('expressionXY.xyChart.iconSelect.tagIconLabel', {
defaultMessage: 'Tag',
}),
},
{
value: AvailableReferenceLineIcons.TRIANGLE,
label: i18n.translate('expressionXY.xyChart.iconSelect.triangleIconLabel', {
defaultMessage: 'Triangle',
}),
icon: TriangleIcon,
shouldRotate: true,
canFill: true,
},
];

View file

@ -14,5 +14,5 @@ export * from './fitting_functions';
export * from './axes_configuration';
export * from './icon';
export * from './color_assignment';
export * from './annotations_icon_set';
export * from './annotations';
export * from './data_layers';

View file

@ -6,32 +6,36 @@
* Side Public License, v 1.
*/
import { DataLayerConfigResult, XYChartProps } from '../../common';
import { DataLayerConfig, XYChartProps } from '../../common';
import { sampleArgs } from '../../common/__mocks__';
import { calculateMinInterval } from './interval';
describe('calculateMinInterval', () => {
let xyProps: XYChartProps;
let layer: DataLayerConfig;
beforeEach(() => {
xyProps = sampleArgs();
(xyProps.args.layers[0] as DataLayerConfigResult).xScaleType = 'time';
const { layers, ...restArgs } = sampleArgs().args;
xyProps = { args: { ...restArgs, layers } };
layer = xyProps.args.layers[0] as DataLayerConfig;
layer.xScaleType = 'time';
});
it('should use first valid layer and determine interval', async () => {
xyProps.data.tables.first.columns[2].meta.source = 'esaggs';
xyProps.data.tables.first.columns[2].meta.sourceParams = {
layer.table.columns[2].meta.source = 'esaggs';
layer.table.columns[2].meta.sourceParams = {
type: 'date_histogram',
params: {
used_interval: '5m',
},
};
xyProps.args.layers[0] = layer;
const result = await calculateMinInterval(xyProps);
expect(result).toEqual(5 * 60 * 1000);
});
it('should return interval of number histogram if available on first x axis columns', async () => {
(xyProps.args.layers[0] as DataLayerConfigResult).xScaleType = 'linear';
xyProps.data.tables.first.columns[2].meta = {
layer.xScaleType = 'linear';
layer.table.columns[2].meta = {
source: 'esaggs',
type: 'number',
field: 'someField',
@ -43,19 +47,22 @@ describe('calculateMinInterval', () => {
},
},
};
xyProps.args.layers[0] = layer;
const result = await calculateMinInterval(xyProps);
expect(result).toEqual(5);
});
it('should return undefined if data table is empty', async () => {
xyProps.data.tables.first.rows = [];
xyProps.data.tables.first.columns[2].meta.source = 'esaggs';
xyProps.data.tables.first.columns[2].meta.sourceParams = {
layer.table.rows = [];
layer.table.columns[2].meta.source = 'esaggs';
layer.table.columns[2].meta.sourceParams = {
type: 'date_histogram',
params: {
used_interval: '5m',
},
};
xyProps.args.layers[0] = layer;
const result = await calculateMinInterval(xyProps);
expect(result).toEqual(undefined);
});
@ -66,14 +73,16 @@ describe('calculateMinInterval', () => {
});
it('should return undefined if date column is not found', async () => {
xyProps.data.tables.first.columns.splice(2, 1);
layer.table.columns.splice(2, 1);
xyProps.args.layers[0] = layer;
const result = await calculateMinInterval(xyProps);
expect(result).toEqual(undefined);
});
it('should return undefined if x axis is not a date', async () => {
(xyProps.args.layers[0] as DataLayerConfigResult).xScaleType = 'ordinal';
xyProps.data.tables.first.columns.splice(2, 1);
layer.xScaleType = 'ordinal';
xyProps.args.layers[0] = layer;
xyProps.args.layers[0].table.columns.splice(2, 1);
const result = await calculateMinInterval(xyProps);
expect(result).toEqual(undefined);
});

View file

@ -11,11 +11,11 @@ import { XYChartProps } from '../../common';
import { getFilteredLayers } from './layers';
import { isDataLayer } from './visualization';
export function calculateMinInterval({ args: { layers }, data }: XYChartProps) {
const filteredLayers = getFilteredLayers(layers, data);
export function calculateMinInterval({ args: { layers } }: XYChartProps) {
const filteredLayers = getFilteredLayers(layers);
if (filteredLayers.length === 0) return;
const isTimeViz = filteredLayers.every((l) => isDataLayer(l) && l.xScaleType === 'time');
const xColumn = data.tables[filteredLayers[0].layerId].columns.find(
const xColumn = filteredLayers[0].table.columns.find(
(column) => isDataLayer(filteredLayers[0]) && column.id === filteredLayers[0].xAccessor
);

View file

@ -6,24 +6,42 @@
* Side Public License, v 1.
*/
import { LensMultiTable } from '../../common';
import { DataLayerConfigResult, XYLayerConfigResult } from '../../common/types';
import { getDataLayers } from './visualization';
import { Datatable } from '@kbn/expressions-plugin/common';
import {
CommonXYDataLayerConfig,
CommonXYLayerConfig,
CommonXYReferenceLineLayerConfig,
} from '../../common/types';
import { isDataLayer, isReferenceLayer } from './visualization';
export function getFilteredLayers(layers: CommonXYLayerConfig[]) {
return layers.filter<CommonXYReferenceLineLayerConfig | CommonXYDataLayerConfig>(
(layer): layer is CommonXYReferenceLineLayerConfig | CommonXYDataLayerConfig => {
let table: Datatable | undefined;
let accessors: string[] = [];
let xAccessor: undefined | string | number;
let splitAccessor: undefined | string | number;
if (isDataLayer(layer)) {
xAccessor = layer.xAccessor;
splitAccessor = layer.splitAccessor;
}
if (isDataLayer(layer) || isReferenceLayer(layer)) {
table = layer.table;
accessors = layer.accessors;
}
export function getFilteredLayers(layers: XYLayerConfigResult[], data: LensMultiTable) {
return getDataLayers(layers).filter<DataLayerConfigResult>(
(layer): layer is DataLayerConfigResult => {
const { layerId, xAccessor, accessors, splitAccessor } = layer;
return !(
!accessors.length ||
!data.tables[layerId] ||
data.tables[layerId].rows.length === 0 ||
!table ||
table.rows.length === 0 ||
(xAccessor &&
data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) ||
table.rows.every((row) => xAccessor && typeof row[xAccessor] === 'undefined')) ||
// stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty
(!xAccessor &&
splitAccessor &&
data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined'))
table.rows.every((row) => splitAccessor && typeof row[splitAccessor] === 'undefined'))
);
}
);

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import type { SeriesType, XYLayerConfigResult, YConfig } from '../../common';
import type { CommonXYLayerConfig, SeriesType, ExtendedYConfig, YConfig } from '../../common';
import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization';
export function isHorizontalSeries(seriesType: SeriesType) {
@ -21,16 +21,14 @@ export function isStackedChart(seriesType: SeriesType) {
return seriesType.includes('stacked');
}
export function isHorizontalChart(layers: XYLayerConfigResult[]) {
export function isHorizontalChart(layers: CommonXYLayerConfig[]) {
return getDataLayers(layers).every((l) => isHorizontalSeries(l.seriesType));
}
export const getSeriesColor = (layer: XYLayerConfigResult, accessor: string) => {
export const getSeriesColor = (layer: CommonXYLayerConfig, accessor: string) => {
if ((isDataLayer(layer) && layer.splitAccessor) || isAnnotationsLayer(layer)) {
return null;
}
return (
layer?.yConfig?.find((yConfig: YConfig) => yConfig.forAccessor === accessor)?.color || null
);
const yConfig: Array<YConfig | ExtendedYConfig> | undefined = layer?.yConfig;
return yConfig?.find((yConf) => yConf.forAccessor === accessor)?.color || null;
};

View file

@ -6,38 +6,40 @@
* Side Public License, v 1.
*/
import {
DataLayerConfigResult,
ReferenceLineLayerConfigResult,
XYLayerConfigResult,
AnnotationLayerConfigResult,
} from '../../common/types';
import { LayerTypes } from '../../common/constants';
import {
CommonXYLayerConfig,
CommonXYDataLayerConfig,
CommonXYReferenceLineLayerConfig,
CommonXYAnnotationLayerConfig,
} from '../../common/types';
export const isDataLayer = (layer: XYLayerConfigResult): layer is DataLayerConfigResult =>
export const isDataLayer = (layer: CommonXYLayerConfig): layer is CommonXYDataLayerConfig =>
layer.layerType === LayerTypes.DATA || !layer.layerType;
export const getDataLayers = (layers: XYLayerConfigResult[]) =>
(layers || []).filter((layer): layer is DataLayerConfigResult => isDataLayer(layer));
export const getDataLayers = (layers: CommonXYLayerConfig[]) =>
(layers || []).filter((layer): layer is CommonXYDataLayerConfig => isDataLayer(layer));
export const isReferenceLayer = (
layer: XYLayerConfigResult
): layer is ReferenceLineLayerConfigResult => layer.layerType === LayerTypes.REFERENCELINE;
layer: CommonXYLayerConfig
): layer is CommonXYReferenceLineLayerConfig => layer.layerType === LayerTypes.REFERENCELINE;
export const getReferenceLayers = (layers: XYLayerConfigResult[]) =>
(layers || []).filter((layer): layer is ReferenceLineLayerConfigResult =>
export const getReferenceLayers = (layers: CommonXYLayerConfig[]) =>
(layers || []).filter((layer): layer is CommonXYReferenceLineLayerConfig =>
isReferenceLayer(layer)
);
const isAnnotationLayerCommon = (
layer: XYLayerConfigResult
): layer is AnnotationLayerConfigResult => layer.layerType === LayerTypes.ANNOTATIONS;
layer: CommonXYLayerConfig
): layer is CommonXYAnnotationLayerConfig => layer.layerType === LayerTypes.ANNOTATIONS;
export const isAnnotationsLayer = (
layer: XYLayerConfigResult
): layer is AnnotationLayerConfigResult => isAnnotationLayerCommon(layer);
layer: CommonXYLayerConfig
): layer is CommonXYAnnotationLayerConfig => isAnnotationLayerCommon(layer);
export const getAnnotationsLayers = (
layers: XYLayerConfigResult[]
): AnnotationLayerConfigResult[] =>
(layers || []).filter((layer): layer is AnnotationLayerConfigResult => isAnnotationsLayer(layer));
layers: CommonXYLayerConfig[]
): CommonXYAnnotationLayerConfig[] =>
(layers || []).filter((layer): layer is CommonXYAnnotationLayerConfig =>
isAnnotationsLayer(layer)
);

View file

@ -16,17 +16,22 @@ import { EventAnnotationPluginSetup } from '@kbn/event-annotation-plugin/public'
import { ExpressionXyPluginSetup, ExpressionXyPluginStart, SetupDeps } from './types';
import {
xyVisFunction,
layeredXyVisFunction,
dataLayerFunction,
extendedDataLayerFunction,
yAxisConfigFunction,
extendedYAxisConfigFunction,
legendConfigFunction,
gridlinesConfigFunction,
dataLayerConfigFunction,
axisExtentConfigFunction,
tickLabelsConfigFunction,
annotationLayerConfigFunction,
referenceLineLayerFunction,
extendedReferenceLineLayerFunction,
annotationLayerFunction,
labelsOrientationConfigFunction,
referenceLineLayerConfigFunction,
axisTitlesVisibilityConfigFunction,
} from '../common';
extendedAnnotationLayerFunction,
} from '../common/expression_functions';
import { GetStartDepsFn, getXyChartRenderer } from './expression_renderers';
export interface XYPluginStartDependencies {
@ -51,16 +56,21 @@ export class ExpressionXyPlugin {
{ expressions, charts }: SetupDeps
): ExpressionXyPluginSetup {
expressions.registerFunction(yAxisConfigFunction);
expressions.registerFunction(extendedYAxisConfigFunction);
expressions.registerFunction(legendConfigFunction);
expressions.registerFunction(gridlinesConfigFunction);
expressions.registerFunction(dataLayerConfigFunction);
expressions.registerFunction(dataLayerFunction);
expressions.registerFunction(extendedDataLayerFunction);
expressions.registerFunction(axisExtentConfigFunction);
expressions.registerFunction(tickLabelsConfigFunction);
expressions.registerFunction(annotationLayerConfigFunction);
expressions.registerFunction(annotationLayerFunction);
expressions.registerFunction(extendedAnnotationLayerFunction);
expressions.registerFunction(labelsOrientationConfigFunction);
expressions.registerFunction(referenceLineLayerConfigFunction);
expressions.registerFunction(referenceLineLayerFunction);
expressions.registerFunction(extendedReferenceLineLayerFunction);
expressions.registerFunction(axisTitlesVisibilityConfigFunction);
expressions.registerFunction(xyVisFunction);
expressions.registerFunction(layeredXyVisFunction);
const getStartDeps: GetStartDepsFn = async () => {
const [coreStart, deps] = await core.getStartServices();

View file

@ -12,16 +12,21 @@ import { ExpressionXyPluginSetup, ExpressionXyPluginStart } from './types';
import {
xyVisFunction,
yAxisConfigFunction,
extendedYAxisConfigFunction,
legendConfigFunction,
gridlinesConfigFunction,
dataLayerConfigFunction,
dataLayerFunction,
axisExtentConfigFunction,
tickLabelsConfigFunction,
annotationLayerConfigFunction,
annotationLayerFunction,
labelsOrientationConfigFunction,
referenceLineLayerConfigFunction,
referenceLineLayerFunction,
axisTitlesVisibilityConfigFunction,
} from '../common';
extendedDataLayerFunction,
extendedReferenceLineLayerFunction,
layeredXyVisFunction,
extendedAnnotationLayerFunction,
} from '../common/expression_functions';
import { SetupDeps } from './types';
export class ExpressionXyPlugin
@ -29,16 +34,21 @@ export class ExpressionXyPlugin
{
public setup(core: CoreSetup, { expressions }: SetupDeps) {
expressions.registerFunction(yAxisConfigFunction);
expressions.registerFunction(extendedYAxisConfigFunction);
expressions.registerFunction(legendConfigFunction);
expressions.registerFunction(gridlinesConfigFunction);
expressions.registerFunction(dataLayerConfigFunction);
expressions.registerFunction(dataLayerFunction);
expressions.registerFunction(extendedDataLayerFunction);
expressions.registerFunction(axisExtentConfigFunction);
expressions.registerFunction(tickLabelsConfigFunction);
expressions.registerFunction(annotationLayerConfigFunction);
expressions.registerFunction(annotationLayerFunction);
expressions.registerFunction(extendedAnnotationLayerFunction);
expressions.registerFunction(labelsOrientationConfigFunction);
expressions.registerFunction(referenceLineLayerConfigFunction);
expressions.registerFunction(referenceLineLayerFunction);
expressions.registerFunction(extendedReferenceLineLayerFunction);
expressions.registerFunction(axisTitlesVisibilityConfigFunction);
expressions.registerFunction(xyVisFunction);
expressions.registerFunction(layeredXyVisFunction);
}
public start(core: CoreStart) {}

View file

@ -0,0 +1,24 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const AvailableAnnotationIcons = {
ASTERISK: 'asterisk',
ALERT: 'alert',
BELL: 'bell',
BOLT: 'bolt',
BUG: 'bug',
CIRCLE: 'circle',
EDITOR_COMMENT: 'editorComment',
FLAG: 'flag',
HEART: 'heart',
MAP_MARKER: 'mapMarker',
PIN_FILLED: 'pinFilled',
STAR_EMPTY: 'starEmpty',
TAG: 'tag',
TRIANGLE: 'triangle',
} as const;

View file

@ -17,4 +17,8 @@ export type {
export { manualPointEventAnnotation, manualRangeEventAnnotation } from './manual_event_annotation';
export { eventAnnotationGroup } from './event_annotation_group';
export type { EventAnnotationGroupArgs } from './event_annotation_group';
export type { EventAnnotationConfig, RangeEventAnnotationConfig } from './types';
export type {
EventAnnotationConfig,
RangeEventAnnotationConfig,
AvailableAnnotationIcon,
} from './types';

View file

@ -8,6 +8,8 @@
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { i18n } from '@kbn/i18n';
import { AvailableAnnotationIcons } from '../constants';
import type {
ManualRangeEventAnnotationArgs,
ManualRangeEventAnnotationOutput,
@ -65,6 +67,8 @@ export const manualPointEventAnnotation: ExpressionFunctionDefinition<
help: i18n.translate('eventAnnotation.manualAnnotation.args.icon', {
defaultMessage: 'An optional icon used for annotation lines',
}),
options: [...Object.values(AvailableAnnotationIcons)],
strict: true,
},
textVisibility: {
types: ['boolean'],

View file

@ -6,15 +6,18 @@
* Side Public License, v 1.
*/
import { $Values } from '@kbn/utility-types';
import { AvailableAnnotationIcons } from './constants';
export type LineStyle = 'solid' | 'dashed' | 'dotted';
export type Fill = 'inside' | 'outside' | 'none';
export type AnnotationType = 'manual';
export type KeyType = 'point_in_time' | 'range';
export type AvailableAnnotationIcon = $Values<typeof AvailableAnnotationIcons>;
export interface PointStyleProps {
label: string;
color?: string;
icon?: string;
icon?: AvailableAnnotationIcon;
lineWidth?: number;
lineStyle?: LineStyle;
textVisibility?: boolean;

View file

@ -714,7 +714,7 @@ describe('Execution', () => {
expect(result).toMatchObject({
type: 'error',
error: {
message: '[requiredArg] > requiredArg requires an argument',
message: '[requiredArg] > requiredArg requires the "arg" argument',
},
});
});
@ -725,7 +725,7 @@ describe('Execution', () => {
expect(result).toMatchObject({
type: 'error',
error: {
message: '[var_set] > var_set requires an "name" argument',
message: '[var_set] > var_set requires the "name" argument',
},
});
});

View file

@ -481,7 +481,7 @@ export class Execution<
);
// Check for missing required arguments.
for (const { aliases, default: argDefault, name, required } of Object.values(argDefs)) {
for (const { default: argDefault, name, required } of Object.values(argDefs)) {
if (!(name in dealiasedArgAsts) && typeof argDefault !== 'undefined') {
dealiasedArgAsts[name] = [parse(argDefault as string, 'argument')];
}
@ -490,13 +490,7 @@ export class Execution<
continue;
}
if (!aliases?.length) {
throw new Error(`${fnDef.name} requires an argument`);
}
// use an alias if _ is the missing arg
const errorArg = name === '_' ? aliases[0] : name;
throw new Error(`${fnDef.name} requires an "${errorArg}" argument`);
throw new Error(`${fnDef.name} requires the "${name}" argument`);
}
// Create the functions to resolve the argument ASTs into values

View file

@ -57,8 +57,7 @@ export type CustomPaletteParamsConfig = CustomPaletteParams & {
export type LayerType = typeof layerTypes[keyof typeof layerTypes];
// Shared by XY Chart and Heatmap as for now
export type ValueLabelConfig = 'hide' | 'inside' | 'outside';
export type ValueLabelConfig = 'hide' | 'show';
export type PieChartType = $Values<typeof PieChartTypes>;
export type CategoryDisplayType = $Values<typeof CategoryDisplay>;

View file

@ -145,8 +145,6 @@ export function LayerPanel(
const isEmptyLayer = !groups.some((d) => d.accessors.length > 0);
const { activeId, activeGroup } = activeDimension;
const { setDimension, removeDimension } = activeVisualization;
const allAccessors = groups.flatMap((group) =>
group.accessors.map((accessor) => accessor.columnId)
);
@ -209,7 +207,7 @@ export function LayerPanel(
previousColumn = typeof dropResult === 'object' ? dropResult.deleted : undefined;
}
}
const newVisState = setDimension({
const newVisState = activeVisualization.setDimension({
columnId,
groupId,
layerId: targetLayerId,
@ -221,7 +219,7 @@ export function LayerPanel(
if (typeof dropResult === 'object') {
// When a column is moved, we delete the reference to the old
updateVisualization(
removeDimension({
activeVisualization.removeDimension({
columnId: dropResult.deleted,
layerId: targetLayerId,
prevState: newVisState,
@ -234,7 +232,7 @@ export function LayerPanel(
}
} else {
if (dropType === 'duplicate_compatible' || dropType === 'reorder') {
const newVisState = setDimension({
const newVisState = activeVisualization.setDimension({
columnId,
groupId,
layerId: targetLayerId,
@ -247,16 +245,15 @@ export function LayerPanel(
}
};
}, [
framePublicAPI,
layerDatasource,
setNextFocusedButtonId,
groups,
layerDatasourceOnDrop,
props.visualizationState,
updateVisualization,
setDimension,
removeDimension,
layerDatasourceDropProps,
setNextFocusedButtonId,
layerDatasource,
activeVisualization,
props.visualizationState,
framePublicAPI,
updateVisualization,
]);
const isDimensionPanelOpen = Boolean(activeId);

View file

@ -9,11 +9,10 @@ import { Ast, AstFunction, fromExpression } from '@kbn/interpreter';
import { DatasourceStates } from '../../state_management';
import { Visualization, DatasourceMap, DatasourceLayers } from '../../types';
export function prependDatasourceExpression(
visualizationExpression: Ast | string | null,
export function getDatasourceExpressionsByLayers(
datasourceMap: DatasourceMap,
datasourceStates: DatasourceStates
): Ast | null {
): null | Record<string, Ast> {
const datasourceExpressions: Array<[string, Ast | string]> = [];
Object.entries(datasourceMap).forEach(([datasourceId, datasource]) => {
@ -28,19 +27,41 @@ export function prependDatasourceExpression(
});
});
if (datasourceExpressions.length === 0 || visualizationExpression === null) {
if (datasourceExpressions.length === 0) {
return null;
}
const parsedDatasourceExpressions: Array<[string, Ast]> = datasourceExpressions.map(
([layerId, expr]) => [layerId, typeof expr === 'string' ? fromExpression(expr) : expr]
return datasourceExpressions.reduce(
(exprs, [layerId, expr]) => ({
...exprs,
[layerId]: typeof expr === 'string' ? fromExpression(expr) : expr,
}),
{}
);
}
export function prependDatasourceExpression(
visualizationExpression: Ast | string | null,
datasourceMap: DatasourceMap,
datasourceStates: DatasourceStates
): Ast | null {
const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers(
datasourceMap,
datasourceStates
);
if (datasourceExpressionsByLayers === null || visualizationExpression === null) {
return null;
}
const parsedDatasourceExpressions = Object.entries(datasourceExpressionsByLayers);
const datafetchExpression: AstFunction = {
type: 'function',
function: 'lens_merge_tables',
arguments: {
layerIds: parsedDatasourceExpressions.map(([id]) => id),
tables: parsedDatasourceExpressions.map(([id, expr]) => expr),
tables: parsedDatasourceExpressions.map(([, expr]) => expr),
},
};
@ -79,16 +100,32 @@ export function buildExpression({
if (visualization === null) {
return null;
}
if (visualization.shouldBuildDatasourceExpressionManually?.()) {
const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers(
datasourceMap,
datasourceStates
);
const visualizationExpression = visualization.toExpression(
visualizationState,
datasourceLayers,
{
title,
description,
},
datasourceExpressionsByLayers ?? undefined
);
return typeof visualizationExpression === 'string'
? fromExpression(visualizationExpression)
: visualizationExpression;
}
const visualizationExpression = visualization.toExpression(visualizationState, datasourceLayers, {
title,
description,
});
const completeExpression = prependDatasourceExpression(
visualizationExpression,
datasourceMap,
datasourceStates
);
return completeExpression;
return prependDatasourceExpression(visualizationExpression, datasourceMap, datasourceStates);
}

View file

@ -22,7 +22,7 @@ import {
EuiText,
} from '@elastic/eui';
import { IconType } from '@elastic/eui/src/components/icon/icon';
import { Ast, toExpression } from '@kbn/interpreter';
import { Ast, fromExpression, toExpression } from '@kbn/interpreter';
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import { ExecutionContextSearch } from '@kbn/data-plugin/public';
@ -39,7 +39,10 @@ import {
DatasourceLayers,
} from '../../types';
import { getSuggestions, switchToSuggestion } from './suggestion_helpers';
import { prependDatasourceExpression } from './expression_helpers';
import {
getDatasourceExpressionsByLayers,
prependDatasourceExpression,
} from './expression_helpers';
import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry';
import {
getMissingIndexPattern,
@ -485,6 +488,7 @@ function getPreviewExpression(
visualizableState: VisualizableState,
visualization: Visualization,
datasources: Record<string, Datasource>,
datasourceStates: DatasourceStates,
frame: FramePublicAPI
) {
if (!visualization.toPreviewExpression) {
@ -518,6 +522,19 @@ function getPreviewExpression(
});
}
if (visualization.shouldBuildDatasourceExpressionManually?.()) {
const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers(
datasources,
datasourceStates
);
return visualization.toPreviewExpression(
visualizableState.visualizationState,
suggestionFrameApi.datasourceLayers,
datasourceExpressionsByLayers ?? undefined
);
}
return visualization.toPreviewExpression(
visualizableState.visualizationState,
suggestionFrameApi.datasourceLayers
@ -534,10 +551,25 @@ function preparePreviewExpression(
const suggestionDatasourceId = visualizableState.datasourceId;
const suggestionDatasourceState = visualizableState.datasourceState;
const datasourceStatesWithSuggestions = suggestionDatasourceId
? {
...datasourceStates,
[suggestionDatasourceId]: {
isLoading: false,
state: suggestionDatasourceState,
},
}
: datasourceStates;
const previewExprDatasourcesStates = visualization.shouldBuildDatasourceExpressionManually?.()
? datasourceStatesWithSuggestions
: datasourceStates;
const expression = getPreviewExpression(
visualizableState,
visualization,
datasourceMap,
previewExprDatasourcesStates,
framePublicAPI
);
@ -545,19 +577,9 @@ function preparePreviewExpression(
return;
}
const expressionWithDatasource = prependDatasourceExpression(
expression,
datasourceMap,
suggestionDatasourceId
? {
...datasourceStates,
[suggestionDatasourceId]: {
isLoading: false,
state: suggestionDatasourceState,
},
}
: datasourceStates
);
if (visualization.shouldBuildDatasourceExpressionManually?.()) {
return typeof expression === 'string' ? fromExpression(expression) : expression;
}
return expressionWithDatasource;
return prependDatasourceExpression(expression, datasourceMap, datasourceStatesWithSuggestions);
}

View file

@ -348,6 +348,7 @@ export class Embeddable
if (!this.savedVis || !this.savedVis.visualizationType) {
return [];
}
return this.deps.visualizationMap[this.savedVis.visualizationType]?.triggers || [];
}
@ -458,6 +459,7 @@ export class Embeddable
this.embeddableTitle = this.getTitle();
isDirty = true;
}
return isDirty;
}

View file

@ -62,11 +62,11 @@ export const HeatmapToolbar = memo(
buttonDataTestSubj="lnsVisualOptionsButton"
>
<ValueLabelsSettings
valueLabels={state?.gridConfig.isCellLabelVisible ? 'inside' : 'hide'}
valueLabels={state?.gridConfig.isCellLabelVisible ? 'show' : 'hide'}
onValueLabelChange={(newMode) => {
setState({
...state,
gridConfig: { ...state.gridConfig, isCellLabelVisible: newMode === 'inside' },
gridConfig: { ...state.gridConfig, isCellLabelVisible: newMode === 'show' },
});
}}
/>

View file

@ -15,6 +15,7 @@ export type {
XYState,
XYReferenceLineLayerConfig,
XYLayerConfig,
ValidLayer,
XYDataLayerConfig,
XYAnnotationLayerConfig,
} from './xy_visualization/types';
@ -70,7 +71,7 @@ export type {
} from './indexpattern_datasource/types';
export type {
XYArgs,
YConfig,
ExtendedYConfig,
XYRender,
LayerType,
YAxisMode,
@ -80,28 +81,27 @@ export type {
YScaleType,
XScaleType,
AxisConfig,
ValidLayer,
XYCurveType,
XYChartProps,
LegendConfig,
IconPosition,
YConfigResult,
ExtendedYConfigResult,
DataLayerArgs,
LensMultiTable,
ValueLabelMode,
AxisExtentMode,
DataLayerConfig,
FittingFunction,
AxisExtentConfig,
LegendConfigResult,
AxesSettingsConfig,
GridlinesConfigResult,
DataLayerConfigResult,
TickLabelsConfigResult,
AxisExtentConfigResult,
ReferenceLineLayerArgs,
LabelsOrientationConfig,
ReferenceLineLayerConfig,
LabelsOrientationConfigResult,
ReferenceLineLayerConfigResult,
AxisTitlesVisibilityConfigResult,
} from '@kbn/expression-xy-plugin/common';
export type { LensEmbeddableInput } from './embeddable';

View file

@ -235,6 +235,7 @@ export function getIndexPatternDatasource({
if (staticValue == null) {
return state;
}
return mergeLayer({
state,
layerId,

View file

@ -30,11 +30,11 @@ export interface LegendLocationSettingsProps {
/**
* Sets the vertical alignment for legend inside chart
*/
verticalAlignment?: VerticalAlignment;
verticalAlignment?: typeof VerticalAlignment.Top | typeof VerticalAlignment.Bottom;
/**
* Sets the vertical alignment for legend inside chart
*/
horizontalAlignment?: HorizontalAlignment;
horizontalAlignment?: typeof HorizontalAlignment.Left | typeof HorizontalAlignment.Right;
/**
* Callback on horizontal alignment option change
*/

View file

@ -58,11 +58,11 @@ export interface LegendSettingsPopoverProps {
/**
* Sets the vertical alignment for legend inside chart
*/
verticalAlignment?: VerticalAlignment;
verticalAlignment?: typeof VerticalAlignment.Top | typeof VerticalAlignment.Bottom;
/**
* Sets the vertical alignment for legend inside chart
*/
horizontalAlignment?: HorizontalAlignment;
horizontalAlignment?: typeof HorizontalAlignment.Left | typeof HorizontalAlignment.Right;
/**
* Callback on horizontal alignment option change
*/
@ -225,13 +225,15 @@ export const LegendSettingsPopover: React.FunctionComponent<LegendSettingsPopove
position={position}
onPositionChange={onPositionChange}
/>
<LegendSizeSettings
legendSize={legendSize}
onLegendSizeChange={onLegendSizeChange}
isVerticalLegend={
!position || position === Position.Left || position === Position.Right
}
/>
{location !== 'inside' && (
<LegendSizeSettings
legendSize={legendSize}
onLegendSizeChange={onLegendSizeChange}
isVerticalLegend={
!position || position === Position.Left || position === Position.Right
}
/>
)}
{location && (
<ColumnsNumberSetting
floatingColumns={floatingColumns}

View file

@ -38,7 +38,7 @@ describe('Value labels Settings', () => {
});
it('should render the passed value if given', () => {
const component = shallow(<ValueLabelsSettings {...props} valueLabels="inside" />);
const component = shallow(<ValueLabelsSettings {...props} valueLabels="show" />);
expect(
component.find('[data-test-subj="lens-value-labels-visibility-btn"]').prop('idSelected')
).toEqual(`value_labels_inside`);

View file

@ -26,7 +26,7 @@ const valueLabelsOptions: Array<{
},
{
id: `value_labels_inside`,
value: 'inside',
value: 'show',
label: i18n.translate('xpack.lens.shared.valueLabelsVisibility.inside', {
defaultMessage: 'Show',
}),

View file

@ -588,7 +588,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
labels?: { buttonAriaLabel: string; buttonLabel: string };
};
interface VisualizationDimensionChangeProps<T> {
export interface VisualizationDimensionChangeProps<T> {
layerId: string;
columnId: string;
prevState: T;
@ -887,7 +887,8 @@ export interface Visualization<T = unknown> {
toExpression: (
state: T,
datasourceLayers: DatasourceLayers,
attributes?: Partial<{ title: string; description: string }>
attributes?: Partial<{ title: string; description: string }>,
datasourceExpressionsByLayers?: Record<string, Ast>
) => ExpressionAstExpression | string | null;
/**
* Expression to render a preview version of the chart in very constrained space.
@ -895,7 +896,8 @@ export interface Visualization<T = unknown> {
*/
toPreviewExpression?: (
state: T,
datasourceLayers: DatasourceLayers
datasourceLayers: DatasourceLayers,
datasourceExpressionsByLayers?: Record<string, Ast>
) => ExpressionAstExpression | string | null;
/**
* The frame will call this function on all visualizations at few stages (pre-build/build error) in order
@ -920,6 +922,12 @@ export interface Visualization<T = unknown> {
* On Edit events the frame will call this to know what's going to be the next visualization state
*/
onEditAction?: (state: T, event: LensEditEvent<LensEditSupportedActions>) => T;
/**
* `datasourceExpressionsByLayers` will be passed to the params of `toExpression` and `toPreviewExpression`
* functions and datasource expressions will not be appended to the expression automatically.
*/
shouldBuildDatasourceExpressionManually?: () => boolean;
}
export interface LensFilterEvent {

View file

@ -30,9 +30,6 @@ Object {
"curveType": Array [
"LINEAR",
],
"description": Array [
"",
],
"emphasizeFitting": Array [
true,
],
@ -135,6 +132,18 @@ Object {
"splitAccessor": Array [
"d",
],
"table": Array [
Object {
"chain": Array [
Object {
"arguments": Object {},
"function": "kibana",
"type": "function",
},
],
"type": "expression",
},
],
"xAccessor": Array [
"a",
],
@ -146,7 +155,7 @@ Object {
"linear",
],
},
"function": "dataLayer",
"function": "extendedDataLayer",
"type": "function",
},
],
@ -204,9 +213,6 @@ Object {
"type": "expression",
},
],
"title": Array [
"",
],
"valueLabels": Array [
"hide",
],
@ -263,7 +269,7 @@ Object {
"",
],
},
"function": "xyVis",
"function": "layeredXyVis",
"type": "function",
},
],

View file

@ -135,6 +135,7 @@ export const setAnnotationsDimension: Visualization<XYState>['setDimension'] = (
: undefined;
let resultAnnotations = [...inputAnnotations] as XYAnnotationLayerConfig['annotations'];
if (!currentConfig) {
resultAnnotations.push({
label: defaultAnnotationLabel,

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import { DataLayerConfigResult } from '@kbn/expression-xy-plugin/common';
import { layerTypes } from '../../common';
import { Datatable } from '@kbn/expressions-plugin/public';
import { getAxesConfiguration } from './axes_configuration';
import { XYDataLayerConfig } from './types';
describe('axes_configuration', () => {
const tables: Record<string, Datatable> = {
@ -219,8 +219,7 @@ describe('axes_configuration', () => {
},
};
const sampleLayer: DataLayerConfigResult = {
type: 'dataLayer',
const sampleLayer: XYDataLayerConfig = {
layerId: 'first',
layerType: layerTypes.DATA,
seriesType: 'line',

View file

@ -7,7 +7,7 @@
import { groupBy, partition } from 'lodash';
import { i18n } from '@kbn/i18n';
import type { YAxisMode, YConfig } from '@kbn/expression-xy-plugin/common';
import type { YAxisMode, ExtendedYConfig } from '@kbn/expression-xy-plugin/common';
import { Datatable } from '@kbn/expressions-plugin/public';
import { layerTypes } from '../../common';
import type { DatasourceLayers, FramePublicAPI, Visualization } from '../types';
@ -34,7 +34,7 @@ export interface ReferenceLineBase {
* * what groups are current defined in data layers
* * what existing reference line are currently defined in reference layers
*/
export function getGroupsToShow<T extends ReferenceLineBase & { config?: YConfig[] }>(
export function getGroupsToShow<T extends ReferenceLineBase & { config?: ExtendedYConfig[] }>(
referenceLayers: T[],
state: XYState | undefined,
datasourceLayers: DatasourceLayers,
@ -104,6 +104,7 @@ export function getStaticValue(
untouchedDataLayers,
accessors,
} = getAccessorCriteriaForGroup(groupId, dataLayers, activeData);
if (
groupId === 'x' &&
filteredLayers.length &&
@ -111,6 +112,7 @@ export function getStaticValue(
) {
return fallbackValue;
}
const computedValue = computeStaticValueForGroup(
filteredLayers,
accessors,
@ -118,6 +120,7 @@ export function getStaticValue(
groupId !== 'x', // histogram axis should compute the min based on the current data
groupId !== 'x'
);
return computedValue ?? fallbackValue;
}
@ -165,6 +168,7 @@ export function computeOverallDataDomain(
const accessorMap = new Set(accessorIds);
let min: number | undefined;
let max: number | undefined;
const [stacked, unstacked] = partition(
dataLayers,
({ seriesType }) => isStackedChart(seriesType) && allowStacking
@ -268,13 +272,17 @@ export const getReferenceSupportedLayer = (
label: 'x' as const,
},
];
const referenceLineGroups = getGroupsRelatedToData(
referenceLineGroupIds,
state,
frame?.datasourceLayers || {},
frame?.activeData
);
const dataLayers = getDataLayers(state?.layers || []);
const layers = state?.layers || [];
const dataLayers = getDataLayers(layers);
const filledDataLayers = dataLayers.filter(
({ accessors, xAccessor }) => accessors.length || xAccessor
);
@ -289,7 +297,7 @@ export const getReferenceSupportedLayer = (
groupId: id,
columnId: generateId(),
dataType: 'number',
label: getAxisName(label, { isHorizontal: isHorizontalChart(state?.layers || []) }),
label: getAxisName(label, { isHorizontal: isHorizontalChart(layers) }),
staticValue: getStaticValue(
dataLayers,
label,
@ -317,6 +325,7 @@ export const getReferenceSupportedLayer = (
initialDimensions,
};
};
export const setReferenceDimension: Visualization<XYState>['setDimension'] = ({
prevState,
layerId,
@ -397,6 +406,7 @@ export const getReferenceConfiguration = ({
return axisMode;
}
);
const groupsToShow = getGroupsToShow(
[
// When a reference layer panel is added, a static reference line should automatically be included by default
@ -422,7 +432,7 @@ export const getReferenceConfiguration = ({
],
state,
frame.datasourceLayers,
frame?.activeData
frame.activeData
);
const isHorizontal = isHorizontalChart(state.layers);
return {

View file

@ -6,7 +6,7 @@
*/
import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
import type { SeriesType, YConfig, ValidLayer } from '@kbn/expression-xy-plugin/common';
import type { SeriesType, ExtendedYConfig } from '@kbn/expression-xy-plugin/common';
import type { FramePublicAPI, DatasourcePublicAPI } from '../types';
import {
visualizationTypes,
@ -58,7 +58,8 @@ export const getSeriesColor = (layer: XYLayerConfig, accessor: string) => {
return null;
}
return (
layer?.yConfig?.find((yConfig: YConfig) => yConfig.forAccessor === accessor)?.color || null
layer?.yConfig?.find((yConfig: ExtendedYConfig) => yConfig.forAccessor === accessor)?.color ||
null
);
};
@ -79,7 +80,7 @@ export const getColumnToLabelMap = (
};
export function hasHistogramSeries(
layers: ValidLayer[] = [],
layers: XYDataLayerConfig[] = [],
datasourceLayers?: FramePublicAPI['datasourceLayers']
) {
if (!datasourceLayers) {
@ -87,7 +88,11 @@ export function hasHistogramSeries(
}
const validLayers = layers.filter(({ accessors }) => accessors.length);
return validLayers.some(({ layerId, xAccessor }: ValidLayer) => {
return validLayers.some(({ layerId, xAccessor }: XYDataLayerConfig) => {
if (!xAccessor) {
return false;
}
const xAxisOperation = datasourceLayers[layerId].getOperationForColumnId(xAccessor);
return (
xAxisOperation &&

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { Ast } from '@kbn/interpreter';
import { Ast, fromExpression } from '@kbn/interpreter';
import { Position } from '@elastic/charts';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { getXyVisualization } from './xy_visualization';
@ -28,6 +28,8 @@ describe('#toExpression', () => {
let mockDatasource: ReturnType<typeof createMockDatasource>;
let frame: ReturnType<typeof createMockFramePublicAPI>;
let datasourceExpressionsByLayers: Record<string, Ast>;
beforeEach(() => {
frame = createMockFramePublicAPI();
mockDatasource = createMockDatasource('testDatasource');
@ -46,6 +48,23 @@ describe('#toExpression', () => {
frame.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
const datasourceExpression = mockDatasource.toExpression(
frame.datasourceLayers.first,
'first'
) ?? {
type: 'expression',
chain: [],
};
const exprAst =
typeof datasourceExpression === 'string'
? fromExpression(datasourceExpression)
: datasourceExpression;
datasourceExpressionsByLayers = {
first: exprAst,
referenceLine: exprAst,
};
});
it('should map to a valid AST', () => {
@ -82,7 +101,9 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
)
).toMatchSnapshot();
});
@ -106,7 +127,9 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
) as Ast
).chain[0].arguments.fittingFunction[0]
).toEqual('None');
@ -129,7 +152,9 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
) as Ast;
expect(
(expression.chain[0].arguments.axisTitlesVisibilitySettings[0] as Ast).chain[0].arguments
@ -157,7 +182,9 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
) as Ast;
expect((expression.chain[0].arguments.layers[0] as Ast).chain[0].arguments.xAccessor).toEqual(
[]
@ -182,7 +209,9 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
)
).toBeNull();
});
@ -204,7 +233,9 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
)! as Ast;
expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('b');
@ -241,7 +272,9 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
) as Ast;
expect(
(expression.chain[0].arguments.tickLabelsVisibilitySettings[0] as Ast).chain[0].arguments
@ -269,7 +302,9 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
) as Ast;
expect((expression.chain[0].arguments.labelsOrientation[0] as Ast).chain[0].arguments).toEqual({
x: [0],
@ -295,7 +330,9 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
) as Ast;
expect(
(expression.chain[0].arguments.gridlinesVisibilitySettings[0] as Ast).chain[0].arguments
@ -310,7 +347,7 @@ describe('#toExpression', () => {
const expression = xyVisualization.toExpression(
{
legend: { position: Position.Bottom, isVisible: true },
valueLabels: 'inside',
valueLabels: 'show',
preferredSeriesType: 'bar',
layers: [
{
@ -323,16 +360,18 @@ describe('#toExpression', () => {
},
],
},
frame.datasourceLayers
frame.datasourceLayers,
undefined,
datasourceExpressionsByLayers
) as Ast;
expect(expression.chain[0].arguments.valueLabels[0] as Ast).toEqual('inside');
expect(expression.chain[0].arguments.valueLabels[0] as Ast).toEqual('show');
});
it('should compute the correct series color fallback based on the layer type', () => {
const expression = xyVisualization.toExpression(
{
legend: { position: Position.Bottom, isVisible: true },
valueLabels: 'inside',
valueLabels: 'show',
preferredSeriesType: 'bar',
layers: [
{
@ -352,7 +391,9 @@ describe('#toExpression', () => {
},
],
},
{ ...frame.datasourceLayers, referenceLine: mockDatasource.publicAPIMock }
{ ...frame.datasourceLayers, referenceLine: mockDatasource.publicAPIMock },
undefined,
datasourceExpressionsByLayers
) as Ast;
function getYConfigColorForLayer(ast: Ast, index: number) {

View file

@ -10,13 +10,15 @@ import { ScaleType } from '@elastic/charts';
import type { PaletteRegistry } from '@kbn/coloring';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import type { ValidLayer, YConfig } from '@kbn/expression-xy-plugin/common';
import type { AxisExtentConfig, ExtendedYConfig, YConfig } from '@kbn/expression-xy-plugin/common';
import type { ExpressionAstExpression } from '@kbn/expressions-plugin/common';
import {
State,
XYDataLayerConfig,
XYReferenceLineLayerConfig,
XYAnnotationLayerConfig,
} from './types';
import type { ValidXYDataLayerConfig } from './types';
import { OperationMetadata, DatasourcePublicAPI, DatasourceLayers } from '../types';
import { getColumnToLabelMap } from './state_helpers';
import { hasIcon } from './xy_config_panel/shared/icon_select';
@ -50,6 +52,7 @@ export const toExpression = (
datasourceLayers: DatasourceLayers,
paletteService: PaletteRegistry,
attributes: Partial<{ title: string; description: string }> = {},
datasourceExpressionsByLayers: Record<string, Ast>,
eventAnnotationService: EventAnnotationServiceType
): Ast | null => {
if (!state || !state.layers.length) {
@ -73,7 +76,7 @@ export const toExpression = (
metadata,
datasourceLayers,
paletteService,
attributes,
datasourceExpressionsByLayers,
eventAnnotationService
);
};
@ -100,6 +103,7 @@ export function toPreviewExpression(
state: State,
datasourceLayers: DatasourceLayers,
paletteService: PaletteRegistry,
datasourceExpressionsByLayers: Record<string, Ast>,
eventAnnotationService: EventAnnotationServiceType
) {
return toExpression(
@ -116,6 +120,7 @@ export function toPreviewExpression(
datasourceLayers,
paletteService,
{},
datasourceExpressionsByLayers,
eventAnnotationService
);
}
@ -151,11 +156,13 @@ export const buildExpression = (
metadata: Record<string, Record<string, OperationMetadata | null>>,
datasourceLayers: DatasourceLayers,
paletteService: PaletteRegistry,
attributes: Partial<{ title: string; description: string }> = {},
datasourceExpressionsByLayers: Record<string, Ast>,
eventAnnotationService: EventAnnotationServiceType
): Ast | null => {
const validDataLayers = getDataLayers(state.layers)
.filter((layer): layer is ValidLayer => Boolean(layer.accessors.length))
const validDataLayers: ValidXYDataLayerConfig[] = getDataLayers(state.layers)
.filter<ValidXYDataLayerConfig>((layer): layer is ValidXYDataLayerConfig =>
Boolean(layer.accessors.length)
)
.map((layer) => ({
...layer,
accessors: getSortedAccessors(datasourceLayers[layer.layerId], layer),
@ -188,10 +195,8 @@ export const buildExpression = (
chain: [
{
type: 'function',
function: 'xyVis',
function: 'layeredXyVis',
arguments: {
title: [attributes?.title || ''],
description: [attributes?.description || ''],
xTitle: [state.xTitle || ''],
yTitle: [state.yTitle || ''],
yRightTitle: [state.yRightTitle || ''],
@ -207,20 +212,26 @@ export const buildExpression = (
showSingleSeries: state.legend.showSingleSeries
? [state.legend.showSingleSeries]
: [],
position: [state.legend.position],
position: !state.legend.isInside ? [state.legend.position] : [],
isInside: state.legend.isInside ? [state.legend.isInside] : [],
legendSize: state.legend.legendSize ? [state.legend.legendSize] : [],
horizontalAlignment: state.legend.horizontalAlignment
? [state.legend.horizontalAlignment]
: [],
verticalAlignment: state.legend.verticalAlignment
? [state.legend.verticalAlignment]
: [],
legendSize:
!state.legend.isInside && state.legend.legendSize
? [state.legend.legendSize]
: [],
horizontalAlignment:
state.legend.horizontalAlignment && state.legend.isInside
? [state.legend.horizontalAlignment]
: [],
verticalAlignment:
state.legend.verticalAlignment && state.legend.isInside
? [state.legend.verticalAlignment]
: [],
// ensure that even if the user types more than 5 columns
// we will only show 5
floatingColumns: state.legend.floatingColumns
? [Math.min(5, state.legend.floatingColumns)]
: [],
floatingColumns:
state.legend.floatingColumns && state.legend.isInside
? [Math.min(5, state.legend.floatingColumns)]
: [],
maxLines: state.legend.maxLines ? [state.legend.maxLines] : [],
shouldTruncate: [
state.legend.shouldTruncate ??
@ -236,50 +247,8 @@ export const buildExpression = (
emphasizeFitting: [state.emphasizeFitting || false],
curveType: [state.curveType || 'LINEAR'],
fillOpacity: [state.fillOpacity || 0.3],
yLeftExtent: [
{
type: 'expression',
chain: [
{
type: 'function',
function: 'axisExtentConfig',
arguments: {
mode: [state?.yLeftExtent?.mode || 'full'],
lowerBound:
state?.yLeftExtent?.lowerBound !== undefined
? [state?.yLeftExtent?.lowerBound]
: [],
upperBound:
state?.yLeftExtent?.upperBound !== undefined
? [state?.yLeftExtent?.upperBound]
: [],
},
},
],
},
],
yRightExtent: [
{
type: 'expression',
chain: [
{
type: 'function',
function: 'axisExtentConfig',
arguments: {
mode: [state?.yRightExtent?.mode || 'full'],
lowerBound:
state?.yRightExtent?.lowerBound !== undefined
? [state?.yRightExtent?.lowerBound]
: [],
upperBound:
state?.yRightExtent?.upperBound !== undefined
? [state?.yRightExtent?.upperBound]
: [],
},
},
],
},
],
yLeftExtent: [axisExtentConfigToExpression(state.yLeftExtent, validDataLayers)],
yRightExtent: [axisExtentConfigToExpression(state.yRightExtent, validDataLayers)],
axisTitlesVisibilitySettings: [
{
type: 'expression',
@ -353,13 +322,15 @@ export const buildExpression = (
layer,
datasourceLayers[layer.layerId],
metadata,
paletteService
paletteService,
datasourceExpressionsByLayers[layer.layerId]
)
),
...validReferenceLayers.map((layer) =>
referenceLineLayerToExpression(
layer,
datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId]
datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId],
datasourceExpressionsByLayers[layer.layerId]
)
),
...validAnnotationsLayers.map((layer) =>
@ -372,25 +343,32 @@ export const buildExpression = (
};
};
const buildTableExpression = (datasourceExpression: Ast): ExpressionAstExpression => ({
type: 'expression',
chain: [{ type: 'function', function: 'kibana', arguments: {} }, ...datasourceExpression.chain],
});
const referenceLineLayerToExpression = (
layer: XYReferenceLineLayerConfig,
datasourceLayer: DatasourcePublicAPI
datasourceLayer: DatasourcePublicAPI,
datasourceExpression: Ast
): Ast => {
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'referenceLineLayer',
function: 'extendedReferenceLineLayer',
arguments: {
layerId: [layer.layerId],
yConfig: layer.yConfig
? layer.yConfig.map((yConfig) =>
yConfigToExpression(yConfig, defaultReferenceLineColor)
extendedYConfigToExpression(yConfig, defaultReferenceLineColor)
)
: [],
accessors: layer.accessors,
columnToLabel: [JSON.stringify(getColumnToLabelMap(layer, datasourceLayer))],
...(datasourceExpression ? { table: [buildTableExpression(datasourceExpression)] } : {}),
},
},
],
@ -406,7 +384,7 @@ const annotationLayerToExpression = (
chain: [
{
type: 'function',
function: 'annotationLayer',
function: 'extendedAnnotationLayer',
arguments: {
hide: [Boolean(layer.hide)],
layerId: [layer.layerId],
@ -420,10 +398,11 @@ const annotationLayerToExpression = (
};
const dataLayerToExpression = (
layer: ValidLayer,
layer: ValidXYDataLayerConfig,
datasourceLayer: DatasourcePublicAPI,
metadata: Record<string, Record<string, OperationMetadata | null>>,
paletteService: PaletteRegistry
paletteService: PaletteRegistry,
datasourceExpression: Ast
): Ast => {
const columnToLabel = getColumnToLabelMap(layer, datasourceLayer);
@ -441,7 +420,7 @@ const dataLayerToExpression = (
chain: [
{
type: 'function',
function: 'dataLayer',
function: 'extendedDataLayer',
arguments: {
layerId: [layer.layerId],
hide: [Boolean(layer.hide)],
@ -458,6 +437,7 @@ const dataLayerToExpression = (
seriesType: [layer.seriesType],
accessors: layer.accessors,
columnToLabel: [JSON.stringify(columnToLabel)],
...(datasourceExpression ? { table: [buildTableExpression(datasourceExpression)] } : {}),
palette: [
{
type: 'expression',
@ -496,6 +476,23 @@ const yConfigToExpression = (yConfig: YConfig, defaultColor?: string): Ast => {
{
type: 'function',
function: 'yConfig',
arguments: {
forAccessor: [yConfig.forAccessor],
axisMode: yConfig.axisMode ? [yConfig.axisMode] : [],
color: yConfig.color ? [yConfig.color] : defaultColor ? [defaultColor] : [],
},
},
],
};
};
const extendedYConfigToExpression = (yConfig: ExtendedYConfig, defaultColor?: string): Ast => {
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'extendedYConfig',
arguments: {
forAccessor: [yConfig.forAccessor],
axisMode: yConfig.axisMode ? [yConfig.axisMode] : [],
@ -514,3 +511,21 @@ const yConfigToExpression = (yConfig: YConfig, defaultColor?: string): Ast => {
],
};
};
const axisExtentConfigToExpression = (
extent: AxisExtentConfig | undefined,
layers: ValidXYDataLayerConfig[]
): Ast => ({
type: 'expression',
chain: [
{
type: 'function',
function: 'axisExtentConfig',
arguments: {
mode: [extent?.mode ?? 'full'],
lowerBound: extent?.lowerBound !== undefined ? [extent?.lowerBound] : [],
upperBound: extent?.upperBound !== undefined ? [extent?.upperBound] : [],
},
},
],
});

View file

@ -16,7 +16,10 @@ import type {
FittingFunction,
LabelsOrientationConfig,
EndValue,
ExtendedYConfig,
YConfig,
YScaleType,
XScaleType,
} from '@kbn/expression-xy-plugin/common';
import { EventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import { LensIconChartArea } from '../assets/chart_area';
@ -43,12 +46,16 @@ export interface XYDataLayerConfig {
yConfig?: YConfig[];
splitAccessor?: string;
palette?: PaletteOutput;
yScaleType?: YScaleType;
xScaleType?: XScaleType;
isHistogram?: boolean;
columnToLabel?: string;
}
export interface XYReferenceLineLayerConfig {
layerId: string;
accessors: string[];
yConfig?: YConfig[];
yConfig?: ExtendedYConfig[];
layerType: 'referenceLine';
}
@ -64,6 +71,13 @@ export type XYLayerConfig =
| XYReferenceLineLayerConfig
| XYAnnotationLayerConfig;
export interface ValidXYDataLayerConfig extends XYDataLayerConfig {
xAccessor: NonNullable<XYDataLayerConfig['xAccessor']>;
layerId: string;
}
export type ValidLayer = ValidXYDataLayerConfig | XYReferenceLineLayerConfig;
// Persisted parts of the state
export interface XYState {
preferredSeriesType: SeriesType;

View file

@ -16,7 +16,12 @@ import { ThemeServiceStart } from '@kbn/core/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import { FillStyle, SeriesType, YAxisMode, YConfig } from '@kbn/expression-xy-plugin/common';
import {
FillStyle,
SeriesType,
YAxisMode,
ExtendedYConfig,
} from '@kbn/expression-xy-plugin/common';
import { getSuggestions } from './xy_suggestions';
import { XyToolbar } from './xy_config_panel';
import { DimensionEditor } from './xy_config_panel/dimension_editor';
@ -61,7 +66,7 @@ import {
} from './visualization_helpers';
import { groupAxesByType } from './axes_configuration';
import { XYState } from './types';
import { ReferenceLinePanel } from './xy_config_panel/reference_line_panel';
import { ReferenceLinePanel } from './xy_config_panel/reference_line_config_panel';
import { AnnotationsPanel } from './xy_config_panel/annotations_config_panel';
import { DimensionTrigger } from '../shared_components/dimension_trigger';
import { defaultAnnotationLabel } from './annotations/helpers';
@ -295,6 +300,7 @@ export const getXyVisualization = ({
setDimension(props) {
const { prevState, layerId, columnId, groupId } = props;
const foundLayer: XYLayerConfig | undefined = prevState.layers.find(
(l) => l.layerId === layerId
);
@ -333,7 +339,7 @@ export const getXyVisualization = ({
}
const isReferenceLine = metrics.some((metric) => metric.agg === 'static_value');
const axisMode = axisPosition as YAxisMode;
const yConfig = metrics.map<YConfig>((metric, idx) => {
const yConfig = metrics.map<ExtendedYConfig>((metric, idx) => {
return {
color: metric.color,
forAccessor: metric.accessor ?? foundLayer.accessors[idx],
@ -444,7 +450,7 @@ export const getXyVisualization = ({
const groupsAvailable = getGroupsAvailableInData(
getDataLayers(prevState.layers),
frame.datasourceLayers,
frame?.activeData
frame.activeData
);
if (
@ -508,10 +514,26 @@ export const getXyVisualization = ({
);
},
toExpression: (state, layers, attributes) =>
toExpression(state, layers, paletteService, attributes, eventAnnotationService),
toPreviewExpression: (state, layers) =>
toPreviewExpression(state, layers, paletteService, eventAnnotationService),
shouldBuildDatasourceExpressionManually: () => true,
toExpression: (state, layers, attributes, datasourceExpressionsByLayers = {}) =>
toExpression(
state,
layers,
paletteService,
attributes,
datasourceExpressionsByLayers,
eventAnnotationService
),
toPreviewExpression: (state, layers, datasourceExpressionsByLayers = {}) =>
toPreviewExpression(
state,
layers,
paletteService,
datasourceExpressionsByLayers,
eventAnnotationService
),
getErrorMessages(state, datasourceLayers) {
// Data error handling below here
@ -592,10 +614,12 @@ export const getXyVisualization = ({
...getDataLayers(state.layers),
...getReferenceLayers(state.layers),
].filter(({ accessors }) => accessors.length > 0);
const accessorsWithArrayValues = [];
for (const layer of filteredLayers) {
const { layerId, accessors } = layer;
const rows = frame.activeData[layerId] && frame.activeData[layerId].rows;
const rows = frame.activeData?.[layerId] && frame.activeData[layerId].rows;
if (!rows) {
break;
}
@ -607,6 +631,7 @@ export const getXyVisualization = ({
}
}
}
return accessorsWithArrayValues.map((label) => (
<FormattedMessage
key={label}

View file

@ -0,0 +1,505 @@
/*
* 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 './index.scss';
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiDatePicker,
EuiFormRow,
EuiSwitch,
EuiSwitchEvent,
EuiButtonGroup,
EuiFormLabel,
EuiFormControlLayout,
EuiText,
transparentize,
} from '@elastic/eui';
import type { PaletteRegistry } from '@kbn/coloring';
import moment from 'moment';
import {
EventAnnotationConfig,
PointInTimeEventAnnotationConfig,
RangeEventAnnotationConfig,
} from '@kbn/event-annotation-plugin/common/types';
import { pick } from 'lodash';
import { search } from '@kbn/data-plugin/public';
import {
defaultAnnotationColor,
defaultAnnotationRangeColor,
isRangeAnnotation,
} from '@kbn/event-annotation-plugin/public';
import Color from 'color';
import { getDataLayers } from '../../visualization_helpers';
import { FormatFactory } from '../../../../common';
import { DimensionEditorSection, NameInput, useDebouncedValue } from '../../../shared_components';
import { isHorizontalChart } from '../../state_helpers';
import { defaultAnnotationLabel, defaultRangeAnnotationLabel } from '../../annotations/helpers';
import { ColorPicker } from '../color_picker';
import { IconSelectSetting, TextDecorationSetting } from '../shared/marker_decoration_settings';
import { LineStyleSettings } from '../shared/line_style_settings';
import { updateLayer } from '..';
import { annotationsIconSet } from './icon_set';
import type { FramePublicAPI, VisualizationDimensionEditorProps } from '../../../types';
import { State, XYState, XYAnnotationLayerConfig, XYDataLayerConfig } from '../../types';
export const toRangeAnnotationColor = (color = defaultAnnotationColor) => {
return new Color(transparentize(color, 0.1)).hexa();
};
export const toLineAnnotationColor = (color = defaultAnnotationRangeColor) => {
return new Color(transparentize(color, 1)).hex();
};
export const getEndTimestamp = (
startTime: string,
{ activeData, dateRange }: FramePublicAPI,
dataLayers: XYDataLayerConfig[]
) => {
const startTimeNumber = moment(startTime).valueOf();
const dateRangeFraction =
(moment(dateRange.toDate).valueOf() - moment(dateRange.fromDate).valueOf()) * 0.1;
const fallbackValue = moment(startTimeNumber + dateRangeFraction).toISOString();
const dataLayersId = dataLayers.map(({ layerId }) => layerId);
if (
!dataLayersId.length ||
!activeData ||
Object.entries(activeData)
.filter(([key]) => dataLayersId.includes(key))
.every(([, { rows }]) => !rows || !rows.length)
) {
return fallbackValue;
}
const xColumn = activeData?.[dataLayersId[0]].columns.find(
(column) => column.id === dataLayers[0].xAccessor
);
if (!xColumn) {
return fallbackValue;
}
const dateInterval = search.aggs.getDateHistogramMetaDataByDatatableColumn(xColumn)?.interval;
if (!dateInterval) return fallbackValue;
const intervalDuration = search.aggs.parseInterval(dateInterval);
if (!intervalDuration) return fallbackValue;
return moment(startTimeNumber + 3 * intervalDuration.as('milliseconds')).toISOString();
};
const sanitizeProperties = (annotation: EventAnnotationConfig) => {
if (isRangeAnnotation(annotation)) {
const rangeAnnotation: RangeEventAnnotationConfig = pick(annotation, [
'label',
'key',
'id',
'isHidden',
'color',
'outside',
]);
return rangeAnnotation;
} else {
const lineAnnotation: PointInTimeEventAnnotationConfig = pick(annotation, [
'id',
'label',
'key',
'isHidden',
'lineStyle',
'lineWidth',
'color',
'icon',
'textVisibility',
]);
return lineAnnotation;
}
};
export const AnnotationsPanel = (
props: VisualizationDimensionEditorProps<State> & {
formatFactory: FormatFactory;
paletteService: PaletteRegistry;
}
) => {
const { state, setState, layerId, accessor, frame } = props;
const isHorizontal = isHorizontalChart(state.layers);
const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue<XYState>({
value: state,
onChange: setState,
});
const index = localState.layers.findIndex((l) => l.layerId === layerId);
const localLayer = localState.layers.find(
(l) => l.layerId === layerId
) as XYAnnotationLayerConfig;
const currentAnnotation = localLayer.annotations?.find((c) => c.id === accessor);
const isRange = isRangeAnnotation(currentAnnotation);
const setAnnotations = useCallback(
(annotation) => {
if (annotation == null) {
return;
}
const newConfigs = [...(localLayer.annotations || [])];
const existingIndex = newConfigs.findIndex((c) => c.id === accessor);
if (existingIndex !== -1) {
newConfigs[existingIndex] = sanitizeProperties({
...newConfigs[existingIndex],
...annotation,
});
} else {
throw new Error(
'should never happen because annotation is created before config panel is opened'
);
}
setLocalState(updateLayer(localState, { ...localLayer, annotations: newConfigs }, index));
},
[accessor, index, localState, localLayer, setLocalState]
);
return (
<>
<DimensionEditorSection
title={i18n.translate('xpack.lens.xyChart.placement', {
defaultMessage: 'Placement',
})}
>
{isRange ? (
<>
<ConfigPanelRangeDatePicker
dataTestSubj="lns-xyAnnotation-fromTime"
prependLabel={i18n.translate('xpack.lens.xyChart.annotationDate.from', {
defaultMessage: 'From',
})}
value={moment(currentAnnotation?.key.timestamp)}
onChange={(date) => {
if (date) {
const currentEndTime = moment(currentAnnotation?.key.endTimestamp).valueOf();
if (currentEndTime < date.valueOf()) {
const currentStartTime = moment(currentAnnotation?.key.timestamp).valueOf();
const dif = currentEndTime - currentStartTime;
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'range' }),
timestamp: date.toISOString(),
endTimestamp: moment(date.valueOf() + dif).toISOString(),
},
});
} else {
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'range' }),
timestamp: date.toISOString(),
},
});
}
}
}}
label={i18n.translate('xpack.lens.xyChart.annotationDate', {
defaultMessage: 'Annotation date',
})}
/>
<ConfigPanelRangeDatePicker
dataTestSubj="lns-xyAnnotation-toTime"
prependLabel={i18n.translate('xpack.lens.xyChart.annotationDate.to', {
defaultMessage: 'To',
})}
value={moment(currentAnnotation?.key.endTimestamp)}
onChange={(date) => {
if (date) {
const currentStartTime = moment(currentAnnotation?.key.timestamp).valueOf();
if (currentStartTime > date.valueOf()) {
const currentEndTime = moment(currentAnnotation?.key.endTimestamp).valueOf();
const dif = currentEndTime - currentStartTime;
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'range' }),
endTimestamp: date.toISOString(),
timestamp: moment(date.valueOf() - dif).toISOString(),
},
});
} else {
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'range' }),
endTimestamp: date.toISOString(),
},
});
}
}
}}
/>
</>
) : (
<ConfigPanelRangeDatePicker
dataTestSubj="lns-xyAnnotation-time"
label={i18n.translate('xpack.lens.xyChart.annotationDate', {
defaultMessage: 'Annotation date',
})}
value={moment(currentAnnotation?.key.timestamp)}
onChange={(date) => {
if (date) {
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'point_in_time' }),
timestamp: date.toISOString(),
},
});
}
}}
/>
)}
<ConfigPanelApplyAsRangeSwitch
annotation={currentAnnotation}
onChange={setAnnotations}
frame={frame}
state={state}
/>
</DimensionEditorSection>
<DimensionEditorSection
title={i18n.translate('xpack.lens.xyChart.appearance', {
defaultMessage: 'Appearance',
})}
>
<NameInput
value={currentAnnotation?.label || defaultAnnotationLabel}
defaultValue={defaultAnnotationLabel}
onChange={(value) => {
setAnnotations({ label: value });
}}
/>
{!isRange && (
<IconSelectSetting
setConfig={setAnnotations}
defaultIcon="triangle"
currentConfig={{
axisMode: 'bottom',
...currentAnnotation,
}}
customIconSet={annotationsIconSet}
/>
)}
{!isRange && (
<TextDecorationSetting
setConfig={setAnnotations}
currentConfig={{
axisMode: 'bottom',
...currentAnnotation,
}}
/>
)}
{!isRange && (
<LineStyleSettings
isHorizontal={isHorizontal}
setConfig={setAnnotations}
currentConfig={currentAnnotation}
/>
)}
{isRange && (
<EuiFormRow
label={i18n.translate('xpack.lens.xyChart.fillStyle', {
defaultMessage: 'Fill',
})}
display="columnCompressed"
fullWidth
>
<EuiButtonGroup
legend={i18n.translate('xpack.lens.xyChart.fillStyle', {
defaultMessage: 'Fill',
})}
data-test-subj="lns-xyAnnotation-fillStyle"
name="fillStyle"
buttonSize="compressed"
options={[
{
id: `lens_xyChart_fillStyle_inside`,
label: i18n.translate('xpack.lens.xyChart.fillStyle.inside', {
defaultMessage: 'Inside',
}),
'data-test-subj': 'lnsXY_fillStyle_inside',
},
{
id: `lens_xyChart_fillStyle_outside`,
label: i18n.translate('xpack.lens.xyChart.fillStyle.outside', {
defaultMessage: 'Outside',
}),
'data-test-subj': 'lnsXY_fillStyle_inside',
},
]}
idSelected={`lens_xyChart_fillStyle_${
Boolean(currentAnnotation?.outside) ? 'outside' : 'inside'
}`}
onChange={(id) => {
setAnnotations({
outside: id === `lens_xyChart_fillStyle_outside`,
});
}}
isFullWidth
/>
</EuiFormRow>
)}
<ColorPicker
{...props}
defaultColor={isRange ? defaultAnnotationRangeColor : defaultAnnotationColor}
showAlpha={isRange}
setConfig={setAnnotations}
disableHelpTooltip
label={i18n.translate('xpack.lens.xyChart.lineColor.label', {
defaultMessage: 'Color',
})}
/>
<ConfigPanelHideSwitch
value={Boolean(currentAnnotation?.isHidden)}
onChange={(ev) => setAnnotations({ isHidden: ev.target.checked })}
/>
</DimensionEditorSection>
</>
);
};
const ConfigPanelApplyAsRangeSwitch = ({
annotation,
onChange,
frame,
state,
}: {
annotation?: EventAnnotationConfig;
onChange: (annotations: Partial<EventAnnotationConfig> | undefined) => void;
frame: FramePublicAPI;
state: XYState;
}) => {
const isRange = isRangeAnnotation(annotation);
return (
<EuiFormRow display="columnCompressed" className="lnsRowCompressedMargin">
<EuiSwitch
data-test-subj="lns-xyAnnotation-rangeSwitch"
label={
<EuiText size="xs">
{i18n.translate('xpack.lens.xyChart.applyAsRange', {
defaultMessage: 'Apply as range',
})}
</EuiText>
}
checked={isRange}
onChange={() => {
if (isRange) {
const newPointAnnotation: PointInTimeEventAnnotationConfig = {
key: {
type: 'point_in_time',
timestamp: annotation.key.timestamp,
},
id: annotation.id,
label:
annotation.label === defaultRangeAnnotationLabel
? defaultAnnotationLabel
: annotation.label,
color: toLineAnnotationColor(annotation.color),
isHidden: annotation.isHidden,
};
onChange(newPointAnnotation);
} else if (annotation) {
const fromTimestamp = moment(annotation?.key.timestamp);
const dataLayers = getDataLayers(state.layers);
const newRangeAnnotation: RangeEventAnnotationConfig = {
key: {
type: 'range',
timestamp: annotation.key.timestamp,
endTimestamp: getEndTimestamp(fromTimestamp.toISOString(), frame, dataLayers),
},
id: annotation.id,
label:
annotation.label === defaultAnnotationLabel
? defaultRangeAnnotationLabel
: annotation.label,
color: toRangeAnnotationColor(annotation.color),
isHidden: annotation.isHidden,
};
onChange(newRangeAnnotation);
}
}}
compressed
/>
</EuiFormRow>
);
};
const ConfigPanelRangeDatePicker = ({
value,
label,
prependLabel,
onChange,
dataTestSubj = 'lnsXY_annotation_date_picker',
}: {
value: moment.Moment;
prependLabel?: string;
label?: string;
onChange: (val: moment.Moment | null) => void;
dataTestSubj?: string;
}) => {
return (
<EuiFormRow display="rowCompressed" fullWidth label={label} className="lnsRowCompressedMargin">
{prependLabel ? (
<EuiFormControlLayout
fullWidth
className="lnsConfigPanelNoPadding"
prepend={
<EuiFormLabel className="lnsConfigPanelDate__label">{prependLabel}</EuiFormLabel>
}
>
<EuiDatePicker
fullWidth
showTimeSelect
selected={value}
onChange={onChange}
dateFormat="MMM D, YYYY @ HH:mm:ss.SSS"
data-test-subj={dataTestSubj}
/>
</EuiFormControlLayout>
) : (
<EuiDatePicker
fullWidth
showTimeSelect
selected={value}
onChange={onChange}
dateFormat="MMM D, YYYY @ HH:mm:ss.SSS"
data-test-subj={dataTestSubj}
/>
)}
</EuiFormRow>
);
};
const ConfigPanelHideSwitch = ({
value,
onChange,
}: {
value: boolean;
onChange: (event: EuiSwitchEvent) => void;
}) => {
return (
<EuiFormRow
label={i18n.translate('xpack.lens.xyChart.annotation.name', {
defaultMessage: 'Hide annotation',
})}
display="columnCompressedSwitch"
fullWidth
>
<EuiSwitch
compressed
label={i18n.translate('xpack.lens.xyChart.annotation.name', {
defaultMessage: 'Hide annotation',
})}
showLabel={false}
data-test-subj="lns-annotations-hide-annotation"
checked={value}
onChange={onChange}
/>
</EuiFormRow>
);
};

View file

@ -4,10 +4,13 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { IconTriangle, IconCircle } from '../../../assets/annotation_icons';
export const annotationsIconSet = [
import { i18n } from '@kbn/i18n';
import { AvailableAnnotationIcon } from '@kbn/event-annotation-plugin/common';
import { IconTriangle, IconCircle } from '../../../assets/annotation_icons';
import { IconSet } from '../shared/icon_select';
export const annotationsIconSet: IconSet<AvailableAnnotationIcon> = [
{
value: 'asterisk',
label: i18n.translate('xpack.lens.xyChart.iconSelect.asteriskIconLabel', {

View file

@ -29,7 +29,7 @@ const customLineStaticAnnotation = {
id: 'ann1',
key: { type: 'point_in_time' as const, timestamp: '2022-03-18T08:25:00.000Z' },
label: 'Event',
icon: 'triangle',
icon: 'triangle' as const,
color: 'red',
lineStyle: 'dashed' as const,
lineWidth: 3,

View file

@ -5,501 +5,4 @@
* 2.0.
*/
import './index.scss';
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import type { PaletteRegistry } from '@kbn/coloring';
import {
EuiDatePicker,
EuiFormRow,
EuiSwitch,
EuiSwitchEvent,
EuiButtonGroup,
EuiFormLabel,
EuiFormControlLayout,
EuiText,
transparentize,
} from '@elastic/eui';
import { pick } from 'lodash';
import moment from 'moment';
import {
EventAnnotationConfig,
PointInTimeEventAnnotationConfig,
RangeEventAnnotationConfig,
} from '@kbn/event-annotation-plugin/common/types';
import { search } from '@kbn/data-plugin/public';
import {
defaultAnnotationColor,
defaultAnnotationRangeColor,
isRangeAnnotation,
} from '@kbn/event-annotation-plugin/public';
import Color from 'color';
import type { FramePublicAPI, VisualizationDimensionEditorProps } from '../../../types';
import { State, XYState, XYAnnotationLayerConfig, XYDataLayerConfig } from '../../types';
import { FormatFactory } from '../../../../common';
import { DimensionEditorSection, NameInput, useDebouncedValue } from '../../../shared_components';
import { isHorizontalChart } from '../../state_helpers';
import { defaultAnnotationLabel, defaultRangeAnnotationLabel } from '../../annotations/helpers';
import { ColorPicker } from '../color_picker';
import { IconSelectSetting, TextDecorationSetting } from '../shared/marker_decoration_settings';
import { LineStyleSettings } from '../shared/line_style_settings';
import { updateLayer } from '..';
import { annotationsIconSet } from './icon_set';
import { getDataLayers } from '../../visualization_helpers';
export const toRangeAnnotationColor = (color = defaultAnnotationColor) => {
return new Color(transparentize(color, 0.1)).hexa();
};
export const toLineAnnotationColor = (color = defaultAnnotationRangeColor) => {
return new Color(transparentize(color, 1)).hex();
};
export const getEndTimestamp = (
startTime: string,
{ activeData, dateRange }: FramePublicAPI,
dataLayers: XYDataLayerConfig[]
) => {
const startTimeNumber = moment(startTime).valueOf();
const dateRangeFraction =
(moment(dateRange.toDate).valueOf() - moment(dateRange.fromDate).valueOf()) * 0.1;
const fallbackValue = moment(startTimeNumber + dateRangeFraction).toISOString();
const dataLayersId = dataLayers.map(({ layerId }) => layerId);
if (
!dataLayersId.length ||
!activeData ||
Object.entries(activeData)
.filter(([key]) => dataLayersId.includes(key))
.every(([, { rows }]) => !rows || !rows.length)
) {
return fallbackValue;
}
const xColumn = activeData?.[dataLayersId[0]].columns.find(
(column) => column.id === dataLayers[0].xAccessor
);
if (!xColumn) {
return fallbackValue;
}
const dateInterval = search.aggs.getDateHistogramMetaDataByDatatableColumn(xColumn)?.interval;
if (!dateInterval) return fallbackValue;
const intervalDuration = search.aggs.parseInterval(dateInterval);
if (!intervalDuration) return fallbackValue;
return moment(startTimeNumber + 3 * intervalDuration.as('milliseconds')).toISOString();
};
const sanitizeProperties = (annotation: EventAnnotationConfig) => {
if (isRangeAnnotation(annotation)) {
const rangeAnnotation: RangeEventAnnotationConfig = pick(annotation, [
'label',
'key',
'id',
'isHidden',
'color',
'outside',
]);
return rangeAnnotation;
} else {
const lineAnnotation: PointInTimeEventAnnotationConfig = pick(annotation, [
'id',
'label',
'key',
'isHidden',
'lineStyle',
'lineWidth',
'color',
'icon',
'textVisibility',
]);
return lineAnnotation;
}
};
export const AnnotationsPanel = (
props: VisualizationDimensionEditorProps<State> & {
formatFactory: FormatFactory;
paletteService: PaletteRegistry;
}
) => {
const { state, setState, layerId, accessor, frame } = props;
const isHorizontal = isHorizontalChart(state.layers);
const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue<XYState>({
value: state,
onChange: setState,
});
const index = localState.layers.findIndex((l) => l.layerId === layerId);
const localLayer = localState.layers.find(
(l) => l.layerId === layerId
) as XYAnnotationLayerConfig;
const currentAnnotation = localLayer.annotations?.find((c) => c.id === accessor);
const isRange = isRangeAnnotation(currentAnnotation);
const setAnnotations = useCallback(
(annotation) => {
if (annotation == null) {
return;
}
const newConfigs = [...(localLayer.annotations || [])];
const existingIndex = newConfigs.findIndex((c) => c.id === accessor);
if (existingIndex !== -1) {
newConfigs[existingIndex] = sanitizeProperties({
...newConfigs[existingIndex],
...annotation,
});
} else {
throw new Error(
'should never happen because annotation is created before config panel is opened'
);
}
setLocalState(updateLayer(localState, { ...localLayer, annotations: newConfigs }, index));
},
[accessor, index, localState, localLayer, setLocalState]
);
return (
<>
<DimensionEditorSection
title={i18n.translate('xpack.lens.xyChart.placement', {
defaultMessage: 'Placement',
})}
>
{isRange ? (
<>
<ConfigPanelRangeDatePicker
dataTestSubj="lns-xyAnnotation-fromTime"
prependLabel={i18n.translate('xpack.lens.xyChart.annotationDate.from', {
defaultMessage: 'From',
})}
value={moment(currentAnnotation?.key.timestamp)}
onChange={(date) => {
if (date) {
const currentEndTime = moment(currentAnnotation?.key.endTimestamp).valueOf();
if (currentEndTime < date.valueOf()) {
const currentStartTime = moment(currentAnnotation?.key.timestamp).valueOf();
const dif = currentEndTime - currentStartTime;
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'range' }),
timestamp: date.toISOString(),
endTimestamp: moment(date.valueOf() + dif).toISOString(),
},
});
} else {
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'range' }),
timestamp: date.toISOString(),
},
});
}
}
}}
label={i18n.translate('xpack.lens.xyChart.annotationDate', {
defaultMessage: 'Annotation date',
})}
/>
<ConfigPanelRangeDatePicker
dataTestSubj="lns-xyAnnotation-toTime"
prependLabel={i18n.translate('xpack.lens.xyChart.annotationDate.to', {
defaultMessage: 'To',
})}
value={moment(currentAnnotation?.key.endTimestamp)}
onChange={(date) => {
if (date) {
const currentStartTime = moment(currentAnnotation?.key.timestamp).valueOf();
if (currentStartTime > date.valueOf()) {
const currentEndTime = moment(currentAnnotation?.key.endTimestamp).valueOf();
const dif = currentEndTime - currentStartTime;
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'range' }),
endTimestamp: date.toISOString(),
timestamp: moment(date.valueOf() - dif).toISOString(),
},
});
} else {
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'range' }),
endTimestamp: date.toISOString(),
},
});
}
}
}}
/>
</>
) : (
<ConfigPanelRangeDatePicker
dataTestSubj="lns-xyAnnotation-time"
label={i18n.translate('xpack.lens.xyChart.annotationDate', {
defaultMessage: 'Annotation date',
})}
value={moment(currentAnnotation?.key.timestamp)}
onChange={(date) => {
if (date) {
setAnnotations({
key: {
...(currentAnnotation?.key || { type: 'point_in_time' }),
timestamp: date.toISOString(),
},
});
}
}}
/>
)}
<ConfigPanelApplyAsRangeSwitch
annotation={currentAnnotation}
onChange={setAnnotations}
frame={frame}
state={state}
/>
</DimensionEditorSection>
<DimensionEditorSection
title={i18n.translate('xpack.lens.xyChart.appearance', {
defaultMessage: 'Appearance',
})}
>
<NameInput
value={currentAnnotation?.label || defaultAnnotationLabel}
defaultValue={defaultAnnotationLabel}
onChange={(value) => {
setAnnotations({ label: value });
}}
/>
{!isRange && (
<IconSelectSetting
setConfig={setAnnotations}
defaultIcon="triangle"
currentConfig={{
axisMode: 'bottom',
...currentAnnotation,
}}
customIconSet={annotationsIconSet}
/>
)}
{!isRange && (
<TextDecorationSetting
setConfig={setAnnotations}
currentConfig={{
axisMode: 'bottom',
...currentAnnotation,
}}
/>
)}
{!isRange && (
<LineStyleSettings
isHorizontal={isHorizontal}
setConfig={setAnnotations}
currentConfig={currentAnnotation}
/>
)}
{isRange && (
<EuiFormRow
label={i18n.translate('xpack.lens.xyChart.fillStyle', {
defaultMessage: 'Fill',
})}
display="columnCompressed"
fullWidth
>
<EuiButtonGroup
legend={i18n.translate('xpack.lens.xyChart.fillStyle', {
defaultMessage: 'Fill',
})}
data-test-subj="lns-xyAnnotation-fillStyle"
name="fillStyle"
buttonSize="compressed"
options={[
{
id: `lens_xyChart_fillStyle_inside`,
label: i18n.translate('xpack.lens.xyChart.fillStyle.inside', {
defaultMessage: 'Inside',
}),
'data-test-subj': 'lnsXY_fillStyle_inside',
},
{
id: `lens_xyChart_fillStyle_outside`,
label: i18n.translate('xpack.lens.xyChart.fillStyle.outside', {
defaultMessage: 'Outside',
}),
'data-test-subj': 'lnsXY_fillStyle_inside',
},
]}
idSelected={`lens_xyChart_fillStyle_${
Boolean(currentAnnotation?.outside) ? 'outside' : 'inside'
}`}
onChange={(id) => {
setAnnotations({
outside: id === `lens_xyChart_fillStyle_outside`,
});
}}
isFullWidth
/>
</EuiFormRow>
)}
<ColorPicker
{...props}
defaultColor={isRange ? defaultAnnotationRangeColor : defaultAnnotationColor}
showAlpha={isRange}
setConfig={setAnnotations}
disableHelpTooltip
label={i18n.translate('xpack.lens.xyChart.lineColor.label', {
defaultMessage: 'Color',
})}
/>
<ConfigPanelHideSwitch
value={Boolean(currentAnnotation?.isHidden)}
onChange={(ev) => setAnnotations({ isHidden: ev.target.checked })}
/>
</DimensionEditorSection>
</>
);
};
const ConfigPanelApplyAsRangeSwitch = ({
annotation,
onChange,
frame,
state,
}: {
annotation?: EventAnnotationConfig;
onChange: (annotations: Partial<EventAnnotationConfig> | undefined) => void;
frame: FramePublicAPI;
state: XYState;
}) => {
const isRange = isRangeAnnotation(annotation);
return (
<EuiFormRow display="columnCompressed" className="lnsRowCompressedMargin">
<EuiSwitch
data-test-subj="lns-xyAnnotation-rangeSwitch"
label={
<EuiText size="xs">
{i18n.translate('xpack.lens.xyChart.applyAsRange', {
defaultMessage: 'Apply as range',
})}
</EuiText>
}
checked={isRange}
onChange={() => {
if (isRange) {
const newPointAnnotation: PointInTimeEventAnnotationConfig = {
key: {
type: 'point_in_time',
timestamp: annotation.key.timestamp,
},
id: annotation.id,
label:
annotation.label === defaultRangeAnnotationLabel
? defaultAnnotationLabel
: annotation.label,
color: toLineAnnotationColor(annotation.color),
isHidden: annotation.isHidden,
};
onChange(newPointAnnotation);
} else if (annotation) {
const fromTimestamp = moment(annotation?.key.timestamp);
const dataLayers = getDataLayers(state.layers);
const newRangeAnnotation: RangeEventAnnotationConfig = {
key: {
type: 'range',
timestamp: annotation.key.timestamp,
endTimestamp: getEndTimestamp(fromTimestamp.toISOString(), frame, dataLayers),
},
id: annotation.id,
label:
annotation.label === defaultAnnotationLabel
? defaultRangeAnnotationLabel
: annotation.label,
color: toRangeAnnotationColor(annotation.color),
isHidden: annotation.isHidden,
};
onChange(newRangeAnnotation);
}
}}
compressed
/>
</EuiFormRow>
);
};
const ConfigPanelRangeDatePicker = ({
value,
label,
prependLabel,
onChange,
dataTestSubj = 'lnsXY_annotation_date_picker',
}: {
value: moment.Moment;
prependLabel?: string;
label?: string;
onChange: (val: moment.Moment | null) => void;
dataTestSubj?: string;
}) => {
return (
<EuiFormRow display="rowCompressed" fullWidth label={label} className="lnsRowCompressedMargin">
{prependLabel ? (
<EuiFormControlLayout
fullWidth
className="lnsConfigPanelNoPadding"
prepend={
<EuiFormLabel className="lnsConfigPanelDate__label">{prependLabel}</EuiFormLabel>
}
>
<EuiDatePicker
fullWidth
showTimeSelect
selected={value}
onChange={onChange}
dateFormat="MMM D, YYYY @ HH:mm:ss.SSS"
data-test-subj={dataTestSubj}
/>
</EuiFormControlLayout>
) : (
<EuiDatePicker
fullWidth
showTimeSelect
selected={value}
onChange={onChange}
dateFormat="MMM D, YYYY @ HH:mm:ss.SSS"
data-test-subj={dataTestSubj}
/>
)}
</EuiFormRow>
);
};
const ConfigPanelHideSwitch = ({
value,
onChange,
}: {
value: boolean;
onChange: (event: EuiSwitchEvent) => void;
}) => {
return (
<EuiFormRow
label={i18n.translate('xpack.lens.xyChart.annotation.name', {
defaultMessage: 'Hide annotation',
})}
display="columnCompressedSwitch"
fullWidth
>
<EuiSwitch
compressed
label={i18n.translate('xpack.lens.xyChart.annotation.name', {
defaultMessage: 'Hide annotation',
})}
showLabel={false}
data-test-subj="lns-annotations-hide-annotation"
checked={value}
onChange={onChange}
/>
</EuiFormRow>
);
};
export { AnnotationsPanel } from './annotations_panel';

View file

@ -76,10 +76,9 @@ export const ColorPicker = ({
frame.datasourceLayers[layer.layerId] ?? layer.accessors,
layer
);
const colorAssignments = getColorAssignments(
getDataLayers(state.layers),
{ tables: frame.activeData },
{ tables: frame.activeData ?? {} },
formatFactory
);
const mappedAccessors = getAccessorColorConfig(
@ -91,7 +90,6 @@ export const ColorPicker = ({
},
paletteService
);
return mappedAccessors.find((a) => a.columnId === accessor)?.color || null;
}
}, [
@ -105,12 +103,12 @@ export const ColorPicker = ({
defaultColor,
]);
const [color, setColor] = useState(currentColor);
useEffect(() => {
setColor(currentColor);
}, [currentColor]);
const [color, setColor] = useState(currentColor);
const handleColor: EuiColorPickerProps['onChange'] = (text, output) => {
setColor(text);
if (output.isValid || text === '') {

Some files were not shown because too many files have changed in this diff Show more