diff --git a/x-pack/plugins/lens/public/assets/chart_heatmap.tsx b/x-pack/plugins/lens/public/assets/chart_heatmap.tsx new file mode 100644 index 0000000000000..7da242f82eb60 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_heatmap.tsx @@ -0,0 +1,31 @@ +/* + * 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 { EuiIconProps } from '@elastic/eui'; +import React from 'react'; + +export const LensIconChartHeatmap = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + + +); diff --git a/x-pack/plugins/lens/public/async_services.ts b/x-pack/plugins/lens/public/async_services.ts index fb3ba62405904..b0ecc412c357f 100644 --- a/x-pack/plugins/lens/public/async_services.ts +++ b/x-pack/plugins/lens/public/async_services.ts @@ -18,6 +18,7 @@ export * from './datatable_visualization/datatable_visualization'; export * from './metric_visualization/metric_visualization'; export * from './pie_visualization/pie_visualization'; export * from './xy_visualization/xy_visualization'; +export * from './heatmap_visualization/heatmap_visualization'; export * from './indexpattern_datasource/indexpattern'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss index 9f4b60b6d3c67..3fafa8b37a42f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss @@ -11,6 +11,10 @@ color: $euiTextSubduedColor; } +.lnsChartSwitch__append { + display: inline-flex; +} + // Targeting img as this won't target normal EuiIcon's only the custom svgs's img.lnsChartSwitch__chartIcon { // stylelint-disable-line selector-no-qualifying-type // The large icons aren't square so max out the width to fill the height @@ -19,4 +23,4 @@ img.lnsChartSwitch__chartIcon { // stylelint-disable-line selector-no-qualifying .lnsChartSwitch__search { width: 7 * $euiSizeXXL; -} \ No newline at end of file +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 5538dd26d0323..ba0e09bdd894c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -16,6 +16,7 @@ import { EuiSelectable, EuiIconTip, EuiSelectableOption, + EuiBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -286,6 +287,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { .map( (v): SelectableEntry => ({ 'aria-label': v.fullLabel || v.label, + className: 'lnsChartSwitch__option', isGroupLabel: false, key: `${v.visualizationId}:${v.id}`, value: `${v.visualizationId}:${v.id}`, @@ -295,22 +297,45 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { ), append: - v.selection.dataLoss !== 'nothing' ? ( - + v.selection.dataLoss !== 'nothing' || v.showBetaBadge ? ( + + {v.selection.dataLoss !== 'nothing' ? ( + + + + ) : null} + {v.showBetaBadge ? ( + + + + + + ) : null} + ) : null, // Apparently checked: null is not valid for TS ...(subVisualizationId === v.id && { checked: 'on' }), @@ -363,6 +388,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { = ({ + data, + args, + timeZone, + formatFactory, + chartsThemeService, + onClickValue, + onSelectRange, +}) => { + const chartTheme = chartsThemeService.useChartsTheme(); + const isDarkTheme = chartsThemeService.useDarkMode(); + + const tableId = Object.keys(data.tables)[0]; + const table = data.tables[tableId]; + + const xAxisColumnIndex = table.columns.findIndex((v) => v.id === args.xAccessor); + const yAxisColumnIndex = table.columns.findIndex((v) => v.id === args.yAccessor); + + const xAxisColumn = table.columns[xAxisColumnIndex]; + const yAxisColumn = table.columns[yAxisColumnIndex]; + const valueColumn = table.columns.find((v) => v.id === args.valueAccessor); + + if (!xAxisColumn || !valueColumn) { + // Chart is not ready + return null; + } + + let chartData = table.rows.filter((v) => typeof v[args.valueAccessor!] === 'number'); + + if (!yAxisColumn) { + // required for tooltip + chartData = chartData.map((row) => { + return { + ...row, + unifiedY: '', + }; + }); + } + + const xAxisMeta = xAxisColumn.meta; + const isTimeBasedSwimLane = xAxisMeta.type === 'date'; + + // Fallback to the ordinal scale type when a single row of data is provided. + // Related issue https://github.com/elastic/elastic-charts/issues/1184 + const xScaleType = + isTimeBasedSwimLane && chartData.length > 1 ? ScaleType.Time : ScaleType.Ordinal; + + const xValuesFormatter = formatFactory(xAxisMeta.params); + const valueFormatter = formatFactory(valueColumn.meta.params); + + const onElementClick = ((e: HeatmapElementEvent[]) => { + const cell = e[0][0]; + const { x, y } = cell.datum; + + const xAxisFieldName = xAxisColumn.meta.field; + const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; + + const points = [ + { + row: table.rows.findIndex((r) => r[xAxisColumn.id] === x), + column: xAxisColumnIndex, + value: x, + }, + ...(yAxisColumn + ? [ + { + row: table.rows.findIndex((r) => r[yAxisColumn.id] === y), + column: yAxisColumnIndex, + value: y, + }, + ] + : []), + ]; + + const context: LensFilterEvent['data'] = { + data: points.map((point) => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + timeFieldName, + }; + onClickValue(desanitizeFilterContext(context)); + }) as ElementClickListener; + + const onBrushEnd = (e: HeatmapBrushEvent) => { + const { x, y } = e; + + const xAxisFieldName = xAxisColumn.meta.field; + const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; + + if (isTimeBasedSwimLane) { + const context: LensBrushEvent['data'] = { + range: x as number[], + table, + column: xAxisColumnIndex, + timeFieldName, + }; + onSelectRange(context); + } else { + const points: Array<{ row: number; column: number; value: string | number }> = []; + + if (yAxisColumn) { + (y as string[]).forEach((v) => { + points.push({ + row: table.rows.findIndex((r) => r[yAxisColumn.id] === v), + column: yAxisColumnIndex, + value: v, + }); + }); + } + + (x as string[]).forEach((v) => { + points.push({ + row: table.rows.findIndex((r) => r[xAxisColumn.id] === v), + column: xAxisColumnIndex, + value: v, + }); + }); + + const context: LensFilterEvent['data'] = { + data: points.map((point) => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + timeFieldName, + }; + onClickValue(desanitizeFilterContext(context)); + } + }; + + const config: HeatmapSpec['config'] = { + onBrushEnd, + grid: { + stroke: { + width: + args.gridConfig.strokeWidth ?? chartTheme.axes?.gridLine?.horizontal?.strokeWidth ?? 1, + color: + args.gridConfig.strokeColor ?? chartTheme.axes?.gridLine?.horizontal?.stroke ?? '#D3DAE6', + }, + cellHeight: { + max: 'fill', + min: 1, + }, + }, + cell: { + maxWidth: 'fill', + maxHeight: 'fill', + label: { + visible: args.gridConfig.isCellLabelVisible ?? false, + }, + border: { + strokeWidth: 0, + }, + }, + yAxisLabel: { + visible: !!yAxisColumn && args.gridConfig.isYAxisLabelVisible, + // eui color subdued + fill: chartTheme.axes?.tickLabel?.fill ?? '#6a717d', + padding: yAxisColumn?.name ? 8 : 0, + name: yAxisColumn?.name ?? '', + ...(yAxisColumn + ? { + formatter: (v: number | string) => formatFactory(yAxisColumn.meta.params).convert(v), + } + : {}), + }, + xAxisLabel: { + visible: args.gridConfig.isXAxisLabelVisible, + // eui color subdued + fill: chartTheme.axes?.tickLabel?.fill ?? `#6a717d`, + formatter: (v: number | string) => xValuesFormatter.convert(v), + name: xAxisColumn.name, + }, + brushMask: { + fill: isDarkTheme ? 'rgb(30,31,35,80%)' : 'rgb(247,247,247,50%)', + }, + brushArea: { + stroke: isDarkTheme ? 'rgb(255, 255, 255)' : 'rgb(105, 112, 125)', + }, + timeZone, + }; + + if (!chartData || !chartData.length) { + return ; + } + + const colorPalette = euiPaletteForTemperature(5); + + return ( + + + valueFormatter.convert(v)} + xScaleType={xScaleType} + ySortPredicate="dataIndex" + config={config} + xSortPredicate="dataIndex" + /> + + ); +}; + +const MemoizedChart = React.memo(HeatmapComponent); + +export function HeatmapChartReportable(props: HeatmapRenderProps) { + const [state, setState] = useState({ + isReady: false, + }); + + // It takes a cycle for the XY chart to render. This prevents + // reporting from printing a blank chart placeholder. + useEffect(() => { + setState({ isReady: true }); + }, [setState]); + + return ( + + + + ); +} diff --git a/x-pack/plugins/lens/public/heatmap_visualization/constants.ts b/x-pack/plugins/lens/public/heatmap_visualization/constants.ts new file mode 100644 index 0000000000000..ee1be917f5bfd --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/constants.ts @@ -0,0 +1,44 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { LensIconChartHeatmap } from '../assets/chart_heatmap'; + +export const LENS_HEATMAP_RENDERER = 'lens_heatmap_renderer'; + +export const LENS_HEATMAP_ID = 'lnsHeatmap'; + +const groupLabel = i18n.translate('xpack.lens.heatmap.groupLabel', { + defaultMessage: 'Heatmap', +}); + +export const CHART_SHAPES = { + HEATMAP: 'heatmap', +} as const; + +export const CHART_NAMES = { + heatmap: { + shapeType: CHART_SHAPES.HEATMAP, + icon: LensIconChartHeatmap, + label: i18n.translate('xpack.lens.heatmap.heatmapLabel', { + defaultMessage: 'Heatmap', + }), + groupLabel, + }, +}; + +export const GROUP_ID = { + X: 'x', + Y: 'y', + CELL: 'cell', +} as const; + +export const FUNCTION_NAME = 'lens_heatmap'; + +export const LEGEND_FUNCTION = 'lens_heatmap_legendConfig'; + +export const HEATMAP_GRID_FUNCTION = 'lens_heatmap_grid'; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/expression.tsx b/x-pack/plugins/lens/public/heatmap_visualization/expression.tsx new file mode 100644 index 0000000000000..f0521dadf88bf --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/expression.tsx @@ -0,0 +1,275 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import ReactDOM from 'react-dom'; +import React from 'react'; +import { Position } from '@elastic/charts'; +import { + ExpressionFunctionDefinition, + IInterpreterRenderHandlers, +} from '../../../../../src/plugins/expressions'; +import { FormatFactory, LensBrushEvent, LensFilterEvent, LensMultiTable } from '../types'; +import { + FUNCTION_NAME, + HEATMAP_GRID_FUNCTION, + LEGEND_FUNCTION, + LENS_HEATMAP_RENDERER, +} from './constants'; +import type { + HeatmapExpressionArgs, + HeatmapExpressionProps, + HeatmapGridConfig, + HeatmapGridConfigResult, + HeatmapRender, + LegendConfigResult, +} from './types'; +import { HeatmapLegendConfig } from './types'; +import { ChartsPluginSetup, PaletteRegistry } from '../../../../../src/plugins/charts/public'; +import { HeatmapChartReportable } from './chart_component'; + +export const heatmapGridConfig: ExpressionFunctionDefinition< + typeof HEATMAP_GRID_FUNCTION, + null, + HeatmapGridConfig, + HeatmapGridConfigResult +> = { + name: HEATMAP_GRID_FUNCTION, + aliases: [], + type: HEATMAP_GRID_FUNCTION, + help: `Configure the heatmap layout `, + inputTypes: ['null'], + args: { + // grid + strokeWidth: { + types: ['number'], + help: i18n.translate('xpack.lens.heatmapChart.config.strokeWidth.help', { + defaultMessage: 'Specifies the grid stroke width', + }), + required: false, + }, + strokeColor: { + types: ['string'], + help: i18n.translate('xpack.lens.heatmapChart.config.strokeColor.help', { + defaultMessage: 'Specifies the grid stroke color', + }), + required: false, + }, + cellHeight: { + types: ['number'], + help: i18n.translate('xpack.lens.heatmapChart.config.cellHeight.help', { + defaultMessage: 'Specifies the grid cell height', + }), + required: false, + }, + cellWidth: { + types: ['number'], + help: i18n.translate('xpack.lens.heatmapChart.config.cellWidth.help', { + defaultMessage: 'Specifies the grid cell width', + }), + required: false, + }, + // cells + isCellLabelVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.heatmapChart.config.isCellLabelVisible.help', { + defaultMessage: 'Specifies whether or not the cell label is visible.', + }), + }, + // Y-axis + isYAxisLabelVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.heatmapChart.config.isYAxisLabelVisible.help', { + defaultMessage: 'Specifies whether or not the Y-axis labels are visible.', + }), + }, + yAxisLabelWidth: { + types: ['number'], + help: i18n.translate('xpack.lens.heatmapChart.config.yAxisLabelWidth.help', { + defaultMessage: 'Specifies the width of the Y-axis labels.', + }), + required: false, + }, + yAxisLabelColor: { + types: ['string'], + help: i18n.translate('xpack.lens.heatmapChart.config.yAxisLabelColor.help', { + defaultMessage: 'Specifies the color of the Y-axis labels.', + }), + required: false, + }, + // X-axis + isXAxisLabelVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.heatmapChart.config.isXAxisLabelVisible.help', { + defaultMessage: 'Specifies whether or not the X-axis labels are visible.', + }), + }, + }, + fn(input, args) { + return { + type: HEATMAP_GRID_FUNCTION, + ...args, + }; + }, +}; + +/** + * TODO check if it's possible to make a shared function + * based on the XY chart + */ +export const heatmapLegendConfig: ExpressionFunctionDefinition< + typeof LEGEND_FUNCTION, + null, + HeatmapLegendConfig, + LegendConfigResult +> = { + name: LEGEND_FUNCTION, + aliases: [], + type: LEGEND_FUNCTION, + help: `Configure the heatmap chart's legend`, + inputTypes: ['null'], + args: { + isVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.heatmapChart.legend.isVisible.help', { + defaultMessage: 'Specifies whether or not the legend is visible.', + }), + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: i18n.translate('xpack.lens.heatmapChart.legend.position.help', { + defaultMessage: 'Specifies the legend position.', + }), + }, + }, + fn(input, args) { + return { + type: LEGEND_FUNCTION, + ...args, + }; + }, +}; + +export const heatmap: ExpressionFunctionDefinition< + typeof FUNCTION_NAME, + LensMultiTable, + HeatmapExpressionArgs, + HeatmapRender +> = { + name: FUNCTION_NAME, + type: 'render', + help: i18n.translate('xpack.lens.heatmap.expressionHelpLabel', { + defaultMessage: 'Heatmap renderer', + }), + args: { + title: { + types: ['string'], + help: i18n.translate('xpack.lens.heatmap.titleLabel', { + defaultMessage: 'Title', + }), + }, + description: { + types: ['string'], + help: '', + }, + xAccessor: { + types: ['string'], + help: '', + }, + yAccessor: { + types: ['string'], + help: '', + }, + valueAccessor: { + types: ['string'], + help: '', + }, + shape: { + types: ['string'], + help: '', + }, + palette: { + default: `{theme "palette" default={system_palette name="default"} }`, + help: '', + types: ['palette'], + }, + legend: { + types: [LEGEND_FUNCTION], + help: i18n.translate('xpack.lens.heatmapChart.legend.help', { + defaultMessage: 'Configure the chart legend.', + }), + }, + gridConfig: { + types: [HEATMAP_GRID_FUNCTION], + help: i18n.translate('xpack.lens.heatmapChart.gridConfig.help', { + defaultMessage: 'Configure the heatmap layout.', + }), + }, + }, + inputTypes: ['lens_multitable'], + fn(data: LensMultiTable, args: HeatmapExpressionArgs) { + return { + type: 'render', + as: LENS_HEATMAP_RENDERER, + value: { + data, + args, + }, + }; + }, +}; + +export const getHeatmapRenderer = (dependencies: { + formatFactory: Promise; + chartsThemeService: ChartsPluginSetup['theme']; + paletteService: PaletteRegistry; + timeZone: string; +}) => ({ + name: LENS_HEATMAP_RENDERER, + displayName: i18n.translate('xpack.lens.heatmap.visualizationName', { + defaultMessage: 'Heatmap', + }), + help: '', + validate: () => undefined, + reuseDomNode: true, + render: async ( + domNode: Element, + config: HeatmapExpressionProps, + handlers: IInterpreterRenderHandlers + ) => { + const formatFactory = await dependencies.formatFactory; + const onClickValue = (data: LensFilterEvent['data']) => { + handlers.event({ name: 'filter', data }); + }; + const onSelectRange = (data: LensBrushEvent['data']) => { + handlers.event({ name: 'brush', data }); + }; + + ReactDOM.render( + + { + + } + , + domNode, + () => { + handlers.done(); + } + ); + + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/heatmap_visualization.ts b/x-pack/plugins/lens/public/heatmap_visualization/heatmap_visualization.ts new file mode 100644 index 0000000000000..894b003b4b371 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/heatmap_visualization.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export * from './expression'; +export * from './types'; +export * from './visualization'; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/index.scss b/x-pack/plugins/lens/public/heatmap_visualization/index.scss new file mode 100644 index 0000000000000..e72356b1a3d7e --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/index.scss @@ -0,0 +1,8 @@ +.lnsHeatmapExpression__container { + height: 100%; + width: 100%; + // the FocusTrap is adding extra divs which are making the visualization redraw twice + // with a visible glitch. This make the chart library resilient to this extra reflow + overflow-x: hidden; + +} diff --git a/x-pack/plugins/lens/public/heatmap_visualization/index.ts b/x-pack/plugins/lens/public/heatmap_visualization/index.ts new file mode 100644 index 0000000000000..4599bd8d2a208 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/index.ts @@ -0,0 +1,55 @@ +/* + * 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 { CoreSetup } from 'kibana/public'; +import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; +import { EditorFrameSetup, FormatFactory } from '../types'; +import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; +import { getTimeZone } from '../utils'; + +export interface HeatmapVisualizationPluginSetupPlugins { + expressions: ExpressionsSetup; + formatFactory: Promise; + editorFrame: EditorFrameSetup; + charts: ChartsPluginSetup; +} + +export class HeatmapVisualization { + constructor() {} + + setup( + core: CoreSetup, + { expressions, formatFactory, editorFrame, charts }: HeatmapVisualizationPluginSetupPlugins + ) { + editorFrame.registerVisualization(async () => { + const timeZone = getTimeZone(core.uiSettings); + + const { + getHeatmapVisualization, + heatmap, + heatmapLegendConfig, + heatmapGridConfig, + getHeatmapRenderer, + } = await import('../async_services'); + const palettes = await charts.palettes.getPalettes(); + + expressions.registerFunction(() => heatmap); + expressions.registerFunction(() => heatmapLegendConfig); + expressions.registerFunction(() => heatmapGridConfig); + + expressions.registerRenderer( + getHeatmapRenderer({ + formatFactory, + chartsThemeService: charts.theme, + paletteService: palettes, + timeZone, + }) + ); + return getHeatmapVisualization({ paletteService: palettes }); + }); + } +} diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts new file mode 100644 index 0000000000000..c11078be6c8b9 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts @@ -0,0 +1,330 @@ +/* + * 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 { getSuggestions } from './suggestions'; +import { HeatmapVisualizationState } from './types'; +import { HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants'; +import { Position } from '@elastic/charts'; + +describe('heatmap suggestions', () => { + describe('rejects suggestions', () => { + test('when currently active and unchanged data', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'unchanged', + }, + state: { + shape: 'heatmap', + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + test('when there are 3 or more buckets', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'date-column-01', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'date-column-02', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'another-bucket-column', + operation: { + isBucketed: true, + dataType: 'string', + scale: 'ratio', + label: 'Bucket', + }, + }, + { + columnId: 'metric-column', + operation: { + isBucketed: false, + dataType: 'number', + scale: 'ratio', + label: 'Metric', + }, + }, + ], + changeType: 'initial', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([]); + }); + + test('when currently active with partial configuration and not extended change type', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'initial', + }, + state: { + shape: 'heatmap', + layerId: 'first', + xAccessor: 'some-field', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + }); + + describe('hides suggestions', () => { + test('when table is reduced', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'reduced', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + shape: 'heatmap', + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + }, + title: 'Heatmap', + hide: true, + previewIcon: 'empty', + score: 0, + }, + ]); + }); + test('for tables with a single bucket dimension', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'test-column', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + ], + changeType: 'reduced', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + shape: 'heatmap', + xAccessor: 'test-column', + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + }, + title: 'Heatmap', + hide: true, + previewIcon: 'empty', + score: 0.3, + }, + ]); + }); + }); + + describe('shows suggestions', () => { + test('when at least one axis and value accessor are available', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'date-column', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'metric-column', + operation: { + isBucketed: false, + dataType: 'number', + scale: 'ratio', + label: 'Metric', + }, + }, + ], + changeType: 'initial', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + shape: 'heatmap', + xAccessor: 'date-column', + valueAccessor: 'metric-column', + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + }, + title: 'Heatmap', + // Temp hide all suggestions while heatmap is in beta + hide: true, + previewIcon: 'empty', + score: 0.6, + }, + ]); + }); + + test('when complete configuration has been resolved', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'date-column', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'metric-column', + operation: { + isBucketed: false, + dataType: 'number', + scale: 'ratio', + label: 'Metric', + }, + }, + { + columnId: 'group-column', + operation: { + isBucketed: true, + dataType: 'string', + scale: 'ratio', + label: 'Group', + }, + }, + ], + changeType: 'initial', + }, + state: { + layerId: 'first', + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + shape: 'heatmap', + xAccessor: 'date-column', + yAccessor: 'group-column', + valueAccessor: 'metric-column', + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + }, + title: 'Heatmap', + // Temp hide all suggestions while heatmap is in beta + hide: true, + previewIcon: 'empty', + score: 0.9, + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts new file mode 100644 index 0000000000000..5cddebe2cc230 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts @@ -0,0 +1,106 @@ +/* + * 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 { partition } from 'lodash'; +import { Position } from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import { Visualization } from '../types'; +import { HeatmapVisualizationState } from './types'; +import { CHART_SHAPES, HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants'; + +export const getSuggestions: Visualization['getSuggestions'] = ({ + table, + state, + keptLayerIds, +}) => { + if ( + state?.shape === CHART_SHAPES.HEATMAP && + (state.xAccessor || state.yAccessor || state.valueAccessor) && + table.changeType !== 'extended' + ) { + return []; + } + + const isUnchanged = state && table.changeType === 'unchanged'; + + if ( + isUnchanged || + keptLayerIds.length > 1 || + (keptLayerIds.length && table.layerId !== keptLayerIds[0]) + ) { + return []; + } + + /** + * The score gets increased based on the config completion. + */ + let score = 0; + + const [groups, metrics] = partition(table.columns, (col) => col.operation.isBucketed); + + if (groups.length >= 3) { + return []; + } + + const isSingleBucketDimension = groups.length === 1 && metrics.length === 0; + + /** + * Hide for: + * - reduced and reorder tables + * - tables with just a single bucket dimension + */ + const hide = + table.changeType === 'reduced' || table.changeType === 'reorder' || isSingleBucketDimension; + + const newState: HeatmapVisualizationState = { + shape: CHART_SHAPES.HEATMAP, + layerId: table.layerId, + legend: { + isVisible: state?.legend?.isVisible ?? true, + position: state?.legend?.position ?? Position.Right, + type: LEGEND_FUNCTION, + }, + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + }; + + const numberMetric = metrics.find((m) => m.operation.dataType === 'number'); + + if (numberMetric) { + score += 0.3; + newState.valueAccessor = numberMetric.columnId; + } + + const [histogram, ordinal] = partition(groups, (g) => g.operation.scale === 'interval'); + + newState.xAccessor = histogram[0]?.columnId || ordinal[0]?.columnId; + newState.yAccessor = groups.find((g) => g.columnId !== newState.xAccessor)?.columnId; + + if (newState.xAccessor) { + score += 0.3; + } + if (newState.yAccessor) { + score += 0.3; + } + + return [ + { + state: newState, + title: i18n.translate('xpack.lens.heatmap.heatmapLabel', { + defaultMessage: 'Heatmap', + }), + // Temp hide all suggestions while heatmap is in beta + hide: true || hide, + previewIcon: 'empty', + score: Number(score.toFixed(1)), + }, + ]; +}; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx new file mode 100644 index 0000000000000..6fd863ba91936 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx @@ -0,0 +1,74 @@ +/* + * 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 React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Position } from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import { VisualizationToolbarProps } from '../types'; +import { LegendSettingsPopover } from '../shared_components'; +import { HeatmapVisualizationState } from './types'; + +const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [ + { + id: `heatmap_legend_show`, + value: 'show', + label: i18n.translate('xpack.lens.heatmapChart.legendVisibility.show', { + defaultMessage: 'Show', + }), + }, + { + id: `heatmap_legend_hide`, + value: 'hide', + label: i18n.translate('xpack.lens.heatmapChart.legendVisibility.hide', { + defaultMessage: 'Hide', + }), + }, +]; + +export const HeatmapToolbar = memo( + (props: VisualizationToolbarProps) => { + const { state, setState } = props; + + const legendMode = state.legend.isVisible ? 'show' : 'hide'; + + return ( + + + + { + const newMode = legendOptions.find(({ id }) => id === optionId)!.value; + if (newMode === 'show') { + setState({ + ...state, + legend: { ...state.legend, isVisible: true }, + }); + } else if (newMode === 'hide') { + setState({ + ...state, + legend: { ...state.legend, isVisible: false }, + }); + } + }} + position={state?.legend.position} + onPositionChange={(id) => { + setState({ + ...state, + legend: { ...state.legend, position: id as Position }, + }); + }} + /> + + + + ); + } +); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/types.ts b/x-pack/plugins/lens/public/heatmap_visualization/types.ts new file mode 100644 index 0000000000000..734fe7f5be754 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/types.ts @@ -0,0 +1,92 @@ +/* + * 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 { Position } from '@elastic/charts'; +import { PaletteOutput } from '../../../../../src/plugins/charts/common'; +import { FormatFactory, LensBrushEvent, LensFilterEvent, LensMultiTable } from '../types'; +import { + CHART_SHAPES, + HEATMAP_GRID_FUNCTION, + LEGEND_FUNCTION, + LENS_HEATMAP_RENDERER, +} from './constants'; +import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; + +export type ChartShapes = typeof CHART_SHAPES[keyof typeof CHART_SHAPES]; + +export interface SharedHeatmapLayerState { + shape: ChartShapes; + xAccessor?: string; + yAccessor?: string; + valueAccessor?: string; + legend: LegendConfigResult; + gridConfig: HeatmapGridConfigResult; +} + +export type HeatmapLayerState = SharedHeatmapLayerState & { + layerId: string; +}; + +export type HeatmapVisualizationState = HeatmapLayerState & { + palette?: PaletteOutput; +}; + +export type HeatmapExpressionArgs = SharedHeatmapLayerState & { + title?: string; + description?: string; + palette: PaletteOutput; +}; + +export interface HeatmapRender { + type: 'render'; + as: typeof LENS_HEATMAP_RENDERER; + value: HeatmapExpressionProps; +} + +export interface HeatmapExpressionProps { + data: LensMultiTable; + args: HeatmapExpressionArgs; +} + +export type HeatmapRenderProps = HeatmapExpressionProps & { + timeZone: string; + formatFactory: FormatFactory; + chartsThemeService: ChartsPluginSetup['theme']; + onClickValue: (data: LensFilterEvent['data']) => void; + onSelectRange: (data: LensBrushEvent['data']) => void; +}; + +export interface HeatmapLegendConfig { + /** + * Flag whether the legend should be shown. If there is just a single series, it will be hidden + */ + isVisible: boolean; + /** + * Position of the legend relative to the chart + */ + position: Position; +} + +export type LegendConfigResult = HeatmapLegendConfig & { type: typeof LEGEND_FUNCTION }; + +export interface HeatmapGridConfig { + // grid + strokeWidth?: number; + strokeColor?: string; + cellHeight?: number; + cellWidth?: number; + // cells + isCellLabelVisible: boolean; + // Y-axis + isYAxisLabelVisible: boolean; + yAxisLabelWidth?: number; + yAxisLabelColor?: string; + // X-axis + isXAxisLabelVisible: boolean; +} + +export type HeatmapGridConfigResult = HeatmapGridConfig & { type: typeof HEATMAP_GRID_FUNCTION }; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts new file mode 100644 index 0000000000000..3ed82bef06105 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -0,0 +1,486 @@ +/* + * 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 { + filterOperationsAxis, + getHeatmapVisualization, + isCellValueSupported, +} from './visualization'; +import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; +import { + CHART_SHAPES, + FUNCTION_NAME, + GROUP_ID, + HEATMAP_GRID_FUNCTION, + LEGEND_FUNCTION, +} from './constants'; +import { Position } from '@elastic/charts'; +import { HeatmapVisualizationState } from './types'; +import { DatasourcePublicAPI, Operation } from '../types'; + +function exampleState(): HeatmapVisualizationState { + return { + layerId: 'test-layer', + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + shape: CHART_SHAPES.HEATMAP, + }; +} + +describe('heatmap', () => { + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + }); + + describe('#intialize', () => { + test('returns a default state', () => { + expect(getHeatmapVisualization({}).initialize(frame)).toEqual({ + layerId: '', + title: 'Empty Heatmap chart', + shape: CHART_SHAPES.HEATMAP, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + }); + }); + + test('returns persisted state', () => { + expect(getHeatmapVisualization({}).initialize(frame, exampleState())).toEqual(exampleState()); + }); + }); + + describe('#getConfiguration', () => { + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + test('resolves configuration from complete state', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + yAccessor: 'y-accessor', + valueAccessor: 'v-accessor', + }; + + expect( + getHeatmapVisualization({}).getConfiguration({ state, frame, layerId: 'first' }) + ).toEqual({ + groups: [ + { + layerId: 'first', + groupId: GROUP_ID.X, + groupLabel: 'Horizontal axis', + accessors: [{ columnId: 'x-accessor' }], + filterOperations: filterOperationsAxis, + supportsMoreColumns: false, + required: true, + dataTestSubj: 'lnsHeatmap_xDimensionPanel', + }, + { + layerId: 'first', + groupId: GROUP_ID.Y, + groupLabel: 'Vertical axis', + accessors: [{ columnId: 'y-accessor' }], + filterOperations: filterOperationsAxis, + supportsMoreColumns: false, + required: false, + dataTestSubj: 'lnsHeatmap_yDimensionPanel', + }, + { + layerId: 'first', + groupId: GROUP_ID.CELL, + groupLabel: 'Cell value', + accessors: [{ columnId: 'v-accessor' }], + filterOperations: isCellValueSupported, + supportsMoreColumns: false, + required: true, + dataTestSubj: 'lnsHeatmap_cellPanel', + }, + ], + }); + }); + + test('resolves configuration from partial state', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + }; + + expect( + getHeatmapVisualization({}).getConfiguration({ state, frame, layerId: 'first' }) + ).toEqual({ + groups: [ + { + layerId: 'first', + groupId: GROUP_ID.X, + groupLabel: 'Horizontal axis', + accessors: [{ columnId: 'x-accessor' }], + filterOperations: filterOperationsAxis, + supportsMoreColumns: false, + required: true, + dataTestSubj: 'lnsHeatmap_xDimensionPanel', + }, + { + layerId: 'first', + groupId: GROUP_ID.Y, + groupLabel: 'Vertical axis', + accessors: [], + filterOperations: filterOperationsAxis, + supportsMoreColumns: true, + required: false, + dataTestSubj: 'lnsHeatmap_yDimensionPanel', + }, + { + layerId: 'first', + groupId: GROUP_ID.CELL, + groupLabel: 'Cell value', + accessors: [], + filterOperations: isCellValueSupported, + supportsMoreColumns: true, + required: true, + dataTestSubj: 'lnsHeatmap_cellPanel', + }, + ], + }); + }); + }); + + describe('#setDimension', () => { + test('set dimension correctly', () => { + const prevState: HeatmapVisualizationState = { + ...exampleState(), + xAccessor: 'x-accessor', + yAccessor: 'y-accessor', + }; + expect( + getHeatmapVisualization({}).setDimension({ + prevState, + layerId: 'first', + columnId: 'new-x-accessor', + groupId: 'x', + }) + ).toEqual({ + ...prevState, + xAccessor: 'new-x-accessor', + }); + }); + }); + + describe('#removeDimension', () => { + test('removes dimension correctly', () => { + const prevState: HeatmapVisualizationState = { + ...exampleState(), + xAccessor: 'x-accessor', + yAccessor: 'y-accessor', + }; + expect( + getHeatmapVisualization({}).removeDimension({ + prevState, + layerId: 'first', + columnId: 'x-accessor', + }) + ).toEqual({ + ...exampleState(), + yAccessor: 'y-accessor', + }); + }); + }); + + describe('#toExpression', () => { + let datasourceLayers: Record; + + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + + datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + test('creates an expression based on state and attributes', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + valueAccessor: 'value-accessor', + }; + const attributes = { + title: 'Test', + }; + + expect(getHeatmapVisualization({}).toExpression(state, datasourceLayers, attributes)).toEqual( + { + type: 'expression', + chain: [ + { + type: 'function', + function: FUNCTION_NAME, + arguments: { + title: ['Test'], + description: [''], + xAccessor: ['x-accessor'], + yAccessor: [''], + valueAccessor: ['value-accessor'], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: LEGEND_FUNCTION, + arguments: { + isVisible: [true], + position: [Position.Right], + }, + }, + ], + }, + ], + gridConfig: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: HEATMAP_GRID_FUNCTION, + arguments: { + // grid + strokeWidth: [], + strokeColor: [], + cellHeight: [], + cellWidth: [], + // cells + isCellLabelVisible: [false], + // Y-axis + isYAxisLabelVisible: [true], + yAxisLabelWidth: [], + yAxisLabelColor: [], + // X-axis + isXAxisLabelVisible: [true], + }, + }, + ], + }, + ], + }, + }, + ], + } + ); + }); + + test('returns null with a missing value accessor', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + }; + const attributes = { + title: 'Test', + }; + + expect(getHeatmapVisualization({}).toExpression(state, datasourceLayers, attributes)).toEqual( + null + ); + }); + }); + + describe('#toPreviewExpression', () => { + let datasourceLayers: Record; + + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + + datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + test('creates a preview expression based on state and attributes', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + layerId: 'first', + xAccessor: 'x-accessor', + }; + + expect(getHeatmapVisualization({}).toPreviewExpression!(state, datasourceLayers)).toEqual({ + type: 'expression', + chain: [ + { + type: 'function', + function: FUNCTION_NAME, + arguments: { + title: [''], + description: [''], + xAccessor: ['x-accessor'], + yAccessor: [''], + valueAccessor: [''], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: LEGEND_FUNCTION, + arguments: { + isVisible: [false], + position: [], + }, + }, + ], + }, + ], + gridConfig: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: HEATMAP_GRID_FUNCTION, + arguments: { + // grid + strokeWidth: [1], + // cells + isCellLabelVisible: [false], + // Y-axis + isYAxisLabelVisible: [false], + // X-axis + isXAxisLabelVisible: [false], + }, + }, + ], + }, + ], + }, + }, + ], + }); + }); + }); + + describe('#getErrorMessages', () => { + test('should not return an error when chart has empty configuration', () => { + const mockState = { + shape: CHART_SHAPES.HEATMAP, + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getErrorMessages(mockState)).toEqual(undefined); + }); + + test('should return an error when the X accessor is missing', () => { + const mockState = { + shape: CHART_SHAPES.HEATMAP, + valueAccessor: 'v-accessor', + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getErrorMessages(mockState)).toEqual([ + { + longMessage: 'Configuration for the horizontal axis is missing.', + shortMessage: 'Missing Horizontal axis.', + }, + ]); + }); + }); + + describe('#getWarningMessages', () => { + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + test('should not return warning messages when the layer it not configured', () => { + const mockState = { + shape: CHART_SHAPES.HEATMAP, + valueAccessor: 'v-accessor', + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getWarningMessages!(mockState, frame)).toEqual(undefined); + }); + + test('should not return warning messages when the data table is empty', () => { + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + const mockState = { + shape: CHART_SHAPES.HEATMAP, + valueAccessor: 'v-accessor', + layerId: 'first', + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getWarningMessages!(mockState, frame)).toEqual(undefined); + }); + + test('should return a warning message when cell value data contains arrays', () => { + frame.activeData = { + first: { + type: 'datatable', + rows: [ + { + 'v-accessor': [1, 2, 3], + }, + ], + columns: [], + }, + }; + + const mockState = { + shape: CHART_SHAPES.HEATMAP, + valueAccessor: 'v-accessor', + layerId: 'first', + } as HeatmapVisualizationState; + expect(getHeatmapVisualization({}).getWarningMessages!(mockState, frame)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx new file mode 100644 index 0000000000000..54f9c70824831 --- /dev/null +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -0,0 +1,415 @@ +/* + * 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 React from 'react'; +import { render } from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { Ast } from '@kbn/interpreter/common'; +import { Position } from '@elastic/charts'; +import { PaletteRegistry } from '../../../../../src/plugins/charts/public'; +import { OperationMetadata, Visualization } from '../types'; +import { HeatmapVisualizationState } from './types'; +import { getSuggestions } from './suggestions'; +import { + CHART_NAMES, + CHART_SHAPES, + FUNCTION_NAME, + GROUP_ID, + HEATMAP_GRID_FUNCTION, + LEGEND_FUNCTION, + LENS_HEATMAP_ID, +} from './constants'; +import { HeatmapToolbar } from './toolbar_component'; +import { LensIconChartHeatmap } from '../assets/chart_heatmap'; + +const groupLabelForBar = i18n.translate('xpack.lens.heatmapVisualization.heatmapGroupLabel', { + defaultMessage: 'Heatmap', +}); + +interface HeatmapVisualizationDeps { + paletteService?: PaletteRegistry; +} + +function getAxisName(axis: 'x' | 'y') { + const vertical = i18n.translate('xpack.lens.heatmap.verticalAxisLabel', { + defaultMessage: 'Vertical axis', + }); + const horizontal = i18n.translate('xpack.lens.heatmap.horizontalAxisLabel', { + defaultMessage: 'Horizontal axis', + }); + if (axis === 'x') { + return horizontal; + } + return vertical; +} + +export const isBucketed = (op: OperationMetadata) => op.isBucketed && op.scale === 'ordinal'; +const isNumericMetric = (op: OperationMetadata) => op.dataType === 'number'; + +export const filterOperationsAxis = (op: OperationMetadata) => + isBucketed(op) || op.scale === 'interval'; + +export const isCellValueSupported = (op: OperationMetadata) => { + return !isBucketed(op) && (op.scale === 'ordinal' || op.scale === 'ratio') && isNumericMetric(op); +}; + +function getInitialState(): Omit { + return { + shape: CHART_SHAPES.HEATMAP, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + }, + }; +} + +export const getHeatmapVisualization = ({ + paletteService, +}: HeatmapVisualizationDeps): Visualization => ({ + id: LENS_HEATMAP_ID, + + visualizationTypes: [ + { + id: 'heatmap', + icon: LensIconChartHeatmap, + label: i18n.translate('xpack.lens.heatmapVisualization.heatmapLabel', { + defaultMessage: 'Heatmap', + }), + groupLabel: groupLabelForBar, + showBetaBadge: true, + }, + ], + + getVisualizationTypeId(state) { + return state.shape; + }, + + getLayerIds(state) { + return [state.layerId]; + }, + + clearLayer(state) { + const newState = { ...state }; + delete newState.valueAccessor; + delete newState.xAccessor; + delete newState.yAccessor; + return newState; + }, + + switchVisualizationType: (visualizationTypeId, state) => { + return { + ...state, + shape: visualizationTypeId as typeof CHART_SHAPES.HEATMAP, + }; + }, + + getDescription(state) { + return CHART_NAMES.heatmap; + }, + + initialize(frame, state, mainPalette) { + return ( + state || { + layerId: frame.addNewLayer(), + title: 'Empty Heatmap chart', + ...getInitialState(), + } + ); + }, + + getSuggestions, + + getConfiguration({ state, frame, layerId }) { + const datasourceLayer = frame.datasourceLayers[layerId]; + + const originalOrder = datasourceLayer.getTableSpec().map(({ columnId }) => columnId); + if (!originalOrder) { + return { groups: [] }; + } + + return { + groups: [ + { + layerId: state.layerId, + groupId: GROUP_ID.X, + groupLabel: getAxisName(GROUP_ID.X), + accessors: state.xAccessor ? [{ columnId: state.xAccessor }] : [], + filterOperations: filterOperationsAxis, + supportsMoreColumns: !state.xAccessor, + required: true, + dataTestSubj: 'lnsHeatmap_xDimensionPanel', + }, + { + layerId: state.layerId, + groupId: GROUP_ID.Y, + groupLabel: getAxisName(GROUP_ID.Y), + accessors: state.yAccessor ? [{ columnId: state.yAccessor }] : [], + filterOperations: filterOperationsAxis, + supportsMoreColumns: !state.yAccessor, + required: false, + dataTestSubj: 'lnsHeatmap_yDimensionPanel', + }, + { + layerId: state.layerId, + groupId: GROUP_ID.CELL, + groupLabel: i18n.translate('xpack.lens.heatmap.cellValueLabel', { + defaultMessage: 'Cell value', + }), + accessors: state.valueAccessor ? [{ columnId: state.valueAccessor }] : [], + filterOperations: isCellValueSupported, + supportsMoreColumns: !state.valueAccessor, + required: true, + dataTestSubj: 'lnsHeatmap_cellPanel', + }, + ], + }; + }, + + setDimension({ prevState, layerId, columnId, groupId, previousColumn }) { + const update: Partial = {}; + if (groupId === GROUP_ID.X) { + update.xAccessor = columnId; + } + if (groupId === GROUP_ID.Y) { + update.yAccessor = columnId; + } + if (groupId === GROUP_ID.CELL) { + update.valueAccessor = columnId; + } + return { + ...prevState, + ...update, + }; + }, + + removeDimension({ prevState, layerId, columnId }) { + const update = { ...prevState }; + + if (prevState.valueAccessor === columnId) { + delete update.valueAccessor; + } + if (prevState.xAccessor === columnId) { + delete update.xAccessor; + } + if (prevState.yAccessor === columnId) { + delete update.yAccessor; + } + + return update; + }, + + renderToolbar(domElement, props) { + render( + + + , + domElement + ); + }, + + toExpression(state, datasourceLayers, attributes): Ast | null { + const datasource = datasourceLayers[state.layerId]; + + const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); + // When we add a column it could be empty, and therefore have no order + + if (!originalOrder || !state.valueAccessor) { + return null; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: FUNCTION_NAME, + arguments: { + title: [attributes?.title ?? ''], + description: [attributes?.description ?? ''], + xAccessor: [state.xAccessor ?? ''], + yAccessor: [state.yAccessor ?? ''], + valueAccessor: [state.valueAccessor ?? ''], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: LEGEND_FUNCTION, + arguments: { + isVisible: [state.legend.isVisible], + position: [state.legend.position], + }, + }, + ], + }, + ], + gridConfig: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: HEATMAP_GRID_FUNCTION, + arguments: { + // grid + strokeWidth: state.gridConfig.strokeWidth + ? [state.gridConfig.strokeWidth] + : [], + strokeColor: state.gridConfig.strokeColor + ? [state.gridConfig.strokeColor] + : [], + cellHeight: state.gridConfig.cellHeight ? [state.gridConfig.cellHeight] : [], + cellWidth: state.gridConfig.cellWidth ? [state.gridConfig.cellWidth] : [], + // cells + isCellLabelVisible: [state.gridConfig.isCellLabelVisible], + // Y-axis + isYAxisLabelVisible: [state.gridConfig.isYAxisLabelVisible], + yAxisLabelWidth: state.gridConfig.yAxisLabelWidth + ? [state.gridConfig.yAxisLabelWidth] + : [], + yAxisLabelColor: state.gridConfig.yAxisLabelColor + ? [state.gridConfig.yAxisLabelColor] + : [], + // X-axis + isXAxisLabelVisible: state.gridConfig.isXAxisLabelVisible + ? [state.gridConfig.isXAxisLabelVisible] + : [], + }, + }, + ], + }, + ], + }, + }, + ], + }; + }, + + toPreviewExpression(state, datasourceLayers): Ast | null { + const datasource = datasourceLayers[state.layerId]; + + const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); + // When we add a column it could be empty, and therefore have no order + + if (!originalOrder) { + return null; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: FUNCTION_NAME, + arguments: { + title: [''], + description: [''], + xAccessor: [state.xAccessor ?? ''], + yAccessor: [state.yAccessor ?? ''], + valueAccessor: [state.valueAccessor ?? ''], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: LEGEND_FUNCTION, + arguments: { + isVisible: [false], + position: [], + }, + }, + ], + }, + ], + gridConfig: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: HEATMAP_GRID_FUNCTION, + arguments: { + // grid + strokeWidth: [1], + // cells + isCellLabelVisible: [false], + // Y-axis + isYAxisLabelVisible: [false], + // X-axis + isXAxisLabelVisible: [false], + }, + }, + ], + }, + ], + }, + }, + ], + }; + }, + + getErrorMessages(state) { + if (!state.yAccessor && !state.xAccessor && !state.valueAccessor) { + // nothing configured yet + return; + } + + const errors: ReturnType = []; + + if (!state.xAccessor) { + errors.push({ + shortMessage: i18n.translate( + 'xpack.lens.heatmapVisualization.missingXAccessorShortMessage', + { + defaultMessage: 'Missing Horizontal axis.', + } + ), + longMessage: i18n.translate('xpack.lens.heatmapVisualization.missingXAccessorLongMessage', { + defaultMessage: 'Configuration for the horizontal axis is missing.', + }), + }); + } + + return errors.length ? errors : undefined; + }, + + getWarningMessages(state, frame) { + if (!state?.layerId || !frame.activeData || !state.valueAccessor) { + return; + } + + const rows = frame.activeData[state.layerId] && frame.activeData[state.layerId].rows; + if (!rows) { + return; + } + + const hasArrayValues = rows.some((row) => Array.isArray(row[state.valueAccessor!])); + + const datasource = frame.datasourceLayers[state.layerId]; + const operation = datasource.getOperationForColumnId(state.valueAccessor); + + return hasArrayValues + ? [ + {operation?.label} }} + />, + ] + : undefined; + }, +}); diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 99e7199c2d802..fe225dba6f256 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -54,6 +54,7 @@ import { EmbeddableComponentProps, getEmbeddableComponent, } from './editor_frame_service/embeddable/embeddable_component'; +import { HeatmapVisualization } from './heatmap_visualization'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -119,6 +120,7 @@ export class LensPlugin { private xyVisualization: XyVisualization; private metricVisualization: MetricVisualization; private pieVisualization: PieVisualization; + private heatmapVisualization: HeatmapVisualization; private stopReportManager?: () => void; @@ -129,6 +131,7 @@ export class LensPlugin { this.xyVisualization = new XyVisualization(); this.metricVisualization = new MetricVisualization(); this.pieVisualization = new PieVisualization(); + this.heatmapVisualization = new HeatmapVisualization(); } setup( @@ -178,6 +181,7 @@ export class LensPlugin { this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); this.pieVisualization.setup(core, dependencies); + this.heatmapVisualization.setup(core, dependencies); visualizations.registerAlias(getLensAliasConfig()); diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx index 7acd5669b4ba5..23d4858c26263 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiButtonGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { ToolbarPopover } from '../shared_components'; +import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; export interface LegendSettingsPopoverProps { /** @@ -44,6 +45,10 @@ export interface LegendSettingsPopoverProps { * Callback on nested switch status change */ onNestedLegendChange?: (event: EuiSwitchEvent) => void; + /** + * Button group position + */ + groupPosition?: ToolbarButtonProps['groupPosition']; } const toggleButtonsIcons = [ @@ -86,6 +91,7 @@ export const LegendSettingsPopover: React.FunctionComponent {}, + groupPosition = 'right', }) => { return ( { diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index c1aab4c18f529..2706fe977c68e 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -7,6 +7,8 @@ import { i18n } from '@kbn/i18n'; import { IndexPattern, IndexPatternsContract, TimefilterContract } from 'src/plugins/data/public'; +import { IUiSettingsClient } from 'kibana/public'; +import moment from 'moment-timezone'; import { LensFilterEvent } from './types'; /** replaces the value `(empty) to empty string for proper filtering` */ @@ -63,6 +65,7 @@ export const getResolvedDateRange = function (timefilter: TimefilterContract) { export function containsDynamicMath(dateMathString: string) { return dateMathString.includes('now'); } + export const TIME_LAG_PERCENTAGE_LIMIT = 0.02; export async function getAllIndexPatterns( @@ -79,3 +82,12 @@ export async function getAllIndexPatterns( // return also the rejected ids in case we want to show something later on return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds }; } + +export function getTimeZone(uiSettings: IUiSettingsClient) { + const configuredTimeZone = uiSettings.get('dateFormat:tz'); + if (configuredTimeZone === 'Browser') { + return moment.tz.guess(); + } + + return configuredTimeZone; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 608971d281981..9b203faee3a64 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -222,7 +222,7 @@ export const xyChart: ExpressionFunctionDefinition< }, }; -export async function calculateMinInterval({ args: { layers }, data }: XYChartProps) { +export function calculateMinInterval({ args: { layers }, data }: XYChartProps) { const filteredLayers = getFilteredLayers(layers, data); if (filteredLayers.length === 0) return; const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time'); @@ -280,7 +280,7 @@ export const getXyChartRenderer = (dependencies: { chartsThemeService={dependencies.chartsThemeService} paletteService={dependencies.paletteService} timeZone={dependencies.timeZone} - minInterval={await calculateMinInterval(config)} + minInterval={calculateMinInterval(config)} onClickValue={onClickValue} onSelectRange={onSelectRange} renderMode={handlers.getRenderMode()} diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index bb9893bd058b5..f29d0f9280246 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -5,12 +5,12 @@ * 2.0. */ -import { CoreSetup, IUiSettingsClient } from 'kibana/public'; -import moment from 'moment-timezone'; +import { CoreSetup } from 'kibana/public'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import { EditorFrameSetup, FormatFactory } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { LensPluginStartDependencies } from '../plugin'; +import { getTimeZone } from '../utils'; export interface XyVisualizationPluginSetupPlugins { expressions: ExpressionsSetup; @@ -19,15 +19,6 @@ export interface XyVisualizationPluginSetupPlugins { charts: ChartsPluginSetup; } -function getTimeZone(uiSettings: IUiSettingsClient) { - const configuredTimeZone = uiSettings.get('dateFormat:tz'); - if (configuredTimeZone === 'Browser') { - return moment.tz.guess(); - } - - return configuredTimeZone; -} - export class XyVisualization { constructor() {} diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index c1041e1fefcfd..f2840b6d3844b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -52,7 +52,7 @@ describe('xy_visualization', () => { }; } - it('should show mixed xy chart when multilple series types', () => { + it('should show mixed xy chart when multiple series types', () => { const desc = xyVisualization.getDescription(mixedState('bar', 'line')); expect(desc.label).toEqual('Mixed XY'); @@ -332,7 +332,7 @@ describe('xy_visualization', () => { expect(options.map((o) => o.groupId)).toEqual(['x', 'y', 'breakdown']); }); - it('should return the correct labels for the 3 dimensios', () => { + it('should return the correct labels for the 3 dimensions', () => { const options = xyVisualization.getConfiguration({ state: exampleState(), frame, @@ -345,7 +345,7 @@ describe('xy_visualization', () => { ]); }); - it('should return the correct labels for the 3 dimensios for a horizontal chart', () => { + it('should return the correct labels for the 3 dimensions for a horizontal chart', () => { const initialState = exampleState(); const state = { ...initialState, diff --git a/x-pack/test/functional/apps/lens/chart_data.ts b/x-pack/test/functional/apps/lens/chart_data.ts index b87d4e999d597..24aaab9807494 100644 --- a/x-pack/test/functional/apps/lens/chart_data.ts +++ b/x-pack/test/functional/apps/lens/chart_data.ts @@ -76,12 +76,47 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it.skip('should render treemap chart', async () => { - await PageObjects.lens.switchToVisualization('treemap'); + await PageObjects.lens.switchToVisualization('treemap', 'treemap'); await PageObjects.lens.waitForVisualization(); const data = await PageObjects.lens.getCurrentChartDebugState(); assertMatchesExpectedData(data!); }); + it('should render heatmap chart', async () => { + await PageObjects.lens.switchToVisualization('heatmap', 'heatmap'); + await PageObjects.lens.waitForVisualization(); + const debugState = await PageObjects.lens.getCurrentChartDebugState(); + + if (!debugState) { + throw new Error('Debug state is not available'); + } + + // assert axes + expect(debugState.axes!.x[0].labels).to.eql([ + '97.220.3.248', + '169.228.188.120', + '78.83.247.30', + '226.82.228.233', + '93.28.27.24', + 'Other', + ]); + expect(debugState.axes!.y[0].labels).to.eql(['']); + + // assert cells + expect(debugState.heatmap!.cells.length).to.eql(6); + + // assert legend + expect(debugState.legend!.items).to.eql([ + { key: '6000', name: '> 6000', color: '#6092c0' }, + { key: '8000', name: '> 8000', color: '#6092c0' }, + { key: '10000', name: '> 10000', color: '#a8bfda' }, + { key: '12000', name: '> 12000', color: '#ebeff5' }, + { key: '14000', name: '> 14000', color: '#ebeff5' }, + { key: '16000', name: '> 16000', color: '#ecb385' }, + { key: '18000', name: '> 18000', color: '#e7664c' }, + ]); + }); + it('should render datatable', async () => { await PageObjects.lens.switchToVisualization('lnsDatatable'); await PageObjects.lens.waitForVisualization(); diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index acc783ad36bf1..2a4d56bbea791 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -407,6 +407,38 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.getDatatableCellText(0, 1)).to.eql('6,011.351'); }); + it('should create a heatmap chart and transition to barchart', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('heatmap', 'heatmap'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsHeatmap_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsHeatmap_yDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.dest', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsHeatmap_cellPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + expect(await PageObjects.lens.hasChartSwitchWarning('bar')).to.eql(false); + await PageObjects.lens.switchToVisualization('bar'); + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel')).to.eql( + '@timestamp' + ); + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql( + 'Average of bytes' + ); + }); + it('should create a valid XY chart with references', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens');