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 cb82cc5b52a01..aa22bbb0c15c6 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 @@ -74,6 +74,10 @@ exports[`xy_expression XYChart component it renders area 1`] = ` tickFormat={[Function]} title="a" /> + <XyEndzones + darkMode={false} + histogramMode={false} + /> <Connect(SpecInstance) areaSeriesStyle={ Object { @@ -271,6 +275,10 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` tickFormat={[Function]} title="a" /> + <XyEndzones + darkMode={false} + histogramMode={false} + /> <Connect(SpecInstance) areaSeriesStyle={ Object { @@ -476,6 +484,10 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` tickFormat={[Function]} title="a" /> + <XyEndzones + darkMode={false} + histogramMode={false} + /> <Connect(SpecInstance) areaSeriesStyle={ Object { @@ -681,6 +693,10 @@ exports[`xy_expression XYChart component it renders line 1`] = ` tickFormat={[Function]} title="a" /> + <XyEndzones + darkMode={false} + histogramMode={false} + /> <Connect(SpecInstance) areaSeriesStyle={ Object { @@ -878,6 +894,10 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` tickFormat={[Function]} title="a" /> + <XyEndzones + darkMode={false} + histogramMode={false} + /> <Connect(SpecInstance) areaSeriesStyle={ Object { @@ -1083,6 +1103,10 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` tickFormat={[Function]} title="a" /> + <XyEndzones + darkMode={false} + histogramMode={false} + /> <Connect(SpecInstance) areaSeriesStyle={ Object { @@ -1296,6 +1320,10 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = tickFormat={[Function]} title="a" /> + <XyEndzones + darkMode={false} + histogramMode={false} + /> <Connect(SpecInstance) areaSeriesStyle={ Object { diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 1130bd7a95d88..96def0b65ddf4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -58,6 +58,9 @@ Object { "type": "expression", }, ], + "hideEndzones": Array [ + true, + ], "layers": Array [ Object { "chain": Array [ diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx index 0be5cfbd6bf7f..48cc45dfacdca 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx @@ -79,4 +79,16 @@ describe('Axes Settings', () => { false ); }); + + it('hides the endzone visibility flag if no setter is passed in', () => { + const component = shallow(<AxisSettingsPopover {...props} />); + expect(component.find('[data-test-subj="lnsshowEndzones"]').length).toBe(0); + }); + + it('shows the switch if setter is present', () => { + const component = shallow( + <AxisSettingsPopover {...props} endzonesVisible={true} setEndzoneVisibility={() => {}} /> + ); + expect(component.find('[data-test-subj="lnsshowEndzones"]').prop('checked')).toBe(true); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx index 2a40f6204c44d..d9c60ae666484 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx @@ -71,6 +71,14 @@ export interface AxisSettingsPopoverProps { * Toggles the axis title visibility */ toggleAxisTitleVisibility: (axis: AxesSettingsConfigKeys, checked: boolean) => void; + /** + * Set endzone visibility + */ + setEndzoneVisibility?: (checked: boolean) => void; + /** + * Flag whether endzones are visible + */ + endzonesVisible?: boolean; } const popoverConfig = ( axis: AxesSettingsConfigKeys, @@ -138,6 +146,8 @@ export const AxisSettingsPopover: React.FunctionComponent<AxisSettingsPopoverPro areGridlinesVisible, isAxisTitleVisible, toggleAxisTitleVisibility, + setEndzoneVisibility, + endzonesVisible, }) => { const [title, setTitle] = useState<string | undefined>(axisTitle); @@ -212,6 +222,20 @@ export const AxisSettingsPopover: React.FunctionComponent<AxisSettingsPopoverPro onChange={() => toggleGridlinesVisibility(axis)} checked={areGridlinesVisible} /> + {setEndzoneVisibility && ( + <> + <EuiSpacer size="m" /> + <EuiSwitch + compressed + data-test-subj={`lnsshowEndzones`} + label={i18n.translate('xpack.lens.xyChart.showEnzones', { + defaultMessage: 'Show partial data markers', + })} + onChange={() => setEndzoneVisibility(!Boolean(endzonesVisible))} + checked={Boolean(endzonesVisible)} + /> + </> + )} </ToolbarPopover> ); }; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index e1dbd4da4b902..fe0513caa08a8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -44,6 +44,7 @@ import { createMockExecutionContext } from '../../../../../src/plugins/expressio import { mountWithIntl } from '@kbn/test/jest'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { EmptyPlaceholder } from '../shared_components/empty_placeholder'; +import { XyEndzones } from './x_domain'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -549,6 +550,135 @@ describe('xy_expression', () => { } `); }); + + describe('endzones', () => { + const { args } = sampleArgs(); + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([ + { a: 1, b: 2, c: new Date('2021-04-22').valueOf(), d: 'Foo' }, + { a: 1, b: 2, c: new Date('2021-04-23').valueOf(), d: 'Foo' }, + { a: 1, b: 2, c: new Date('2021-04-24').valueOf(), d: 'Foo' }, + ]), + }, + dateRange: { + // first and last bucket are partial + fromDate: new Date('2021-04-22T12:00:00.000Z'), + toDate: new Date('2021-04-24T12:00:00.000Z'), + }, + }; + const timeArgs: XYArgs = { + ...args, + layers: [ + { + ...args.layers[0], + seriesType: 'line', + xScaleType: 'time', + isHistogram: true, + splitAccessor: undefined, + }, + ], + }; + + test('it extends interval if data is exceeding it', () => { + const component = shallow( + <XYChart + {...defaultProps} + minInterval={24 * 60 * 60 * 1000} + data={data} + args={timeArgs} + /> + ); + + expect(component.find(Settings).prop('xDomain')).toEqual({ + // shortened to 24th midnight (elastic-charts automatically adds one min interval) + max: new Date('2021-04-24').valueOf(), + // extended to 22nd midnight because of first bucket + min: new Date('2021-04-22').valueOf(), + minInterval: 24 * 60 * 60 * 1000, + }); + }); + + test('it renders endzone component bridging gap between domain and extended domain', () => { + const component = shallow( + <XYChart + {...defaultProps} + minInterval={24 * 60 * 60 * 1000} + data={data} + args={timeArgs} + /> + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + domainStart: new Date('2021-04-22T12:00:00.000Z').valueOf(), + domainEnd: new Date('2021-04-24T12:00:00.000Z').valueOf(), + domainMin: new Date('2021-04-22').valueOf(), + domainMax: new Date('2021-04-24').valueOf(), + }) + ); + }); + + test('should pass enabled histogram mode and min interval to endzones component', () => { + const component = shallow( + <XYChart + {...defaultProps} + minInterval={24 * 60 * 60 * 1000} + data={data} + args={timeArgs} + /> + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + interval: 24 * 60 * 60 * 1000, + isFullBin: false, + }) + ); + }); + + test('should pass disabled histogram mode and min interval to endzones component', () => { + const component = shallow( + <XYChart + {...defaultProps} + minInterval={24 * 60 * 60 * 1000} + data={data} + args={{ + ...args, + layers: [ + { + ...args.layers[0], + seriesType: 'bar', + xScaleType: 'time', + isHistogram: true, + }, + ], + }} + /> + ); + + expect(component.find(XyEndzones).dive().find('Endzones').props()).toEqual( + expect.objectContaining({ + interval: 24 * 60 * 60 * 1000, + isFullBin: true, + }) + ); + }); + + test('it does not render endzones if disabled via settings', () => { + const component = shallow( + <XYChart + {...defaultProps} + minInterval={24 * 60 * 60 * 1000} + data={data} + args={{ ...timeArgs, hideEndzones: true }} + /> + ); + + expect(component.find(XyEndzones).length).toEqual(0); + }); + }); }); test('it has xDomain undefined if the x is not a time scale or a histogram', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 47b8dbfc15f53..5416c8eda0aa9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -57,6 +57,7 @@ import { desanitizeFilterContext } from '../utils'; import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions'; import { getAxesConfiguration } from './axes_configuration'; import { getColorAssignments } from './color_assignment'; +import { getXDomain, XyEndzones } from './x_domain'; declare global { interface Window { @@ -183,6 +184,13 @@ export const xyChart: ExpressionFunctionDefinition< defaultMessage: 'Define how curve type is rendered for a line chart', }), }, + hideEndzones: { + types: ['boolean'], + default: false, + help: i18n.translate('xpack.lens.xyChart.hideEndzones.help', { + defaultMessage: 'Hide endzone markers for partial data', + }), + }, }, fn(data: LensMultiTable, args: XYArgs) { return { @@ -330,9 +338,17 @@ export function XYChart({ renderMode, syncColors, }: XYChartRenderProps) { - const { legend, layers, fittingFunction, gridlinesVisibilitySettings, valueLabels } = args; + const { + legend, + layers, + fittingFunction, + gridlinesVisibilitySettings, + valueLabels, + hideEndzones, + } = args; const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); + const darkMode = chartsThemeService.useDarkMode(); const filteredLayers = getFilteredLayers(layers, data); if (filteredLayers.length === 0) { @@ -387,15 +403,13 @@ export function XYChart({ const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time'); const isHistogramViz = filteredLayers.every((l) => l.isHistogram); - const xDomain = isTimeViz - ? { - min: data.dateRange?.fromDate.getTime(), - max: data.dateRange?.toDate.getTime(), - minInterval, - } - : isHistogramViz - ? { minInterval } - : undefined; + const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain( + layers, + data, + minInterval, + Boolean(isTimeViz), + Boolean(isHistogramViz) + ); const getYAxesTitles = ( axisSeries: Array<{ layer: string; accessor: string }>, @@ -602,6 +616,22 @@ export function XYChart({ /> ))} + {!hideEndzones && ( + <XyEndzones + baseDomain={rawXDomain} + extendedDomain={xDomain} + darkMode={darkMode} + histogramMode={filteredLayers.every( + (layer) => + layer.isHistogram && + (layer.seriesType.includes('stacked') || !layer.splitAccessor) && + (layer.seriesType.includes('stacked') || + !layer.seriesType.includes('bar') || + !chartHasMoreThanOneBarSeries) + )} + /> + )} + {filteredLayers.flatMap((layer, layerIndex) => layer.accessors.map((accessor, accessorIndex) => { const { diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index b726869743312..89dca6e8a3944 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -51,6 +51,7 @@ describe('#toExpression', () => { fittingFunction: 'Carry', tickLabelsVisibilitySettings: { x: false, yLeft: true, yRight: true }, gridlinesVisibilitySettings: { x: false, yLeft: true, yRight: true }, + hideEndzones: true, layers: [ { layerId: 'first', 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 6a1882edde949..02c5f3773d813 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -198,6 +198,7 @@ export const buildExpression = ( }, ], valueLabels: [state?.valueLabels || 'hide'], + hideEndzones: [state?.hideEndzones || false], layers: validLayers.map((layer) => { const columnToLabel = getColumnToLabelMap(layer, datasourceLayers[layer.layerId]); diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 6f1a01acd6e76..0622f1c43f1c3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -414,6 +414,7 @@ export interface XYArgs { tickLabelsVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_tickLabelsConfig' }; gridlinesVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_gridlinesConfig' }; curveType?: XYCurveType; + hideEndzones?: boolean; } export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; @@ -432,6 +433,7 @@ export interface XYState { tickLabelsVisibilitySettings?: AxesSettingsConfig; gridlinesVisibilitySettings?: AxesSettingsConfig; curveType?: XYCurveType; + hideEndzones?: boolean; } export type State = XYState; diff --git a/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx new file mode 100644 index 0000000000000..369063644a754 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/x_domain.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniq } from 'lodash'; +import React from 'react'; +import { Endzones } from '../../../../../src/plugins/charts/public'; +import { LensMultiTable } from '../types'; +import { LayerArgs } from './types'; + +export interface XDomain { + min?: number; + max?: number; + minInterval?: number; +} + +export const getXDomain = ( + layers: LayerArgs[], + data: LensMultiTable, + minInterval: number | undefined, + isTimeViz: boolean, + isHistogram: boolean +) => { + const baseDomain = isTimeViz + ? { + min: data.dateRange?.fromDate.getTime(), + max: data.dateRange?.toDate.getTime(), + minInterval, + } + : isHistogram + ? { minInterval } + : undefined; + + if (isHistogram && isFullyQualified(baseDomain)) { + const xValues = uniq( + layers + .flatMap((layer) => + data.tables[layer.layerId].rows.map((row) => row[layer.xAccessor!].valueOf() as number) + ) + .sort() + ); + + const [firstXValue] = xValues; + const lastXValue = xValues[xValues.length - 1]; + + const domainMin = Math.min(firstXValue, baseDomain.min); + const domainMaxValue = baseDomain.max - baseDomain.minInterval; + const domainMax = Math.max(domainMaxValue, lastXValue); + + return { + extendedDomain: { + min: domainMin, + max: domainMax, + minInterval: baseDomain.minInterval, + }, + baseDomain, + }; + } + + return { + baseDomain, + extendedDomain: baseDomain, + }; +}; + +function isFullyQualified( + xDomain: XDomain | undefined +): xDomain is { min: number; max: number; minInterval: number } { + return Boolean( + xDomain && + typeof xDomain.min === 'number' && + typeof xDomain.max === 'number' && + xDomain.minInterval + ); +} + +export const XyEndzones = function ({ + baseDomain, + extendedDomain, + histogramMode, + darkMode, +}: { + baseDomain?: XDomain; + extendedDomain?: XDomain; + histogramMode: boolean; + darkMode: boolean; +}) { + return isFullyQualified(baseDomain) && isFullyQualified(extendedDomain) ? ( + <Endzones + isFullBin={!histogramMode} + isDarkMode={darkMode} + domainStart={baseDomain.min} + domainEnd={baseDomain.max} + interval={extendedDomain.minInterval} + domainMin={extendedDomain.min} + domainMax={extendedDomain.max} + hideTooltips={false} + /> + ) : null; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index f965140a48ca0..e3e8c6e93e3aa 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -138,6 +138,29 @@ describe('XY Config panels', () => { expect(component.find(AxisSettingsPopover).length).toEqual(3); }); + + it('should pass in endzone visibility setter and current sate for time chart', () => { + (frame.datasourceLayers.first.getOperationForColumnId as jest.Mock).mockReturnValue({ + dataType: 'date', + }); + const state = testState(); + const component = shallow( + <XyToolbar + frame={frame} + setState={jest.fn()} + state={{ + ...state, + hideEndzones: true, + layers: [{ ...state.layers[0], yConfig: [{ axisMode: 'right', forAccessor: 'foo' }] }], + }} + /> + ); + + expect(component.find(AxisSettingsPopover).at(0).prop('setEndzoneVisibility')).toBeFalsy(); + expect(component.find(AxisSettingsPopover).at(1).prop('setEndzoneVisibility')).toBeTruthy(); + expect(component.find(AxisSettingsPopover).at(1).prop('endzonesVisible')).toBe(false); + expect(component.find(AxisSettingsPopover).at(2).prop('setEndzoneVisibility')).toBeFalsy(); + }); }); describe('Dimension Editor', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index c79a7e37f84d1..eccf4d9b64345 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -8,7 +8,7 @@ import './xy_config_panel.scss'; import React, { useMemo, useState, memo } from 'react'; import { i18n } from '@kbn/i18n'; -import { Position } from '@elastic/charts'; +import { Position, ScaleType } from '@elastic/charts'; import { debounce } from 'lodash'; import { EuiButtonGroup, @@ -37,7 +37,7 @@ import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration } from './axes_configuration'; import { PalettePicker } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; -import { getSortedAccessors } from './to_expression'; +import { getScaleType, getSortedAccessors } from './to_expression'; import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; type UnwrapArray<T> = T extends Array<infer P> ? P : T; @@ -187,6 +187,23 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp }); }; + // only allow changing endzone visibility if it could show up theoretically (if it's a time viz) + const onChangeEndzoneVisiblity = state?.layers.every( + (layer) => + layer.xAccessor && + getScaleType( + props.frame.datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor), + ScaleType.Linear + ) === 'time' + ) + ? (checked: boolean): void => { + setState({ + ...state, + hideEndzones: !checked, + }); + } + : undefined; + const legendMode = state?.legend.isVisible && !state?.legend.showSingleSeries ? 'auto' @@ -278,6 +295,8 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp toggleGridlinesVisibility={onGridlinesVisibilitySettingsChange} isAxisTitleVisible={axisTitlesVisibilitySettings.x} toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange} + endzonesVisible={!state?.hideEndzones} + setEndzoneVisibility={onChangeEndzoneVisiblity} /> <TooltipWrapper tooltipContent={