[Lens] Specify Y axis extent (#99203) (#100255)

Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
This commit is contained in:
Kibana Machine 2021-05-18 08:33:04 -04:00 committed by GitHub
parent ee3c3c6f63
commit ac4173058e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 834 additions and 27 deletions

View file

@ -52,6 +52,13 @@ exports[`xy_expression XYChart component it renders area 1`] = `
title="c"
/>
<Connect(SpecInstance)
domain={
Object {
"fit": false,
"max": undefined,
"min": undefined,
}
}
gridLine={
Object {
"visible": false,
@ -253,6 +260,13 @@ exports[`xy_expression XYChart component it renders bar 1`] = `
title="c"
/>
<Connect(SpecInstance)
domain={
Object {
"fit": false,
"max": undefined,
"min": undefined,
}
}
gridLine={
Object {
"visible": false,
@ -462,6 +476,13 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = `
title="c"
/>
<Connect(SpecInstance)
domain={
Object {
"fit": false,
"max": undefined,
"min": undefined,
}
}
gridLine={
Object {
"visible": false,
@ -671,6 +692,13 @@ exports[`xy_expression XYChart component it renders line 1`] = `
title="c"
/>
<Connect(SpecInstance)
domain={
Object {
"fit": false,
"max": undefined,
"min": undefined,
}
}
gridLine={
Object {
"visible": false,
@ -872,6 +900,13 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = `
title="c"
/>
<Connect(SpecInstance)
domain={
Object {
"fit": false,
"max": undefined,
"min": undefined,
}
}
gridLine={
Object {
"visible": false,
@ -1081,6 +1116,13 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = `
title="c"
/>
<Connect(SpecInstance)
domain={
Object {
"fit": false,
"max": undefined,
"min": undefined,
}
}
gridLine={
Object {
"visible": false,
@ -1298,6 +1340,13 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] =
title="c"
/>
<Connect(SpecInstance)
domain={
Object {
"fit": false,
"max": undefined,
"min": undefined,
}
}
gridLine={
Object {
"visible": false,

View file

@ -157,6 +157,46 @@ Object {
"xTitle": Array [
"",
],
"yLeftExtent": Array [
Object {
"chain": Array [
Object {
"arguments": Object {
"lowerBound": Array [],
"mode": Array [
"full",
],
"upperBound": Array [],
},
"function": "lens_xy_axisExtentConfig",
"type": "function",
},
],
"type": "expression",
},
],
"yRightExtent": Array [
Object {
"chain": Array [
Object {
"arguments": Object {
"lowerBound": Array [
123,
],
"mode": Array [
"custom",
],
"upperBound": Array [
456,
],
},
"function": "lens_xy_axisExtentConfig",
"type": "function",
},
],
"type": "expression",
},
],
"yRightTitle": Array [
"",
],

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { XYLayerConfig } from './types';
import { AxisExtentConfig, XYLayerConfig } from './types';
import { Datatable, SerializedFieldFormat } from '../../../../../src/plugins/expressions/public';
import { IFieldFormat } from '../../../../../src/plugins/data/public';
@ -15,7 +15,7 @@ interface FormattedMetric {
fieldFormat: SerializedFieldFormat;
}
type GroupsConfiguration = Array<{
export type GroupsConfiguration = Array<{
groupId: string;
position: 'left' | 'right' | 'bottom' | 'top';
formatter?: IFieldFormat;
@ -111,3 +111,17 @@ export function getAxesConfiguration(
return axisGroups;
}
export function validateExtent(hasBarOrArea: boolean, extent?: AxisExtentConfig) {
const inclusiveZeroError =
extent &&
hasBarOrArea &&
((extent.lowerBound !== undefined && extent.lowerBound > 0) ||
(extent.upperBound !== undefined && extent.upperBound) < 0);
const boundaryError =
extent &&
extent.lowerBound !== undefined &&
extent.upperBound !== undefined &&
extent.upperBound <= extent.lowerBound;
return { inclusiveZeroError, boundaryError };
}

View file

@ -32,6 +32,7 @@ describe('Axes Settings', () => {
toggleAxisTitleVisibility: jest.fn(),
toggleTickLabelsVisibility: jest.fn(),
toggleGridlinesVisibility: jest.fn(),
hasBarOrAreaOnAxis: false,
};
});
@ -91,4 +92,26 @@ describe('Axes Settings', () => {
);
expect(component.find('[data-test-subj="lnsshowEndzones"]').prop('checked')).toBe(true);
});
describe('axis extent', () => {
it('hides the extent section if no extent is passed in', () => {
const component = shallow(<AxisSettingsPopover {...props} />);
expect(component.find('[data-test-subj="lnsXY_axisBounds_groups"]').length).toBe(0);
});
it('renders bound inputs if mode is custom', () => {
const setSpy = jest.fn();
const component = shallow(
<AxisSettingsPopover
{...props}
extent={{ mode: 'custom', lowerBound: 123, upperBound: 456 }}
setExtent={setSpy}
/>
);
const lower = component.find('[data-test-subj="lnsXY_axisExtent_lowerBound"]');
const upper = component.find('[data-test-subj="lnsXY_axisExtent_upperBound"]');
expect(lower.prop('value')).toEqual(123);
expect(upper.prop('value')).toEqual(456);
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -14,9 +14,13 @@ import {
EuiSpacer,
EuiFieldText,
IconType,
EuiFormRow,
EuiButtonGroup,
htmlIdGenerator,
EuiFieldNumber,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { XYLayerConfig, AxesSettingsConfig } from './types';
import { XYLayerConfig, AxesSettingsConfig, AxisExtentConfig } from './types';
import { ToolbarPopover, useDebouncedValue } from '../shared_components';
import { isHorizontalChart } from './state_helpers';
import { EuiIconAxisBottom } from '../assets/axis_bottom';
@ -24,6 +28,7 @@ import { EuiIconAxisLeft } from '../assets/axis_left';
import { EuiIconAxisRight } from '../assets/axis_right';
import { EuiIconAxisTop } from '../assets/axis_top';
import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public';
import { validateExtent } from './axes_configuration';
type AxesSettingsConfigKeys = keyof AxesSettingsConfig;
export interface AxisSettingsPopoverProps {
@ -79,6 +84,16 @@ export interface AxisSettingsPopoverProps {
* Flag whether endzones are visible
*/
endzonesVisible?: boolean;
/**
* axis extent
*/
extent?: AxisExtentConfig;
/**
* set axis extent
*/
setExtent?: (extent: AxisExtentConfig | undefined) => void;
hasBarOrAreaOnAxis: boolean;
dataBounds?: { min: number; max: number };
}
const popoverConfig = (
axis: AxesSettingsConfigKeys,
@ -134,6 +149,8 @@ const popoverConfig = (
}
};
const noop = () => {};
const idPrefix = htmlIdGenerator()();
export const AxisSettingsPopover: React.FunctionComponent<AxisSettingsPopoverProps> = ({
layers,
axis,
@ -148,10 +165,45 @@ export const AxisSettingsPopover: React.FunctionComponent<AxisSettingsPopoverPro
toggleAxisTitleVisibility,
setEndzoneVisibility,
endzonesVisible,
extent,
setExtent,
hasBarOrAreaOnAxis,
dataBounds,
}) => {
const isHorizontal = layers?.length ? isHorizontalChart(layers) : false;
const config = popoverConfig(axis, isHorizontal);
const { inputValue: debouncedExtent, handleInputChange: setDebouncedExtent } = useDebouncedValue<
AxisExtentConfig | undefined
>({
value: extent,
onChange: setExtent || noop,
});
const [localExtent, setLocalExtent] = useState(debouncedExtent);
const { inclusiveZeroError, boundaryError } = validateExtent(hasBarOrAreaOnAxis, localExtent);
useEffect(() => {
// set global extent if local extent is not invalid
if (
setExtent &&
!inclusiveZeroError &&
!boundaryError &&
localExtent &&
localExtent !== debouncedExtent
) {
setDebouncedExtent(localExtent);
}
}, [
localExtent,
inclusiveZeroError,
boundaryError,
setDebouncedExtent,
debouncedExtent,
setExtent,
]);
const { inputValue: title, handleInputChange: onTitleChange } = useDebouncedValue<string>({
value: axisTitle || '',
onChange: updateTitleState,
@ -234,6 +286,177 @@ export const AxisSettingsPopover: React.FunctionComponent<AxisSettingsPopoverPro
/>
</>
)}
{localExtent && setExtent && (
<>
<EuiSpacer size="s" />
<EuiFormRow
display="rowCompressed"
fullWidth
label={i18n.translate('xpack.lens.xyChart.axisExtent.label', {
defaultMessage: 'Bounds',
})}
helpText={
hasBarOrAreaOnAxis
? i18n.translate('xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage', {
defaultMessage: 'Only line charts can be fit to the data bounds',
})
: undefined
}
>
<EuiButtonGroup
isFullWidth
legend={i18n.translate('xpack.lens.xyChart.axisExtent.label', {
defaultMessage: 'Bounds',
})}
data-test-subj="lnsXY_axisBounds_groups"
name="axisBounds"
buttonSize="compressed"
options={[
{
id: `${idPrefix}full`,
label: i18n.translate('xpack.lens.xyChart.axisExtent.full', {
defaultMessage: 'Full',
}),
'data-test-subj': 'lnsXY_axisExtent_groups_full',
},
{
id: `${idPrefix}dataBounds`,
label: i18n.translate('xpack.lens.xyChart.axisExtent.dataBounds', {
defaultMessage: 'Data bounds',
}),
'data-test-subj': 'lnsXY_axisExtent_groups_DataBounds',
isDisabled: hasBarOrAreaOnAxis,
},
{
id: `${idPrefix}custom`,
label: i18n.translate('xpack.lens.xyChart.axisExtent.custom', {
defaultMessage: 'Custom',
}),
'data-test-subj': 'lnsXY_axisExtent_groups_custom',
},
]}
idSelected={`${idPrefix}${
hasBarOrAreaOnAxis && localExtent.mode === 'dataBounds' ? 'full' : localExtent.mode
}`}
onChange={(id) => {
const newMode = id.replace(idPrefix, '') as AxisExtentConfig['mode'];
setLocalExtent({
...localExtent,
mode: newMode,
lowerBound:
newMode === 'custom' && dataBounds ? Math.min(0, dataBounds.min) : undefined,
upperBound: newMode === 'custom' && dataBounds ? dataBounds.max : undefined,
});
}}
/>
</EuiFormRow>
{localExtent.mode === 'custom' && (
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFormRow
display="rowCompressed"
fullWidth
label={i18n.translate('xpack.lens.xyChart.lowerBoundLabel', {
defaultMessage: 'Lower bound',
})}
isInvalid={inclusiveZeroError || boundaryError}
helpText={
hasBarOrAreaOnAxis && !inclusiveZeroError
? i18n.translate('xpack.lens.xyChart.inclusiveZero', {
defaultMessage: 'Bounds must include zero.',
})
: undefined
}
error={
hasBarOrAreaOnAxis && inclusiveZeroError
? i18n.translate('xpack.lens.xyChart.inclusiveZero', {
defaultMessage: 'Bounds must include zero.',
})
: undefined
}
>
<EuiFieldNumber
compressed
value={localExtent.lowerBound ?? ''}
isInvalid={inclusiveZeroError || boundaryError}
data-test-subj="lnsXY_axisExtent_lowerBound"
onChange={(e) => {
const val = Number(e.target.value);
if (e.target.value === '' || Number.isNaN(Number(val))) {
setLocalExtent({
...localExtent,
lowerBound: undefined,
});
} else {
setLocalExtent({
...localExtent,
lowerBound: val,
});
}
}}
onBlur={() => {
if (localExtent.lowerBound === undefined && dataBounds) {
setLocalExtent({
...localExtent,
lowerBound: Math.min(0, dataBounds.min),
});
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
display="rowCompressed"
fullWidth
label={i18n.translate('xpack.lens.xyChart.upperBoundLabel', {
defaultMessage: 'Upper bound',
})}
isInvalid={inclusiveZeroError || boundaryError}
error={
boundaryError
? i18n.translate('xpack.lens.xyChart.boundaryError', {
defaultMessage: 'Lower bound has to be larger than upper bound',
})
: undefined
}
>
<EuiFieldNumber
compressed
value={localExtent.upperBound ?? ''}
data-test-subj="lnsXY_axisExtent_upperBound"
onChange={(e) => {
const val = Number(e.target.value);
if (e.target.value === '' || Number.isNaN(Number(val))) {
setLocalExtent({
...localExtent,
upperBound: undefined,
});
} else {
setLocalExtent({
...localExtent,
upperBound: val,
});
}
}}
onBlur={() => {
if (localExtent.upperBound === undefined && dataBounds) {
setLocalExtent({
...localExtent,
upperBound: dataBounds.max,
});
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</>
)}
</ToolbarPopover>
);
};

View file

@ -283,6 +283,14 @@ const createArgsWithLayers = (layers: LayerArgs[] = [sampleLayer]): XYArgs => ({
yLeft: false,
yRight: false,
},
yLeftExtent: {
mode: 'full',
type: 'lens_xy_axisExtentConfig',
},
yRightExtent: {
mode: 'full',
type: 'lens_xy_axisExtentConfig',
},
layers,
});
@ -681,6 +689,114 @@ describe('xy_expression', () => {
});
});
describe('y axis extents', () => {
test('it passes custom y axis extents to elastic-charts axis spec', () => {
const { data, args } = sampleArgs();
const component = shallow(
<XYChart
{...defaultProps}
data={data}
args={{
...args,
yLeftExtent: {
type: 'lens_xy_axisExtentConfig',
mode: 'custom',
lowerBound: 123,
upperBound: 456,
},
}}
/>
);
expect(component.find(Axis).find('[id="left"]').prop('domain')).toEqual({
fit: false,
min: 123,
max: 456,
});
});
test('it passes fit to bounds y axis extents to elastic-charts axis spec', () => {
const { data, args } = sampleArgs();
const component = shallow(
<XYChart
{...defaultProps}
data={data}
args={{
...args,
yLeftExtent: {
type: 'lens_xy_axisExtentConfig',
mode: 'dataBounds',
},
}}
/>
);
expect(component.find(Axis).find('[id="left"]').prop('domain')).toEqual({
fit: true,
min: undefined,
max: undefined,
});
});
test('it does not allow fit for area chart', () => {
const { data, args } = sampleArgs();
const component = shallow(
<XYChart
{...defaultProps}
data={data}
args={{
...args,
yLeftExtent: {
type: 'lens_xy_axisExtentConfig',
mode: 'dataBounds',
},
layers: [
{
...args.layers[0],
seriesType: 'area',
},
],
}}
/>
);
expect(component.find(Axis).find('[id="left"]').prop('domain')).toEqual({
fit: false,
});
});
test('it does not allow positive lower bound for bar', () => {
const { data, args } = sampleArgs();
const component = shallow(
<XYChart
{...defaultProps}
data={data}
args={{
...args,
yLeftExtent: {
type: 'lens_xy_axisExtentConfig',
mode: 'custom',
lowerBound: 123,
upperBound: 456,
},
layers: [
{
...args.layers[0],
seriesType: 'bar',
},
],
}}
/>
);
expect(component.find(Axis).find('[id="left"]').prop('domain')).toEqual({
fit: false,
min: undefined,
max: undefined,
});
});
});
test('it has xDomain undefined if the x is not a time scale or a histogram', () => {
const { data, args } = sampleArgs();
@ -1761,6 +1877,14 @@ describe('xy_expression', () => {
yLeft: false,
yRight: false,
},
yLeftExtent: {
mode: 'full',
type: 'lens_xy_axisExtentConfig',
},
yRightExtent: {
mode: 'full',
type: 'lens_xy_axisExtentConfig',
},
layers: [
{
layerId: 'first',
@ -1835,6 +1959,14 @@ describe('xy_expression', () => {
yLeft: false,
yRight: false,
},
yLeftExtent: {
mode: 'full',
type: 'lens_xy_axisExtentConfig',
},
yRightExtent: {
mode: 'full',
type: 'lens_xy_axisExtentConfig',
},
layers: [
{
layerId: 'first',
@ -1895,6 +2027,14 @@ describe('xy_expression', () => {
yLeft: false,
yRight: false,
},
yLeftExtent: {
mode: 'full',
type: 'lens_xy_axisExtentConfig',
},
yRightExtent: {
mode: 'full',
type: 'lens_xy_axisExtentConfig',
},
layers: [
{
layerId: 'first',

View file

@ -55,7 +55,7 @@ import {
import { EmptyPlaceholder } from '../shared_components';
import { desanitizeFilterContext } from '../utils';
import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions';
import { getAxesConfiguration } from './axes_configuration';
import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axes_configuration';
import { getColorAssignments } from './color_assignment';
import { getXDomain, XyEndzones } from './x_domain';
@ -135,6 +135,18 @@ export const xyChart: ExpressionFunctionDefinition<
defaultMessage: 'Y right axis title',
}),
},
yLeftExtent: {
types: ['lens_xy_axisExtentConfig'],
help: i18n.translate('xpack.lens.xyChart.yLeftExtent.help', {
defaultMessage: 'Y left axis extents',
}),
},
yRightExtent: {
types: ['lens_xy_axisExtentConfig'],
help: i18n.translate('xpack.lens.xyChart.yRightExtent.help', {
defaultMessage: 'Y right axis extents',
}),
},
legend: {
types: ['lens_xy_legendConfig'],
help: i18n.translate('xpack.lens.xyChart.legend.help', {
@ -345,6 +357,8 @@ export function XYChart({
gridlinesVisibilitySettings,
valueLabels,
hideEndzones,
yLeftExtent,
yRightExtent,
} = args;
const chartTheme = chartsThemeService.useChartsTheme();
const chartBaseTheme = chartsThemeService.useChartsBaseTheme();
@ -445,6 +459,33 @@ export function XYChart({
return style;
};
const getYAxisDomain = (axis: GroupsConfiguration[number]) => {
const extent = axis.groupId === 'left' ? yLeftExtent : yRightExtent;
const hasBarOrArea = Boolean(
axis.series.some((series) => {
const seriesType = filteredLayers.find((l) => l.layerId === series.layer)?.seriesType;
return seriesType?.includes('bar') || seriesType?.includes('area');
})
);
const fit = !hasBarOrArea && extent.mode === 'dataBounds';
let min: undefined | number;
let max: undefined | number;
if (extent.mode === 'custom') {
const { inclusiveZeroError, boundaryError } = validateExtent(hasBarOrArea, extent);
if (!inclusiveZeroError && !boundaryError) {
min = extent.lowerBound;
max = extent.upperBound;
}
}
return {
fit,
min,
max,
};
};
const shouldShowValueLabels =
// No stacked bar charts
filteredLayers.every((layer) => !layer.seriesType.includes('stacked')) &&
@ -597,24 +638,27 @@ export function XYChart({
}}
/>
{yAxesConfiguration.map((axis) => (
<Axis
key={axis.groupId}
id={axis.groupId}
groupId={axis.groupId}
position={axis.position}
title={getYAxesTitles(axis.series, axis.groupId)}
gridLine={{
visible:
axis.groupId === 'right'
? gridlinesVisibilitySettings?.yRight
: gridlinesVisibilitySettings?.yLeft,
}}
hide={filteredLayers[0].hide}
tickFormat={(d) => axis.formatter?.convert(d) || ''}
style={getYAxesStyle(axis.groupId)}
/>
))}
{yAxesConfiguration.map((axis) => {
return (
<Axis
key={axis.groupId}
id={axis.groupId}
groupId={axis.groupId}
position={axis.position}
title={getYAxesTitles(axis.series, axis.groupId)}
gridLine={{
visible:
axis.groupId === 'right'
? gridlinesVisibilitySettings?.yRight
: gridlinesVisibilitySettings?.yLeft,
}}
hide={filteredLayers[0].hide}
tickFormat={(d) => axis.formatter?.convert(d) || ''}
style={getYAxesStyle(axis.groupId)}
domain={getYAxisDomain(axis)}
/>
);
})}
{!hideEndzones && (
<XyEndzones

View file

@ -42,6 +42,7 @@ export class XyVisualization {
tickLabelsConfig,
gridlinesConfig,
axisTitlesVisibilityConfig,
axisExtentConfig,
layerConfig,
xyChart,
getXyChartRenderer,
@ -52,6 +53,7 @@ export class XyVisualization {
expressions.registerFunction(() => legendConfig);
expressions.registerFunction(() => yAxisConfig);
expressions.registerFunction(() => tickLabelsConfig);
expressions.registerFunction(() => axisExtentConfig);
expressions.registerFunction(() => gridlinesConfig);
expressions.registerFunction(() => axisTitlesVisibilityConfig);
expressions.registerFunction(() => layerConfig);

View file

@ -52,6 +52,11 @@ describe('#toExpression', () => {
tickLabelsVisibilitySettings: { x: false, yLeft: true, yRight: true },
gridlinesVisibilitySettings: { x: false, yLeft: true, yRight: true },
hideEndzones: true,
yRightExtent: {
mode: 'custom',
lowerBound: 123,
upperBound: 456,
},
layers: [
{
layerId: 'first',

View file

@ -149,6 +149,50 @@ export const buildExpression = (
],
fittingFunction: [state.fittingFunction || 'None'],
curveType: [state.curveType || 'LINEAR'],
yLeftExtent: [
{
type: 'expression',
chain: [
{
type: 'function',
function: 'lens_xy_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: 'lens_xy_axisExtentConfig',
arguments: {
mode: [state?.yRightExtent?.mode || 'full'],
lowerBound:
state?.yRightExtent?.lowerBound !== undefined
? [state?.yRightExtent?.lowerBound]
: [],
upperBound:
state?.yRightExtent?.upperBound !== undefined
? [state?.yRightExtent?.upperBound]
: [],
},
},
],
},
],
axisTitlesVisibilitySettings: [
{
type: 'expression',

View file

@ -211,6 +211,54 @@ export const axisTitlesVisibilityConfig: ExpressionFunctionDefinition<
},
};
export interface AxisExtentConfig {
mode: 'full' | 'dataBounds' | 'custom';
lowerBound?: number;
upperBound?: number;
}
export const axisExtentConfig: ExpressionFunctionDefinition<
'lens_xy_axisExtentConfig',
null,
AxisExtentConfig,
AxisExtentConfigResult
> = {
name: 'lens_xy_axisExtentConfig',
aliases: [],
type: 'lens_xy_axisExtentConfig',
help: `Configure the xy chart's axis extents`,
inputTypes: ['null'],
args: {
mode: {
types: ['string'],
options: ['full', 'dataBounds', 'custom'],
help: i18n.translate('xpack.lens.xyChart.extentMode.help', {
defaultMessage: 'The extent mode',
}),
},
lowerBound: {
types: ['number'],
help: i18n.translate('xpack.lens.xyChart.extentMode.help', {
defaultMessage: 'The extent mode',
}),
},
upperBound: {
types: ['number'],
help: i18n.translate('xpack.lens.xyChart.extentMode.help', {
defaultMessage: 'The extent mode',
}),
},
},
fn: function fn(input: unknown, args: AxisExtentConfig) {
return {
type: 'lens_xy_axisExtentConfig',
...args,
};
},
};
export type AxisExtentConfigResult = AxisExtentConfig & { type: 'lens_xy_axisExtentConfig' };
interface AxisConfig {
title: string;
hide?: boolean;
@ -404,6 +452,8 @@ export interface XYArgs {
xTitle: string;
yTitle: string;
yRightTitle: string;
yLeftExtent: AxisExtentConfigResult;
yRightExtent: AxisExtentConfigResult;
legend: LegendConfig & { type: 'lens_xy_legendConfig' };
valueLabels: ValueLabelConfig;
layers: LayerArgs[];
@ -425,6 +475,8 @@ export interface XYState {
legend: LegendConfig;
valueLabels?: ValueLabelConfig;
fittingFunction?: FittingFunction;
yLeftExtent?: AxisExtentConfig;
yRightExtent?: AxisExtentConfig;
layers: XYLayerConfig[];
xTitle?: string;
yTitle?: string;

View file

@ -161,6 +161,90 @@ describe('XY Config panels', () => {
expect(component.find(AxisSettingsPopover).at(1).prop('endzonesVisible')).toBe(false);
expect(component.find(AxisSettingsPopover).at(2).prop('setEndzoneVisibility')).toBeFalsy();
});
it('should pass in information about current data bounds', () => {
const state = testState();
frame.activeData = {
first: {
type: 'datatable',
rows: [{ bar: -5 }, { bar: 50 }],
columns: [
{
id: 'baz',
meta: {
type: 'number',
},
name: 'baz',
},
{
id: 'foo',
meta: {
type: 'number',
},
name: 'foo',
},
{
id: 'bar',
meta: {
type: 'number',
},
name: 'bar',
},
],
},
};
const component = shallow(
<XyToolbar
frame={frame}
setState={jest.fn()}
state={{
...state,
yLeftExtent: {
mode: 'custom',
lowerBound: 123,
upperBound: 456,
},
}}
/>
);
expect(component.find(AxisSettingsPopover).at(0).prop('dataBounds')).toEqual({
min: -5,
max: 50,
});
});
it('should pass in extent information', () => {
const state = testState();
const component = shallow(
<XyToolbar
frame={frame}
setState={jest.fn()}
state={{
...state,
yLeftExtent: {
mode: 'custom',
lowerBound: 123,
upperBound: 456,
},
}}
/>
);
expect(component.find(AxisSettingsPopover).at(0).prop('extent')).toEqual({
mode: 'custom',
lowerBound: 123,
upperBound: 456,
});
expect(component.find(AxisSettingsPopover).at(0).prop('setExtent')).toBeTruthy();
expect(component.find(AxisSettingsPopover).at(1).prop('extent')).toBeFalsy();
expect(component.find(AxisSettingsPopover).at(1).prop('setExtent')).toBeFalsy();
// default extent
expect(component.find(AxisSettingsPopover).at(2).prop('extent')).toEqual({
mode: 'full',
});
expect(component.find(AxisSettingsPopover).at(2).prop('setExtent')).toBeTruthy();
});
});
describe('Dimension Editor', () => {

View file

@ -6,7 +6,7 @@
*/
import './xy_config_panel.scss';
import React, { useMemo, useState, memo } from 'react';
import React, { useMemo, useState, memo, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { Position, ScaleType } from '@elastic/charts';
import { debounce } from 'lodash';
@ -27,14 +27,22 @@ import {
VisualizationToolbarProps,
VisualizationDimensionEditorProps,
FormatFactory,
FramePublicAPI,
} from '../types';
import { State, SeriesType, visualizationTypes, YAxisMode, AxesSettingsConfig } from './types';
import {
State,
SeriesType,
visualizationTypes,
YAxisMode,
AxesSettingsConfig,
AxisExtentConfig,
} from './types';
import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers';
import { trackUiEvent } from '../lens_ui_telemetry';
import { LegendSettingsPopover } from '../shared_components';
import { AxisSettingsPopover } from './axis_settings_popover';
import { TooltipWrapper } from './tooltip_wrapper';
import { getAxesConfiguration } from './axes_configuration';
import { getAxesConfiguration, GroupsConfiguration } from './axes_configuration';
import { PalettePicker } from '../shared_components';
import { getAccessorColorConfig, getColorAssignments } from './color_assignment';
import { getScaleType, getSortedAccessors } from './to_expression';
@ -123,11 +131,44 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps<State>) {
);
}
const getDataBounds = function (
activeData: FramePublicAPI['activeData'],
axes: GroupsConfiguration
) {
const groups: Partial<Record<string, { min: number; max: number }>> = {};
axes.forEach((axis) => {
let min = Number.MAX_VALUE;
let max = Number.MIN_VALUE;
axis.series.forEach((series) => {
activeData?.[series.layer].rows.forEach((row) => {
const value = row[series.accessor];
if (!Number.isNaN(value)) {
if (value < min) {
min = value;
}
if (value > max) {
max = value;
}
}
});
});
if (min !== Number.MAX_VALUE && max !== Number.MIN_VALUE) {
groups[axis.groupId] = {
min: Math.round((min + Number.EPSILON) * 100) / 100,
max: Math.round((max + Number.EPSILON) * 100) / 100,
};
}
});
return groups;
};
export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProps<State>) {
const { state, setState, frame } = props;
const shouldRotate = state?.layers.length ? isHorizontalChart(state.layers) : false;
const axisGroups = getAxesConfiguration(state?.layers, shouldRotate);
const axisGroups = getAxesConfiguration(state?.layers, shouldRotate, frame.activeData);
const dataBounds = getDataBounds(frame.activeData, axisGroups);
const tickLabelsVisibilitySettings = {
x: state?.tickLabelsVisibilitySettings?.x ?? true,
@ -210,6 +251,40 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp
: !state?.legend.isVisible
? 'hide'
: 'show';
const hasBarOrAreaOnLeftAxis = Boolean(
axisGroups
.find((group) => group.groupId === 'left')
?.series?.some((series) => {
const seriesType = state.layers.find((l) => l.layerId === series.layer)?.seriesType;
return seriesType?.includes('bar') || seriesType?.includes('area');
})
);
const setLeftExtent = useCallback(
(extent: AxisExtentConfig | undefined) => {
setState({
...state,
yLeftExtent: extent,
});
},
[setState, state]
);
const hasBarOrAreaOnRightAxis = Boolean(
axisGroups
.find((group) => group.groupId === 'left')
?.series?.some((series) => {
const seriesType = state.layers.find((l) => l.layerId === series.layer)?.seriesType;
return seriesType?.includes('bar') || seriesType?.includes('area');
})
);
const setRightExtent = useCallback(
(extent: AxisExtentConfig | undefined) => {
setState({
...state,
yRightExtent: extent,
});
},
[setState, state]
);
return (
<EuiFlexGroup gutterSize="m" justifyContent="spaceBetween" responsive={false}>
@ -282,6 +357,10 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp
}
isAxisTitleVisible={axisTitlesVisibilitySettings.yLeft}
toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange}
extent={state?.yLeftExtent || { mode: 'full' }}
setExtent={setLeftExtent}
hasBarOrAreaOnAxis={hasBarOrAreaOnLeftAxis}
dataBounds={dataBounds.left}
/>
</TooltipWrapper>
<AxisSettingsPopover
@ -297,6 +376,7 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp
toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange}
endzonesVisible={!state?.hideEndzones}
setEndzoneVisibility={onChangeEndzoneVisiblity}
hasBarOrAreaOnAxis={false}
/>
<TooltipWrapper
tooltipContent={
@ -327,6 +407,10 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp
}
isAxisTitleVisible={axisTitlesVisibilitySettings.yRight}
toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange}
extent={state?.yRightExtent || { mode: 'full' }}
setExtent={setRightExtent}
hasBarOrAreaOnAxis={hasBarOrAreaOnRightAxis}
dataBounds={dataBounds.right}
/>
</TooltipWrapper>
</EuiFlexGroup>

View file

@ -527,6 +527,9 @@ function buildSuggestion({
xTitle: currentState?.xTitle,
yTitle: currentState?.yTitle,
yRightTitle: currentState?.yRightTitle,
hideEndzones: currentState?.hideEndzones,
yLeftExtent: currentState?.yLeftExtent,
yRightExtent: currentState?.yRightExtent,
axisTitlesVisibilitySettings: currentState?.axisTitlesVisibilitySettings || {
x: true,
yLeft: true,