mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Lens] Specify Y axis extent (#99203)
This commit is contained in:
parent
574f6595ad
commit
3c166a1472
14 changed files with 834 additions and 27 deletions
|
@ -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,
|
||||
|
|
|
@ -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 [
|
||||
"",
|
||||
],
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue