diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts index 29b0fb1352e5b..47bb1f91b4ab2 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts @@ -30,6 +30,7 @@ interface AxisConfig { export type YAxisMode = 'auto' | 'left' | 'right' | 'bottom'; export type LineStyle = 'solid' | 'dashed' | 'dotted'; export type FillStyle = 'none' | 'above' | 'below'; +export type IconPosition = 'auto' | 'left' | 'right' | 'above' | 'below'; export interface YConfig { forAccessor: string; @@ -39,6 +40,7 @@ export interface YConfig { lineWidth?: number; lineStyle?: LineStyle; fill?: FillStyle; + iconPosition?: IconPosition; } export type AxisTitlesVisibilityConfigResult = AxesSettingsConfig & { @@ -180,6 +182,11 @@ export const yAxisConfig: ExpressionFunctionDefinition< types: ['string'], help: 'An optional icon used for threshold lines', }, + iconPosition: { + types: ['string'], + options: ['auto', 'above', 'below', 'left', 'right'], + help: 'The placement of the icon for the threshold line', + }, fill: { types: ['string'], options: ['none', 'above', 'below'], diff --git a/x-pack/plugins/lens/public/shared_components/tooltip_wrapper.tsx b/x-pack/plugins/lens/public/shared_components/tooltip_wrapper.tsx index 0b361c8fa7f1e..5ab7800e05349 100644 --- a/x-pack/plugins/lens/public/shared_components/tooltip_wrapper.tsx +++ b/x-pack/plugins/lens/public/shared_components/tooltip_wrapper.tsx @@ -10,6 +10,7 @@ import { EuiToolTip, EuiToolTipProps } from '@elastic/eui'; export type TooltipWrapperProps = Partial> & { tooltipContent: string; + /** When the condition is truthy, the tooltip will be shown */ condition: boolean; }; diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index 6326d8680757e..fe3137c905ffb 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -28,6 +28,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` "color": undefined, }, "barSeriesStyle": Object {}, + "chartMargins": Object {}, "legend": Object { "labelOptions": Object { "maxLines": 0, @@ -55,9 +56,11 @@ exports[`xy_expression XYChart component it renders area 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": 0, "visible": true, }, @@ -86,9 +89,11 @@ exports[`xy_expression XYChart component it renders area 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": -90, "visible": false, }, @@ -252,6 +257,7 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` "color": undefined, }, "barSeriesStyle": Object {}, + "chartMargins": Object {}, "legend": Object { "labelOptions": Object { "maxLines": 0, @@ -279,9 +285,11 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": 0, "visible": true, }, @@ -310,9 +318,11 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": -90, "visible": false, }, @@ -490,6 +500,7 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` "color": undefined, }, "barSeriesStyle": Object {}, + "chartMargins": Object {}, "legend": Object { "labelOptions": Object { "maxLines": 0, @@ -517,9 +528,11 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": 0, "visible": true, }, @@ -548,9 +561,11 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": -90, "visible": false, }, @@ -728,6 +743,7 @@ exports[`xy_expression XYChart component it renders line 1`] = ` "color": undefined, }, "barSeriesStyle": Object {}, + "chartMargins": Object {}, "legend": Object { "labelOptions": Object { "maxLines": 0, @@ -755,9 +771,11 @@ exports[`xy_expression XYChart component it renders line 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": 0, "visible": true, }, @@ -786,9 +804,11 @@ exports[`xy_expression XYChart component it renders line 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": -90, "visible": false, }, @@ -952,6 +972,7 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` "color": undefined, }, "barSeriesStyle": Object {}, + "chartMargins": Object {}, "legend": Object { "labelOptions": Object { "maxLines": 0, @@ -979,9 +1000,11 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": 0, "visible": true, }, @@ -1010,9 +1033,11 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": -90, "visible": false, }, @@ -1184,6 +1209,7 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` "color": undefined, }, "barSeriesStyle": Object {}, + "chartMargins": Object {}, "legend": Object { "labelOptions": Object { "maxLines": 0, @@ -1211,9 +1237,11 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": 0, "visible": true, }, @@ -1242,9 +1270,11 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": -90, "visible": false, }, @@ -1430,6 +1460,7 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = "color": undefined, }, "barSeriesStyle": Object {}, + "chartMargins": Object {}, "legend": Object { "labelOptions": Object { "maxLines": 0, @@ -1457,9 +1488,11 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": 0, "visible": true, }, @@ -1488,9 +1521,11 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = style={ Object { "axisTitle": Object { + "padding": undefined, "visible": true, }, "tickLabel": Object { + "padding": undefined, "rotation": -90, "visible": false, }, diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index c4315b7ccea85..0cea52b5d3c9e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -59,7 +59,11 @@ import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axe import { getColorAssignments } from './color_assignment'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './get_legend_action'; -import { ThresholdAnnotations } from './expression_thresholds'; +import { + computeChartMargins, + getThresholdRequiredPaddings, + ThresholdAnnotations, +} from './expression_thresholds'; declare global { interface Window { @@ -314,6 +318,12 @@ export function XYChart({ Boolean(isHistogramViz) ); + const yAxesMap = { + left: yAxesConfiguration.find(({ groupId }) => groupId === 'left'), + right: yAxesConfiguration.find(({ groupId }) => groupId === 'right'), + }; + const thresholdPaddings = getThresholdRequiredPaddings(thresholdLayers, yAxesMap); + const getYAxesTitles = ( axisSeries: Array<{ layer: string; accessor: string }>, groupId: string @@ -330,23 +340,38 @@ export function XYChart({ ); }; - const getYAxesStyle = (groupId: string) => { + const getYAxesStyle = (groupId: 'left' | 'right') => { + const tickVisible = + groupId === 'right' + ? tickLabelsVisibilitySettings?.yRight + : tickLabelsVisibilitySettings?.yLeft; + const style = { tickLabel: { - visible: - groupId === 'right' - ? tickLabelsVisibilitySettings?.yRight - : tickLabelsVisibilitySettings?.yLeft, + visible: tickVisible, rotation: groupId === 'right' ? args.labelsOrientation?.yRight || 0 : args.labelsOrientation?.yLeft || 0, + padding: + thresholdPaddings[groupId] != null + ? { + inner: thresholdPaddings[groupId], + } + : undefined, }, axisTitle: { visible: groupId === 'right' ? axisTitlesVisibilitySettings?.yRight : axisTitlesVisibilitySettings?.yLeft, + // if labels are not visible add the padding to the title + padding: + !tickVisible && thresholdPaddings[groupId] != null + ? { + inner: thresholdPaddings[groupId], + } + : undefined, }, }; return style; @@ -510,6 +535,17 @@ export function XYChart({ legend: { labelOptions: { maxLines: legend.shouldTruncate ? legend?.maxLines ?? 1 : 0 }, }, + // if not title or labels are shown for axes, add some padding if required by threshold markers + chartMargins: { + ...chartTheme.chartPaddings, + ...computeChartMargins( + thresholdPaddings, + tickLabelsVisibilitySettings, + axisTitlesVisibilitySettings, + yAxesMap, + shouldRotate + ), + }, }} baseTheme={chartBaseTheme} tooltip={{ @@ -545,9 +581,15 @@ export function XYChart({ tickLabel: { visible: tickLabelsVisibilitySettings?.x, rotation: labelsOrientation?.x, + padding: + thresholdPaddings.bottom != null ? { inner: thresholdPaddings.bottom } : undefined, }, axisTitle: { visible: axisTitlesVisibilitySettings.x, + padding: + !tickLabelsVisibilitySettings?.x && thresholdPaddings.bottom != null + ? { inner: thresholdPaddings.bottom } + : undefined, }, }} /> @@ -568,7 +610,7 @@ export function XYChart({ }} hide={filteredLayers[0].hide} tickFormat={(d) => axis.formatter?.convert(d) || ''} - style={getYAxesStyle(axis.groupId)} + style={getYAxesStyle(axis.groupId as 'left' | 'right')} domain={getYAxisDomain(axis)} /> ); @@ -839,10 +881,15 @@ export function XYChart({ syncColors={syncColors} paletteService={paletteService} formatters={{ - left: yAxesConfiguration.find(({ groupId }) => groupId === 'left')?.formatter, - right: yAxesConfiguration.find(({ groupId }) => groupId === 'right')?.formatter, + left: yAxesMap.left?.formatter, + right: yAxesMap.right?.formatter, bottom: xAxisFormatter, }} + axesMap={{ + left: Boolean(yAxesMap.left), + right: Boolean(yAxesMap.right), + }} + isHorizontal={shouldRotate} /> ) : null} diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx index d9c0b36702639..7532d41f091d1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx @@ -8,25 +8,144 @@ import React from 'react'; import { groupBy } from 'lodash'; import { EuiIcon } from '@elastic/eui'; -import { RectAnnotation, AnnotationDomainType, LineAnnotation } from '@elastic/charts'; +import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { FieldFormat } from 'src/plugins/field_formats/common'; import { euiLightVars } from '@kbn/ui-shared-deps-src/theme'; -import type { LayerArgs } from '../../common/expressions'; +import type { LayerArgs, YConfig } from '../../common/expressions'; import type { LensMultiTable } from '../../common/types'; +const THRESHOLD_ICON_SIZE = 20; + +export const computeChartMargins = ( + thresholdPaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && thresholdPaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = thresholdPaddings.bottom; + } + if ( + thresholdPaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = thresholdPaddings.left; + } + if ( + thresholdPaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = thresholdPaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (thresholdPaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = thresholdPaddings.top; + } + return result; +}; + +function hasIcon(icon: string | undefined): icon is string { + return icon != null && icon !== 'none'; +} + +// Note: it does not take into consideration whether the threshold is in view or not +export const getThresholdRequiredPaddings = ( + thresholdLayers: LayerArgs[], + axesMap: Record<'left' | 'right', unknown> +) => { + const positions = Object.keys(Position); + return thresholdLayers.reduce((memo, layer) => { + if (positions.some((pos) => !(pos in memo))) { + layer.yConfig?.forEach(({ axisMode, icon, iconPosition }) => { + if (axisMode && hasIcon(icon)) { + const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap); + memo[placement] = THRESHOLD_ICON_SIZE; + } + }); + } + return memo; + }, {} as Partial>); +}; + +function mapVerticalToHorizontalPlacement(placement: Position) { + switch (placement) { + case Position.Top: + return Position.Right; + case Position.Bottom: + return Position.Left; + case Position.Left: + return Position.Bottom; + case Position.Right: + return Position.Top; + } +} + +// if there's just one axis, put it on the other one +// otherwise use the same axis +// this function assume the chart is vertical +function getBaseIconPlacement( + iconPosition: YConfig['iconPosition'], + axisMode: YConfig['axisMode'], + axesMap: Record +) { + if (iconPosition === 'auto') { + if (axisMode === 'bottom') { + return Position.Top; + } + if (axisMode === 'left') { + return axesMap.right ? Position.Left : Position.Right; + } + return axesMap.left ? Position.Right : Position.Left; + } + + if (iconPosition === 'left') { + return Position.Left; + } + if (iconPosition === 'right') { + return Position.Right; + } + if (iconPosition === 'below') { + return Position.Bottom; + } + return Position.Top; +} + +function getIconPlacement( + iconPosition: YConfig['iconPosition'], + axisMode: YConfig['axisMode'], + axesMap: Record, + isHorizontal: boolean +) { + const vPosition = getBaseIconPlacement(iconPosition, axisMode, axesMap); + if (isHorizontal) { + return mapVerticalToHorizontalPlacement(vPosition); + } + return vPosition; +} + export const ThresholdAnnotations = ({ thresholdLayers, data, formatters, paletteService, syncColors, + axesMap, + isHorizontal, }: { thresholdLayers: LayerArgs[]; data: LensMultiTable; formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; paletteService: PaletteRegistry; syncColors: boolean; + axesMap: Record<'left' | 'right', boolean>; + isHorizontal: boolean; }) => { return ( <> @@ -63,7 +182,13 @@ export const ThresholdAnnotations = ({ const props = { groupId, - marker: yConfig.icon ? : undefined, + marker: hasIcon(yConfig.icon) ? : undefined, + markerPosition: getIconPlacement( + yConfig.iconPosition, + yConfig.axisMode, + axesMap, + isHorizontal + ), }; const annotations = []; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index b66f0ca4687b8..2fce7c6a612ae 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -340,6 +340,7 @@ export const buildExpression = ( lineWidth: [yConfig.lineWidth || 1], fill: [yConfig.fill || 'none'], icon: yConfig.icon ? [yConfig.icon] : [], + iconPosition: [yConfig.iconPosition || 'auto'], }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index 1427a3d28ea39..41d00e2eef32a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -565,7 +565,7 @@ export function DimensionEditor( } if (layer.layerType === 'threshold') { - return ; + return ; } return ( diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx index 087eee9005c06..cdf5bb2cc2ef1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx @@ -14,11 +14,11 @@ import type { VisualizationDimensionEditorProps } from '../../types'; import { State, XYState } from '../types'; import { FormatFactory } from '../../../common'; import { YConfig } from '../../../common/expressions'; -import { LineStyle, FillStyle } from '../../../common/expressions/xy_chart'; +import { LineStyle, FillStyle, IconPosition } from '../../../common/expressions/xy_chart'; import { ColorPicker } from './color_picker'; import { updateLayer, idPrefix } from '.'; -import { useDebouncedValue } from '../../shared_components'; +import { TooltipWrapper, useDebouncedValue } from '../../shared_components'; const icons = [ { @@ -109,13 +109,82 @@ const IconSelect = ({ ); }; +function getIconPositionOptions({ + isHorizontal, + axisMode, +}: { + isHorizontal: boolean; + axisMode: YConfig['axisMode']; +}) { + const options = [ + { + id: `${idPrefix}auto`, + label: i18n.translate('xpack.lens.xyChart.thresholdMarker.auto', { + defaultMessage: 'Auto', + }), + 'data-test-subj': 'lnsXY_markerPosition_auto', + }, + ]; + const topLabel = i18n.translate('xpack.lens.xyChart.markerPosition.above', { + defaultMessage: 'Top', + }); + const bottomLabel = i18n.translate('xpack.lens.xyChart.markerPosition.below', { + defaultMessage: 'Bottom', + }); + const leftLabel = i18n.translate('xpack.lens.xyChart.markerPosition.left', { + defaultMessage: 'Left', + }); + const rightLabel = i18n.translate('xpack.lens.xyChart.markerPosition.right', { + defaultMessage: 'Right', + }); + if (axisMode === 'bottom') { + const bottomOptions = [ + { + id: `${idPrefix}above`, + label: isHorizontal ? rightLabel : topLabel, + 'data-test-subj': 'lnsXY_markerPosition_above', + }, + { + id: `${idPrefix}below`, + label: isHorizontal ? leftLabel : bottomLabel, + 'data-test-subj': 'lnsXY_markerPosition_below', + }, + ]; + if (isHorizontal) { + // above -> below + // left -> right + bottomOptions.reverse(); + } + return [...options, ...bottomOptions]; + } + const yOptions = [ + { + id: `${idPrefix}left`, + label: isHorizontal ? bottomLabel : leftLabel, + 'data-test-subj': 'lnsXY_markerPosition_left', + }, + { + id: `${idPrefix}right`, + label: isHorizontal ? topLabel : rightLabel, + 'data-test-subj': 'lnsXY_markerPosition_right', + }, + ]; + if (isHorizontal) { + // left -> right + // above -> below + yOptions.reverse(); + } + return [...options, ...yOptions]; +} + export const ThresholdPanel = ( props: VisualizationDimensionEditorProps & { formatFactory: FormatFactory; paletteService: PaletteRegistry; + isHorizontal: boolean; } ) => { - const { state, setState, layerId, accessor } = props; + const { state, setState, layerId, accessor, isHorizontal } = props; const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ value: state, @@ -265,7 +334,7 @@ export const ThresholdPanel = ( @@ -276,6 +345,44 @@ export const ThresholdPanel = ( }} /> + + + { + const newMode = id.replace(idPrefix, '') as IconPosition; + setYConfig({ forAccessor: accessor, iconPosition: newMode }); + }} + /> + + ); };