diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts b/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts index ed0391a16af25..053b46e480c7b 100644 --- a/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts +++ b/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts @@ -49,7 +49,7 @@ export const pie: ExpressionFunctionDefinition< }, shape: { types: ['string'], - options: ['pie', 'donut', 'treemap'], + options: ['pie', 'donut', 'treemap', 'mosaic'], help: '', }, hideLabels: { diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts index 8712675740f1c..00fc7abaa043b 100644 --- a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts +++ b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts @@ -8,6 +8,8 @@ import type { PaletteOutput } from '../../../../../../src/plugins/charts/common'; import type { LensMultiTable, LayerType } from '../../types'; +export type PieChartTypes = 'donut' | 'pie' | 'treemap' | 'mosaic'; + export interface SharedPieLayerState { groups: string[]; metric?: string; @@ -27,7 +29,7 @@ export type PieLayerState = SharedPieLayerState & { }; export interface PieVisualizationState { - shape: 'donut' | 'pie' | 'treemap'; + shape: PieChartTypes; layers: PieLayerState[]; palette?: PaletteOutput; } @@ -35,7 +37,7 @@ export interface PieVisualizationState { export type PieExpressionArgs = SharedPieLayerState & { title?: string; description?: string; - shape: 'pie' | 'donut' | 'treemap'; + shape: PieChartTypes; hideLabels: boolean; palette: PaletteOutput; }; diff --git a/x-pack/plugins/lens/public/assets/chart_mosaic.tsx b/x-pack/plugins/lens/public/assets/chart_mosaic.tsx new file mode 100644 index 0000000000000..c385f0df1a008 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_mosaic.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { EuiIconProps } from '@elastic/eui'; + +export const LensIconChartMosaic = ({ title, titleId, ...props }: Omit) => ( + + {title ? : null} + + + +); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 92633d5e7305b..a6be4acfbbcf1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -225,7 +225,39 @@ describe('LayerPanel', () => { const group = instance .find(EuiFormRow) - .findWhere((e) => e.prop('error') === 'Required dimension'); + .findWhere((e) => e.prop('error') === 'Requires field'); + + expect(group).toHaveLength(1); + }); + + it('should render the required warning when only one group is configured (with requiredMinDimensionCount)', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + { + groupLabel: 'B', + groupId: 'b', + accessors: [{ columnId: 'y' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + requiredMinDimensionCount: 2, + }, + ], + }); + + const { instance } = await mountWithProvider(); + + const group = instance + .find(EuiFormRow) + .findWhere((e) => e.prop('error') === 'Requires 2 fields'); expect(group).toHaveLength(1); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 6af3d88b17d41..84c7722ca1b88 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -385,7 +385,27 @@ export function LayerPanel( {groups.map((group, groupIndex) => { - const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; + let isMissing = false; + + if (!isEmptyLayer) { + if (group.requiredMinDimensionCount) { + isMissing = group.accessors.length < group.requiredMinDimensionCount; + } else if (group.required) { + isMissing = group.accessors.length === 0; + } + } + + const isMissingError = group.requiredMinDimensionCount + ? i18n.translate('xpack.lens.editorFrame.requiresTwoOrMoreFieldsWarningLabel', { + defaultMessage: 'Requires {requiredMinDimensionCount} fields', + values: { + requiredMinDimensionCount: group.requiredMinDimensionCount, + }, + }) + : i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', { + defaultMessage: 'Requires field', + }); + const isOptional = !group.required; return ( <> {group.accessors.length ? ( diff --git a/x-pack/plugins/lens/public/pie_visualization/constants.ts b/x-pack/plugins/lens/public/pie_visualization/constants.ts index 9a2f39e7d34a5..be0afc65aed3b 100644 --- a/x-pack/plugins/lens/public/pie_visualization/constants.ts +++ b/x-pack/plugins/lens/public/pie_visualization/constants.ts @@ -6,41 +6,100 @@ */ import { i18n } from '@kbn/i18n'; +import { PartitionLayout } from '@elastic/charts'; import { LensIconChartDonut } from '../assets/chart_donut'; import { LensIconChartPie } from '../assets/chart_pie'; import { LensIconChartTreemap } from '../assets/chart_treemap'; +import { LensIconChartMosaic } from '../assets/chart_mosaic'; + +import type { SharedPieLayerState } from '../../common/expressions'; + +interface CategoryOption { + value: SharedPieLayerState['categoryDisplay']; + inputDisplay: string; +} const groupLabel = i18n.translate('xpack.lens.pie.groupLabel', { defaultMessage: 'Proportion', }); +const categoryOptions: CategoryOption[] = [ + { + value: 'default', + inputDisplay: i18n.translate('xpack.lens.pieChart.showCategoriesLabel', { + defaultMessage: 'Inside or outside', + }), + }, + { + value: 'inside', + inputDisplay: i18n.translate('xpack.lens.pieChart.fitInsideOnlyLabel', { + defaultMessage: 'Inside only', + }), + }, + { + value: 'hide', + inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { + defaultMessage: 'Hide labels', + }), + }, +]; + +const categoryOptionsTreemap: CategoryOption[] = [ + { + value: 'default', + inputDisplay: i18n.translate('xpack.lens.pieChart.showTreemapCategoriesLabel', { + defaultMessage: 'Show labels', + }), + }, + { + value: 'hide', + inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { + defaultMessage: 'Hide labels', + }), + }, +]; + export const CHART_NAMES = { donut: { icon: LensIconChartDonut, label: i18n.translate('xpack.lens.pie.donutLabel', { defaultMessage: 'Donut', }), + partitionType: PartitionLayout.sunburst, groupLabel, + categoryOptions, }, pie: { icon: LensIconChartPie, label: i18n.translate('xpack.lens.pie.pielabel', { defaultMessage: 'Pie', }), - + partitionType: PartitionLayout.sunburst, groupLabel, + categoryOptions, }, treemap: { icon: LensIconChartTreemap, label: i18n.translate('xpack.lens.pie.treemaplabel', { defaultMessage: 'Treemap', }), - + partitionType: PartitionLayout.treemap, + groupLabel, + categoryOptions: categoryOptionsTreemap, + }, + mosaic: { + icon: LensIconChartMosaic, + label: i18n.translate('xpack.lens.pie.mosaiclabel', { + defaultMessage: 'Mosaic', + }), + partitionType: PartitionLayout.mosaic, groupLabel, + categoryOptions: [] as CategoryOption[], }, }; export const MAX_PIE_BUCKETS = 3; export const MAX_TREEMAP_BUCKETS = 2; +export const MAX_MOSAIC_BUCKETS = 2; export const DEFAULT_PERCENT_DECIMALS = 2; diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 05b9ca9c34168..2bf9827bb976e 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -16,7 +16,6 @@ import { Partition, PartitionConfig, PartitionLayer, - PartitionLayout, PartitionFillLabel, RecursivePartial, Position, @@ -29,7 +28,13 @@ import { VisualizationContainer } from '../visualization_container'; import { CHART_NAMES, DEFAULT_PERCENT_DECIMALS } from './constants'; import type { FormatFactory } from '../../common'; import type { PieExpressionProps } from '../../common/expressions'; -import { getSliceValue, getFilterContext } from './render_helpers'; +import { + getSliceValue, + getFilterContext, + isTreemapOrMosaicShape, + byDataColorPaletteMap, + extractUniqTermsMap, +} from './render_helpers'; import { EmptyPlaceholder } from '../shared_components'; import './visualization.scss'; import { @@ -110,6 +115,22 @@ export function PieComponent( }) ).length; + const shouldUseByDataPalette = !syncColors && ['mosaic'].includes(shape) && bucketColumns[1]?.id; + let byDataPalette: ReturnType; + if (shouldUseByDataPalette) { + byDataPalette = byDataColorPaletteMap( + firstTable, + bucketColumns[1].id, + paletteService.get(palette.name), + palette + ); + } + + let sortingMap: Record; + if (shape === 'mosaic') { + sortingMap = extractUniqTermsMap(firstTable, bucketColumns[0].id); + } + const layers: PartitionLayer[] = bucketColumns.map((col, layerIndex) => { return { groupByRollup: (d: Datum) => d[col.id] ?? EMPTY_SLICE, @@ -124,13 +145,29 @@ export function PieComponent( return String(d); }, fillLabel, + sortPredicate: + shape === 'mosaic' + ? ([name1, node1], [, node2]) => { + // Sorting for first group + if (bucketColumns.length === 1 || (node1.children.length && name1 in sortingMap)) { + return sortingMap[name1]; + } + // Sorting for second group + return node2.value - node1.value; + } + : undefined, shape: { fillColor: (d) => { const seriesLayers: SeriesLayer[] = []; + // Mind the difference here: the contrast computation for the text ignores the alpha/opacity + // therefore change it for dask mode + const defaultColor = isDarkMode ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)'; + // Color is determined by round-robin on the index of the innermost slice // This has to be done recursively until we get to the slice index let tempParent: typeof d | typeof d['parent'] = d; + while (tempParent.parent && tempParent.depth > 0) { seriesLayers.unshift({ name: String(tempParent.parent.children[tempParent.sortIndex][0]), @@ -140,12 +177,14 @@ export function PieComponent( tempParent = tempParent.parent; } - if (shape === 'treemap') { + if (byDataPalette && seriesLayers[1]) { + return byDataPalette.getColor(seriesLayers[1].name) || defaultColor; + } + + if (isTreemapOrMosaicShape(shape)) { // Only highlight the innermost color of the treemap, as it accurately represents area if (layerIndex < bucketColumns.length - 1) { - // Mind the difference here: the contrast computation for the text ignores the alpha/opacity - // therefore change it for dask mode - return isDarkMode ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)'; + return defaultColor; } // only use the top level series layer for coloring if (seriesLayers.length > 1) { @@ -164,14 +203,14 @@ export function PieComponent( palette.params ); - return outputColor || 'rgba(0,0,0,0)'; + return outputColor || defaultColor; }, }, }; }); const config: RecursivePartial = { - partitionLayout: shape === 'treemap' ? PartitionLayout.treemap : PartitionLayout.sunburst, + partitionLayout: CHART_NAMES[shape].partitionType, fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, outerSizeRatio: 1, specialFirstInnermostSector: true, @@ -191,7 +230,7 @@ export function PieComponent( sectorLineWidth: 1.5, circlePadding: 4, }; - if (shape === 'treemap') { + if (isTreemapOrMosaicShape(shape)) { if (hideLabels || categoryDisplay === 'hide') { config.fillLabel = { textColor: 'rgba(0,0,0,0)' }; } @@ -279,7 +318,9 @@ export function PieComponent( showLegend={ !hideLabels && (legendDisplay === 'show' || - (legendDisplay === 'default' && bucketColumns.length > 1 && shape !== 'treemap')) + (legendDisplay === 'default' && + bucketColumns.length > 1 && + !isTreemapOrMosaicShape(shape))) } legendPosition={legendPosition || Position.Right} legendMaxDepth={nestedLegend ? undefined : 1 /* Color is based only on first layer */} diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts index 7c55c0fa61931..dd27632b36e44 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts @@ -5,8 +5,16 @@ * 2.0. */ -import { Datatable } from 'src/plugins/expressions/public'; -import { getSliceValue, getFilterContext } from './render_helpers'; +import type { Datatable } from 'src/plugins/expressions/public'; +import type { PaletteDefinition, PaletteOutput } from 'src/plugins/charts/public'; + +import { + getSliceValue, + getFilterContext, + byDataColorPaletteMap, + extractUniqTermsMap, +} from './render_helpers'; +import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; describe('render helpers', () => { describe('#getSliceValue', () => { @@ -200,4 +208,113 @@ describe('render helpers', () => { }); }); }); + + describe('#extractUniqTermsMap', () => { + it('should extract map', () => { + const table: Datatable = { + type: 'datatable', + columns: [ + { id: 'a', name: 'A', meta: { type: 'string' } }, + { id: 'b', name: 'B', meta: { type: 'string' } }, + { id: 'c', name: 'C', meta: { type: 'number' } }, + ], + rows: [ + { a: 'Hi', b: 'Two', c: 2 }, + { a: 'Test', b: 'Two', c: 5 }, + { a: 'Foo', b: 'Three', c: 6 }, + ], + }; + expect(extractUniqTermsMap(table, 'a')).toMatchInlineSnapshot(` + Object { + "Foo": 2, + "Hi": 0, + "Test": 1, + } + `); + expect(extractUniqTermsMap(table, 'b')).toMatchInlineSnapshot(` + Object { + "Three": 1, + "Two": 0, + } + `); + }); + }); + + describe('#byDataColorPaletteMap', () => { + let datatable: Datatable; + let paletteDefinition: PaletteDefinition; + let palette: PaletteOutput; + const columnId = 'foo'; + + beforeEach(() => { + datatable = { + rows: [ + { + [columnId]: '1', + }, + { + [columnId]: '2', + }, + ], + } as unknown as Datatable; + paletteDefinition = chartPluginMock.createPaletteRegistry().get('default'); + palette = { type: 'palette' } as PaletteOutput; + }); + + it('should create byDataColorPaletteMap', () => { + expect(byDataColorPaletteMap(datatable, columnId, paletteDefinition, palette)) + .toMatchInlineSnapshot(` + Object { + "getColor": [Function], + } + `); + }); + + it('should get color', () => { + const colorPaletteMap = byDataColorPaletteMap( + datatable, + columnId, + paletteDefinition, + palette + ); + + expect(colorPaletteMap.getColor('1')).toBe('black'); + }); + + it('should return undefined in case if values not in datatable', () => { + const colorPaletteMap = byDataColorPaletteMap( + datatable, + columnId, + paletteDefinition, + palette + ); + + expect(colorPaletteMap.getColor('wrong')).toBeUndefined(); + }); + + it('should increase rankAtDepth for each new value', () => { + const colorPaletteMap = byDataColorPaletteMap( + datatable, + columnId, + paletteDefinition, + palette + ); + colorPaletteMap.getColor('1'); + colorPaletteMap.getColor('2'); + + expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith( + 1, + [{ name: '1', rankAtDepth: 0, totalSeriesAtDepth: 2 }], + { behindText: false }, + undefined + ); + + expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith( + 2, + [{ name: '2', rankAtDepth: 1, totalSeriesAtDepth: 2 }], + { behindText: false }, + undefined + ); + }); + }); }); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts index d2858efa90153..bdffacde65639 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts @@ -5,9 +5,11 @@ * 2.0. */ -import { Datum, LayerValue } from '@elastic/charts'; -import { Datatable, DatatableColumn } from 'src/plugins/expressions/public'; -import { LensFilterEvent } from '../types'; +import type { Datum, LayerValue } from '@elastic/charts'; +import type { Datatable, DatatableColumn } from 'src/plugins/expressions/public'; +import type { LensFilterEvent } from '../types'; +import type { PieChartTypes } from '../../common/expressions/pie_chart/types'; +import type { PaletteDefinition, PaletteOutput } from '../../../../../src/plugins/charts/public'; export function getSliceValue(d: Datum, metricColumn: DatatableColumn) { const value = d[metricColumn.id]; @@ -35,3 +37,61 @@ export function getFilterContext( })), }; } + +export const isPartitionShape = (shape: PieChartTypes | string) => + ['donut', 'pie', 'treemap', 'mosaic'].includes(shape); + +export const isTreemapOrMosaicShape = (shape: PieChartTypes | string) => + ['treemap', 'mosaic'].includes(shape); + +export const extractUniqTermsMap = (dataTable: Datatable, columnId: string) => + [...new Set(dataTable.rows.map((item) => item[columnId]))].reduce( + (acc, item, index) => ({ + ...acc, + [item]: index, + }), + {} + ); + +export const byDataColorPaletteMap = ( + dataTable: Datatable, + columnId: string, + paletteDefinition: PaletteDefinition, + { params }: PaletteOutput +) => { + const colorMap = new Map( + dataTable.rows.map((item) => [String(item[columnId]), undefined]) + ); + let rankAtDepth = 0; + + return { + getColor: (item: unknown) => { + const key = String(item); + + if (colorMap.has(key)) { + let color = colorMap.get(key); + + if (color) { + return color; + } + color = + paletteDefinition.getCategoricalColor( + [ + { + name: key, + totalSeriesAtDepth: colorMap.size, + rankAtDepth: rankAtDepth++, + }, + ], + { + behindText: false, + }, + params + ) || undefined; + + colorMap.set(key, color); + return color; + } + }, + }; +}; diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 5a57371eb6459..656d00960766e 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -6,9 +6,9 @@ */ import { PaletteOutput } from 'src/plugins/charts/public'; -import { DataType, SuggestionRequest } from '../types'; import { suggestions } from './suggestions'; -import { PieVisualizationState } from '../../common/expressions'; +import type { DataType, SuggestionRequest } from '../types'; +import type { PieLayerState, PieVisualizationState } from '../../common/expressions'; import { layerTypes } from '../../common'; describe('suggestions', () => { @@ -144,6 +144,38 @@ describe('suggestions', () => { ).toHaveLength(0); }); + it('should not reject histogram operations in case of switching between partition charts', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'b', + operation: { + label: 'Durations', + dataType: 'number' as DataType, + isBucketed: true, + scale: 'interval', + }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: { + shape: 'mosaic', + layers: [{} as PieLayerState], + }, + keptLayerIds: ['first'], + }).length + ).toBeGreaterThan(0); + }); + it('should reject when there are too many buckets', () => { expect( suggestions({ @@ -272,7 +304,7 @@ describe('suggestions', () => { state: undefined, keptLayerIds: ['first'], }); - expect(currentSuggestions).toHaveLength(3); + expect(currentSuggestions).toHaveLength(4); expect(currentSuggestions.every((s) => s.hide)).toEqual(true); }); @@ -292,7 +324,7 @@ describe('suggestions', () => { state: undefined, keptLayerIds: ['first'], }); - expect(currentSuggestions).toHaveLength(3); + expect(currentSuggestions).toHaveLength(4); expect(currentSuggestions.every((s) => s.hide)).toEqual(true); }); @@ -721,4 +753,172 @@ describe('suggestions', () => { ); }); }); + + describe('mosaic', () => { + it('should reject when currently active and unchanged data', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'unchanged', + }, + state: { + shape: 'mosaic', + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + groups: [], + metric: 'a', + + numberDisplay: 'hidden', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('mosaic type should be added only in case of 2 groups', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 6', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'unchanged', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + groups: ['a', 'b'], + metric: 'c', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + legendMaxLines: 1, + truncateLegend: true, + nestedLegend: true, + }, + ], + }, + keptLayerIds: ['first'], + }).filter(({ hide, state }) => !hide && state.shape === 'mosaic') + ).toMatchInlineSnapshot(` + Array [ + Object { + "hide": false, + "previewIcon": "bullseye", + "score": 0.6, + "state": Object { + "layers": Array [ + Object { + "categoryDisplay": "default", + "groups": Array [ + "a", + "b", + ], + "layerId": "first", + "layerType": "data", + "legendDisplay": "show", + "legendMaxLines": 1, + "metric": "c", + "nestedLegend": true, + "numberDisplay": "hidden", + "percentDecimals": 0, + "truncateLegend": true, + }, + ], + "palette": undefined, + "shape": "mosaic", + }, + "title": "As Mosaic", + }, + ] + `); + }); + + it('mosaic type should be added only in case of 2 groups (negative test)', () => { + const meta: Parameters[0] = { + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'unchanged', + }, + state: { + shape: 'pie', + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + groups: ['a', 'b'], + metric: 'c', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + legendMaxLines: 1, + truncateLegend: true, + nestedLegend: true, + }, + ], + }, + keptLayerIds: ['first'], + }; + + // test with 1 group + expect( + suggestions(meta).filter(({ hide, state }) => !hide && state.shape === 'mosaic') + ).toMatchInlineSnapshot(`Array []`); + + meta.table.columns.push({ + columnId: 'b', + operation: { label: 'Top 6', dataType: 'string' as DataType, isBucketed: true }, + }); + + meta.table.columns.push({ + columnId: 'c', + operation: { label: 'Top 7', dataType: 'string' as DataType, isBucketed: true }, + }); + + // test with 3 groups + expect( + suggestions(meta).filter(({ hide, state }) => !hide && state.shape === 'mosaic') + ).toMatchInlineSnapshot(`Array []`); + }); + }); }); diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 9078e18588a2f..30cd63752f420 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -7,17 +7,26 @@ import { partition } from 'lodash'; import { i18n } from '@kbn/i18n'; -import type { SuggestionRequest, VisualizationSuggestion } from '../types'; +import type { SuggestionRequest, TableSuggestionColumn, VisualizationSuggestion } from '../types'; import { layerTypes } from '../../common'; import type { PieVisualizationState } from '../../common/expressions'; -import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; +import { CHART_NAMES, MAX_MOSAIC_BUCKETS, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; +import { isPartitionShape, isTreemapOrMosaicShape } from './render_helpers'; + +function hasIntervalScale(columns: TableSuggestionColumn[]) { + return columns.some((col) => col.operation.scale === 'interval'); +} + +function shouldReject({ table, keptLayerIds, state }: SuggestionRequest) { + // Histograms are not good for pi. But we should not reject them on switching between partition charts. + const shouldRejectIntervals = + state?.shape && isPartitionShape(state.shape) ? false : hasIntervalScale(table.columns); -function shouldReject({ table, keptLayerIds }: SuggestionRequest) { return ( keptLayerIds.length > 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || table.changeType === 'reorder' || - table.columns.some((col) => col.operation.scale === 'interval') // Histograms are not good for pie + shouldRejectIntervals ); } @@ -52,7 +61,7 @@ export function suggestions({ const results: Array> = []; - if (groups.length <= MAX_PIE_BUCKETS && subVisualizationId !== 'treemap') { + if (groups.length <= MAX_PIE_BUCKETS && !isTreemapOrMosaicShape(subVisualizationId!)) { let newShape: PieVisualizationState['shape'] = (subVisualizationId as PieVisualizationState['shape']) || 'donut'; if (groups.length !== 1 && !subVisualizationId) { @@ -65,7 +74,7 @@ export function suggestions({ values: { chartName: CHART_NAMES[newShape].label }, description: 'chartName is already translated', }), - score: state && state.shape !== 'treemap' ? 0.6 : 0.4, + score: state && !isTreemapOrMosaicShape(state.shape) ? 0.6 : 0.4, state: { shape: newShape, palette: mainPalette || state?.palette, @@ -92,7 +101,10 @@ export function suggestions({ }, previewIcon: 'bullseye', // dont show suggestions for same type - hide: table.changeType === 'reduced' || (state && state.shape !== 'treemap'), + hide: + table.changeType === 'reduced' || + hasIntervalScale(groups) || + (state && !isTreemapOrMosaicShape(state.shape)), }; results.push(baseSuggestion); @@ -153,7 +165,54 @@ export function suggestions({ }, previewIcon: 'bullseye', // hide treemap suggestions from bottom bar, but keep them for chart switcher - hide: table.changeType === 'reduced' || !state || (state && state.shape === 'treemap'), + hide: + table.changeType === 'reduced' || + !state || + hasIntervalScale(groups) || + (state && state.shape === 'treemap'), + }); + } + + if ( + groups.length <= MAX_MOSAIC_BUCKETS && + (!subVisualizationId || subVisualizationId === 'mosaic') + ) { + results.push({ + title: i18n.translate('xpack.lens.pie.mosaicSuggestionLabel', { + defaultMessage: 'As Mosaic', + }), + score: state?.shape === 'mosaic' ? 0.7 : 0.5, + state: { + shape: 'mosaic', + palette: mainPalette || state?.palette, + layers: [ + state?.layers[0] + ? { + ...state.layers[0], + layerId: table.layerId, + groups: groups.map((col) => col.columnId), + metric: metricColumnId, + categoryDisplay: 'default', + layerType: layerTypes.DATA, + } + : { + layerId: table.layerId, + groups: groups.map((col) => col.columnId), + metric: metricColumnId, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + layerType: layerTypes.DATA, + }, + ], + }, + previewIcon: 'bullseye', + hide: + groups.length !== 2 || + table.changeType === 'reduced' || + hasIntervalScale(groups) || + (state && state.shape === 'mosaic'), }); } diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 685a8392dcfd3..23003a4ec3404 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import type { Position } from '@elastic/charts'; import type { PaletteRegistry } from 'src/plugins/charts/public'; -import { DEFAULT_PERCENT_DECIMALS } from './constants'; +import { DEFAULT_PERCENT_DECIMALS, CHART_NAMES } from './constants'; import type { PieVisualizationState, SharedPieLayerState } from '../../common/expressions'; import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components'; @@ -47,48 +47,6 @@ const numberOptions: Array<{ }, ]; -const categoryOptions: Array<{ - value: SharedPieLayerState['categoryDisplay']; - inputDisplay: string; -}> = [ - { - value: 'default', - inputDisplay: i18n.translate('xpack.lens.pieChart.showCategoriesLabel', { - defaultMessage: 'Inside or outside', - }), - }, - { - value: 'inside', - inputDisplay: i18n.translate('xpack.lens.pieChart.fitInsideOnlyLabel', { - defaultMessage: 'Inside only', - }), - }, - { - value: 'hide', - inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { - defaultMessage: 'Hide labels', - }), - }, -]; - -const categoryOptionsTreemap: Array<{ - value: SharedPieLayerState['categoryDisplay']; - inputDisplay: string; -}> = [ - { - value: 'default', - inputDisplay: i18n.translate('xpack.lens.pieChart.showTreemapCategoriesLabel', { - defaultMessage: 'Show labels', - }), - }, - { - value: 'hide', - inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { - defaultMessage: 'Hide labels', - }), - }, -]; - const legendOptions: Array<{ value: SharedPieLayerState['legendDisplay']; label: string; @@ -133,25 +91,27 @@ export function PieToolbar(props: VisualizationToolbarProps - - { - setState({ - ...state, - layers: [{ ...layer, categoryDisplay: option }], - }); - }} - /> - + {state.shape && CHART_NAMES[state.shape].categoryOptions.length ? ( + + { + setState({ + ...state, + layers: [{ ...layer, categoryDisplay: option }], + }); + }} + /> + + ) : null} op.isBucketed; const numberMetricOperations = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; +const applyPaletteToColumnConfig = ( + columns: AccessorConfig[], + { shape, palette }: PieVisualizationState, + paletteService: PaletteRegistry +) => { + const colorPickerIndex = shape === 'mosaic' ? columns.length - 1 : 0; + + if (colorPickerIndex >= 0) { + columns[colorPickerIndex] = { + columnId: columns[colorPickerIndex].columnId, + triggerIcon: 'colorBy', + palette: paletteService + .get(palette?.name || 'default') + .getCategoricalColors(10, palette?.params), + }; + } +}; + export const getPieVisualization = ({ paletteService, }: { @@ -61,6 +84,13 @@ export const getPieVisualization = ({ label: CHART_NAMES.treemap.label, groupLabel: CHART_NAMES.treemap.groupLabel, }, + { + id: 'mosaic', + icon: CHART_NAMES.mosaic.icon, + label: CHART_NAMES.mosaic.label, + showExperimentalBadge: true, + groupLabel: CHART_NAMES.mosaic.groupLabel, + }, ], getVisualizationTypeId(state) { @@ -79,13 +109,7 @@ export const getPieVisualization = ({ }, getDescription(state) { - if (state.shape === 'treemap') { - return CHART_NAMES.treemap; - } - if (state.shape === 'donut') { - return CHART_NAMES.donut; - } - return CHART_NAMES.pie; + return CHART_NAMES[state.shape] ?? CHART_NAMES.pie; }, switchVisualizationType: (visualizationTypeId, state) => ({ @@ -122,76 +146,58 @@ export const getPieVisualization = ({ const sortedColumns: AccessorConfig[] = Array.from( new Set(originalOrder.concat(layer.groups)) ).map((accessor) => ({ columnId: accessor })); - if (sortedColumns.length > 0) { - sortedColumns[0] = { - columnId: sortedColumns[0].columnId, - triggerIcon: 'colorBy', - palette: paletteService - .get(state.palette?.name || 'default') - .getCategoricalColors(10, state.palette?.params), - }; + + if (sortedColumns.length) { + applyPaletteToColumnConfig(sortedColumns, state, paletteService); } - if (state.shape === 'treemap') { - return { - groups: [ - { - groupId: 'groups', + const getSliceByGroup = (): VisualizationDimensionGroupConfig => { + const baseProps = { + required: true, + groupId: 'groups', + accessors: sortedColumns, + enableDimensionEditor: true, + filterOperations: bucketedOperations, + }; + + switch (state.shape) { + case 'mosaic': + case 'treemap': + return { + ...baseProps, groupLabel: i18n.translate('xpack.lens.pie.treemapGroupLabel', { defaultMessage: 'Group by', }), - layerId, - accessors: sortedColumns, supportsMoreColumns: sortedColumns.length < MAX_TREEMAP_BUCKETS, - filterOperations: bucketedOperations, - required: true, dataTestSubj: 'lnsPie_groupByDimensionPanel', - enableDimensionEditor: true, - }, - { - groupId: 'metric', - groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { - defaultMessage: 'Size by', + requiredMinDimensionCount: state.shape === 'mosaic' ? 2 : undefined, + }; + default: + return { + ...baseProps, + groupLabel: i18n.translate('xpack.lens.pie.sliceGroupLabel', { + defaultMessage: 'Slice by', }), - layerId, - accessors: layer.metric ? [{ columnId: layer.metric }] : [], - supportsMoreColumns: !layer.metric, - filterOperations: numberMetricOperations, - required: true, - dataTestSubj: 'lnsPie_sizeByDimensionPanel', - }, - ], - }; - } + supportsMoreColumns: sortedColumns.length < MAX_PIE_BUCKETS, + dataTestSubj: 'lnsPie_sliceByDimensionPanel', + }; + } + }; + + const getMetricGroup = (): VisualizationDimensionGroupConfig => ({ + groupId: 'metric', + groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { + defaultMessage: 'Size by', + }), + accessors: layer.metric ? [{ columnId: layer.metric }] : [], + supportsMoreColumns: !layer.metric, + filterOperations: numberMetricOperations, + required: true, + dataTestSubj: 'lnsPie_sizeByDimensionPanel', + }); return { - groups: [ - { - groupId: 'groups', - groupLabel: i18n.translate('xpack.lens.pie.sliceGroupLabel', { - defaultMessage: 'Slice by', - }), - layerId, - accessors: sortedColumns, - supportsMoreColumns: sortedColumns.length < MAX_PIE_BUCKETS, - filterOperations: bucketedOperations, - required: true, - dataTestSubj: 'lnsPie_sliceByDimensionPanel', - enableDimensionEditor: true, - }, - { - groupId: 'metric', - groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { - defaultMessage: 'Size by', - }), - layerId, - accessors: layer.metric ? [{ columnId: layer.metric }] : [], - supportsMoreColumns: !layer.metric, - filterOperations: numberMetricOperations, - required: true, - dataTestSubj: 'lnsPie_sizeByDimensionPanel', - }, - ], + groups: [getSliceByGroup(), getMetricGroup()], }; }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a9a9539064659..ab6f1d8d55082 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -470,6 +470,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { supportsMoreColumns: boolean; /** If required, a warning will appear if accessors are empty */ required?: boolean; + requiredMinDimensionCount?: number; dataTestSubj?: string; /** diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a27ec93d02c98..a681581edbf82 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -14116,7 +14116,6 @@ "xpack.lens.editorFrame.noColorIndicatorLabel": "このディメンションには個別の色がありません", "xpack.lens.editorFrame.paletteColorIndicatorLabel": "このディメンションはパレットを使用しています", "xpack.lens.editorFrame.previewErrorLabel": "レンダリングのプレビューに失敗しました", - "xpack.lens.editorFrame.requiredDimensionWarningLabel": "必要な次元", "xpack.lens.editorFrame.suggestionPanelTitle": "提案", "xpack.lens.editorFrame.workspaceLabel": "ワークスペース", "xpack.lens.embeddable.failure": "ビジュアライゼーションを表示できませんでした", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e63a873cac3e4..ab953dcdb49a2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -14304,7 +14304,6 @@ "xpack.lens.editorFrame.noColorIndicatorLabel": "此维度没有单独的颜色", "xpack.lens.editorFrame.paletteColorIndicatorLabel": "此维度正在使用调色板", "xpack.lens.editorFrame.previewErrorLabel": "预览呈现失败", - "xpack.lens.editorFrame.requiredDimensionWarningLabel": "所需尺寸", "xpack.lens.editorFrame.suggestionPanelTitle": "建议", "xpack.lens.editorFrame.workspaceLabel": "工作区", "xpack.lens.embeddable.failure": "无法显示可视化",