-
Notifications
You must be signed in to change notification settings - Fork 8.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Lens] Color mapping for categorical dimensions (#162389)
## Summary This PR introduces the new color mapping feature into Lens. The color mapping feature is introduced as a standalone sharable component available from `@kbn/coloring`. The [README.md](https://github.com/elastic/kibana/blob/ddd216457d66912de43c7688ae99044c2c34bbd2/packages/kbn-coloring/src/shared_components/color_mapping/README.md) file describes the components and the logic behind it. The Color Mapping component is also connected to Lens and is available in the following charts: - XY (you can specify the mappings from a breakdown dimension - Partition (you can specify the mappings from the main slice/group by dimension) - Tag cloud (you can specify the mappings from the tags dimension) This MVP feature will be released under the Tech Preview flag. This PR needs to prove the user experience and the ease of use. UI styles, design improvements and embellishments will be released in subsequent PRs. The current MVP-provided palettes are just a placeholder. I'm coordinating with @gvnmagni for a final set of palettes. close #155037 close #6480 fix #28618 fix #96044 fix #101942 fix #112839 fix #116634 ## Release note This feature introduces the ability to change and map colors to break down dimensions in Lens. The feature provides an improved way to specify colors and their association with categories by giving the user a predefined set of color choices or customized one that drives the user toward a correct color selection. It provides ways to pick new colors and generate gradients. This feature is in Tech Preview and is enabled by default on every new visualization but can be turned off at will. ![image](https://github.com/elastic/kibana/assets/1421091/d03e59f8-4a6f-4761-ab4c-c53a57c1723a)
- Loading branch information
Showing
132 changed files
with
5,209 additions
and
215 deletions.
There are no files selected for viewing
87 changes: 87 additions & 0 deletions
87
packages/kbn-coloring/src/shared_components/color_mapping/README.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
# Color Mapping | ||
|
||
This shared component can be used to define a color mapping as an association of one or multiple string values to a color definition. | ||
|
||
This package provides: | ||
- a React component, called `CategoricalColorMapping` that provides a simplified UI (that in general can be hosted in a flyout), that helps the user generate a `ColorMapping.Config` object that descibes the mappings configuration | ||
- a function `getColorFactory` that given a color mapping configuration returns a function that maps a passed category to the corresponding color | ||
- a definition scheme for the color mapping, based on the type `ColorMapping.Config`, that provides an extensible way of describing the link between colors and rules. Collects the minimal information required apply colors based on categories. Together with the `ColorMappingInputData` can be used to get colors in a deterministic way. | ||
|
||
|
||
An example of the configuration is the following: | ||
```ts | ||
const DEFAULT_COLOR_MAPPING_CONFIG: ColorMapping.Config = { | ||
assignmentMode: 'auto', | ||
assignments: [ | ||
{ | ||
rule: { | ||
type: 'matchExactly', | ||
values: ['']; | ||
}, | ||
color: { | ||
type: 'categorical', | ||
paletteId: 'eui', | ||
colorIndex: 2, | ||
} | ||
} | ||
], | ||
specialAssignments: [ | ||
{ | ||
rule: { | ||
type: 'other', | ||
}, | ||
color: { | ||
type: 'categorical', | ||
paletteId: 'neutral', | ||
colorIndex: 2 | ||
}, | ||
touched: false, | ||
}, | ||
], | ||
paletteId: EUIPalette.id, | ||
colorMode: { | ||
type: 'categorical', | ||
}, | ||
}; | ||
``` | ||
|
||
The function `getColorFactory` is a curry function where, given the model, a palette getter, the theme mode (dark/light) and a list of categories, returns a function that can be used to pick the right color based on a given category. | ||
|
||
```ts | ||
function getColorFactory( | ||
model: ColorMapping.Config, | ||
getPaletteFn: (paletteId: string) => ColorMapping.CategoricalPalette, | ||
isDarkMode: boolean, | ||
data: { | ||
type: 'categories'; | ||
categories: Array<string | string[]>; | ||
} | ||
): (category: string | string[]) => Color | ||
``` | ||
|
||
|
||
|
||
A `category` can be in the shape of a plain string or an array of strings. Numbers, MultiFieldKey, IP etc needs to be stringified. | ||
|
||
|
||
The `CategoricalColorMapping` React component has the following props: | ||
|
||
```tsx | ||
function CategoricalColorMapping(props: { | ||
/** The initial color mapping model, usually coming from a the visualization saved object */ | ||
model: ColorMapping.Config; | ||
/** A map of paletteId and palette configuration */ | ||
palettes: Map<string, ColorMapping.CategoricalPalette>; | ||
/** A data description of what needs to be colored */ | ||
data: ColorMappingInputData; | ||
/** Theme dark mode */ | ||
isDarkMode: boolean; | ||
/** A map between original and formatted tokens used to handle special cases, like the Other bucket and the empty bucket */ | ||
specialTokens: Map<string, string>; | ||
/** A function called at every change in the model */ | ||
onModelUpdate: (model: ColorMapping.Config) => void; | ||
}) | ||
``` | ||
|
||
the `onModelUpdate` callback is called everytime a change in the model is applied from within the component. Is not called when the `model` prop is updated. |
132 changes: 132 additions & 0 deletions
132
...es/kbn-coloring/src/shared_components/color_mapping/__stories__/color_mapping.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
/* | ||
* 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, { FC } from 'react'; | ||
import { EuiFlyout, EuiForm } from '@elastic/eui'; | ||
import { ComponentStory } from '@storybook/react'; | ||
import { CategoricalColorMapping, ColorMappingProps } from '../categorical_color_mapping'; | ||
import { AVAILABLE_PALETTES } from '../palettes'; | ||
import { DEFAULT_COLOR_MAPPING_CONFIG } from '../config/default_color_mapping'; | ||
|
||
export default { | ||
title: 'Color Mapping', | ||
component: CategoricalColorMapping, | ||
decorators: [ | ||
(story: Function) => ( | ||
<EuiFlyout style={{ width: 350, padding: '8px' }} onClose={() => {}} hideCloseButton> | ||
<EuiForm>{story()}</EuiForm> | ||
</EuiFlyout> | ||
), | ||
], | ||
}; | ||
|
||
const Template: ComponentStory<FC<ColorMappingProps>> = (args) => ( | ||
<CategoricalColorMapping {...args} /> | ||
); | ||
|
||
export const Default = Template.bind({}); | ||
|
||
Default.args = { | ||
model: { | ||
...DEFAULT_COLOR_MAPPING_CONFIG, | ||
assignmentMode: 'manual', | ||
colorMode: { | ||
type: 'gradient', | ||
steps: [ | ||
{ | ||
type: 'categorical', | ||
colorIndex: 0, | ||
paletteId: DEFAULT_COLOR_MAPPING_CONFIG.paletteId, | ||
touched: false, | ||
}, | ||
{ | ||
type: 'categorical', | ||
colorIndex: 1, | ||
paletteId: DEFAULT_COLOR_MAPPING_CONFIG.paletteId, | ||
touched: false, | ||
}, | ||
{ | ||
type: 'categorical', | ||
colorIndex: 2, | ||
paletteId: DEFAULT_COLOR_MAPPING_CONFIG.paletteId, | ||
touched: false, | ||
}, | ||
], | ||
sort: 'asc', | ||
}, | ||
assignments: [ | ||
{ | ||
rule: { | ||
type: 'matchExactly', | ||
values: ['this is', 'a multi-line combobox that is very long and that will be truncated'], | ||
}, | ||
color: { | ||
type: 'gradient', | ||
}, | ||
touched: false, | ||
}, | ||
{ | ||
rule: { | ||
type: 'matchExactly', | ||
values: ['b', ['double', 'value']], | ||
}, | ||
color: { | ||
type: 'gradient', | ||
}, | ||
touched: false, | ||
}, | ||
{ | ||
rule: { | ||
type: 'matchExactly', | ||
values: ['c'], | ||
}, | ||
color: { | ||
type: 'gradient', | ||
}, | ||
touched: false, | ||
}, | ||
{ | ||
rule: { | ||
type: 'matchExactly', | ||
values: [ | ||
'this is', | ||
'a multi-line wrap', | ||
'combo box', | ||
'test combo', | ||
'3 lines', | ||
['double', 'value'], | ||
], | ||
}, | ||
color: { | ||
type: 'gradient', | ||
}, | ||
touched: false, | ||
}, | ||
], | ||
}, | ||
isDarkMode: false, | ||
data: { | ||
type: 'categories', | ||
categories: [ | ||
'a', | ||
'b', | ||
'c', | ||
'd', | ||
'this is', | ||
'a multi-line wrap', | ||
'combo box', | ||
'test combo', | ||
'3 lines', | ||
], | ||
}, | ||
|
||
palettes: AVAILABLE_PALETTES, | ||
specialTokens: new Map(), | ||
// eslint-disable-next-line no-console | ||
onModelUpdate: (model) => console.log(model), | ||
}; |
115 changes: 115 additions & 0 deletions
115
packages/kbn-coloring/src/shared_components/color_mapping/categorical_color_mapping.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
/* | ||
* 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 { mount } from 'enzyme'; | ||
import { CategoricalColorMapping, ColorMappingInputData } from './categorical_color_mapping'; | ||
import { AVAILABLE_PALETTES } from './palettes'; | ||
import { DEFAULT_COLOR_MAPPING_CONFIG } from './config/default_color_mapping'; | ||
import { MULTI_FIELD_KEY_SEPARATOR } from '@kbn/data-plugin/common'; | ||
|
||
const AUTO_ASSIGN_SWITCH = '[data-test-subj="lns-colorMapping-autoAssignSwitch"]'; | ||
const ASSIGNMENTS_LIST = '[data-test-subj="lns-colorMapping-assignmentsList"]'; | ||
const ASSIGNMENT_ITEM = (i: number) => `[data-test-subj="lns-colorMapping-assignmentsItem${i}"]`; | ||
|
||
describe('color mapping', () => { | ||
it('load a default color mapping', () => { | ||
const dataInput: ColorMappingInputData = { | ||
type: 'categories', | ||
categories: ['categoryA', 'categoryB'], | ||
}; | ||
const onModelUpdateFn = jest.fn(); | ||
const component = mount( | ||
<CategoricalColorMapping | ||
data={dataInput} | ||
isDarkMode={false} | ||
model={{ ...DEFAULT_COLOR_MAPPING_CONFIG }} | ||
palettes={AVAILABLE_PALETTES} | ||
onModelUpdate={onModelUpdateFn} | ||
specialTokens={new Map()} | ||
/> | ||
); | ||
|
||
expect(component.find(AUTO_ASSIGN_SWITCH).hostNodes().prop('aria-checked')).toEqual(true); | ||
expect(component.find(ASSIGNMENTS_LIST).hostNodes().children().length).toEqual( | ||
dataInput.categories.length | ||
); | ||
dataInput.categories.forEach((category, index) => { | ||
const assignment = component.find(ASSIGNMENT_ITEM(index)).hostNodes(); | ||
expect(assignment.text()).toEqual(category); | ||
expect(assignment.hasClass('euiComboBox-isDisabled')).toEqual(true); | ||
}); | ||
expect(onModelUpdateFn).not.toBeCalled(); | ||
}); | ||
|
||
it('switch to manual assignments', () => { | ||
const dataInput: ColorMappingInputData = { | ||
type: 'categories', | ||
categories: ['categoryA', 'categoryB'], | ||
}; | ||
const onModelUpdateFn = jest.fn(); | ||
const component = mount( | ||
<CategoricalColorMapping | ||
data={dataInput} | ||
isDarkMode={false} | ||
model={{ ...DEFAULT_COLOR_MAPPING_CONFIG }} | ||
palettes={AVAILABLE_PALETTES} | ||
onModelUpdate={onModelUpdateFn} | ||
specialTokens={new Map()} | ||
/> | ||
); | ||
component.find(AUTO_ASSIGN_SWITCH).hostNodes().simulate('click'); | ||
expect(onModelUpdateFn).toBeCalledTimes(1); | ||
expect(component.find(AUTO_ASSIGN_SWITCH).hostNodes().prop('aria-checked')).toEqual(false); | ||
expect(component.find(ASSIGNMENTS_LIST).hostNodes().children().length).toEqual( | ||
dataInput.categories.length | ||
); | ||
dataInput.categories.forEach((category, index) => { | ||
const assignment = component.find(ASSIGNMENT_ITEM(index)).hostNodes(); | ||
expect(assignment.text()).toEqual(category); | ||
expect(assignment.hasClass('euiComboBox-isDisabled')).toEqual(false); | ||
}); | ||
}); | ||
|
||
it('handle special tokens, multi-fields keys and non-trimmed whitespaces', () => { | ||
const dataInput: ColorMappingInputData = { | ||
type: 'categories', | ||
categories: ['__other__', ['fieldA', 'fieldB'], '__empty__', ' with-whitespaces '], | ||
}; | ||
const onModelUpdateFn = jest.fn(); | ||
const component = mount( | ||
<CategoricalColorMapping | ||
data={dataInput} | ||
isDarkMode={false} | ||
model={{ ...DEFAULT_COLOR_MAPPING_CONFIG }} | ||
palettes={AVAILABLE_PALETTES} | ||
onModelUpdate={onModelUpdateFn} | ||
specialTokens={ | ||
new Map([ | ||
['__other__', 'Other'], | ||
['__empty__', '(Empty)'], | ||
]) | ||
} | ||
/> | ||
); | ||
expect(component.find(ASSIGNMENTS_LIST).hostNodes().children().length).toEqual( | ||
dataInput.categories.length | ||
); | ||
const assignment1 = component.find(ASSIGNMENT_ITEM(0)).hostNodes(); | ||
expect(assignment1.text()).toEqual('Other'); | ||
|
||
const assignment2 = component.find(ASSIGNMENT_ITEM(1)).hostNodes(); | ||
expect(assignment2.text()).toEqual(`fieldA${MULTI_FIELD_KEY_SEPARATOR}fieldB`); | ||
|
||
const assignment3 = component.find(ASSIGNMENT_ITEM(2)).hostNodes(); | ||
expect(assignment3.text()).toEqual('(Empty)'); | ||
|
||
const assignment4 = component.find(ASSIGNMENT_ITEM(3)).hostNodes(); | ||
expect(assignment4.text()).toEqual(' with-whitespaces '); | ||
}); | ||
}); |
Oops, something went wrong.