diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9b8fee1bd5612..c398316e634b9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -34,10 +34,12 @@ /src/plugins/vis_types/vislib/ @elastic/kibana-vis-editors /src/plugins/vis_types/xy/ @elastic/kibana-vis-editors /src/plugins/vis_types/pie/ @elastic/kibana-vis-editors +/src/plugins/vis_types/heatmap/ @elastic/kibana-vis-editors /src/plugins/visualize/ @elastic/kibana-vis-editors /src/plugins/visualizations/ @elastic/kibana-vis-editors /src/plugins/chart_expressions/expression_tagcloud/ @elastic/kibana-vis-editors /src/plugins/chart_expressions/expression_metric/ @elastic/kibana-vis-editors +/src/plugins/chart_expressions/expression_heatmap/ @elastic/kibana-vis-editors /src/plugins/url_forwarding/ @elastic/kibana-vis-editors /packages/kbn-tinymath/ @elastic/kibana-vis-editors /x-pack/test/functional/apps/lens @elastic/kibana-vis-editors diff --git a/.i18nrc.json b/.i18nrc.json index 80dbfee949a6c..9485f5b9b84e7 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -28,6 +28,7 @@ "expressionRepeatImage": "src/plugins/expression_repeat_image", "expressionRevealImage": "src/plugins/expression_reveal_image", "expressionShape": "src/plugins/expression_shape", + "expressionHeatmap": "src/plugins/chart_expressions/expression_heatmap", "expressionTagcloud": "src/plugins/chart_expressions/expression_tagcloud", "expressionMetricVis": "src/plugins/chart_expressions/expression_metric", "inputControl": "src/plugins/input_control_vis", @@ -69,6 +70,7 @@ "visTypeVislib": "src/plugins/vis_types/vislib", "visTypeXy": "src/plugins/vis_types/xy", "visTypePie": "src/plugins/vis_types/pie", + "visTypeHeatmap": "src/plugins/vis_types/heatmap", "visualizations": "src/plugins/visualizations", "visualize": "src/plugins/visualize", "apmOss": "src/plugins/apm_oss", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 2f0be0c39a3b8..e997c0bc68cde 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -94,6 +94,10 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |Expression Error plugin adds an error renderer to the expression plugin. The renderer will display the error image. +|{kib-repo}blob/{branch}/src/plugins/chart_expressions/expression_heatmap[expressionHeatmap] +|WARNING: Missing README. + + |{kib-repo}blob/{branch}/src/plugins/expression_image/README.md[expressionImage] |Expression Image plugin adds an image renderer to the expression plugin. The renderer will display the given image. @@ -274,6 +278,10 @@ It acts as a container for a particular visualization and options tabs. Contains The plugin exposes the static DefaultEditorController class to consume. +|{kib-repo}blob/{branch}/src/plugins/vis_types/heatmap[visTypeHeatmap] +|WARNING: Missing README. + + |{kib-repo}blob/{branch}/src/plugins/vis_type_markdown/README.md[visTypeMarkdown] |The markdown visualization that can be used to place text panels on dashboards. diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 581f13cbc2992..5906f2dd5008f 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -521,7 +521,10 @@ Enables the legacy time axis for charts in Lens, Discover, Visualize and TSVB The maximum number of buckets a datasource can return. High numbers can have a negative impact on your browser rendering performance. [[visualization-visualize-pieChartslibrary]]`visualization:visualize:legacyPieChartsLibrary`:: -The visualize editor uses new pie charts with improved performance, color palettes, label positioning, and more. Enable this option if you prefer to use to the legacy charts library. +The visualize editor uses new pie charts with improved performance, color palettes, label positioning, and more. Enable this option if you prefer to use the legacy charts library. + +[[visualization-visualize-heatmapChartslibrary]]`visualization:visualize:legacyHeatmapChartsLibrary`:: +Disable this option if you prefer to use the new heatmap charts with improved performance, legend settings, and more.. [[visualize-enablelabs]]`visualize:enableLabs`:: Enables users to create, view, and edit experimental visualizations. When disabled, diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index e08b34f70b2a1..41c4d3bdd1b35 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -96,6 +96,7 @@ pageLoadAssetSize: securitySolution: 273763 customIntegrations: 28810 expressionMetricVis: 23121 + expressionHeatmap: 27505 visTypeMetric: 23332 bfetch: 22837 kibanaUtils: 79713 @@ -115,3 +116,4 @@ pageLoadAssetSize: dataViewFieldEditor: 20000 dataViewManagement: 5000 reporting: 57003 + visTypeHeatmap: 25340 diff --git a/src/plugins/chart_expressions/expression_heatmap/common/constants.ts b/src/plugins/chart_expressions/expression_heatmap/common/constants.ts new file mode 100644 index 0000000000000..12939483e64fa --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/constants.ts @@ -0,0 +1,12 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const EXPRESSION_HEATMAP_NAME = 'heatmap'; +export const EXPRESSION_HEATMAP_LEGEND_NAME = 'heatmap_legend'; +export const EXPRESSION_HEATMAP_GRID_NAME = 'heatmap_grid'; +export const HEATMAP_FUNCTION_RENDERER_NAME = 'heatmap_renderer'; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap new file mode 100644 index 0000000000000..55b7ddfcaea53 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/__snapshots__/heatmap_function.test.ts.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#heatmap logs correct datatable to inspector 1`] = ` +Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "dimensionName": undefined, + "type": "number", + }, + "name": "Count", + }, + Object { + "id": "col-1-2", + "meta": Object { + "dimensionName": undefined, + "type": "string", + }, + "name": "Dest", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + }, + ], + "type": "datatable", +} +`; + +exports[`interpreter/functions#heatmap returns an object with the correct structure 1`] = ` +Object { + "as": "heatmap", + "type": "render", + "value": Object { + "args": Object { + "gridConfig": Object { + "isCellLabelVisible": true, + "isXAxisLabelVisible": true, + "isYAxisLabelVisible": true, + "type": "heatmap_grid", + }, + "highlightInHover": false, + "lastRangeIsRightOpen": true, + "legend": Object { + "isVisible": true, + "position": "top", + "type": "heatmap_legend", + }, + "palette": Object { + "name": "", + "params": Object { + "colors": Array [ + "rgb(0, 0, 0, 0)", + "rgb(112, 38, 231)", + ], + "gradient": false, + "range": "number", + "rangeMax": 150, + "rangeMin": 0, + "stops": Array [ + 0, + 10000, + ], + }, + "type": "palette", + }, + "percentageMode": false, + "showTooltip": true, + "splitColumnAccessor": undefined, + "splitRowAccessor": undefined, + "valueAccessor": "col-0-1", + "xAccessor": "col-1-2", + "yAccessor": undefined, + }, + "data": Object { + "columns": Array [ + Object { + "id": "col-0-1", + "meta": Object { + "type": "number", + }, + "name": "Count", + }, + Object { + "id": "col-1-2", + "meta": Object { + "type": "string", + }, + "name": "Dest", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + }, + ], + "type": "datatable", + }, + }, +} +`; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts new file mode 100644 index 0000000000000..0b0cdf565dc33 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts @@ -0,0 +1,77 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { heatmapFunction } from './heatmap_function'; +import type { HeatmapArguments } from '../../common'; +import { functionWrapper } from '../../../../expressions/common/expression_functions/specs/tests/utils'; +import { Datatable } from '../../../../expressions/common/expression_types/specs'; +import { EXPRESSION_HEATMAP_GRID_NAME, EXPRESSION_HEATMAP_LEGEND_NAME } from '../constants'; + +describe('interpreter/functions#heatmap', () => { + const fn = functionWrapper(heatmapFunction()); + const context: Datatable = { + type: 'datatable', + rows: [{ 'col-0-1': 0 }], + columns: [ + { id: 'col-0-1', name: 'Count', meta: { type: 'number' } }, + { id: 'col-1-2', name: 'Dest', meta: { type: 'string' } }, + ], + }; + const args: HeatmapArguments = { + percentageMode: false, + legend: { + isVisible: true, + position: 'top', + type: EXPRESSION_HEATMAP_LEGEND_NAME, + }, + gridConfig: { + isCellLabelVisible: true, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + type: EXPRESSION_HEATMAP_GRID_NAME, + }, + palette: { + type: 'palette', + name: '', + params: { + colors: ['rgb(0, 0, 0, 0)', 'rgb(112, 38, 231)'], + stops: [0, 10000], + gradient: false, + rangeMin: 0, + rangeMax: 150, + range: 'number', + }, + }, + showTooltip: true, + highlightInHover: false, + xAccessor: 'col-1-2', + valueAccessor: 'col-0-1', + }; + + it('returns an object with the correct structure', () => { + const actual = fn(context, args, undefined); + + expect(actual).toMatchSnapshot(); + }); + + it('logs correct datatable to inspector', async () => { + let loggedTable: Datatable; + const handlers = { + inspectorAdapters: { + tables: { + logDatatable: (name: string, datatable: Datatable) => { + loggedTable = datatable; + }, + }, + }, + }; + await fn(context, args, handlers as any); + + expect(loggedTable!).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts new file mode 100644 index 0000000000000..6ebec4b118c76 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts @@ -0,0 +1,204 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import type { DatatableColumn } from '../../../../expressions/public'; +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; +import { prepareLogTable, Dimension } from '../../../../visualizations/common/prepare_log_table'; +import { HeatmapExpressionFunctionDefinition } from '../types'; +import { + EXPRESSION_HEATMAP_NAME, + EXPRESSION_HEATMAP_GRID_NAME, + EXPRESSION_HEATMAP_LEGEND_NAME, +} from '../constants'; + +const convertToVisDimension = (columns: DatatableColumn[], accessor: string) => { + const column = columns.find((c) => c.id === accessor); + if (!column) return; + return { + accessor: Number(column.id), + format: { + id: column.meta.params?.id, + params: { ...column.meta.params?.params }, + }, + type: 'vis_dimension', + } as ExpressionValueVisDimension; +}; + +const prepareHeatmapLogTable = ( + columns: DatatableColumn[], + accessor: string | ExpressionValueVisDimension, + table: Dimension[], + label: string +) => { + const dimension = + typeof accessor === 'string' ? convertToVisDimension(columns, accessor) : accessor; + if (dimension) { + table.push([[dimension], label]); + } +}; + +export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({ + name: EXPRESSION_HEATMAP_NAME, + type: 'render', + inputTypes: ['datatable'], + help: i18n.translate('expressionHeatmap.function.help', { + defaultMessage: 'Heatmap visualization', + }), + args: { + // used only in legacy heatmap, consider it as @deprecated + percentageMode: { + types: ['boolean'], + default: false, + help: i18n.translate('expressionHeatmap.function.percentageMode.help', { + defaultMessage: 'When is on, tooltip and legends appear as percentages.', + }), + }, + palette: { + types: ['palette'], + help: i18n.translate('expressionHeatmap.function.palette.help', { + defaultMessage: 'Provides colors for the values, based on the bounds.', + }), + }, + legend: { + types: [EXPRESSION_HEATMAP_LEGEND_NAME], + help: i18n.translate('expressionHeatmap.function.legendConfig.help', { + defaultMessage: 'Configure the chart legend.', + }), + }, + gridConfig: { + types: [EXPRESSION_HEATMAP_GRID_NAME], + help: i18n.translate('expressionHeatmap.function.gridConfig.help', { + defaultMessage: 'Configure the heatmap layout.', + }), + }, + showTooltip: { + types: ['boolean'], + help: i18n.translate('expressionHeatmap.function.args.addTooltipHelpText', { + defaultMessage: 'Show tooltip on hover', + }), + default: true, + }, + // not supported yet + highlightInHover: { + types: ['boolean'], + help: i18n.translate('expressionHeatmap.function.args.highlightInHoverHelpText', { + defaultMessage: + 'When this is enabled, it highlights the ranges of the same color on legend hover', + }), + }, + lastRangeIsRightOpen: { + types: ['boolean'], + help: i18n.translate('expressionHeatmap.function.args.lastRangeIsRightOpen', { + defaultMessage: 'If is set to true, the last range value will be right open', + }), + default: true, + }, + xAccessor: { + types: ['string', 'vis_dimension'], + help: i18n.translate('expressionHeatmap.function.args.xAccessorHelpText', { + defaultMessage: 'The id of the x axis column or the corresponding dimension', + }), + }, + yAccessor: { + types: ['string', 'vis_dimension'], + + help: i18n.translate('expressionHeatmap.function.args.yAccessorHelpText', { + defaultMessage: 'The id of the y axis column or the corresponding dimension', + }), + }, + valueAccessor: { + types: ['string', 'vis_dimension'], + + help: i18n.translate('expressionHeatmap.function.args.valueAccessorHelpText', { + defaultMessage: 'The id of the value column or the corresponding dimension', + }), + }, + // not supported yet, small multiples accessor + splitRowAccessor: { + types: ['string', 'vis_dimension'], + + help: i18n.translate('expressionHeatmap.function.args.splitRowAccessorHelpText', { + defaultMessage: 'The id of the split row or the corresponding dimension', + }), + }, + // not supported yet, small multiples accessor + splitColumnAccessor: { + types: ['string', 'vis_dimension'], + + help: i18n.translate('expressionHeatmap.function.args.splitColumnAccessorHelpText', { + defaultMessage: 'The id of the split column or the corresponding dimension', + }), + }, + }, + fn(data, args, handlers) { + if (handlers?.inspectorAdapters?.tables) { + const argsTable: Dimension[] = []; + if (args.valueAccessor) { + prepareHeatmapLogTable( + data.columns, + args.valueAccessor, + argsTable, + i18n.translate('expressionHeatmap.function.dimension.metric', { + defaultMessage: 'Metric', + }) + ); + } + if (args.yAccessor) { + prepareHeatmapLogTable( + data.columns, + args.yAccessor, + argsTable, + i18n.translate('expressionHeatmap.function.dimension.yaxis', { + defaultMessage: 'Y axis', + }) + ); + } + if (args.xAccessor) { + prepareHeatmapLogTable( + data.columns, + args.xAccessor, + argsTable, + i18n.translate('expressionHeatmap.function.dimension.xaxis', { + defaultMessage: 'X axis', + }) + ); + } + if (args.splitRowAccessor) { + prepareHeatmapLogTable( + data.columns, + args.splitRowAccessor, + argsTable, + i18n.translate('expressionHeatmap.function.dimension.splitRow', { + defaultMessage: 'Split by row', + }) + ); + } + if (args.splitColumnAccessor) { + prepareHeatmapLogTable( + data.columns, + args.splitColumnAccessor, + argsTable, + i18n.translate('expressionHeatmap.function.dimension.splitColumn', { + defaultMessage: 'Split by column', + }) + ); + } + const logTable = prepareLogTable(data, argsTable); + handlers.inspectorAdapters.tables.logDatatable('default', logTable); + } + return { + type: 'render', + as: EXPRESSION_HEATMAP_NAME, + value: { + data, + args, + }, + }; + }, +}); diff --git a/x-pack/plugins/lens/common/expressions/heatmap_chart/heatmap_grid.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_grid.ts similarity index 54% rename from x-pack/plugins/lens/common/expressions/heatmap_chart/heatmap_grid.ts rename to src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_grid.ts index 5fe7f4b8f6c62..17513555d394d 100644 --- a/x-pack/plugins/lens/common/expressions/heatmap_chart/heatmap_grid.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_grid.ts @@ -1,70 +1,53 @@ /* * 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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import { i18n } from '@kbn/i18n'; -import type { ExpressionFunctionDefinition } from '../../../../../../src/plugins/expressions/common'; - -export const HEATMAP_GRID_FUNCTION = 'lens_heatmap_grid'; - -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 }; +import type { ExpressionFunctionDefinition } from '../../../../expressions/common'; +import { EXPRESSION_HEATMAP_GRID_NAME } from '../constants'; +import { HeatmapGridConfig, HeatmapGridConfigResult } from '../types'; export const heatmapGridConfig: ExpressionFunctionDefinition< - typeof HEATMAP_GRID_FUNCTION, + typeof EXPRESSION_HEATMAP_GRID_NAME, null, HeatmapGridConfig, HeatmapGridConfigResult > = { - name: HEATMAP_GRID_FUNCTION, + name: EXPRESSION_HEATMAP_GRID_NAME, aliases: [], - type: HEATMAP_GRID_FUNCTION, + type: EXPRESSION_HEATMAP_GRID_NAME, help: `Configure the heatmap layout `, inputTypes: ['null'], args: { // grid strokeWidth: { types: ['number'], - help: i18n.translate('xpack.lens.heatmapChart.config.strokeWidth.help', { + help: i18n.translate('expressionHeatmap.function.args.grid.strokeWidth.help', { defaultMessage: 'Specifies the grid stroke width', }), required: false, }, strokeColor: { types: ['string'], - help: i18n.translate('xpack.lens.heatmapChart.config.strokeColor.help', { + help: i18n.translate('expressionHeatmap.function.args.grid.strokeColor.help', { defaultMessage: 'Specifies the grid stroke color', }), required: false, }, cellHeight: { types: ['number'], - help: i18n.translate('xpack.lens.heatmapChart.config.cellHeight.help', { + help: i18n.translate('expressionHeatmap.function.args.grid.cellHeight.help', { defaultMessage: 'Specifies the grid cell height', }), required: false, }, cellWidth: { types: ['number'], - help: i18n.translate('xpack.lens.heatmapChart.config.cellWidth.help', { + help: i18n.translate('expressionHeatmap.function.args.grid.cellWidth.help', { defaultMessage: 'Specifies the grid cell width', }), required: false, @@ -72,27 +55,27 @@ export const heatmapGridConfig: ExpressionFunctionDefinition< // cells isCellLabelVisible: { types: ['boolean'], - help: i18n.translate('xpack.lens.heatmapChart.config.isCellLabelVisible.help', { + help: i18n.translate('expressionHeatmap.function.args.grid.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', { + help: i18n.translate('expressionHeatmap.function.args.grid.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', { + help: i18n.translate('expressionHeatmap.function.args.grid.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', { + help: i18n.translate('expressionHeatmap.function.args.grid.yAxisLabelColor.help', { defaultMessage: 'Specifies the color of the Y-axis labels.', }), required: false, @@ -100,14 +83,14 @@ export const heatmapGridConfig: ExpressionFunctionDefinition< // X-axis isXAxisLabelVisible: { types: ['boolean'], - help: i18n.translate('xpack.lens.heatmapChart.config.isXAxisLabelVisible.help', { + help: i18n.translate('expressionHeatmap.function.args.grid.isXAxisLabelVisible.help', { defaultMessage: 'Specifies whether or not the X-axis labels are visible.', }), }, }, fn(input, args) { return { - type: HEATMAP_GRID_FUNCTION, + type: EXPRESSION_HEATMAP_GRID_NAME, ...args, }; }, diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts new file mode 100644 index 0000000000000..efbc251f6360b --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts @@ -0,0 +1,59 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { Position } from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import type { ExpressionFunctionDefinition } from '../../../../expressions/common'; +import { EXPRESSION_HEATMAP_LEGEND_NAME } from '../constants'; +import { HeatmapLegendConfig, HeatmapLegendConfigResult } from '../types'; + +export const heatmapLegendConfig: ExpressionFunctionDefinition< + typeof EXPRESSION_HEATMAP_LEGEND_NAME, + null, + HeatmapLegendConfig, + HeatmapLegendConfigResult +> = { + name: EXPRESSION_HEATMAP_LEGEND_NAME, + aliases: [], + type: EXPRESSION_HEATMAP_LEGEND_NAME, + help: `Configure the heatmap chart's legend`, + inputTypes: ['null'], + args: { + isVisible: { + types: ['boolean'], + help: i18n.translate('expressionHeatmap.function.args.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('expressionHeatmap.function.args.legend.position.help', { + defaultMessage: 'Specifies the legend position.', + }), + }, + maxLines: { + types: ['number'], + help: i18n.translate('expressionHeatmap.function.args.legend.maxLines.help', { + defaultMessage: 'Specifies the number of lines per legend item.', + }), + }, + shouldTruncate: { + types: ['boolean'], + default: true, + help: i18n.translate('expressionHeatmap.function.args.legend.shouldTruncate.help', { + defaultMessage: 'Specifies whether or not the legend items should be truncated.', + }), + }, + }, + fn(input, args) { + return { + type: EXPRESSION_HEATMAP_LEGEND_NAME, + ...args, + }; + }, +}; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/index.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/index.ts new file mode 100644 index 0000000000000..f926de038a7e7 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/index.ts @@ -0,0 +1,11 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { heatmapFunction } from './heatmap_function'; +export { heatmapLegendConfig } from './heatmap_legend'; +export { heatmapGridConfig } from './heatmap_grid'; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/index.ts b/src/plugins/chart_expressions/expression_heatmap/common/index.ts new file mode 100755 index 0000000000000..56bafc2a0d612 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/index.ts @@ -0,0 +1,28 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const PLUGIN_ID = 'expressionHeatmap'; +export const PLUGIN_NAME = 'expressionHeatmap'; + +export type { + HeatmapExpressionProps, + FilterEvent, + BrushEvent, + FormatFactory, + HeatmapRenderProps, + CustomPaletteParams, + ColorStop, + RequiredPaletteParamTypes, + HeatmapLegendConfigResult, + HeatmapGridConfigResult, + HeatmapArguments, +} from './types'; + +export { heatmapFunction, heatmapLegendConfig, heatmapGridConfig } from './expression_functions'; + +export { EXPRESSION_HEATMAP_NAME } from './constants'; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts new file mode 100644 index 0000000000000..a983da669c56d --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts @@ -0,0 +1,100 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { Position } from '@elastic/charts'; +import { + Datatable, + ExpressionFunctionDefinition, + ExpressionValueRender, +} from '../../../../expressions'; +import { ExpressionValueVisDimension } from '../../../../visualizations/common'; + +import { CustomPaletteState, PaletteOutput } from '../../../../charts/common'; +import { + EXPRESSION_HEATMAP_NAME, + EXPRESSION_HEATMAP_LEGEND_NAME, + EXPRESSION_HEATMAP_GRID_NAME, + HEATMAP_FUNCTION_RENDERER_NAME, +} from '../constants'; + +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; + /** + * Defines the number of lines per legend item + */ + maxLines?: number; + /** + * Defines if the legend items should be truncated + */ + shouldTruncate?: boolean; +} + +export type HeatmapLegendConfigResult = HeatmapLegendConfig & { + type: typeof EXPRESSION_HEATMAP_LEGEND_NAME; +}; + +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 EXPRESSION_HEATMAP_GRID_NAME; +}; + +export interface HeatmapArguments { + percentageMode?: boolean; + lastRangeIsRightOpen?: boolean; + showTooltip?: boolean; + highlightInHover?: boolean; + palette?: PaletteOutput; + xAccessor?: string | ExpressionValueVisDimension; + yAccessor?: string | ExpressionValueVisDimension; + valueAccessor?: string | ExpressionValueVisDimension; + splitRowAccessor?: string | ExpressionValueVisDimension; + splitColumnAccessor?: string | ExpressionValueVisDimension; + legend: HeatmapLegendConfigResult; + gridConfig: HeatmapGridConfigResult; +} + +export type HeatmapInput = Datatable; + +export interface HeatmapExpressionProps { + data: Datatable; + args: HeatmapArguments; +} + +export interface HeatmapRender { + type: 'render'; + as: typeof HEATMAP_FUNCTION_RENDERER_NAME; + value: HeatmapExpressionProps; +} + +export type HeatmapExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof EXPRESSION_HEATMAP_NAME, + HeatmapInput, + HeatmapArguments, + ExpressionValueRender +>; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_renderers.ts new file mode 100644 index 0000000000000..1498c04ca1b79 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_renderers.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ChartsPluginSetup, PaletteRegistry } from '../../../../charts/public'; +import type { IFieldFormat, SerializedFieldFormat } from '../../../../field_formats/common'; +import type { RangeSelectContext, ValueClickContext } from '../../../../embeddable/public'; +import type { PersistedState } from '../../../../visualizations/public'; +import type { HeatmapExpressionProps } from './expression_functions'; + +export interface FilterEvent { + name: 'filter'; + data: ValueClickContext['data']; +} + +export interface BrushEvent { + name: 'brush'; + data: RangeSelectContext['data']; +} + +export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; + +export type HeatmapRenderProps = HeatmapExpressionProps & { + timeZone?: string; + formatFactory: FormatFactory; + chartsThemeService: ChartsPluginSetup['theme']; + onClickValue: (data: FilterEvent['data']) => void; + onSelectRange: (data: BrushEvent['data']) => void; + paletteService: PaletteRegistry; + uiState: PersistedState; +}; + +export interface ColorStop { + color: string; + stop: number; +} + +export interface CustomPaletteParams { + name?: string; + reverse?: boolean; + rangeType?: 'number' | 'percent'; + continuity?: 'above' | 'below' | 'all' | 'none'; + progression?: 'fixed'; + rangeMin?: number; + rangeMax?: number; + stops?: ColorStop[]; + colorStops?: ColorStop[]; + steps?: number; +} + +export type RequiredPaletteParamTypes = Required; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/types/index.ts b/src/plugins/chart_expressions/expression_heatmap/common/types/index.ts new file mode 100644 index 0000000000000..9c50bfab1305d --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/common/types/index.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './expression_functions'; +export * from './expression_renderers'; diff --git a/src/plugins/chart_expressions/expression_heatmap/jest.config.js b/src/plugins/chart_expressions/expression_heatmap/jest.config.js new file mode 100644 index 0000000000000..319364343ee09 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/jest.config.js @@ -0,0 +1,19 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../', + roots: ['/src/plugins/chart_expressions/expression_heatmap'], + coverageDirectory: + '/target/kibana-coverage/jest/src/plugins/chart_expressions/expression_heatmap', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/chart_expressions/expression_heatmap/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/chart_expressions/expression_heatmap/kibana.json b/src/plugins/chart_expressions/expression_heatmap/kibana.json new file mode 100755 index 0000000000000..604919ec54592 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "expressionHeatmap", + "version": "1.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + }, + "description": "Expression Heatmap plugin adds a `heatmap` renderer and function to the expression plugin. The renderer will display the `heatmap` chart.", + "server": true, + "ui": true, + "requiredPlugins": ["expressions", "fieldFormats", "charts", "visualizations", "presentationUtil", "data"], + "requiredBundles": ["kibanaUtils", "kibanaReact"], + "optionalPlugins": [], + "extraPublicDirs": ["common"] +} diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx new file mode 100644 index 0000000000000..b725f9eed3555 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx @@ -0,0 +1,246 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Settings, TooltipType, Heatmap } from '@elastic/charts'; +import { chartPluginMock } from '../../../../charts/public/mocks'; +import { EmptyPlaceholder } from '../../../../charts/public'; +import { fieldFormatsServiceMock } from '../../../../field_formats/public/mocks'; +import type { Datatable } from '../../../../expressions/public'; +import { mountWithIntl, shallowWithIntl } from '@kbn/test/jest'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from 'react-dom/test-utils'; +import { HeatmapRenderProps, HeatmapArguments } from '../../common'; +import HeatmapComponent from './heatmap_component'; + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + + return { + ...original, + getSpecId: jest.fn(() => {}), + }; +}); + +const chartsThemeService = chartPluginMock.createSetupContract().theme; +const palettesRegistry = chartPluginMock.createPaletteRegistry(); +const formatService = fieldFormatsServiceMock.createStartContract(); +const args: HeatmapArguments = { + percentageMode: false, + legend: { + isVisible: true, + position: 'top', + type: 'heatmap_legend', + }, + gridConfig: { + isCellLabelVisible: true, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + type: 'heatmap_grid', + }, + palette: { + type: 'palette', + name: '', + params: { + colors: ['rgb(0, 0, 0)', 'rgb(112, 38, 231)'], + stops: [0, 150], + gradient: false, + rangeMin: 0, + rangeMax: 150, + range: 'number', + }, + }, + showTooltip: true, + highlightInHover: false, + xAccessor: 'col-1-2', + valueAccessor: 'col-0-1', +}; +const data: Datatable = { + type: 'datatable', + rows: [ + { 'col-0-1': 0, 'col-1-2': 'a' }, + { 'col-0-1': 148, 'col-1-2': 'b' }, + ], + columns: [ + { id: 'col-0-1', name: 'Count', meta: { type: 'number' } }, + { id: 'col-1-2', name: 'Dest', meta: { type: 'string' } }, + ], +}; + +const mockState = new Map(); +const uiState = { + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + setSilent: jest.fn(), +} as any; + +describe('HeatmapComponent', function () { + let wrapperProps: HeatmapRenderProps; + + beforeAll(() => { + wrapperProps = { + data, + chartsThemeService, + args, + uiState, + onClickValue: jest.fn(), + onSelectRange: jest.fn(), + paletteService: palettesRegistry, + formatFactory: formatService.deserialize, + }; + }); + + it('renders the legend on the correct position', () => { + const component = shallowWithIntl(); + expect(component.find(Settings).prop('legendPosition')).toEqual('top'); + }); + + it('renders the legend toggle component if uiState is set', async () => { + const component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'vislibToggleLegend').length).toBe(1); + }); + }); + + it('not renders the legend toggle component if uiState is undefined', async () => { + const newProps = { ...wrapperProps, uiState: undefined } as unknown as HeatmapRenderProps; + const component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'vislibToggleLegend').length).toBe(0); + }); + }); + + it('renders the legendColorPicker if uiState is set', async () => { + const component = mountWithIntl(); + await act(async () => { + expect(component.find(Settings).prop('legendColorPicker')).toBeDefined(); + }); + }); + + it('not renders the legendColorPicker if uiState is undefined', async () => { + const newProps = { ...wrapperProps, uiState: undefined } as unknown as HeatmapRenderProps; + const component = mountWithIntl(); + await act(async () => { + expect(component.find(Settings).prop('legendColorPicker')).toBeUndefined(); + }); + }); + + it('computes the bands correctly for infinite bounds', async () => { + const component = mountWithIntl(); + await act(async () => { + expect(component.find(Heatmap).prop('colorScale')).toEqual({ + bands: [ + { color: 'rgb(0, 0, 0)', end: 0, start: 0 }, + { color: 'rgb(112, 38, 231)', end: Infinity, start: 0 }, + ], + type: 'bands', + }); + }); + }); + + it('computes the bands correctly for distinct bounds', async () => { + const newProps = { + ...wrapperProps, + args: { ...wrapperProps.args, lastRangeIsRightOpen: false }, + } as unknown as HeatmapRenderProps; + const component = mountWithIntl(); + await act(async () => { + expect(component.find(Heatmap).prop('colorScale')).toEqual({ + bands: [ + { color: 'rgb(0, 0, 0)', end: 0, start: 0 }, + { color: 'rgb(112, 38, 231)', end: 150, start: 0 }, + ], + type: 'bands', + }); + }); + }); + + it('hides the legend if the legend isVisible args is false', async () => { + const newProps = { + ...wrapperProps, + args: { ...wrapperProps.args, legend: { ...wrapperProps.args.legend, isVisible: false } }, + } as unknown as HeatmapRenderProps; + const component = mountWithIntl(); + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + + it('defaults on displaying the tooltip', () => { + const component = shallowWithIntl(); + expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.Follow }); + }); + + it('hides the legend if the showTooltip is false', async () => { + const newProps = { + ...wrapperProps, + args: { ...wrapperProps.args, showTooltip: false }, + } as unknown as HeatmapRenderProps; + const component = mountWithIntl(); + expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.None }); + }); + + it('not renders the component if no value accessor is given', () => { + const newProps = { ...wrapperProps, valueAccessor: undefined } as unknown as HeatmapRenderProps; + const component = mountWithIntl(); + expect(component).toEqual({}); + }); + + it('renders the EmptyPlaceholder if no data are provided', () => { + const newData: Datatable = { + type: 'datatable', + rows: [], + columns: [ + { id: 'col-0-1', name: 'Count', meta: { type: 'number' } }, + { id: 'col-1-2', name: 'Dest', meta: { type: 'string' } }, + ], + }; + const newProps = { ...wrapperProps, data: newData }; + const component = mountWithIntl(); + expect(component.find(EmptyPlaceholder).length).toBe(1); + }); + + it('calls filter callback', () => { + const component = shallowWithIntl(); + component.find(Settings).first().prop('onElementClick')!([ + [ + { + x: 436.68671874999995, + y: 1, + yIndex: 0, + width: 143.22890625, + height: 198.5, + datum: { + x: 'Vienna International Airport', + y: 'ES-Air', + value: 6, + originalIndex: 12, + }, + fill: { + color: [78, 175, 98, 1], + }, + stroke: { + color: [128, 128, 128, 1], + width: 0, + }, + value: 6, + visible: true, + formatted: '6', + fontSize: 18, + textColor: 'rgba(0, 0, 0, 1)', + }, + { + specId: 'heatmap', + key: 'spec{heatmap}', + }, + ], + ]); + expect(wrapperProps.onClickValue).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx similarity index 51% rename from x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx rename to src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index 4300208109b76..a53cb6359c800 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -1,11 +1,11 @@ /* * 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. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ - -import React, { FC, useMemo } from 'react'; +import React, { FC, useMemo, useState, useCallback } from 'react'; import { Chart, ElementClickListener, @@ -16,23 +16,21 @@ import { HeatmapSpec, ScaleType, Settings, + TooltipType, + TooltipProps, ESFixedIntervalUnit, ESCalendarIntervalUnit, } from '@elastic/charts'; -import type { CustomPaletteState } from 'src/plugins/charts/public'; -import { VisualizationContainer } from '../visualization_container'; -import type { HeatmapRenderProps } from './types'; -import './index.scss'; -import type { LensBrushEvent, LensFilterEvent } from '../types'; -import { - applyPaletteParams, - defaultPaletteParams, - EmptyPlaceholder, - findMinMaxByColumnId, -} from '../shared_components'; -import { LensIconChartHeatmap } from '../assets/chart_heatmap'; -import { DEFAULT_PALETTE_NAME } from './constants'; -import { search } from '../../../../../src/plugins/data/public'; +import type { CustomPaletteState } from '../../../../charts/public'; +import { search } from '../../../../data/public'; +import { LegendToggle, EmptyPlaceholder } from '../../../../charts/public'; +import type { DatatableColumn } from '../../../../expressions/public'; +import { ExpressionValueVisDimension } from '../../../../visualizations/public'; +import type { HeatmapRenderProps, FilterEvent, BrushEvent } from '../../common'; +import { applyPaletteParams, findMinMaxByColumnId, getSortPredicate } from './helpers'; +import { getColorPicker } from '../utils/get_color_picker'; +import { DEFAULT_PALETTE_NAME, defaultPaletteParams } from '../constants'; +import { HeatmapIcon } from './heatmap_icon'; declare global { interface Window { @@ -113,7 +111,18 @@ function computeColorRanges( return { colors, ranges }; } -export const HeatmapComponent: FC = ({ +const getAccessor = (value: string | ExpressionValueVisDimension, columns: DatatableColumn[]) => { + if (typeof value === 'string') { + return value; + } + const accessor = value.accessor; + if (typeof accessor === 'number') { + return columns[accessor].id; + } + return accessor.id; +}; + +const HeatmapComponent: FC = ({ data, args, timeZone, @@ -122,33 +131,73 @@ export const HeatmapComponent: FC = ({ onClickValue, onSelectRange, paletteService, + uiState, }) => { const chartTheme = chartsThemeService.useChartsTheme(); const isDarkTheme = chartsThemeService.useDarkMode(); + // legacy heatmap legend is handled by the uiState + const [showLegend, setShowLegend] = useState(() => { + const bwcLegendStateDefault = args.legend.isVisible ?? true; + return uiState?.get('vis.legendOpen', bwcLegendStateDefault); + }); + + const toggleLegend = useCallback(() => { + setShowLegend((value) => { + const newValue = !value; + uiState?.set?.('vis.legendOpen', newValue); + return newValue; + }); + }, [uiState]); + + const setColor = useCallback( + (newColor: string | null, seriesLabel: string | number) => { + const colors = uiState?.get('vis.colors') || {}; + if (colors[seriesLabel] === newColor || !newColor) { + delete colors[seriesLabel]; + } else { + colors[seriesLabel] = newColor; + } + uiState?.setSilent('vis.colors', null); + uiState?.set('vis.colors', colors); + uiState?.emit('reload'); + uiState?.emit('colorChanged'); + }, + [uiState] + ); - const tableId = Object.keys(data.tables)[0]; - const table = data.tables[tableId]; + const legendColorPicker = useMemo( + () => getColorPicker(args.legend.position, setColor, uiState), + [args.legend.position, setColor, uiState] + ); + const table = data; + const valueAccessor = args.valueAccessor + ? getAccessor(args.valueAccessor, table.columns) + : undefined; + const minMaxByColumnId = useMemo( + () => findMinMaxByColumnId([valueAccessor!], table), + [valueAccessor, table] + ); const paletteParams = args.palette?.params; + const xAccessor = args.xAccessor ? getAccessor(args.xAccessor, table.columns) : undefined; + const yAccessor = args.yAccessor ? getAccessor(args.yAccessor, table.columns) : undefined; - const xAxisColumnIndex = table.columns.findIndex((v) => v.id === args.xAccessor); - const yAxisColumnIndex = table.columns.findIndex((v) => v.id === args.yAccessor); + const xAxisColumnIndex = table.columns.findIndex((v) => v.id === xAccessor); + const yAxisColumnIndex = table.columns.findIndex((v) => v.id === yAccessor); const xAxisColumn = table.columns[xAxisColumnIndex]; const yAxisColumn = table.columns[yAxisColumnIndex]; - const valueColumn = table.columns.find((v) => v.id === args.valueAccessor); + const valueColumn = table.columns.find((v) => v.id === valueAccessor); - const minMaxByColumnId = useMemo( - () => findMinMaxByColumnId([args.valueAccessor!], table), - [args.valueAccessor, table] - ); - - if (!xAxisColumn || !valueColumn) { + if (!valueColumn) { // Chart is not ready return null; } - let chartData = table.rows.filter((v) => typeof v[args.valueAccessor!] === 'number'); + let chartData = table.rows.filter((v) => typeof v[valueAccessor!] === 'number'); + if (!chartData || !chartData.length) { + return ; + } if (!yAxisColumn) { // required for tooltip @@ -159,17 +208,23 @@ export const HeatmapComponent: FC = ({ }; }); } - - const xAxisMeta = xAxisColumn.meta; - const isTimeBasedSwimLane = xAxisMeta.type === 'date'; + const { min, max } = minMaxByColumnId[valueAccessor!]; + // formatters + const xAxisMeta = xAxisColumn?.meta; + const xValuesFormatter = formatFactory(xAxisMeta?.params); + const metricFormatter = formatFactory( + typeof args.valueAccessor === 'string' ? valueColumn.meta.params : args?.valueAccessor?.format + ); + const isTimeBasedSwimLane = xAxisMeta?.type === 'date'; + const dateHistogramMeta = xAxisColumn + ? search.aggs.getDateHistogramMetaDataByDatatableColumn(xAxisColumn) + : undefined; // Fallback to the ordinal scale type when a single row of data is provided. // Related issue https://github.com/elastic/elastic-charts/issues/1184 - let xScale: HeatmapSpec['xScale'] = { type: ScaleType.Ordinal }; if (isTimeBasedSwimLane && chartData.length > 1) { - const dateInterval = - search.aggs.getDateHistogramMetaDataByDatatableColumn(xAxisColumn)?.interval; + const dateInterval = dateHistogramMeta?.interval; const esInterval = dateInterval ? search.aggs.parseEsInterval(dateInterval) : undefined; if (esInterval) { xScale = { @@ -190,24 +245,48 @@ export const HeatmapComponent: FC = ({ } } - const xValuesFormatter = formatFactory(xAxisMeta.params); - const valueFormatter = formatFactory(valueColumn.meta.params); + const tooltip: TooltipProps = { + type: args.showTooltip ? TooltipType.Follow : TooltipType.None, + }; + + const valueFormatter = (d: number) => { + let value = d; + + if (args.percentageMode) { + const percentageNumber = (Math.abs(value - min) / (max - min)) * 100; + value = parseInt(percentageNumber.toString(), 10) / 100; + } + return metricFormatter.convert(value); + }; const { colors, ranges } = computeColorRanges( paletteService, paletteParams, isDarkTheme ? '#000' : '#fff', - minMaxByColumnId[args.valueAccessor!] + minMaxByColumnId[valueAccessor!] ); + // adds a very small number to the max value to make sure the max value will be included + const endValue = + paletteParams && paletteParams.range === 'number' ? paletteParams.rangeMax : max + 0.00000001; + const overwriteColors = uiState?.get('vis.colors') ?? null; + const bands = ranges.map((start, index, array) => { + // by default the last range is right-open + let end = index === array.length - 1 ? Infinity : array[index + 1]; + // if the lastRangeIsRightOpen is set to false, we need to set the last range to the max value + if (args.lastRangeIsRightOpen === false) { + const lastBand = max === start ? Infinity : endValue; + end = index === array.length - 1 ? lastBand : array[index + 1]; + } + const overwriteArrayIdx = `${metricFormatter.convert(start)} - ${metricFormatter.convert(end)}`; + const overwriteColor = overwriteColors?.[overwriteArrayIdx]; return { // with the default continuity:above the every range is left-closed start, - // with the default continuity:above the last range is right-open - end: index === array.length - 1 ? Infinity : array[index + 1], + end, // the current colors array contains a duplicated color at the beginning that we need to skip - color: colors[index + 1], + color: overwriteColor ?? colors[index + 1], }; }); @@ -215,7 +294,7 @@ export const HeatmapComponent: FC = ({ const cell = e[0][0]; const { x, y } = cell.datum; - const xAxisFieldName = xAxisColumn.meta.field; + const xAxisFieldName = xAxisColumn?.meta?.field; const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; const points = [ @@ -235,7 +314,7 @@ export const HeatmapComponent: FC = ({ : []), ]; - const context: LensFilterEvent['data'] = { + const context: FilterEvent['data'] = { data: points.map((point) => ({ row: point.row, column: point.column, @@ -250,11 +329,11 @@ export const HeatmapComponent: FC = ({ const onBrushEnd = (e: HeatmapBrushEvent) => { const { x, y } = e; - const xAxisFieldName = xAxisColumn.meta.field; + const xAxisFieldName = xAxisColumn?.meta?.field; const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : ''; if (isTimeBasedSwimLane) { - const context: LensBrushEvent['data'] = { + const context: BrushEvent['data'] = { range: x as number[], table, column: xAxisColumnIndex, @@ -273,16 +352,17 @@ export const HeatmapComponent: FC = ({ }); }); } - - (x as string[]).forEach((v) => { - points.push({ - row: table.rows.findIndex((r) => r[xAxisColumn.id] === v), - column: xAxisColumnIndex, - value: v, + if (xAxisColumn) { + (x as string[]).forEach((v) => { + points.push({ + row: table.rows.findIndex((r) => r[xAxisColumn.id] === v), + column: xAxisColumnIndex, + value: v, + }); }); - }); + } - const context: LensFilterEvent['data'] = { + const context: FilterEvent['data'] = { data: points.map((point) => ({ row: point.row, column: point.column, @@ -334,11 +414,12 @@ export const HeatmapComponent: FC = ({ : {}), }, xAxisLabel: { - visible: args.gridConfig.isXAxisLabelVisible, + visible: Boolean(args.gridConfig.isXAxisLabelVisible && xAxisColumn), // eui color subdued textColor: chartTheme.axes?.tickLabel?.fill ?? `#6a717d`, + padding: xAxisColumn?.name ? 8 : 0, formatter: (v: number | string) => xValuesFormatter.convert(v), - name: xAxisColumn.name, + name: xAxisColumn?.name ?? '', }, brushMask: { fill: isDarkTheme ? 'rgb(30,31,35,80%)' : 'rgb(247,247,247,50%)', @@ -349,56 +430,65 @@ export const HeatmapComponent: FC = ({ timeZone, }; - if (!chartData || !chartData.length) { - return ; - } - return ( - - - valueFormatter.convert(v)} - xScale={xScale} - ySortPredicate="dataIndex" - config={config} - xSortPredicate="dataIndex" - /> - + <> + {showLegend !== undefined && ( + + )} + + + + + ); }; -const MemoizedChart = React.memo(HeatmapComponent); - -export function HeatmapChartReportable(props: HeatmapRenderProps) { - return ( - - - - ); -} +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { HeatmapComponent as default }; diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_icon.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_icon.tsx new file mode 100644 index 0000000000000..e6ff3a65e02bb --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_icon.tsx @@ -0,0 +1,32 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiIconProps } from '@elastic/eui'; +import React from 'react'; + +export const HeatmapIcon = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + + +); diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/helpers.test.ts b/src/plugins/chart_expressions/expression_heatmap/public/components/helpers.test.ts new file mode 100644 index 0000000000000..7e9ccee19aa11 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/helpers.test.ts @@ -0,0 +1,433 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import type { DatatableColumn } from 'src/plugins/expressions/public'; +import { + applyPaletteParams, + getDataMinMax, + getPaletteStops, + getStepValue, + remapStopsByNewInterval, + reversePalette, + getSortPredicate, +} from './helpers'; + +describe('applyPaletteParams', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + it('should return a palette stops array only by the name', () => { + expect( + applyPaletteParams( + paletteRegistry, + { name: 'default', type: 'palette', params: { name: 'default' } }, + { min: 0, max: 100 } + ) + ).toEqual([ + // stops are 0 and 50 by with a 20 offset (100 divided by 5 steps) for display + // the mock palette service has only 2 colors so tests are a bit off by that + { color: 'red', stop: 20 }, + { color: 'black', stop: 70 }, + ]); + }); + + it('should return a palette stops array reversed', () => { + expect( + applyPaletteParams( + paletteRegistry, + { name: 'default', type: 'palette', params: { name: 'default', reverse: true } }, + { min: 0, max: 100 } + ) + ).toEqual([ + { color: 'black', stop: 20 }, + { color: 'red', stop: 70 }, + ]); + }); + + it('should pick the default palette from the activePalette object when passed', () => { + expect( + applyPaletteParams(paletteRegistry, { name: 'mocked', type: 'palette' }, { min: 0, max: 100 }) + ).toEqual([ + { color: 'blue', stop: 20 }, + { color: 'yellow', stop: 70 }, + ]); + }); +}); + +describe('remapStopsByNewInterval', () => { + it('should correctly remap the current palette from 0..1 to 0...100', () => { + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'red', stop: 0.9 }, + ], + { newInterval: 100, oldInterval: 1, newMin: 0, oldMin: 0 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 90 }, + ]); + + // now test the other way around + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 90 }, + ], + { newInterval: 1, oldInterval: 100, newMin: 0, oldMin: 0 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'red', stop: 0.9 }, + ]); + }); + + it('should correctly handle negative numbers to/from', () => { + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: -100 }, + { color: 'green', stop: -50 }, + { color: 'red', stop: -1 }, + ], + { newInterval: 100, oldInterval: 100, newMin: 0, oldMin: -100 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 99 }, + ]); + + // now map the other way around + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 99 }, + ], + { newInterval: 100, oldInterval: 100, newMin: -100, oldMin: 0 } + ) + ).toEqual([ + { color: 'black', stop: -100 }, + { color: 'green', stop: -50 }, + { color: 'red', stop: -1 }, + ]); + + // and test also palettes that also contains negative values + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: -50 }, + { color: 'green', stop: 0 }, + { color: 'red', stop: 50 }, + ], + { newInterval: 100, oldInterval: 100, newMin: 0, oldMin: -50 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 100 }, + ]); + }); +}); + +describe('getDataMinMax', () => { + it('should pick the correct min/max based on the current range type', () => { + expect(getDataMinMax('percent', { min: -100, max: 0 })).toEqual({ min: 0, max: 100 }); + }); + + it('should pick the correct min/max apply percent by default', () => { + expect(getDataMinMax(undefined, { min: -100, max: 0 })).toEqual({ min: 0, max: 100 }); + }); +}); + +describe('getPaletteStops', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + it('should correctly compute a predefined palette stops definition from only the name', () => { + expect( + getPaletteStops(paletteRegistry, { name: 'mock' }, { dataBounds: { min: 0, max: 100 } }) + ).toEqual([ + { color: 'blue', stop: 20 }, + { color: 'yellow', stop: 70 }, + ]); + }); + + it('should correctly compute a predefined palette stops definition from explicit prevPalette (override)', () => { + expect( + getPaletteStops( + paletteRegistry, + { name: 'default' }, + { dataBounds: { min: 0, max: 100 }, prevPalette: 'mock' } + ) + ).toEqual([ + { color: 'blue', stop: 20 }, + { color: 'yellow', stop: 70 }, + ]); + }); + + it('should infer the domain from dataBounds but start from 0', () => { + expect( + getPaletteStops( + paletteRegistry, + { name: 'default', rangeType: 'number' }, + { dataBounds: { min: 1, max: 11 }, prevPalette: 'mock' } + ) + ).toEqual([ + { color: 'blue', stop: 2 }, + { color: 'yellow', stop: 7 }, + ]); + }); + + it('should override the minStop when requested', () => { + expect( + getPaletteStops( + paletteRegistry, + { name: 'default', rangeType: 'number' }, + { dataBounds: { min: 1, max: 11 }, mapFromMinValue: true } + ) + ).toEqual([ + { color: 'red', stop: 1 }, + { color: 'black', stop: 6 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + rangeType: 'number', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 80 }, + ], + }, + { dataBounds: { min: 0, max: 100 } } + ) + ).toEqual([ + { color: 'green', stop: 40 }, + { color: 'blue', stop: 80 }, + { color: 'red', stop: 100 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user - handle stop at the end', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + rangeType: 'number', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 100 }, + ], + }, + { dataBounds: { min: 0, max: 100 } } + ) + ).toEqual([ + { color: 'green', stop: 40 }, + { color: 'blue', stop: 100 }, + { color: 'red', stop: 101 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user - handle stop at the end (fractional)', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + rangeType: 'number', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 0.4 }, + { color: 'red', stop: 1 }, + ], + }, + { dataBounds: { min: 0, max: 1 } } + ) + ).toEqual([ + { color: 'green', stop: 0.4 }, + { color: 'blue', stop: 1 }, + { color: 'red', stop: 2 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user - stretch the stops to 100% percent', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 0.4 }, + { color: 'red', stop: 1 }, + ], + }, + { dataBounds: { min: 0, max: 1 } } + ) + ).toEqual([ + { color: 'green', stop: 0.4 }, + { color: 'blue', stop: 1 }, + { color: 'red', stop: 100 }, // default rangeType is percent, hence stretch to 100% + ]); + }); +}); + +describe('reversePalette', () => { + it('should correctly reverse color and stops', () => { + expect( + reversePalette([ + { color: 'red', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'blue', stop: 0.9 }, + ]) + ).toEqual([ + { color: 'blue', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'red', stop: 0.9 }, + ]); + }); +}); + +describe('getStepValue', () => { + it('should compute the next step based on the last 2 stops', () => { + expect( + getStepValue( + // first arg is taken as max reference + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + 100 + ) + ).toBe(50); + + expect( + getStepValue( + // first arg is taken as max reference + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 80 }, + ], + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + 90 + ) + ).toBe(10); // 90 - 80 + + expect( + getStepValue( + // first arg is taken as max reference + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 100 }, + ], + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + 100 + ) + ).toBe(1); + }); +}); + +describe('getSortPredicate', () => { + it('should return dataIndex if otherbucker it enabled', () => { + const column = { + id: '0c4cfb78-3c2f-4eaf-82b3-4b2c1c6abe5a', + name: 'Top values of Carrier', + meta: { + type: 'string', + source: 'esaggs', + sourceParams: { + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 3, + otherBucket: true, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: '(missing value)', + }, + schema: 'segment', + }, + }, + } as DatatableColumn; + expect(getSortPredicate(column)).toEqual('dataIndex'); + }); + + it('should return numDesc for descending metric sorting', () => { + const column = { + id: 'col-0-2', + name: 'Dest: Descending', + meta: { + type: 'string', + source: 'esaggs', + sourceParams: { + params: { + field: 'Dest', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + } as DatatableColumn; + expect(getSortPredicate(column)).toEqual('numDesc'); + }); + + it('should return alphaAsc for ascending alphabetical sorting', () => { + const column = { + id: 'col-0-2', + name: 'Dest: Ascending', + meta: { + type: 'string', + source: 'esaggs', + sourceParams: { + params: { + field: 'Dest', + orderBy: '_key', + order: 'asc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + } as DatatableColumn; + expect(getSortPredicate(column)).toEqual('alphaAsc'); + }); +}); diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/helpers.ts b/src/plugins/chart_expressions/expression_heatmap/public/components/helpers.ts new file mode 100644 index 0000000000000..c9bfa68da9f22 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/helpers.ts @@ -0,0 +1,248 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import type { Datatable, DatatableColumn } from 'src/plugins/expressions/public'; +import type { CustomPaletteParams, ColorStop } from '../../common'; +import { + CUSTOM_PALETTE, + defaultPaletteParams, + DEFAULT_MAX_STOP, + DEFAULT_MIN_STOP, +} from '../constants'; + +// very simple heuristic: pick last two stops and compute a new stop based on the same distance +// if the new stop is above max, then reduce the step to reach max, or if zero then just 1. +// +// it accepts two series of stops as the function is used also when computing stops from colorStops +export function getStepValue(colorStops: ColorStop[], newColorStops: ColorStop[], max: number) { + const length = newColorStops.length; + // workout the steps from the last 2 items + const dataStep = newColorStops[length - 1].stop - newColorStops[length - 2].stop || 1; + let step = Number(dataStep.toFixed(2)); + if (max < colorStops[length - 1].stop + step) { + const diffToMax = max - colorStops[length - 1].stop; + // if the computed step goes way out of bound, fallback to 1, otherwise reach max + step = diffToMax > 0 ? diffToMax : 1; + } + return step; +} + +// Need to shift the Custom palette in order to correctly visualize it when in display mode +function shiftPalette(stops: ColorStop[], max: number) { + // shift everything right and add an additional stop at the end + const result = stops.map((entry, i, array) => ({ + ...entry, + stop: i + 1 < array.length ? array[i + 1].stop : max, + })); + if (stops[stops.length - 1].stop === max) { + // extends the range by a fair amount to make it work the extra case for the last stop === max + const computedStep = getStepValue(stops, result, max) || 1; + // do not go beyond the unit step in this case + const step = Math.min(1, computedStep); + result[stops.length - 1].stop = max + step; + } + return result; +} + +function getOverallMinMax( + params: CustomPaletteParams | undefined, + dataBounds: { min: number; max: number } +) { + const { min: dataMin, max: dataMax } = getDataMinMax(params?.rangeType, dataBounds); + const minStopValue = params?.colorStops?.[0]?.stop ?? Infinity; + const maxStopValue = params?.colorStops?.[params.colorStops.length - 1]?.stop ?? -Infinity; + const overallMin = Math.min(dataMin, minStopValue); + const overallMax = Math.max(dataMax, maxStopValue); + return { min: overallMin, max: overallMax }; +} + +export function getDataMinMax( + rangeType: CustomPaletteParams['rangeType'] | undefined, + dataBounds: { min: number; max: number } +) { + const dataMin = rangeType === 'number' ? dataBounds.min : DEFAULT_MIN_STOP; + const dataMax = rangeType === 'number' ? dataBounds.max : DEFAULT_MAX_STOP; + return { min: dataMin, max: dataMax }; +} +// Utility to remap color stops within new domain +export function remapStopsByNewInterval( + controlStops: ColorStop[], + { + newInterval, + oldInterval, + newMin, + oldMin, + }: { newInterval: number; oldInterval: number; newMin: number; oldMin: number } +) { + return (controlStops || []).map(({ color, stop }) => { + return { + color, + stop: newMin + ((stop - oldMin) * newInterval) / oldInterval, + }; + }); +} +/** + * This is a generic function to compute stops from the current parameters. + */ +export function getPaletteStops( + palettes: PaletteRegistry, + activePaletteParams: CustomPaletteParams, + // used to customize color resolution + { + prevPalette, + dataBounds, + mapFromMinValue, + defaultPaletteName, + }: { + prevPalette?: string; + dataBounds: { min: number; max: number }; + mapFromMinValue?: boolean; + defaultPaletteName?: string; + } +) { + const { min: minValue, max: maxValue } = getOverallMinMax(activePaletteParams, dataBounds); + const interval = maxValue - minValue; + const { stops: currentStops, ...otherParams } = activePaletteParams || {}; + + if (activePaletteParams.name === 'custom' && activePaletteParams?.colorStops) { + // need to generate the palette from the existing controlStops + return shiftPalette(activePaletteParams.colorStops, maxValue); + } + // generate a palette from predefined ones and customize the domain + const colorStopsFromPredefined = palettes + .get( + prevPalette || activePaletteParams?.name || defaultPaletteName || defaultPaletteParams.name + ) + .getCategoricalColors(defaultPaletteParams.steps, otherParams); + + const newStopsMin = mapFromMinValue ? minValue : interval / defaultPaletteParams.steps; + + const stops = remapStopsByNewInterval( + colorStopsFromPredefined.map((color, index) => ({ color, stop: index })), + { + newInterval: interval, + oldInterval: colorStopsFromPredefined.length, + newMin: newStopsMin, + oldMin: 0, + } + ); + return stops; +} + +export function reversePalette(paletteColorRepresentation: ColorStop[] = []) { + const stops = paletteColorRepresentation.map(({ stop }) => stop); + return paletteColorRepresentation + .map(({ color }, i) => ({ + color, + stop: stops[paletteColorRepresentation.length - i - 1], + })) + .reverse(); +} + +/** + * Some name conventions here: + * * `displayStops` => It's an additional transformation of `stops` into a [0, N] domain for the EUIPaletteDisplay component. + * * `stops` => final steps used to table coloring. It is a rightShift of the colorStops + * * `colorStops` => user's color stop inputs. Used to compute range min. + * + * When the user inputs the colorStops, they are designed to be the initial part of the color segment, + * so the next stops indicate where the previous stop ends. + * Both table coloring logic and EuiPaletteDisplay format implementation works differently than our current `colorStops`, + * by having the stop values at the end of each color segment rather than at the beginning: `stops` values are computed by a rightShift of `colorStops`. + * EuiPaletteDisplay has an additional requirement as it is always mapped against a domain [0, N]: from `stops` the `displayStops` are computed with + * some continuity enrichment and a remap against a [0, 100] domain to make the palette component work ok. + * + * These naming conventions would be useful to track the code flow in this feature as multiple transformations are happening + * for a single change. + */ + +export function applyPaletteParams>( + palettes: PaletteRegistry, + activePalette: T, + dataBounds: { min: number; max: number } +) { + // make a copy of it as they have to be manipulated later on + let displayStops = getPaletteStops(palettes, activePalette?.params || {}, { + dataBounds, + defaultPaletteName: activePalette?.name, + }); + + if (activePalette?.params?.reverse && activePalette?.params?.name !== CUSTOM_PALETTE) { + displayStops = reversePalette(displayStops); + } + return displayStops; +} + +function getId(id: string) { + return id; +} + +export function getNumericValue(rowValue: number | number[] | undefined) { + if (rowValue == null || Array.isArray(rowValue)) { + return; + } + return rowValue; +} + +export const findMinMaxByColumnId = ( + columnIds: string[], + table: Datatable | undefined, + getOriginalId: (id: string) => string = getId +) => { + const minMax: Record = {}; + + if (table != null) { + for (const columnId of columnIds) { + const originalId = getOriginalId(columnId); + minMax[originalId] = minMax[originalId] || { max: -Infinity, min: Infinity }; + table.rows.forEach((row) => { + const rowValue = row[columnId]; + const numericValue = getNumericValue(rowValue); + if (numericValue != null) { + if (minMax[originalId].min > numericValue) { + minMax[originalId].min = numericValue; + } + if (minMax[originalId].max < numericValue) { + minMax[originalId].max = numericValue; + } + } + }); + // what happens when there's no data in the table? Fallback to a percent range + if (minMax[originalId].max === -Infinity) { + minMax[originalId] = { max: 100, min: 0, fallback: true }; + } + } + } + return minMax; +}; +interface SourceParams { + order?: string; + orderBy?: string; + otherBucket?: boolean; +} + +export const getSortPredicate = (column: DatatableColumn) => { + const params = column.meta?.sourceParams?.params as SourceParams | undefined; + const sort: string | undefined = params?.orderBy; + if (params?.otherBucket || !sort) return 'dataIndex'; + // metric sorting + if (sort && sort !== '_key') { + if (params?.order === 'desc') { + return 'numDesc'; + } else { + return 'numAsc'; + } + // alphabetical sorting + } else { + if (params?.order === 'desc') { + return 'alphaDesc'; + } else { + return 'alphaAsc'; + } + } +}; diff --git a/src/plugins/chart_expressions/expression_heatmap/public/constants.ts b/src/plugins/chart_expressions/expression_heatmap/public/constants.ts new file mode 100644 index 0000000000000..79e24d05e3fde --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/constants.ts @@ -0,0 +1,27 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { RequiredPaletteParamTypes } from '../common'; +export const DEFAULT_PALETTE_NAME = 'temperature'; +export const FIXED_PROGRESSION = 'fixed' as const; +export const CUSTOM_PALETTE = 'custom'; +export const DEFAULT_CONTINUITY = 'above'; +export const DEFAULT_MIN_STOP = 0; +export const DEFAULT_MAX_STOP = 100; +export const DEFAULT_COLOR_STEPS = 5; +export const defaultPaletteParams: RequiredPaletteParamTypes = { + name: DEFAULT_PALETTE_NAME, + reverse: false, + rangeType: 'percent', + rangeMin: DEFAULT_MIN_STOP, + rangeMax: DEFAULT_MAX_STOP, + progression: FIXED_PROGRESSION, + stops: [], + steps: DEFAULT_COLOR_STEPS, + colorStops: [], + continuity: DEFAULT_CONTINUITY, +}; diff --git a/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/heatmap_renderer.tsx b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/heatmap_renderer.tsx new file mode 100644 index 0000000000000..efdb6aee7782d --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/heatmap_renderer.tsx @@ -0,0 +1,73 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; +import React, { memo } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import type { PersistedState } from '../../../../visualizations/public'; +import { ThemeServiceStart } from '../../../../../core/public'; +import { KibanaThemeProvider } from '../../../../kibana_react/public'; +import { ExpressionRenderDefinition } from '../../../../expressions/common/expression_renderers'; +import { + EXPRESSION_HEATMAP_NAME, + HeatmapExpressionProps, + FilterEvent, + BrushEvent, +} from '../../common'; +import { getFormatService, getPaletteService, getUISettings, getThemeService } from '../services'; +import { getTimeZone } from '../utils/get_timezone'; + +import HeatmapComponent from '../components/heatmap_component'; +import './index.scss'; +const MemoizedChart = memo(HeatmapComponent); + +interface ExpressioHeatmapRendererDependencies { + theme: ThemeServiceStart; +} + +export const heatmapRenderer: ( + deps: ExpressioHeatmapRendererDependencies +) => ExpressionRenderDefinition = ({ theme }) => ({ + name: EXPRESSION_HEATMAP_NAME, + displayName: i18n.translate('expressionHeatmap.visualizationName', { + defaultMessage: 'Heatmap', + }), + reuseDomNode: true, + render: async (domNode, config, handlers) => { + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + const onClickValue = (data: FilterEvent['data']) => { + handlers.event({ name: 'filter', data }); + }; + const onSelectRange = (data: BrushEvent['data']) => { + handlers.event({ name: 'brush', data }); + }; + + const timeZone = getTimeZone(getUISettings()); + render( + +
+ +
+
, + domNode, + () => { + handlers.done(); + } + ); + }, +}); diff --git a/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.scss b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.scss new file mode 100644 index 0000000000000..6e1afd91c476d --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.scss @@ -0,0 +1,26 @@ +.heatmap-container { + @include euiScrollBar; + 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: auto hidden; + user-select: text; + padding: $euiSizeS; +} + +.heatmap-chart__empty { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.heatmap-chart-icon__subdued { + fill: $euiTextSubduedColor; +} + +.heatmap-chart-icon__accent { + fill: $euiColorVis0; +} \ No newline at end of file diff --git a/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.ts b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.ts new file mode 100644 index 0000000000000..d74f77b4eb5fc --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { heatmapRenderer } from './heatmap_renderer'; diff --git a/src/plugins/chart_expressions/expression_heatmap/public/index.ts b/src/plugins/chart_expressions/expression_heatmap/public/index.ts new file mode 100644 index 0000000000000..fbbf8027eb343 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/index.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionHeatmapPlugin } from './plugin'; + +export function plugin() { + return new ExpressionHeatmapPlugin(); +} diff --git a/src/plugins/chart_expressions/expression_heatmap/public/plugin.ts b/src/plugins/chart_expressions/expression_heatmap/public/plugin.ts new file mode 100644 index 0000000000000..cabb938f6f6b4 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/plugin.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ChartsPluginSetup } from '../../../charts/public'; +import { CoreSetup, CoreStart } from '../../../../core/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../../expressions/public'; +import { heatmapFunction, heatmapLegendConfig, heatmapGridConfig } from '../common'; +import { setFormatService, setPaletteService, setUISettings, setThemeService } from './services'; +import { heatmapRenderer } from './expression_renderers'; +import type { FieldFormatsStart } from '../../../field_formats/public'; + +/** @internal */ +export interface ExpressionHeatmapPluginSetup { + expressions: ReturnType; + charts: ChartsPluginSetup; +} + +/** @internal */ +export interface ExpressionHeatmapPluginStart { + fieldFormats: FieldFormatsStart; +} + +/** @internal */ +export class ExpressionHeatmapPlugin { + public setup(core: CoreSetup, { expressions, charts }: ExpressionHeatmapPluginSetup) { + charts.palettes.getPalettes().then((palettes) => { + setPaletteService(palettes); + }); + setUISettings(core.uiSettings); + setThemeService(charts.theme); + expressions.registerFunction(heatmapFunction); + expressions.registerFunction(heatmapLegendConfig); + expressions.registerFunction(heatmapGridConfig); + expressions.registerRenderer(heatmapRenderer({ theme: core.theme })); + } + + public start(core: CoreStart, { fieldFormats }: ExpressionHeatmapPluginStart) { + setFormatService(fieldFormats); + } +} diff --git a/src/plugins/chart_expressions/expression_heatmap/public/services/format_service.ts b/src/plugins/chart_expressions/expression_heatmap/public/services/format_service.ts new file mode 100644 index 0000000000000..73b66341c4d9a --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/services/format_service.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createGetterSetter } from '../../../../kibana_utils/public'; +import { FieldFormatsStart } from '../../../../field_formats/public'; + +export const [getFormatService, setFormatService] = + createGetterSetter('fieldFormats'); diff --git a/src/plugins/chart_expressions/expression_heatmap/public/services/index.ts b/src/plugins/chart_expressions/expression_heatmap/public/services/index.ts new file mode 100644 index 0000000000000..a86d9e4fee7c2 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/services/index.ts @@ -0,0 +1,16 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { getFormatService, setFormatService } from './format_service'; +export { + getPaletteService, + setPaletteService, + setThemeService, + getThemeService, +} from './palette_service'; +export { getUISettings, setUISettings } from './ui_settings'; diff --git a/src/plugins/chart_expressions/expression_heatmap/public/services/palette_service.ts b/src/plugins/chart_expressions/expression_heatmap/public/services/palette_service.ts new file mode 100644 index 0000000000000..4e76e3149c7a6 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/services/palette_service.ts @@ -0,0 +1,16 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createGetterSetter } from '../../../../kibana_utils/public'; +import { PaletteRegistry, ChartsPluginSetup } from '../../../../charts/public'; + +export const [getPaletteService, setPaletteService] = + createGetterSetter('palette'); + +export const [getThemeService, setThemeService] = + createGetterSetter('charts.theme'); diff --git a/src/plugins/chart_expressions/expression_heatmap/public/services/ui_settings.ts b/src/plugins/chart_expressions/expression_heatmap/public/services/ui_settings.ts new file mode 100644 index 0000000000000..5e49e8da28840 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/services/ui_settings.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createGetterSetter } from '../../../../kibana_utils/public'; +import { CoreSetup } from '../../../../../core/public'; + +export const [getUISettings, setUISettings] = + createGetterSetter('core.uiSettings'); diff --git a/src/plugins/chart_expressions/expression_heatmap/public/utils/get_color_picker.tsx b/src/plugins/chart_expressions/expression_heatmap/public/utils/get_color_picker.tsx new file mode 100644 index 0000000000000..2f5297c5fd475 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/utils/get_color_picker.tsx @@ -0,0 +1,85 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import { LegendColorPicker, Position } from '@elastic/charts'; +import { PopoverAnchorPosition, EuiPopover, EuiOutsideClickDetector } from '@elastic/eui'; +import type { PersistedState } from '../../../../visualizations/public'; +import { ColorPicker } from '../../../../charts/public'; + +const KEY_CODE_ENTER = 13; + +function getAnchorPosition(legendPosition: Position): PopoverAnchorPosition { + switch (legendPosition) { + case Position.Bottom: + return 'upCenter'; + case Position.Top: + return 'downCenter'; + case Position.Left: + return 'rightCenter'; + default: + return 'leftCenter'; + } +} + +export const getColorPicker = + ( + legendPosition: Position, + setColor: (newColor: string | null, seriesKey: string | number) => void, + uiState: PersistedState + ): LegendColorPicker => + ({ anchor, color, onClose, onChange, seriesIdentifiers: [seriesIdentifier] }) => { + const seriesName = seriesIdentifier.key; + const overwriteColors: Record = uiState?.get('vis.colors', {}) ?? {}; + const colorIsOverwritten = seriesName.toString() in overwriteColors; + let keyDownEventOn = false; + const handleChange = (newColor: string | null) => { + if (newColor) { + onChange(newColor); + } + setColor(newColor, seriesName); + // close the popover if no color is applied or the user has clicked a color + if (!newColor || !keyDownEventOn) { + onClose(); + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === KEY_CODE_ENTER) { + onClose?.(); + } + keyDownEventOn = true; + }; + + const handleOutsideClick = useCallback(() => { + onClose?.(); + }, [onClose]); + + return ( + + + + + + ); + }; diff --git a/src/plugins/chart_expressions/expression_heatmap/public/utils/get_timezone.ts b/src/plugins/chart_expressions/expression_heatmap/public/utils/get_timezone.ts new file mode 100644 index 0000000000000..8d33a94c956d0 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/utils/get_timezone.ts @@ -0,0 +1,22 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import type { IUiSettingsClient } from '../../../../../core/public'; + +/** + * Get timeZone from uiSettings + */ +export function getTimeZone(uiSettings: IUiSettingsClient) { + if (uiSettings.isDefault('dateFormat:tz')) { + const detectedTimeZone = moment.tz.guess(); + return detectedTimeZone || moment().format('Z'); + } else { + return uiSettings.get('dateFormat:tz', 'Browser'); + } +} diff --git a/src/plugins/chart_expressions/expression_heatmap/server/index.ts b/src/plugins/chart_expressions/expression_heatmap/server/index.ts new file mode 100644 index 0000000000000..fbbf8027eb343 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/server/index.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionHeatmapPlugin } from './plugin'; + +export function plugin() { + return new ExpressionHeatmapPlugin(); +} diff --git a/src/plugins/chart_expressions/expression_heatmap/server/plugin.ts b/src/plugins/chart_expressions/expression_heatmap/server/plugin.ts new file mode 100644 index 0000000000000..858c67da86a6e --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/server/plugin.ts @@ -0,0 +1,37 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../../core/public'; +import { ExpressionsServerStart, ExpressionsServerSetup } from '../../../expressions/server'; +import { heatmapFunction, heatmapLegendConfig, heatmapGridConfig } from '../common'; + +interface SetupDeps { + expressions: ExpressionsServerSetup; +} + +interface StartDeps { + expression: ExpressionsServerStart; +} + +export type ExpressionHeatmapPluginSetup = void; +export type ExpressionHeatmapPluginStart = void; + +export class ExpressionHeatmapPlugin + implements + Plugin +{ + public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionHeatmapPluginSetup { + expressions.registerFunction(heatmapFunction); + expressions.registerFunction(heatmapLegendConfig); + expressions.registerFunction(heatmapGridConfig); + } + + public start(core: CoreStart): ExpressionHeatmapPluginStart {} + + public stop() {} +} diff --git a/src/plugins/chart_expressions/expression_heatmap/tsconfig.json b/src/plugins/chart_expressions/expression_heatmap/tsconfig.json new file mode 100644 index 0000000000000..ff5089c7f4d21 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "isolatedModules": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../../../core/tsconfig.json" }, + { "path": "../../expressions/tsconfig.json" }, + { "path": "../../presentation_util/tsconfig.json" }, + { "path": "../../field_formats/tsconfig.json" }, + { "path": "../../charts/tsconfig.json" }, + { "path": "../../visualizations/tsconfig.json" }, + ] +} diff --git a/src/plugins/charts/public/static/components/empty_placeholder.tsx b/src/plugins/charts/public/static/components/empty_placeholder.tsx new file mode 100644 index 0000000000000..db3f3fb6739d5 --- /dev/null +++ b/src/plugins/charts/public/static/components/empty_placeholder.tsx @@ -0,0 +1,23 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export const EmptyPlaceholder = (props: { icon: IconType }) => ( + <> + + + +

+ +

+
+ +); diff --git a/src/plugins/charts/public/static/components/index.ts b/src/plugins/charts/public/static/components/index.ts index 7f3af50a01aa4..ea3e66e7f30a3 100644 --- a/src/plugins/charts/public/static/components/index.ts +++ b/src/plugins/charts/public/static/components/index.ts @@ -9,4 +9,5 @@ export { LegendToggle } from './legend_toggle'; export { ColorPicker } from './color_picker'; export { CurrentTime } from './current_time'; +export { EmptyPlaceholder } from './empty_placeholder'; export * from './endzones'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 356aaf60b423c..c2a4f18218dd4 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -396,6 +396,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'visualization:visualize:legacyHeatmapChartsLibrary': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'doc_table:legacy': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 69287d37dfa28..69ed647f0845a 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -28,6 +28,7 @@ export interface UsageStats { 'autocomplete:valueSuggestionMethod': string; 'search:timeout': number; 'visualization:visualize:legacyPieChartsLibrary': boolean; + 'visualization:visualize:legacyHeatmapChartsLibrary': boolean; 'doc_table:legacy': boolean; 'discover:modifyColumnsOnSwitch': boolean; 'discover:searchFieldsFromSource': boolean; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index cd5d3818bcdec..31bf9c3f08e71 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7742,6 +7742,12 @@ "description": "Non-default value of setting." } }, + "visualization:visualize:legacyHeatmapChartsLibrary": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "doc_table:legacy": { "type": "boolean", "_meta": { diff --git a/src/plugins/vis_default_editor/public/components/options/color_schema.tsx b/src/plugins/vis_default_editor/public/components/options/color_schema.tsx index 4e6fec5c98558..3ce9e2ec72fa0 100644 --- a/src/plugins/vis_default_editor/public/components/options/color_schema.tsx +++ b/src/plugins/vis_default_editor/public/components/options/color_schema.tsx @@ -51,6 +51,7 @@ function ColorSchemaOptions({ { uiState.set('vis.colors', null); + uiState?.emit('reload'); setIsCustomColors(false); }} > diff --git a/src/plugins/vis_default_editor/public/components/options/percentage_mode.tsx b/src/plugins/vis_default_editor/public/components/options/percentage_mode.tsx index bfb6d2051452b..0a593dd753b53 100644 --- a/src/plugins/vis_default_editor/public/components/options/percentage_mode.tsx +++ b/src/plugins/vis_default_editor/public/components/options/percentage_mode.tsx @@ -22,6 +22,7 @@ export interface PercentageModeOptionProps { percentageMode: boolean; formatPattern?: string; 'data-test-subj'?: string; + disabled?: boolean; } function PercentageModeOption({ @@ -29,6 +30,7 @@ function PercentageModeOption({ setValue, percentageMode, formatPattern, + disabled, }: PercentageModeOptionProps) { const { services } = useKibana(); const defaultPattern = services.uiSettings?.get( @@ -45,6 +47,7 @@ function PercentageModeOption({ paramName="percentageMode" value={percentageMode} setValue={setValue} + disabled={disabled} /> ; diff --git a/src/plugins/vis_types/heatmap/jest.config.js b/src/plugins/vis_types/heatmap/jest.config.js new file mode 100644 index 0000000000000..6b9df821c48db --- /dev/null +++ b/src/plugins/vis_types/heatmap/jest.config.js @@ -0,0 +1,18 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/src/plugins/vis_types/heatmap'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/vis_types/heatmap', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/vis_types/heatmap/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/vis_types/heatmap/kibana.json b/src/plugins/vis_types/heatmap/kibana.json new file mode 100644 index 0000000000000..c8df98e2b343a --- /dev/null +++ b/src/plugins/vis_types/heatmap/kibana.json @@ -0,0 +1,14 @@ +{ + "id": "visTypeHeatmap", + "version": "kibana", + "ui": true, + "server": true, + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection", "fieldFormats"], + "requiredBundles": ["visDefaultEditor"], + "extraPublicDirs": ["common/index"], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + }, + "description": "Contains the heatmap implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy heatmap charts library advanced setting." + } diff --git a/src/plugins/vis_types/heatmap/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/heatmap/public/__snapshots__/to_ast.test.ts.snap new file mode 100644 index 0000000000000..f7a8299920212 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/__snapshots__/to_ast.test.ts.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`heatmap vis toExpressionAst function should match basic snapshot 1`] = ` +Object { + "addArgument": [Function], + "arguments": Object { + "gridConfig": Array [ + Object { + "toAst": [Function], + }, + ], + "highlightInHover": Array [ + false, + ], + "lastRangeIsRightOpen": Array [ + false, + ], + "legend": Array [ + Object { + "toAst": [Function], + }, + ], + "palette": Array [ + Object { + "toAst": [Function], + }, + ], + "percentageMode": Array [ + false, + ], + "showTooltip": Array [ + true, + ], + "valueAccessor": Array [ + Object { + "toAst": [Function], + }, + ], + "xAccessor": Array [ + Object { + "toAst": [Function], + }, + ], + "yAccessor": Array [ + Object { + "toAst": [Function], + }, + ], + }, + "getArgument": [Function], + "name": "heatmap", + "removeArgument": [Function], + "replaceArgument": [Function], + "toAst": [Function], + "toString": [Function], + "type": "expression_function_builder", +} +`; diff --git a/src/plugins/vis_types/heatmap/public/editor/collections.ts b/src/plugins/vis_types/heatmap/public/editor/collections.ts new file mode 100644 index 0000000000000..932a2c3205057 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/editor/collections.ts @@ -0,0 +1,59 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; +import { ScaleType } from '../types'; + +export const legendPositions = [ + { + text: i18n.translate('visTypeHeatmap.legendPositions.topText', { + defaultMessage: 'Top', + }), + value: Position.Top, + }, + { + text: i18n.translate('visTypeHeatmap.legendPositions.leftText', { + defaultMessage: 'Left', + }), + value: Position.Left, + }, + { + text: i18n.translate('visTypeHeatmap.legendPositions.rightText', { + defaultMessage: 'Right', + }), + value: Position.Right, + }, + { + text: i18n.translate('visTypeHeatmap.legendPositions.bottomText', { + defaultMessage: 'Bottom', + }), + value: Position.Bottom, + }, +]; + +export const scaleTypes = [ + { + text: i18n.translate('visTypeHeatmap.scaleTypes.linearText', { + defaultMessage: 'Linear', + }), + value: ScaleType.Linear, + }, + { + text: i18n.translate('visTypeHeatmap.scaleTypes.logText', { + defaultMessage: 'Log', + }), + value: ScaleType.Log, + }, + { + text: i18n.translate('visTypeHeatmap.scaleTypes.squareRootText', { + defaultMessage: 'Square root', + }), + value: ScaleType.SquareRoot, + }, +]; diff --git a/src/plugins/vis_types/heatmap/public/editor/components/heatmap.test.tsx b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.test.tsx new file mode 100644 index 0000000000000..5f57083072202 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.test.tsx @@ -0,0 +1,205 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import type { PersistedState } from '../../../../../visualizations/public'; +import HeatmapOptions, { HeatmapOptionsProps } from './heatmap'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from 'react-dom/test-utils'; + +describe('PalettePicker', function () { + let props: HeatmapOptionsProps; + let component: ReactWrapper; + const mockState = new Map(); + const uiState = { + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + on: jest.fn(), + setSilent: jest.fn(), + } as unknown as PersistedState; + + beforeAll(() => { + props = { + showElasticChartsOptions: true, + uiState, + setValidity: jest.fn(), + vis: { + type: { + editorConfig: { + collections: { + legendPositions: [ + { + text: 'Top', + value: 'top', + }, + { + text: 'Left', + value: 'left', + }, + { + text: 'Right', + value: 'right', + }, + { + text: 'Bottom', + value: 'bottom', + }, + ], + }, + }, + }, + }, + stateParams: { + percentageMode: false, + addTooltip: true, + addLegend: true, + enableHover: false, + legendPosition: 'right', + colorsNumber: 8, + colorSchema: 'Blues', + setColorRange: false, + colorsRange: [], + invertColors: false, + truncateLegend: true, + maxLegendLines: 1, + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'left', + show: true, + style: {}, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + show: true, + rotate: 0, + filter: false, + truncate: 100, + overwriteColor: true, + }, + title: { + text: 'Count', + }, + }, + ], + }, + setValue: jest.fn(), + } as unknown as HeatmapOptionsProps; + }); + + it('renders the long legend options for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapLongLegendsOptions').length).toBe(1); + }); + }); + + it('not renders the long legend options for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapLongLegendsOptions').length).toBe(0); + }); + }); + + it('disables the highlight range switch for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapHighlightRange').prop('disabled')).toBeTruthy(); + }); + }); + + it('enables the highlight range switch for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapHighlightRange').prop('disabled')).toBeFalsy(); + }); + }); + + it('disables the color scale dropdown for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapColorScale').prop('disabled')).toBeTruthy(); + }); + }); + + it('enables the color scale dropdown for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapColorScale').prop('disabled')).toBeFalsy(); + }); + }); + + it('not renders the scale to data bounds switch for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapScaleToDataBounds').length).toBe(0); + }); + }); + + it('renders the scale to data bounds for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapScaleToDataBounds').length).toBe(1); + }); + }); + + it('disables the labels rotate for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapLabelsRotate').prop('disabled')).toBeTruthy(); + }); + }); + + it('enables the labels rotate for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapLabelsRotate').prop('disabled')).toBeFalsy(); + }); + }); + + it('disables the overwtite color switch for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect( + findTestSubject(component, 'heatmapLabelsOverwriteColor').prop('disabled') + ).toBeTruthy(); + }); + }); + + it('enables the overwtite color switch for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect( + findTestSubject(component, 'heatmapLabelsOverwriteColor').prop('disabled') + ).toBeFalsy(); + }); + }); + + it('disables the color picker for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapLabelsColor').prop('disabled')).toBeTruthy(); + }); + }); + + it('enables the color picker for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'heatmapLabelsColor').prop('disabled')).toBeFalsy(); + }); + }); +}); diff --git a/src/plugins/vis_types/vislib/public/editor/components/heatmap/index.tsx b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx similarity index 50% rename from src/plugins/vis_types/vislib/public/editor/components/heatmap/index.tsx rename to src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx index 4e5d43eb7089a..ca5f6d7c44b3c 100644 --- a/src/plugins/vis_types/vislib/public/editor/components/heatmap/index.tsx +++ b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx @@ -7,13 +7,10 @@ */ import React, { useCallback, useEffect, useState } from 'react'; - -import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPanel, EuiTitle, EuiSpacer, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { ValueAxis } from '../../../../../xy/public'; import { BasicOptions, SelectOption, @@ -24,16 +21,21 @@ import { ColorSchemaOptions, NumberInputOption, PercentageModeOption, -} from '../../../../../../vis_default_editor/public'; - -import { HeatmapVisParams } from '../../../heatmap'; + LongLegendOptions, +} from '../../../../../vis_default_editor/public'; +import { colorSchemas } from '../../../../../charts/public'; +import { VisEditorOptionsProps } from '../../../../../visualizations/public'; +import { HeatmapVisParams, HeatmapTypeProps, ValueAxis } from '../../types'; import { LabelsPanel } from './labels_panel'; -import { getHeatmapCollections } from './../../collections'; +import { legendPositions, scaleTypes } from '../collections'; -const heatmapCollections = getHeatmapCollections(); +export interface HeatmapOptionsProps + extends VisEditorOptionsProps, + HeatmapTypeProps {} -function HeatmapOptions(props: VisEditorOptionsProps) { - const { stateParams, uiState, setValue, setValidity, setTouched } = props; +const HeatmapOptions = (props: HeatmapOptionsProps) => { + const { stateParams, uiState, setValue, setValidity, setTouched, showElasticChartsOptions } = + props; const [valueAxis] = stateParams.valueAxes; const isColorsNumberInvalid = stateParams.colorsNumber < 2 || stateParams.colorsNumber > 10; const [isColorRangesValid, setIsColorRangesValid] = useState(false); @@ -56,32 +58,55 @@ function HeatmapOptions(props: VisEditorOptionsProps) { setValidity(stateParams.setColorRange ? isColorRangesValid : !isColorsNumberInvalid); }, [stateParams.setColorRange, isColorRangesValid, isColorsNumberInvalid, setValidity]); + useEffect(() => { + if (stateParams.setColorRange) { + stateParams.percentageMode = false; + } + }, [stateParams]); + return ( <>

- + + {showElasticChartsOptions && ( + + )}
@@ -91,7 +116,7 @@ function HeatmapOptions(props: VisEditorOptionsProps) {

@@ -100,35 +125,54 @@ function HeatmapOptions(props: VisEditorOptionsProps) { + + + + - - - + {!showElasticChartsOptions && ( + + )} @@ -138,7 +182,7 @@ function HeatmapOptions(props: VisEditorOptionsProps) { data-test-subj="heatmapColorsNumber" disabled={stateParams.setColorRange} isInvalid={isColorsNumberInvalid} - label={i18n.translate('visTypeVislib.controls.heatmapOptions.colorsNumberLabel', { + label={i18n.translate('visTypeHeatmap.controls.heatmapOptions.colorsNumberLabel', { defaultMessage: 'Number of colors', })} max={10} @@ -150,7 +194,7 @@ function HeatmapOptions(props: VisEditorOptionsProps) { ) { setValue={setValue} /> - {stateParams.setColorRange && ( + {stateParams.setColorRange && stateParams.colorsRange && ( ) { - + ); -} +}; // default export required for React.Lazy // eslint-disable-next-line import/no-default-export diff --git a/src/plugins/vis_types/heatmap/public/editor/components/index.tsx b/src/plugins/vis_types/heatmap/public/editor/components/index.tsx new file mode 100644 index 0000000000000..1313d335b06fe --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/editor/components/index.tsx @@ -0,0 +1,25 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { lazy } from 'react'; +import { VisEditorOptionsProps } from '../../../../../visualizations/public'; +import { HeatmapVisParams, HeatmapTypeProps } from '../../types'; + +const HeatmapOptionsLazy = lazy(() => import('./heatmap')); + +export const getHeatmapOptions = + ({ showElasticChartsOptions, palettes, trackUiMetric }: HeatmapTypeProps) => + (props: VisEditorOptionsProps) => + ( + + ); diff --git a/src/plugins/vis_types/vislib/public/editor/components/heatmap/labels_panel.tsx b/src/plugins/vis_types/heatmap/public/editor/components/labels_panel.tsx similarity index 62% rename from src/plugins/vis_types/vislib/public/editor/components/heatmap/labels_panel.tsx rename to src/plugins/vis_types/heatmap/public/editor/components/labels_panel.tsx index e5e41500e39e7..3bdfcb34eb13b 100644 --- a/src/plugins/vis_types/vislib/public/editor/components/heatmap/labels_panel.tsx +++ b/src/plugins/vis_types/heatmap/public/editor/components/labels_panel.tsx @@ -13,19 +13,18 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { SwitchOption } from '../../../../../../vis_default_editor/public'; -import { ValueAxis } from '../../../../../xy/public'; - -import { HeatmapVisParams } from '../../../heatmap'; +import { SwitchOption } from '../../../../../vis_default_editor/public'; +import { HeatmapVisParams, ValueAxis } from '../../types'; const VERTICAL_ROTATION = 270; interface LabelsPanelProps { valueAxis: ValueAxis; setValue: VisEditorOptionsProps['setValue']; + isNewLibrary?: boolean; } -function LabelsPanel({ valueAxis, setValue }: LabelsPanelProps) { +function LabelsPanel({ valueAxis, setValue, isNewLibrary }: LabelsPanelProps) { const rotateLabels = valueAxis.labels.rotate === VERTICAL_ROTATION; const setValueAxisLabels = useCallback( @@ -55,7 +54,7 @@ function LabelsPanel({ valueAxis, setValue }: LabelsPanelProps) {

@@ -63,48 +62,59 @@ function LabelsPanel({ valueAxis, setValue }: LabelsPanelProps) { diff --git a/src/plugins/vis_types/heatmap/public/index.ts b/src/plugins/vis_types/heatmap/public/index.ts new file mode 100644 index 0000000000000..34387430adbe0 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/index.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { VisTypeHeatmapPlugin } from './plugin'; + +export { heatmapVisType } from './vis_type'; + +export const plugin = () => new VisTypeHeatmapPlugin(); diff --git a/src/plugins/vis_types/heatmap/public/plugin.ts b/src/plugins/vis_types/heatmap/public/plugin.ts new file mode 100644 index 0000000000000..622f68ed707e5 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/plugin.ts @@ -0,0 +1,54 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup } from 'src/core/public'; +import type { VisualizationsSetup } from '../../../visualizations/public'; +import type { ChartsPluginSetup } from '../../../charts/public'; +import type { FieldFormatsStart } from '../../../field_formats/public'; +import type { UsageCollectionSetup } from '../../../usage_collection/public'; +import type { DataPublicPluginStart } from '../../../data/public'; +import { LEGACY_HEATMAP_CHARTS_LIBRARY } from '../common'; +import { heatmapVisType } from './vis_type'; + +/** @internal */ +export interface VisTypeHeatmapSetupDependencies { + visualizations: VisualizationsSetup; + charts: ChartsPluginSetup; + usageCollection: UsageCollectionSetup; +} + +/** @internal */ +export interface VisTypeHeatmapPluginStartDependencies { + data: DataPublicPluginStart; + fieldFormats: FieldFormatsStart; +} + +export class VisTypeHeatmapPlugin { + setup( + core: CoreSetup, + { visualizations, charts, usageCollection }: VisTypeHeatmapSetupDependencies + ) { + if (!core.uiSettings.get(LEGACY_HEATMAP_CHARTS_LIBRARY)) { + const trackUiMetric = usageCollection?.reportUiCounter.bind( + usageCollection, + 'vis_type_heatmap' + ); + + visualizations.createBaseVisualization( + heatmapVisType({ + showElasticChartsOptions: true, + palettes: charts.palettes, + trackUiMetric, + }) + ); + } + return {}; + } + + start() {} +} diff --git a/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts new file mode 100644 index 0000000000000..a7e9f53e703ec --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/sample_vis.test.mocks.ts @@ -0,0 +1,1792 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +export const sampleAreaVis = { + type: { + name: 'heatmap', + title: 'Heatmap', + description: 'Creates a heatmap viz', + icon: 'visHeatmap', + stage: 'production', + options: { + showTimePicker: true, + showQueryBar: true, + showFilterBar: true, + showIndexSelection: true, + hierarchicalData: false, + }, + visConfig: { + defaults: { + type: 'heatmap', + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'left', + show: true, + style: {}, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + show: true, + rotate: 0, + filter: false, + truncate: 100, + }, + title: { + text: 'Count', + }, + }, + ], + percentageMode: false, + addTooltip: true, + addLegend: true, + enableHover: false, + legendPosition: 'right', + colorsNumber: 8, + colorSchema: 'Blues', + setColorRange: false, + colorsRange: [], + invertColors: false, + truncateLegend: true, + maxLegendLines: 1, + }, + }, + editorConfig: { + optionTabs: [ + { + name: 'advanced', + title: 'Metrics & axes', + }, + { + name: 'options', + title: 'Panel settings', + }, + ], + schemas: { + all: [ + { + group: 'metrics', + name: 'metric', + title: 'Y-axis', + aggFilter: ['!geo_centroid', '!geo_bounds'], + min: 1, + defaults: [ + { + schema: 'metric', + type: 'count', + }, + ], + max: null, + editor: false, + params: [], + }, + { + group: 'metrics', + name: 'radius', + title: 'Dot size', + min: 0, + max: 1, + aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality'], + editor: false, + params: [], + }, + { + group: 'buckets', + name: 'segment', + title: 'X-axis', + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + editor: false, + params: [], + }, + { + group: 'buckets', + name: 'group', + title: 'Split series', + min: 0, + max: 3, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + editor: false, + params: [], + }, + { + group: 'buckets', + name: 'split', + title: 'Split chart', + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [ + { + name: 'row', + default: true, + }, + ], + editor: false, + }, + ], + buckets: [null, null, null], + metrics: [null, null], + }, + }, + hidden: false, + hierarchicalData: false, + }, + title: '[eCommerce] Sales by Category', + description: '', + params: { + type: 'heatmap', + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'left', + show: true, + style: {}, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + show: true, + rotate: 0, + filter: false, + truncate: 100, + }, + title: { + text: 'Sum of total_quantity', + }, + }, + ], + percentageMode: false, + addTooltip: true, + addLegend: true, + enableHover: false, + legendPosition: 'right', + colorsNumber: 8, + colorSchema: 'Blues', + setColorRange: false, + colorsRange: [], + invertColors: false, + truncateLegend: true, + maxLegendLines: 1, + dimensions: { + x: { + accessor: 0, + format: { + id: 'date', + params: { + pattern: 'YYYY-MM-DD HH:mm', + }, + }, + params: { + date: true, + interval: 43200000, + format: 'YYYY-MM-DD HH:mm', + bounds: { + min: '2020-09-30T12:41:13.795Z', + max: '2020-10-15T17:00:00.000Z', + }, + }, + label: 'order_date per 12 hours', + aggType: 'date_histogram', + }, + y: [ + { + accessor: 2, + format: { + id: 'number', + params: { + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + }, + }, + params: {}, + label: 'Sum of total_quantity', + aggType: 'sum', + }, + ], + series: [ + { + accessor: 1, + format: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + params: {}, + label: 'category.keyword: Descending', + aggType: 'terms', + }, + ], + }, + }, + data: { + searchSource: { + id: 'data_source1', + requestStartHandlers: [], + inheritOptions: {}, + history: [], + fields: { + query: { + query: '', + language: 'kuery', + }, + filter: [], + index: { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + title: 'kibana_sample_data_ecommerce', + fieldFormatMap: { + taxful_total_price: { + id: 'number', + params: { + pattern: '$0,0.[00]', + }, + }, + }, + fields: [ + { + count: 0, + name: '_id', + type: 'string', + esTypes: ['_id'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: '_index', + type: 'string', + esTypes: ['_index'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: '_score', + type: 'number', + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_source', + type: '_source', + esTypes: ['_source'], + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_type', + type: 'string', + esTypes: ['_type'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: 'category', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'category.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'category', + }, + }, + }, + { + count: 0, + name: 'currency', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'customer_birth_date', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'customer_first_name', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'customer_first_name.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'customer_first_name', + }, + }, + }, + { + count: 0, + name: 'customer_full_name', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'customer_full_name.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'customer_full_name', + }, + }, + }, + { + count: 0, + name: 'customer_gender', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'customer_id', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'customer_last_name', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'customer_last_name.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'customer_last_name', + }, + }, + }, + { + count: 0, + name: 'customer_phone', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'day_of_week', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'day_of_week_i', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'email', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'event.dataset', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'geoip.city_name', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'geoip.continent_name', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'geoip.country_iso_code', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'geoip.location', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'geoip.region_name', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'manufacturer', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'manufacturer.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'manufacturer', + }, + }, + }, + { + count: 0, + name: 'order_date', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'order_id', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products._id', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'products._id.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'products._id', + }, + }, + }, + { + count: 0, + name: 'products.base_price', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.base_unit_price', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.category', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'products.category.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'products.category', + }, + }, + }, + { + count: 0, + name: 'products.created_on', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.discount_amount', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.discount_percentage', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.manufacturer', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'products.manufacturer.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'products.manufacturer', + }, + }, + }, + { + count: 0, + name: 'products.min_price', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.price', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.product_id', + type: 'number', + esTypes: ['long'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.product_name', + type: 'string', + esTypes: ['text'], + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: 'products.product_name.keyword', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { + multi: { + parent: 'products.product_name', + }, + }, + }, + { + count: 0, + name: 'products.quantity', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.sku', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.tax_amount', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.taxful_price', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.taxless_price', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'products.unit_discount_amount', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'sku', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'taxful_total_price', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'taxless_total_price', + type: 'number', + esTypes: ['half_float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'total_quantity', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'total_unique_products', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'type', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'user', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + ], + timeFieldName: 'order_date', + metaFields: ['_source', '_id', '_type', '_index', '_score'], + version: 'WzEzLDFd', + originalSavedObjectBody: { + title: 'kibana_sample_data_ecommerce', + timeFieldName: 'order_date', + fields: + '[{"count":0,"name":"_id","type":"string","esTypes":["_id"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_index","type":"string","esTypes":["_index"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_score","type":"number","scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_source","type":"_source","esTypes":["_source"],"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_type","type":"string","esTypes":["_type"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"category","type":"string","esTypes":["text"],"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"category.keyword","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"category"}}},{"count":0,"name":"currency","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"customer_birth_date","type":"date","esTypes":["date"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"customer_first_name","type":"string","esTypes":["text"],"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"customer_first_name.keyword","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"customer_first_name"}}},{"count":0,"name":"customer_full_name","type":"string","esTypes":["text"],"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"customer_full_name.keyword","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"customer_full_name"}}},{"count":0,"name":"customer_gender","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"customer_id","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"customer_last_name","type":"string","esTypes":["text"],"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"customer_last_name.keyword","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"customer_last_name"}}},{"count":0,"name":"customer_phone","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"day_of_week","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"day_of_week_i","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"email","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"event.dataset","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"geoip.city_name","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"geoip.continent_name","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"geoip.country_iso_code","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"geoip.location","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"geoip.region_name","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"manufacturer","type":"string","esTypes":["text"],"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"manufacturer.keyword","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"manufacturer"}}},{"count":0,"name":"order_date","type":"date","esTypes":["date"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"order_id","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products._id","type":"string","esTypes":["text"],"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"products._id.keyword","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"products._id"}}},{"count":0,"name":"products.base_price","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.base_unit_price","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.category","type":"string","esTypes":["text"],"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"products.category.keyword","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"products.category"}}},{"count":0,"name":"products.created_on","type":"date","esTypes":["date"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.discount_amount","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.discount_percentage","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.manufacturer","type":"string","esTypes":["text"],"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"products.manufacturer.keyword","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"products.manufacturer"}}},{"count":0,"name":"products.min_price","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.price","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.product_id","type":"number","esTypes":["long"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.product_name","type":"string","esTypes":["text"],"scripted":false,"searchable":true,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"products.product_name.keyword","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true,"subType":{"multi":{"parent":"products.product_name"}}},{"count":0,"name":"products.quantity","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.sku","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.tax_amount","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.taxful_price","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.taxless_price","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"products.unit_discount_amount","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"sku","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"taxful_total_price","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"taxless_total_price","type":"number","esTypes":["half_float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"total_quantity","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"total_unique_products","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"type","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"user","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true}]', + fieldFormatMap: + '{"taxful_total_price":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"$0,0.[00]"}}}', + }, + shortDotsEnable: false, + fieldFormats: { + fieldFormats: {}, + defaultMap: { + ip: { + id: 'ip', + params: {}, + }, + date: { + id: 'date', + params: {}, + }, + date_nanos: { + id: 'date_nanos', + params: {}, + es: true, + }, + number: { + id: 'number', + params: {}, + }, + boolean: { + id: 'boolean', + params: {}, + }, + _source: { + id: '_source', + params: {}, + }, + _default_: { + id: 'string', + params: {}, + }, + }, + metaParamsOptions: {}, + }, + }, + }, + dependencies: { + legacy: { + loadingCount$: { + _isScalar: false, + observers: [ + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + destination: { + closed: true, + }, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [ + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 13, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 3, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + null, + ], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + null, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + }, + }, + aggs: { + typesRegistry: {}, + bySchemaName: () => [ + { + id: '1', + enabled: true, + type: 'sum', + params: { + field: 'total_quantity', + }, + schema: 'metric', + makeLabel: () => 'Total quantity', + toSerializedFieldFormat: () => ({ + id: 'number', + params: { + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + }, + }), + }, + ], + getResponseAggs: () => [ + { + id: '1', + enabled: true, + type: 'sum', + params: { + field: 'total_quantity', + }, + schema: 'metric', + toSerializedFieldFormat: () => ({ + id: 'number', + params: { + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + }, + }), + }, + { + id: '2', + enabled: true, + type: 'date_histogram', + params: { + field: 'order_date', + timeRange: { + from: '2020-09-30T12:41:13.795Z', + to: '2020-10-15T17:00:00.000Z', + }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: 'auto', + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + schema: 'segment', + toSerializedFieldFormat: () => ({ + id: 'date', + params: { pattern: 'HH:mm:ss.SSS' }, + }), + }, + { + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'category.keyword', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'group', + toSerializedFieldFormat: () => ({ + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + }, + }), + }, + ], + }, + }, + isHierarchical: () => false, + uiState: {}, +}; diff --git a/src/plugins/vis_types/heatmap/public/to_ast.test.ts b/src/plugins/vis_types/heatmap/public/to_ast.test.ts new file mode 100644 index 0000000000000..ec6dfd92e5d6a --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/to_ast.test.ts @@ -0,0 +1,49 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Vis } from 'src/plugins/visualizations/public'; +import { sampleAreaVis } from './sample_vis.test.mocks'; +import { buildExpression } from '../../../expressions/public'; + +import { toExpressionAst } from './to_ast'; +import { HeatmapVisParams } from './types'; + +jest.mock('../../../expressions/public', () => ({ + ...(jest.requireActual('../../../expressions/public') as any), + buildExpression: jest.fn().mockImplementation(() => ({ + toAst: () => ({ + type: 'expression', + chain: [], + }), + })), +})); + +jest.mock('./to_ast_esaggs', () => ({ + getEsaggsFn: jest.fn(), +})); + +describe('heatmap vis toExpressionAst function', () => { + let vis: Vis; + + const params = { + timefilter: {}, + timeRange: {}, + abortSignal: {}, + } as any; + + beforeEach(() => { + vis = sampleAreaVis as any; + }); + + it('should match basic snapshot', () => { + toExpressionAst(vis, params); + const [, builtExpression] = (buildExpression as jest.Mock).mock.calls.pop()[0]; + + expect(builtExpression).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_types/heatmap/public/to_ast.ts b/src/plugins/vis_types/heatmap/public/to_ast.ts new file mode 100644 index 0000000000000..d4fa5c8574dfe --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/to_ast.ts @@ -0,0 +1,130 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { VisToExpressionAst, getVisSchemas, SchemaConfig } from '../../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../../expressions/public'; +import { getStopsWithColorsFromRanges, getStopsWithColorsFromColorsNumber } from './utils/palette'; +import type { HeatmapVisParams } from './types'; +import { getEsaggsFn } from './to_ast_esaggs'; + +const DEFAULT_PERCENT_DECIMALS = 2; + +const prepareLegend = (params: HeatmapVisParams) => { + const legend = buildExpressionFunction('heatmap_legend', { + isVisible: params.addLegend, + position: params.legendPosition, + shouldTruncate: params.truncateLegend ?? true, + maxLines: params.maxLegendLines ?? 1, + }); + + return buildExpression([legend]); +}; + +const prepareDimension = (params: SchemaConfig) => { + const visdimension = buildExpressionFunction('visdimension', { accessor: params.accessor }); + + if (params.format) { + visdimension.addArgument('format', params.format.id); + visdimension.addArgument('formatParams', JSON.stringify(params.format.params)); + } + + return buildExpression([visdimension]); +}; + +const prepareGrid = (params: HeatmapVisParams) => { + const gridConfig = buildExpressionFunction('heatmap_grid', { + isCellLabelVisible: params.valueAxes?.[0].labels.show ?? false, + isXAxisLabelVisible: true, + }); + + return buildExpression([gridConfig]); +}; + +export const toExpressionAst: VisToExpressionAst = async (vis, params) => { + const schemas = getVisSchemas(vis, params); + + // fix formatter for percentage mode + if (vis.params.percentageMode === true) { + schemas.metric.forEach((metric: SchemaConfig) => { + metric.format = { + id: 'percent', + params: { + pattern: + vis.params.percentageFormatPattern ?? `0,0.[${'0'.repeat(DEFAULT_PERCENT_DECIMALS)}]%`, + }, + }; + }); + } + + const expressionArgs = { + showTooltip: vis.params.addTooltip, + highlightInHover: vis.params.enableHover, + lastRangeIsRightOpen: vis.params.lastRangeIsRightOpen ?? false, + percentageMode: vis.params.percentageMode, + legend: prepareLegend(vis.params), + gridConfig: prepareGrid(vis.params), + }; + + const visTypeHeatmap = buildExpressionFunction('heatmap', expressionArgs); + if (schemas.metric.length) { + visTypeHeatmap.addArgument('valueAccessor', prepareDimension(schemas.metric[0])); + } + if (schemas.segment && schemas.segment.length) { + visTypeHeatmap.addArgument('xAccessor', prepareDimension(schemas.segment[0])); + } + if (schemas.group && schemas.group.length) { + visTypeHeatmap.addArgument('yAccessor', prepareDimension(schemas.group[0])); + } + if (schemas.split_row && schemas.split_row.length) { + visTypeHeatmap.addArgument('splitRowAccessor', prepareDimension(schemas.split_row[0])); + } + if (schemas.split_column && schemas.split_column.length) { + visTypeHeatmap.addArgument('splitColumnAccessor', prepareDimension(schemas.split_column[0])); + } + let palette; + if (vis.params.setColorRange && vis.params.colorsRange && vis.params.colorsRange.length) { + const stopsWithColors = getStopsWithColorsFromRanges( + vis.params.colorsRange, + vis.params.colorSchema, + vis.params.invertColors + ); + // palette is type of number, if user gives specific ranges + palette = buildExpressionFunction('palette', { + ...stopsWithColors, + range: 'number', + continuity: 'none', + rangeMin: + vis.params.setColorRange && vis.params.colorsRange && vis.params.colorsRange.length + ? vis.params.colorsRange[0].from + : undefined, + rangeMax: + vis.params.setColorRange && vis.params.colorsRange && vis.params.colorsRange.length + ? vis.params.colorsRange[vis.params?.colorsRange.length - 1].to + : undefined, + }); + } else { + // palette is type of percent, if user wants dynamic calulated ranges + const stopsWithColors = getStopsWithColorsFromColorsNumber( + vis.params.colorsNumber, + vis.params.colorSchema, + vis.params.invertColors + ); + palette = buildExpressionFunction('palette', { + ...stopsWithColors, + range: 'percent', + continuity: 'none', + rangeMin: 0, + rangeMax: 100, + }); + } + visTypeHeatmap.addArgument('palette', buildExpression([palette])); + + const ast = buildExpression([getEsaggsFn(vis), visTypeHeatmap]); + + return ast.toAst(); +}; diff --git a/src/plugins/vis_types/heatmap/public/to_ast_esaggs.ts b/src/plugins/vis_types/heatmap/public/to_ast_esaggs.ts new file mode 100644 index 0000000000000..9b6e02928f7a9 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/to_ast_esaggs.ts @@ -0,0 +1,33 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Vis } from '../../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../../expressions/public'; +import { + EsaggsExpressionFunctionDefinition, + IndexPatternLoadExpressionFunctionDefinition, +} from '../../../data/public'; + +import { HeatmapVisParams } from './types'; + +/** + * Get esaggs expressions function + * @param vis + */ +export function getEsaggsFn(vis: Vis) { + return buildExpressionFunction('esaggs', { + index: buildExpression([ + buildExpressionFunction('indexPatternLoad', { + id: vis.data.indexPattern!.id!, + }), + ]), + metricsAtAllLevels: vis.isHierarchical(), + partialRows: false, + aggs: vis.data.aggs!.aggs.map((agg) => buildExpression(agg.toExpressionAst())), + }); +} diff --git a/src/plugins/vis_types/heatmap/public/types.ts b/src/plugins/vis_types/heatmap/public/types.ts new file mode 100644 index 0000000000000..b02dad8656c83 --- /dev/null +++ b/src/plugins/vis_types/heatmap/public/types.ts @@ -0,0 +1,80 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { UiCounterMetricType } from '@kbn/analytics'; +import type { Position } from '@elastic/charts'; +import type { ChartsPluginSetup, Style, Labels, ColorSchemas } from '../../../charts/public'; +import { Range } from '../../../expressions/public'; + +export interface HeatmapTypeProps { + showElasticChartsOptions?: boolean; + palettes?: ChartsPluginSetup['palettes']; + trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; +} + +export interface HeatmapVisParams { + addLegend: boolean; + addTooltip: boolean; + enableHover: boolean; + legendPosition: Position; + truncateLegend?: boolean; + maxLegendLines?: number; + lastRangeIsRightOpen: boolean; + percentageMode: boolean; + valueAxes: ValueAxis[]; + colorSchema: ColorSchemas; + invertColors: boolean; + colorsNumber: number | ''; + setColorRange: boolean; + colorsRange?: Range[]; + percentageFormatPattern?: string; +} + +// ToDo: move them to constants +export enum ScaleType { + Linear = 'linear', + Log = 'log', + SquareRoot = 'square root', +} + +export enum AxisType { + Category = 'category', + Value = 'value', +} +export enum AxisMode { + Normal = 'normal', + Percentage = 'percentage', + Wiggle = 'wiggle', + Silhouette = 'silhouette', +} + +export interface Scale { + boundsMargin?: number | ''; + defaultYExtents?: boolean; + max?: number | null; + min?: number | null; + mode?: AxisMode; + setYExtents?: boolean; + type: ScaleType; +} + +interface CategoryAxis { + id: string; + labels: Labels; + position: Position; + scale: Scale; + show: boolean; + title?: { + text?: string; + }; + type: AxisType; + style?: Partial