Skip to content

Commit

Permalink
[Lens] Color mapping for categorical dimensions (#162389)
Browse files Browse the repository at this point in the history
## 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
markov00 authored Sep 28, 2023
1 parent 6ab0c68 commit b12a422
Show file tree
Hide file tree
Showing 132 changed files with 5,209 additions and 215 deletions.
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.
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),
};
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 ');
});
});
Loading

0 comments on commit b12a422

Please sign in to comment.