[Lens] Fitting functions (#69820)

This commit is contained in:
Joe Reuter 2020-07-03 16:15:06 +02:00 committed by GitHub
parent e70fcc708e
commit bbda3f99ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 379 additions and 37 deletions

View file

@ -68,29 +68,33 @@ export function WorkspacePanelWrapper({
return (
<EuiFlexGroup gutterSize="s" direction="column" alignItems="stretch" responsive={false}>
<EuiFlexItem grow={false}>
<ChartSwitch
data-test-subj="lnsChartSwitcher"
visualizationMap={visualizationMap}
visualizationId={visualizationId}
visualizationState={visualizationState}
datasourceMap={datasourceMap}
datasourceStates={datasourceStates}
dispatch={dispatch}
framePublicAPI={framePublicAPI}
/>
<EuiFlexGroup gutterSize="s" direction="row" responsive={false}>
<EuiFlexItem grow={false}>
<ChartSwitch
data-test-subj="lnsChartSwitcher"
visualizationMap={visualizationMap}
visualizationId={visualizationId}
visualizationState={visualizationState}
datasourceMap={datasourceMap}
datasourceStates={datasourceStates}
dispatch={dispatch}
framePublicAPI={framePublicAPI}
/>
</EuiFlexItem>
{activeVisualization && activeVisualization.renderToolbar && (
<EuiFlexItem grow>
<NativeRenderer
render={activeVisualization.renderToolbar}
nativeProps={{
frame: framePublicAPI,
state: visualizationState,
setState: setVisualizationState,
}}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
{activeVisualization && activeVisualization.renderToolbar && (
<EuiFlexItem grow={false}>
<NativeRenderer
render={activeVisualization.renderToolbar}
nativeProps={{
frame: framePublicAPI,
state: visualizationState,
setState: setVisualizationState,
}}
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiPageContent className="lnsWorkspacePanelWrapper">
{(!emptyExpression || title) && (

View file

@ -5,6 +5,9 @@ Object {
"chain": Array [
Object {
"arguments": Object {
"fittingFunction": Array [
"Carry",
],
"layers": Array [
Object {
"chain": Array [

View file

@ -54,6 +54,11 @@ exports[`xy_expression XYChart component it renders area 1`] = `
]
}
enableHistogramMode={false}
fit={
Object {
"type": "none",
}
}
groupId="left"
id="d-a"
key="0-0"
@ -93,6 +98,11 @@ exports[`xy_expression XYChart component it renders area 1`] = `
]
}
enableHistogramMode={false}
fit={
Object {
"type": "none",
}
}
groupId="left"
id="d-b"
key="0-1"
@ -402,6 +412,11 @@ exports[`xy_expression XYChart component it renders line 1`] = `
]
}
enableHistogramMode={false}
fit={
Object {
"type": "none",
}
}
groupId="left"
id="d-a"
key="0-0"
@ -441,6 +456,11 @@ exports[`xy_expression XYChart component it renders line 1`] = `
]
}
enableHistogramMode={false}
fit={
Object {
"type": "none",
}
}
groupId="left"
id="d-b"
key="0-1"

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Fit } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
export type FittingFunction = typeof fittingFunctionDefinitions[number]['id'];
export const fittingFunctionDefinitions = [
{
id: 'None',
title: i18n.translate('xpack.lens.fittingFunctionsTitle.none', {
defaultMessage: 'Hide',
}),
description: i18n.translate('xpack.lens.fittingFunctionsDescription.none', {
defaultMessage: 'Do not fill gaps',
}),
},
{
id: 'Zero',
title: i18n.translate('xpack.lens.fittingFunctionsTitle.zero', {
defaultMessage: 'Zero',
}),
description: i18n.translate('xpack.lens.fittingFunctionsDescription.zero', {
defaultMessage: 'Fill gaps with zeros',
}),
},
{
id: 'Linear',
title: i18n.translate('xpack.lens.fittingFunctionsTitle.linear', {
defaultMessage: 'Linear',
}),
description: i18n.translate('xpack.lens.fittingFunctionsDescription.linear', {
defaultMessage: 'Fill gaps with a line',
}),
},
{
id: 'Carry',
title: i18n.translate('xpack.lens.fittingFunctionsTitle.carry', {
defaultMessage: 'Last',
}),
description: i18n.translate('xpack.lens.fittingFunctionsDescription.carry', {
defaultMessage: 'Fill gaps with the last value',
}),
},
{
id: 'Lookahead',
title: i18n.translate('xpack.lens.fittingFunctionsTitle.lookahead', {
defaultMessage: 'Next',
}),
description: i18n.translate('xpack.lens.fittingFunctionsDescription.lookahead', {
defaultMessage: 'Fill gaps with the next value',
}),
},
] as const;
export function getFitEnum(fittingFunction?: FittingFunction) {
if (fittingFunction) {
return Fit[fittingFunction];
}
return Fit.None;
}
export function getFitOptions(fittingFunction?: FittingFunction) {
return { type: getFitEnum(fittingFunction) };
}

View file

@ -40,6 +40,7 @@ describe('#toExpression', () => {
{
legend: { position: Position.Bottom, isVisible: true },
preferredSeriesType: 'bar',
fittingFunction: 'Carry',
layers: [
{
layerId: 'first',
@ -55,6 +56,27 @@ describe('#toExpression', () => {
).toMatchSnapshot();
});
it('should default the fitting function to None', () => {
expect(
(xyVisualization.toExpression(
{
legend: { position: Position.Bottom, isVisible: true },
preferredSeriesType: 'bar',
layers: [
{
layerId: 'first',
seriesType: 'area',
splitAccessor: 'd',
xAccessor: 'a',
accessors: ['b', 'c'],
},
],
},
frame
) as Ast).chain[0].arguments.fittingFunction[0]
).toEqual('None');
});
it('should not generate an expression when missing x', () => {
expect(
xyVisualization.toExpression(

View file

@ -133,6 +133,7 @@ export const buildExpression = (
],
},
],
fittingFunction: [state.fittingFunction || 'None'],
layers: validLayers.map((layer) => {
const columnToLabel: Record<string, string> = {};

View file

@ -16,6 +16,7 @@ import chartBarHorizontalStackedSVG from '../assets/chart_bar_horizontal_stacked
import chartLineSVG from '../assets/chart_line.svg';
import { VisualizationType } from '../index';
import { FittingFunction } from './fitting_functions';
export interface LegendConfig {
isVisible: boolean;
@ -225,12 +226,14 @@ export interface XYArgs {
yTitle: string;
legend: LegendConfig & { type: 'lens_xy_legendConfig' };
layers: LayerArgs[];
fittingFunction?: FittingFunction;
}
// Persisted parts of the state
export interface XYState {
preferredSeriesType: SeriesType;
legend: LegendConfig;
fittingFunction?: FittingFunction;
layers: LayerConfig[];
}

View file

@ -0,0 +1,3 @@
.lnsXyToolbar__popover {
width: 400px;
}

View file

@ -5,15 +5,15 @@
*/
import React from 'react';
import { mountWithIntl as mount } from 'test_utils/enzyme_helpers';
import { EuiButtonGroupProps } from '@elastic/eui';
import { LayerContextMenu } from './xy_config_panel';
import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers';
import { EuiButtonGroupProps, EuiSuperSelect } from '@elastic/eui';
import { LayerContextMenu, XyToolbar } from './xy_config_panel';
import { FramePublicAPI } from '../types';
import { State } from './types';
import { Position } from '@elastic/charts';
import { createMockFramePublicAPI, createMockDatasource } from '../editor_frame_service/mocks';
describe('LayerContextMenu', () => {
describe('XY Config panels', () => {
let frame: FramePublicAPI;
function testState(): State {
@ -39,11 +39,6 @@ describe('LayerContextMenu', () => {
};
});
test.skip('allows toggling of legend visibility', () => {});
test.skip('allows changing legend position', () => {});
test.skip('allows toggling the y axis gridlines', () => {});
test.skip('allows toggling the x axis gridlines', () => {});
describe('LayerContextMenu', () => {
test('enables stacked chart types even when there is no split series', () => {
const state = testState();
@ -92,4 +87,45 @@ describe('LayerContextMenu', () => {
expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]);
});
});
describe('XyToolbar', () => {
it('should show currently selected fitting function', () => {
const state = testState();
const component = shallow(
<XyToolbar
frame={frame}
setState={jest.fn()}
state={{
...state,
layers: [{ ...state.layers[0], seriesType: 'line' }],
fittingFunction: 'Carry',
}}
/>
);
expect(component.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('Carry');
});
it('should disable the select if there is no unstacked area or line series', () => {
const state = testState();
const component = shallow(
<XyToolbar
frame={frame}
setState={jest.fn()}
state={{
...state,
layers: [
{ ...state.layers[0], seriesType: 'bar' },
{ ...state.layers[0], seriesType: 'area_stacked' },
],
fittingFunction: 'Carry',
}}
/>
);
expect(component.find(EuiSuperSelect).prop('disabled')).toEqual(true);
});
});
});

View file

@ -8,8 +8,14 @@ import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { debounce } from 'lodash';
import {
EuiButtonEmpty,
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiSuperSelect,
EuiFormRow,
EuiPopover,
EuiText,
htmlIdGenerator,
EuiForm,
EuiColorPicker,
@ -17,10 +23,17 @@ import {
EuiToolTip,
EuiIcon,
} from '@elastic/eui';
import {
VisualizationLayerWidgetProps,
VisualizationDimensionEditorProps,
VisualizationToolbarProps,
} from '../types';
import { State, SeriesType, visualizationTypes, YAxisMode } from './types';
import { VisualizationDimensionEditorProps, VisualizationLayerWidgetProps } from '../types';
import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers';
import { trackUiEvent } from '../lens_ui_telemetry';
import { fittingFunctionDefinitions } from './fitting_functions';
import './xy_config_panel.scss';
type UnwrapArray<T> = T extends Array<infer P> ? P : T;
@ -78,6 +91,75 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps<State>) {
);
}
export function XyToolbar(props: VisualizationToolbarProps<State>) {
const [open, setOpen] = useState(false);
const hasNonBarSeries = props.state?.layers.some(
(layer) => layer.seriesType === 'line' || layer.seriesType === 'area'
);
return (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiPopover
panelClassName="lnsXyToolbar__popover"
button={
<EuiButtonEmpty
color="text"
iconType="arrowDown"
iconSide="right"
onClick={() => {
setOpen(!open);
}}
>
{i18n.translate('xpack.lens.xyChart.settingsLabel', { defaultMessage: 'Settings' })}
</EuiButtonEmpty>
}
isOpen={open}
closePopover={() => {
setOpen(false);
}}
anchorPosition="downRight"
>
<EuiFormRow
display="columnCompressed"
label={i18n.translate('xpack.lens.xyChart.fittingLabel', {
defaultMessage: 'Fill missing values',
})}
helpText={
!hasNonBarSeries &&
i18n.translate('xpack.lens.xyChart.fittingDisabledHelpText', {
defaultMessage:
'This setting only applies to line charts and unstacked area charts.',
})
}
>
<EuiSuperSelect
compressed
disabled={!hasNonBarSeries}
options={fittingFunctionDefinitions.map(({ id, title, description }) => {
return {
value: id,
dropdownDisplay: (
<>
<strong>{title}</strong>
<EuiText size="xs" color="subdued">
<p>{description}</p>
</EuiText>
</>
),
inputDisplay: title,
};
})}
valueOfSelected={props.state?.fittingFunction || 'None'}
onChange={(value) => props.setState({ ...props.state, fittingFunction: value })}
itemLayoutAlign="top"
hasDividers
/>
</EuiFormRow>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
);
}
const idPrefix = htmlIdGenerator()();
export function DimensionEditor(props: VisualizationDimensionEditorProps<State>) {

View file

@ -15,6 +15,7 @@ import {
GeometryValue,
XYChartSeriesIdentifier,
SeriesNameFn,
Fit,
} from '@elastic/charts';
import { xyChart, XYChart } from './xy_expression';
import { LensMultiTable } from '../types';
@ -1554,5 +1555,66 @@ describe('xy_expression', () => {
expect(component.find(Settings).prop('showLegend')).toEqual(true);
});
test('it should apply the fitting function to all non-bar series', () => {
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' },
]),
},
};
const args: XYArgs = createArgsWithLayers([
{ ...sampleLayer, accessors: ['a'] },
{ ...sampleLayer, seriesType: 'bar', accessors: ['a'] },
{ ...sampleLayer, seriesType: 'area', accessors: ['a'] },
{ ...sampleLayer, seriesType: 'area_stacked', accessors: ['a'] },
]);
const component = shallow(
<XYChart
data={{ ...data }}
args={{ ...args, fittingFunction: 'Carry' }}
formatFactory={getFormatSpy}
timeZone="UTC"
chartsThemeService={chartsThemeService}
histogramBarTarget={50}
onClickValue={onClickValue}
onSelectRange={onSelectRange}
/>
);
expect(component.find(LineSeries).prop('fit')).toEqual({ type: Fit.Carry });
expect(component.find(BarSeries).prop('fit')).toEqual(undefined);
expect(component.find(AreaSeries).at(0).prop('fit')).toEqual({ type: Fit.Carry });
expect(component.find(AreaSeries).at(0).prop('stackAccessors')).toEqual([]);
// stacked area series doesn't get the fit prop
expect(component.find(AreaSeries).at(1).prop('fit')).toEqual(undefined);
expect(component.find(AreaSeries).at(1).prop('stackAccessors')).toEqual(['c']);
});
test('it should apply None fitting function if not specified', () => {
const { data, args } = sampleArgs();
args.layers[0].accessors = ['a'];
const component = shallow(
<XYChart
data={{ ...data }}
args={{ ...args }}
formatFactory={getFormatSpy}
timeZone="UTC"
chartsThemeService={chartsThemeService}
histogramBarTarget={50}
onClickValue={onClickValue}
onSelectRange={onSelectRange}
/>
);
expect(component.find(LineSeries).prop('fit')).toEqual({ type: Fit.None });
});
});
});

View file

@ -40,6 +40,7 @@ import { parseInterval } from '../../../../../src/plugins/data/common';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { EmptyPlaceholder } from '../shared_components';
import { desanitizeFilterContext } from '../utils';
import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions';
import { getAxesConfiguration } from './axes_configuration';
type InferPropType<T> = T extends React.FunctionComponent<infer P> ? P : T;
@ -94,6 +95,13 @@ export const xyChart: ExpressionFunctionDefinition<
defaultMessage: 'Configure the chart legend.',
}),
},
fittingFunction: {
types: ['string'],
options: [...fittingFunctionDefinitions.map(({ id }) => id)],
help: i18n.translate('xpack.lens.xyChart.fittingFunction.help', {
defaultMessage: 'Define how missing values are treated',
}),
},
layers: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
types: ['lens_xy_layer'] as any,
@ -191,7 +199,7 @@ export function XYChart({
onClickValue,
onSelectRange,
}: XYChartRenderProps) {
const { legend, layers } = args;
const { legend, layers, fittingFunction } = args;
const chartTheme = chartsThemeService.useChartsTheme();
const chartBaseTheme = chartsThemeService.useChartsBaseTheme();
@ -463,7 +471,7 @@ export function XYChart({
}
// 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 acccessor, so we show the human-readable name
// * 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]] ?? '';
},
};
@ -472,17 +480,29 @@ export function XYChart({
switch (seriesType) {
case 'line':
return <LineSeries key={index} {...seriesProps} />;
return (
<LineSeries key={index} {...seriesProps} fit={getFitOptions(fittingFunction)} />
);
case 'bar':
case 'bar_stacked':
case 'bar_horizontal':
case 'bar_horizontal_stacked':
return <BarSeries key={index} {...seriesProps} />;
default:
case 'area_stacked':
return <AreaSeries key={index} {...seriesProps} />;
case 'area':
return (
<AreaSeries key={index} {...seriesProps} fit={getFitOptions(fittingFunction)} />
);
default:
return assertNever(seriesType);
}
})
)}
</Chart>
);
}
function assertNever(x: never): never {
throw new Error('Unexpected series type: ' + x);
}

View file

@ -331,6 +331,7 @@ describe('xy_suggestions', () => {
test('makes a visible seriesType suggestion for unchanged table without split', () => {
const currentState: XYState = {
legend: { isVisible: true, position: 'bottom' },
fittingFunction: 'None',
preferredSeriesType: 'bar',
layers: [
{
@ -368,6 +369,7 @@ describe('xy_suggestions', () => {
const currentState: XYState = {
legend: { isVisible: true, position: 'bottom' },
preferredSeriesType: 'bar',
fittingFunction: 'None',
layers: [
{
accessors: ['price', 'quantity'],
@ -408,6 +410,7 @@ describe('xy_suggestions', () => {
(generateId as jest.Mock).mockReturnValueOnce('dummyCol');
const currentState: XYState = {
legend: { isVisible: true, position: 'bottom' },
fittingFunction: 'None',
preferredSeriesType: 'bar',
layers: [
{
@ -440,6 +443,7 @@ describe('xy_suggestions', () => {
const currentState: XYState = {
legend: { isVisible: true, position: 'bottom' },
preferredSeriesType: 'bar',
fittingFunction: 'None',
layers: [
{
accessors: ['price'],
@ -474,6 +478,7 @@ describe('xy_suggestions', () => {
const currentState: XYState = {
legend: { isVisible: true, position: 'bottom' },
preferredSeriesType: 'bar',
fittingFunction: 'None',
layers: [
{
accessors: ['price', 'quantity'],
@ -512,6 +517,7 @@ describe('xy_suggestions', () => {
const currentState: XYState = {
legend: { isVisible: true, position: 'bottom' },
preferredSeriesType: 'bar',
fittingFunction: 'None',
layers: [
{
accessors: ['price'],
@ -551,6 +557,7 @@ describe('xy_suggestions', () => {
const currentState: XYState = {
legend: { isVisible: true, position: 'bottom' },
preferredSeriesType: 'bar',
fittingFunction: 'None',
layers: [
{
accessors: ['price', 'quantity'],

View file

@ -402,6 +402,7 @@ function buildSuggestion({
const state: State = {
legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right },
fittingFunction: currentState?.fittingFunction || 'None',
preferredSeriesType: seriesType,
layers: [...keptLayers, newLayer],
};

View file

@ -11,7 +11,7 @@ import { Position } from '@elastic/charts';
import { I18nProvider } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { getSuggestions } from './xy_suggestions';
import { DimensionEditor, LayerContextMenu } from './xy_config_panel';
import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel';
import { Visualization, OperationMetadata, VisualizationType } from '../types';
import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types';
import chartBarStackedSVG from '../assets/chart_bar_stacked.svg';
@ -264,6 +264,15 @@ export const xyVisualization: Visualization<State, PersistableState> = {
);
},
renderToolbar(domElement, props) {
render(
<I18nProvider>
<XyToolbar {...props} />
</I18nProvider>,
domElement
);
},
renderDimensionEditor(domElement, props) {
render(
<I18nProvider>