[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](ddd216457d/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 https://github.com/elastic/kibana/issues/155037
close https://github.com/elastic/kibana/issues/6480
fix https://github.com/elastic/kibana/issues/28618
fix https://github.com/elastic/kibana/issues/96044
fix https://github.com/elastic/kibana/issues/101942
fix https://github.com/elastic/kibana/issues/112839
fix https://github.com/elastic/kibana/issues/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](d03e59f8-4a6f-4761-ab4c-c53a57c1723a)
This commit is contained in:
Marco Vettorello 2023-09-28 14:14:58 +02:00 committed by GitHub
parent 6ab0c68ae6
commit b12a42261b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
132 changed files with 5209 additions and 215 deletions

View file

@ -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.

View file

@ -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),
};

View file

@ -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 ');
});
});

View file

@ -0,0 +1,97 @@
/*
* 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 { Provider } from 'react-redux';
import { type EnhancedStore, configureStore } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { colorMappingReducer, updateModel } from './state/color_mapping';
import { Container } from './components/container/container';
import { ColorMapping } from './config';
import { uiReducer } from './state/ui';
/**
* A configuration object that is required to populate correctly the visible categories
* or the ranges in the CategoricalColorMapping component
*/
export type ColorMappingInputData =
| {
type: 'categories';
/** an ORDERED array of categories rendered in the visualization */
categories: Array<string | string[]>;
}
| {
type: 'ranges';
min: number;
max: number;
bins: number;
};
/**
* The props of the CategoricalColorMapping component
*/
export interface ColorMappingProps {
/** 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 React component for mapping categorical values to colors
*/
export class CategoricalColorMapping extends React.Component<ColorMappingProps> {
store: EnhancedStore<{ colorMapping: ColorMapping.Config }>;
unsubscribe: () => void;
constructor(props: ColorMappingProps) {
super(props);
// configure the store at mount time
this.store = configureStore({
preloadedState: {
colorMapping: props.model,
},
reducer: {
colorMapping: colorMappingReducer,
ui: uiReducer,
},
});
// subscribe to store changes to update external tools
this.unsubscribe = this.store.subscribe(() => {
this.props.onModelUpdate(this.store.getState().colorMapping);
});
}
componentWillUnmount() {
this.unsubscribe();
}
componentDidUpdate(prevProps: Readonly<ColorMappingProps>) {
if (!isEqual(prevProps.model, this.props.model)) {
this.store.dispatch(updateModel(this.props.model));
}
}
render() {
const { palettes, data, isDarkMode, specialTokens } = this.props;
return (
<Provider store={this.store}>
<Container
palettes={palettes}
data={data}
isDarkMode={isDarkMode}
specialTokens={specialTokens}
/>
</Provider>
);
}
}

View file

@ -0,0 +1,294 @@
/*
* 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 {
DEFAULT_COLOR_MAPPING_CONFIG,
DEFAULT_NEUTRAL_PALETTE_INDEX,
} from '../config/default_color_mapping';
import { getColorFactory } from './color_handling';
import { getPalette, AVAILABLE_PALETTES } from '../palettes';
import {
EUIAmsterdamColorBlindPalette,
EUI_AMSTERDAM_PALETTE_COLORS,
} from '../palettes/eui_amsterdam';
import { NeutralPalette, NEUTRAL_COLOR_DARK, NEUTRAL_COLOR_LIGHT } from '../palettes/neutral';
import { toHex } from './color_math';
import { ColorMapping } from '../config';
describe('Color mapping - color generation', () => {
const getPaletteFn = getPalette(AVAILABLE_PALETTES, NeutralPalette);
it('returns EUI light colors from default config', () => {
const colorFactory = getColorFactory(DEFAULT_COLOR_MAPPING_CONFIG, getPaletteFn, false, {
type: 'categories',
categories: ['catA', 'catB', 'catC'],
});
expect(colorFactory('catA')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[0]);
expect(colorFactory('catB')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[1]);
expect(colorFactory('catC')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[2]);
// if the category is not available in the `categories` list then a default neutral color is used
expect(colorFactory('not_available')).toBe(NEUTRAL_COLOR_LIGHT[DEFAULT_NEUTRAL_PALETTE_INDEX]);
});
it('returns max number of colors defined in palette, use other color otherwise', () => {
const twoColorPalette: ColorMapping.CategoricalPalette = {
id: 'twoColors',
name: 'twoColors',
colorCount: 2,
type: 'categorical',
getColor(valueInRange, isDarkMode) {
return ['red', 'blue'][valueInRange];
},
};
const simplifiedGetPaletteGn = getPalette(
new Map([[twoColorPalette.id, twoColorPalette]]),
NeutralPalette
);
const colorFactory = getColorFactory(
{
...DEFAULT_COLOR_MAPPING_CONFIG,
paletteId: twoColorPalette.id,
},
simplifiedGetPaletteGn,
false,
{
type: 'categories',
categories: ['cat1', 'cat2', 'cat3', 'cat4'],
}
);
expect(colorFactory('cat1')).toBe('#ff0000');
expect(colorFactory('cat2')).toBe('#0000ff');
// return a palette color only up to the max number of color in the palette
expect(colorFactory('cat3')).toBe(NEUTRAL_COLOR_LIGHT[DEFAULT_NEUTRAL_PALETTE_INDEX]);
expect(colorFactory('cat4')).toBe(NEUTRAL_COLOR_LIGHT[DEFAULT_NEUTRAL_PALETTE_INDEX]);
});
// currently there is no difference in the two colors, but this could change in the future
// this test will catch the change
it('returns EUI dark colors from default config', () => {
const colorFactory = getColorFactory(DEFAULT_COLOR_MAPPING_CONFIG, getPaletteFn, true, {
type: 'categories',
categories: ['catA', 'catB', 'catC'],
});
expect(colorFactory('catA')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[0]);
expect(colorFactory('catB')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[1]);
expect(colorFactory('catC')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[2]);
// if the category is not available in the `categories` list then a default neutral color is used
expect(colorFactory('not_available')).toBe(NEUTRAL_COLOR_DARK[DEFAULT_NEUTRAL_PALETTE_INDEX]);
});
it('handles special tokens, multi-field categories and non-trimmed whitespaces', () => {
const colorFactory = getColorFactory(DEFAULT_COLOR_MAPPING_CONFIG, getPaletteFn, false, {
type: 'categories',
categories: ['__other__', ['fieldA', 'fieldB'], '__empty__', ' with-whitespaces '],
});
expect(colorFactory('__other__')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[0]);
expect(colorFactory(['fieldA', 'fieldB'])).toBe(EUI_AMSTERDAM_PALETTE_COLORS[1]);
expect(colorFactory('__empty__')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[2]);
expect(colorFactory(' with-whitespaces ')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[3]);
});
it('ignores configured assignments in auto mode', () => {
const colorFactory = getColorFactory(
{
...DEFAULT_COLOR_MAPPING_CONFIG,
assignments: [
{
color: { type: 'colorCode', colorCode: 'red' },
rule: { type: 'matchExactly', values: ['assignmentToIgnore'] },
touched: false,
},
],
},
getPaletteFn,
false,
{
type: 'categories',
categories: ['catA', 'catB', 'assignmentToIgnore'],
}
);
expect(colorFactory('catA')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[0]);
expect(colorFactory('catB')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[1]);
expect(colorFactory('assignmentToIgnore')).toBe(EUI_AMSTERDAM_PALETTE_COLORS[2]);
});
it('color with auto rule are assigned in order of the configured data input', () => {
const colorFactory = getColorFactory(
{
...DEFAULT_COLOR_MAPPING_CONFIG,
assignmentMode: 'manual',
assignments: [
{
color: { type: 'colorCode', colorCode: 'red' },
rule: { type: 'auto' },
touched: false,
},
{
color: { type: 'colorCode', colorCode: 'blue' },
rule: { type: 'matchExactly', values: ['blueCat'] },
touched: false,
},
{
color: { type: 'colorCode', colorCode: 'green' },
rule: { type: 'auto' },
touched: false,
},
],
},
getPaletteFn,
false,
{
type: 'categories',
categories: ['blueCat', 'redCat', 'greenCat'],
}
);
// this matches exactly
expect(colorFactory('blueCat')).toBe('blue');
// this matches with the first availabe "auto" rule
expect(colorFactory('redCat')).toBe('red');
// this matches with the second availabe "auto" rule
expect(colorFactory('greenCat')).toBe('green');
// if the category is not available in the `categories` list then a default neutral color is used
expect(colorFactory('not_available')).toBe(NEUTRAL_COLOR_LIGHT[DEFAULT_NEUTRAL_PALETTE_INDEX]);
});
it('returns sequential gradient colors from darker to lighter [desc, lightMode]', () => {
const colorFactory = getColorFactory(
{
...DEFAULT_COLOR_MAPPING_CONFIG,
colorMode: {
type: 'gradient',
steps: [
{
type: 'categorical',
paletteId: EUIAmsterdamColorBlindPalette.id,
colorIndex: 0,
touched: false,
},
],
sort: 'desc',
},
},
getPaletteFn,
false,
{
type: 'categories',
categories: ['cat1', 'cat2', 'cat3'],
}
);
// this matches exactly with the initial step selected
expect(toHex(colorFactory('cat1'))).toBe(toHex(EUI_AMSTERDAM_PALETTE_COLORS[0]));
expect(toHex(colorFactory('cat2'))).toBe('#93cebc');
expect(toHex(colorFactory('cat3'))).toBe('#cce8e0');
});
it('returns sequential gradient colors from lighter to darker [asc, lightMode]', () => {
const colorFactory = getColorFactory(
{
...DEFAULT_COLOR_MAPPING_CONFIG,
colorMode: {
type: 'gradient',
steps: [
{
type: 'categorical',
paletteId: EUIAmsterdamColorBlindPalette.id,
colorIndex: 0,
touched: false,
},
],
sort: 'asc',
},
},
getPaletteFn,
false,
{
type: 'categories',
categories: ['cat1', 'cat2', 'cat3'],
}
);
expect(toHex(colorFactory('cat1'))).toBe('#cce8e0');
expect(toHex(colorFactory('cat2'))).toBe('#93cebc');
// this matches exactly with the initial step selected
expect(toHex(colorFactory('cat3'))).toBe(toHex(EUI_AMSTERDAM_PALETTE_COLORS[0]));
});
it('returns 2 colors gradient [desc, lightMode]', () => {
const colorFactory = getColorFactory(
{
...DEFAULT_COLOR_MAPPING_CONFIG,
colorMode: {
type: 'gradient',
steps: [
{
type: 'categorical',
paletteId: EUIAmsterdamColorBlindPalette.id,
colorIndex: 0,
touched: false,
},
{
type: 'categorical',
paletteId: EUIAmsterdamColorBlindPalette.id,
colorIndex: 2,
touched: false,
},
],
sort: 'desc',
},
},
getPaletteFn,
false,
{
type: 'categories',
categories: ['cat1', 'cat2', 'cat3'],
}
);
expect(toHex(colorFactory('cat1'))).toBe(toHex(EUI_AMSTERDAM_PALETTE_COLORS[0])); // EUI green
expect(toHex(colorFactory('cat2'))).toBe('#a4908f'); // red gray green
expect(toHex(colorFactory('cat3'))).toBe(toHex(EUI_AMSTERDAM_PALETTE_COLORS[2])); // EUI pink
});
it('returns divergent gradient [asc, darkMode]', () => {
const colorFactory = getColorFactory(
{
...DEFAULT_COLOR_MAPPING_CONFIG,
colorMode: {
type: 'gradient',
steps: [
{
type: 'categorical',
paletteId: EUIAmsterdamColorBlindPalette.id,
colorIndex: 0,
touched: false,
},
{ type: 'categorical', paletteId: NeutralPalette.id, colorIndex: 0, touched: false },
{
type: 'categorical',
paletteId: EUIAmsterdamColorBlindPalette.id,
colorIndex: 2,
touched: false,
},
],
sort: 'asc', // testing in ascending order
},
},
getPaletteFn,
true, // testing in dark mode
{
type: 'categories',
categories: ['cat1', 'cat2', 'cat3'],
}
);
expect(toHex(colorFactory('cat1'))).toBe(toHex(EUI_AMSTERDAM_PALETTE_COLORS[2])); // EUI pink
expect(toHex(colorFactory('cat2'))).toBe(NEUTRAL_COLOR_DARK[0]); // NEUTRAL LIGHT GRAY
expect(toHex(colorFactory('cat3'))).toBe(toHex(EUI_AMSTERDAM_PALETTE_COLORS[0])); // EUI green
expect(toHex(colorFactory('not available cat'))).toBe(
toHex(NEUTRAL_COLOR_DARK[DEFAULT_NEUTRAL_PALETTE_INDEX])
); // check the other
});
});

View file

@ -0,0 +1,164 @@
/*
* 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 chroma from 'chroma-js';
import { ColorMapping } from '../config';
import { changeAlpha, combineColors, getValidColor } from './color_math';
import { generateAutoAssignmentsForCategories } from '../config/assignment_from_categories';
import { getPalette } from '../palettes';
import { ColorMappingInputData } from '../categorical_color_mapping';
import { ruleMatch } from './rule_matching';
import { GradientColorMode } from '../config/types';
export function getAssignmentColor(
colorMode: ColorMapping.Config['colorMode'],
color: ColorMapping.Config['assignments'][number]['color'],
getPaletteFn: ReturnType<typeof getPalette>,
isDarkMode: boolean,
index: number,
total: number
) {
switch (color.type) {
case 'colorCode':
case 'categorical':
return getColor(color, getPaletteFn, isDarkMode);
case 'gradient': {
if (colorMode.type === 'categorical') {
return 'red';
}
const colorScale = getGradientColorScale(colorMode, getPaletteFn, isDarkMode);
return total === 0 ? 'red' : total === 1 ? colorScale(0) : colorScale(index / (total - 1));
}
}
}
export function getColor(
color: ColorMapping.ColorCode | ColorMapping.CategoricalColor,
getPaletteFn: ReturnType<typeof getPalette>,
isDarkMode: boolean
) {
return color.type === 'colorCode'
? color.colorCode
: getValidColor(getPaletteFn(color.paletteId).getColor(color.colorIndex, isDarkMode)).hex();
}
export function getColorFactory(
model: ColorMapping.Config,
getPaletteFn: ReturnType<typeof getPalette>,
isDarkMode: boolean,
data: ColorMappingInputData
): (category: string | string[]) => string {
const palette = getPaletteFn(model.paletteId);
// generate on-the-fly assignments in auto-mode based on current data.
// This simplify the code by always using assignments, even if there is no real static assigmnets
const assignments =
model.assignmentMode === 'auto'
? generateAutoAssignmentsForCategories(data, palette, model.colorMode)
: model.assignments;
// find auto-assigned colors
const autoAssignedColors =
data.type === 'categories'
? assignments.filter((a) => {
return (
a.rule.type === 'auto' || (a.rule.type === 'matchExactly' && a.rule.values.length === 0)
);
})
: [];
// find all categories that doesn't match with an assignment
const nonAssignedCategories =
data.type === 'categories'
? data.categories.filter((category) => {
return !assignments.some(({ rule }) => ruleMatch(rule, category));
})
: [];
return (category: string | string[]) => {
if (typeof category === 'string' || Array.isArray(category)) {
const nonAssignedCategoryIndex = nonAssignedCategories.indexOf(category);
// return color for a non assigned category
if (nonAssignedCategoryIndex > -1) {
if (nonAssignedCategoryIndex < autoAssignedColors.length) {
const autoAssignmentIndex = assignments.findIndex(
(d) => d === autoAssignedColors[nonAssignedCategoryIndex]
);
return getAssignmentColor(
model.colorMode,
autoAssignedColors[nonAssignedCategoryIndex].color,
getPaletteFn,
isDarkMode,
autoAssignmentIndex,
assignments.length
);
}
// if no auto-assign color rule/color is available then use the other color
// TODO: the specialAssignment[0] position is arbitrary, we should fix it better
return getColor(model.specialAssignments[0].color, getPaletteFn, isDarkMode);
}
// find the assignment where the category matches the rule
const matchingAssignmentIndex = assignments.findIndex(({ rule }) => {
return ruleMatch(rule, category);
});
// return the assigned color
if (matchingAssignmentIndex > -1) {
const assignment = assignments[matchingAssignmentIndex];
return getAssignmentColor(
model.colorMode,
assignment.color,
getPaletteFn,
isDarkMode,
matchingAssignmentIndex,
assignments.length
);
}
// if no assign color rule/color is available then use the other color
// TODO: the specialAssignment[0] position is arbitrary, we should fix it better
return getColor(model.specialAssignments[0].color, getPaletteFn, isDarkMode);
} else {
const matchingAssignmentIndex = assignments.findIndex(({ rule }) => {
return ruleMatch(rule, category);
});
if (matchingAssignmentIndex > -1) {
const assignment = assignments[matchingAssignmentIndex];
return getAssignmentColor(
model.colorMode,
assignment.color,
getPaletteFn,
isDarkMode,
matchingAssignmentIndex,
assignments.length
);
}
return getColor(model.specialAssignments[0].color, getPaletteFn, isDarkMode);
}
};
}
export function getGradientColorScale(
colorMode: GradientColorMode,
getPaletteFn: ReturnType<typeof getPalette>,
isDarkMode: boolean
): (value: number) => string {
const steps =
colorMode.steps.length === 1
? [
getColor(colorMode.steps[0], getPaletteFn, isDarkMode),
combineColors(
changeAlpha(getColor(colorMode.steps[0], getPaletteFn, isDarkMode), 0.3),
isDarkMode ? 'black' : 'white'
),
]
: colorMode.steps.map((d) => getColor(d, getPaletteFn, isDarkMode));
steps.sort(() => (colorMode.sort === 'asc' ? -1 : 1));
const scale = chroma.scale(steps).mode('lab');
return (value: number) => scale(value).hex();
}

View file

@ -0,0 +1,60 @@
/*
* 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 chroma from 'chroma-js';
export function getValidColor(color: string): chroma.Color {
try {
return chroma(color);
} catch {
return chroma('red');
}
}
export function hasEnoughContrast(color: string, isDark: boolean, threshold = 4.5) {
return chroma.contrast(getValidColor(color), isDark ? 'black' : 'white') >= threshold;
}
export function changeAlpha(color: string, alpha: number) {
const [r, g, b] = getValidColor(color).rgb();
return `rgba(${r},${g},${b},${alpha})`;
}
export function toHex(color: string) {
return getValidColor(color).hex().toLowerCase();
}
export function isSameColor(color1: string, color2: string) {
return toHex(color1) === toHex(color2);
}
/**
* Blend a foreground (fg) color with a background (bg) color
*/
export function combineColors(fg: string, bg: string): string {
const [fgR, fgG, fgB, fgA] = getValidColor(fg).rgba();
const [bgR, bgG, bgB, bgA] = getValidColor(bg).rgba();
// combine colors only if foreground has transparency
if (fgA === 1) {
return chroma.rgb(fgR, fgG, fgB).hex();
}
// For reference on alpha calculations:
// https://en.wikipedia.org/wiki/Alpha_compositing
const alpha = fgA + bgA * (1 - fgA);
if (alpha === 0) {
return '#00000000';
}
const r = Math.round((fgR * fgA + bgR * bgA * (1 - fgA)) / alpha);
const g = Math.round((fgG * fgA + bgG * bgA * (1 - fgA)) / alpha);
const b = Math.round((fgB * fgA + bgB * bgA * (1 - fgA)) / alpha);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View file

@ -0,0 +1,46 @@
/*
* 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 { ColorMapping } from '../config';
export function ruleMatch(
rule: ColorMapping.Config['assignments'][number]['rule'],
value: string | number | string[]
) {
switch (rule.type) {
case 'matchExactly':
if (Array.isArray(value)) {
return rule.values.some(
(v) =>
Array.isArray(v) && v.length === value.length && v.every((part, i) => part === value[i])
);
}
return rule.values.includes(`${value}`);
case 'matchExactlyCI':
return rule.values.some((d) => d.toLowerCase() === `${value}`);
case 'range':
// TODO: color by value not yet possible in all charts in elastic-charts
return typeof value === 'number' ? rangeMatch(rule, value) : false;
default:
return false;
}
}
export function rangeMatch(rule: ColorMapping.RuleRange, value: number) {
return (
(rule.min === rule.max && rule.min === value) ||
((rule.minInclusive ? value >= rule.min : value > rule.min) &&
(rule.maxInclusive ? value <= rule.max : value < rule.max))
);
}
// TODO: move in some data/table related package
export const SPECIAL_TOKENS_STRING_CONVERTION = new Map([
['__other__', 'Other'],
['', '(empty)'],
]);

View file

@ -0,0 +1,150 @@
/*
* 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 { useDispatch } from 'react-redux';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import {
removeAssignment,
updateAssignmentColor,
updateAssignmentRule,
} from '../../state/color_mapping';
import { ColorMapping } from '../../config';
import { Range } from './range';
import { Match } from './match';
import { getPalette } from '../../palettes';
import { ColorMappingInputData } from '../../categorical_color_mapping';
import { ColorSwatch } from '../color_picker/color_swatch';
export function Assignment({
data,
assignment,
disableDelete,
index,
total,
canPickColor,
editable,
palette,
colorMode,
getPaletteFn,
isDarkMode,
specialTokens,
assignmentValuesCounter,
}: {
data: ColorMappingInputData;
index: number;
total: number;
colorMode: ColorMapping.Config['colorMode'];
assignment: ColorMapping.Config['assignments'][number];
disableDelete: boolean;
palette: ColorMapping.CategoricalPalette;
getPaletteFn: ReturnType<typeof getPalette>;
canPickColor: boolean;
editable: boolean;
isDarkMode: boolean;
specialTokens: Map<string, string>;
assignmentValuesCounter: Map<string | string[], number>;
}) {
const dispatch = useDispatch();
return (
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="flexStart"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={0}>
<ColorSwatch
forType="assignment"
isDarkMode={isDarkMode}
swatchShape="square"
canPickColor={canPickColor}
colorMode={colorMode}
assignmentColor={assignment.color}
getPaletteFn={getPaletteFn}
index={index}
palette={palette}
total={total}
onColorChange={(color) => {
dispatch(updateAssignmentColor({ assignmentIndex: index, color }));
}}
/>
</EuiFlexItem>
{assignment.rule.type === 'auto' ||
assignment.rule.type === 'matchExactly' ||
assignment.rule.type === 'matchExactlyCI' ? (
<Match
editable={editable}
index={index}
rule={assignment.rule}
options={data.type === 'categories' ? data.categories : []}
specialTokens={specialTokens}
updateValue={(values: Array<string | string[]>) => {
dispatch(
updateAssignmentRule({
assignmentIndex: index,
rule: values.length === 0 ? { type: 'auto' } : { type: 'matchExactly', values },
})
);
}}
assignmentValuesCounter={assignmentValuesCounter}
/>
) : assignment.rule.type === 'range' ? (
<Range
rule={assignment.rule}
editable={editable}
updateValue={(min, max, minInclusive, maxInclusive) => {
const rule: ColorMapping.RuleRange = {
type: 'range',
min,
max,
minInclusive,
maxInclusive,
};
dispatch(updateAssignmentRule({ assignmentIndex: index, rule }));
}}
/>
) : null}
<EuiFlexItem grow={0}>
<EuiButtonIcon
iconType="trash"
size="xs"
disabled={disableDelete}
onClick={() => dispatch(removeAssignment(index))}
aria-label={i18n.translate(
'coloring.colorMapping.assignments.deleteAssignmentButtonLabel',
{
defaultMessage: 'Delete this assignment',
}
)}
color="danger"
css={
!disableDelete
? css`
color: ${euiThemeVars.euiTextSubduedColor};
transition: ${euiThemeVars.euiAnimSpeedFast} ease-in-out;
transition-property: color;
&:hover,
&:focus {
color: ${euiThemeVars.euiColorDangerText};
}
`
: undefined
}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,101 @@
/*
* 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 { EuiComboBox, EuiFlexItem, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { MULTI_FIELD_KEY_SEPARATOR } from '@kbn/data-plugin/common';
import { euiThemeVars } from '@kbn/ui-theme';
import { ColorMapping } from '../../config';
export const Match: React.FC<{
index: number;
editable: boolean;
rule:
| ColorMapping.RuleAuto
| ColorMapping.RuleMatchExactly
| ColorMapping.RuleMatchExactlyCI
| ColorMapping.RuleRegExp;
updateValue: (values: Array<string | string[]>) => void;
options: Array<string | string[]>;
specialTokens: Map<unknown, string>;
assignmentValuesCounter: Map<string | string[], number>;
}> = ({ index, rule, updateValue, editable, options, specialTokens, assignmentValuesCounter }) => {
const selectedOptions =
rule.type === 'auto'
? []
: typeof rule.values === 'string'
? [
{
label: rule.values,
value: rule.values,
append:
(assignmentValuesCounter.get(rule.values) ?? 0) > 1 ? (
<EuiIcon size="s" type="warning" color={euiThemeVars.euiColorWarningText} />
) : undefined,
},
]
: rule.values.map((value) => {
const ruleValues = Array.isArray(value) ? value : [value];
return {
label: ruleValues.map((v) => specialTokens.get(v) ?? v).join(MULTI_FIELD_KEY_SEPARATOR),
value,
append:
(assignmentValuesCounter.get(value) ?? 0) > 1 ? (
<EuiIcon size="s" type="warning" color={euiThemeVars.euiColorWarningText} />
) : undefined,
};
});
const convertedOptions = options.map((value) => {
const ruleValues = Array.isArray(value) ? value : [value];
return {
label: ruleValues.map((v) => specialTokens.get(v) ?? v).join(MULTI_FIELD_KEY_SEPARATOR),
value,
};
});
return (
<EuiFlexItem style={{ minWidth: 1, width: 1 }}>
<EuiComboBox
data-test-subj={`lns-colorMapping-assignmentsItem${index}`}
isDisabled={!editable}
fullWidth={true}
aria-label={i18n.translate('coloring.colorMapping.assignments.autoAssignedTermAriaLabel', {
defaultMessage:
"This color will be automatically assigned to the first term that doesn't match with all the other assignments",
})}
placeholder={i18n.translate(
'coloring.colorMapping.assignments.autoAssignedTermPlaceholder',
{
defaultMessage: 'Auto assigned',
}
)}
options={convertedOptions}
selectedOptions={selectedOptions}
onChange={(changedOptions) => {
updateValue(
changedOptions.reduce<Array<string | string[]>>((acc, option) => {
if (option.value !== undefined) {
acc.push(option.value);
}
return acc;
}, [])
);
}}
onCreateOption={(label) => {
if (selectedOptions.findIndex((option) => option.label.toLowerCase() === label) === -1) {
updateValue([...selectedOptions, { label, value: label }].map((d) => d.value));
}
}}
isClearable={false}
compressed
/>
</EuiFlexItem>
);
};

View file

@ -0,0 +1,67 @@
/*
* 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 { EuiButtonEmpty, EuiFieldNumber, EuiFlexItem } from '@elastic/eui';
import { ColorMapping } from '../../config';
export const Range: React.FC<{
rule: ColorMapping.RuleRange;
editable: boolean;
updateValue: (min: number, max: number, minInclusive: boolean, maxInclusive: boolean) => void;
}> = ({ rule, updateValue, editable }) => {
const minValid = rule.min <= rule.max;
const maxValid = rule.max >= rule.min;
return (
<>
<EuiFlexItem>
<EuiFieldNumber
compressed
prepend={
<EuiButtonEmpty
size="xs"
onClick={() => updateValue(rule.min, rule.max, !rule.minInclusive, rule.maxInclusive)}
>
{rule.minInclusive ? 'GTE' : 'GT'}
</EuiButtonEmpty>
}
placeholder="min"
value={rule.min}
isInvalid={!minValid}
disabled={!editable}
onChange={(e) =>
updateValue(+e.currentTarget.value, rule.max, rule.minInclusive, rule.maxInclusive)
}
aria-label="The min value"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFieldNumber
compressed
isInvalid={!maxValid}
prepend={
<EuiButtonEmpty
size="xs"
onClick={() => updateValue(rule.min, rule.max, rule.minInclusive, !rule.maxInclusive)}
>
{rule.maxInclusive ? 'LTE' : 'LT'}
</EuiButtonEmpty>
}
placeholder="max"
disabled={!editable}
value={rule.max}
onChange={(e) =>
updateValue(rule.min, +e.currentTarget.value, rule.minInclusive, rule.maxInclusive)
}
aria-label="The max value"
/>
</EuiFlexItem>
</>
);
};

View file

@ -0,0 +1,79 @@
/*
* 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 { EuiFieldText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { ColorMapping } from '../../config';
import { getPalette } from '../../palettes';
import { ColorSwatch } from '../color_picker/color_swatch';
import { updateSpecialAssignmentColor } from '../../state/color_mapping';
export function SpecialAssignment({
assignment,
index,
palette,
getPaletteFn,
isDarkMode,
total,
}: {
isDarkMode: boolean;
index: number;
assignment: ColorMapping.Config['specialAssignments'][number];
palette: ColorMapping.CategoricalPalette;
getPaletteFn: ReturnType<typeof getPalette>;
total: number;
}) {
const dispatch = useDispatch();
const canPickColor = true;
return (
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem grow={0}>
<ColorSwatch
forType="specialAssignment"
canPickColor={canPickColor}
colorMode={{ type: 'categorical' }}
assignmentColor={assignment.color}
getPaletteFn={getPaletteFn}
index={index}
palette={palette}
total={total}
swatchShape="square"
isDarkMode={isDarkMode}
onColorChange={(color) => {
dispatch(
updateSpecialAssignmentColor({
assignmentIndex: index,
color,
})
);
}}
/>
</EuiFlexItem>
<EuiFlexItem
style={{
marginRight: 32,
}}
>
<EuiFieldText
compressed
fullWidth
disabled={true}
placeholder={i18n.translate('coloring.colorMapping.assignments.unassignedPlaceholder', {
defaultMessage: 'Unassigned terms',
})}
aria-label={i18n.translate('coloring.colorMapping.assignments.unassignedAriaLabel', {
defaultMessage:
'Assign this color to every unassigned not described in the assignment list',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,117 @@
/*
* 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, { useState } from 'react';
import {
EuiButtonEmpty,
EuiPopoverTitle,
EuiTab,
EuiTabs,
EuiTitle,
EuiHorizontalRule,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ColorMapping } from '../../config';
import { getPalette } from '../../palettes';
import { PaletteColors } from './palette_colors';
import { RGBPicker } from './rgb_picker';
import { NeutralPalette } from '../../palettes/neutral';
export function ColorPicker({
palette,
getPaletteFn,
color,
close,
selectColor,
isDarkMode,
deleteStep,
}: {
color: ColorMapping.CategoricalColor | ColorMapping.ColorCode;
getPaletteFn: ReturnType<typeof getPalette>;
palette: ColorMapping.CategoricalPalette;
isDarkMode: boolean;
close: () => void;
selectColor: (color: ColorMapping.CategoricalColor | ColorMapping.ColorCode) => void;
deleteStep?: () => void;
}) {
const [tab, setTab] = useState(
color.type === 'categorical' &&
(color.paletteId === palette.id || color.paletteId === NeutralPalette.id)
? 'palette'
: 'custom'
);
return (
<div style={{ width: 168 }}>
<EuiPopoverTitle
paddingSize="none"
style={{
borderBottom: 'none',
}}
>
<EuiTabs size="m" expand>
<EuiTab onClick={() => setTab('palette')} isSelected={tab === 'palette'}>
<EuiTitle size="xxxs">
<span>
{i18n.translate('coloring.colorMapping.colorPicker.paletteTabLabel', {
defaultMessage: 'Colors',
})}
</span>
</EuiTitle>
</EuiTab>
<EuiTab onClick={() => setTab('custom')} isSelected={tab === 'custom'}>
<EuiTitle size="xxxs">
<span>
{i18n.translate('coloring.colorMapping.colorPicker.customTabLabel', {
defaultMessage: 'Custom',
})}
</span>
</EuiTitle>
</EuiTab>
</EuiTabs>
</EuiPopoverTitle>
{tab === 'palette' ? (
<PaletteColors
color={color}
getPaletteFn={getPaletteFn}
palette={palette}
selectColor={selectColor}
isDarkMode={isDarkMode}
/>
) : (
<RGBPicker
color={color}
getPaletteFn={getPaletteFn}
isDarkMode={isDarkMode}
selectColor={selectColor}
palette={palette}
close={close}
/>
)}
{deleteStep ? (
<>
<EuiHorizontalRule margin="xs" />
<EuiButtonEmpty
color="danger"
size="xs"
iconType="trash"
onClick={() => {
close();
deleteStep();
}}
style={{ paddingBottom: 8 }}
>
{i18n.translate('coloring.colorMapping.colorPicker.removeGradientColorButtonLabel', {
defaultMessage: 'Remove color step',
})}
</EuiButtonEmpty>
</>
) : null}
</div>
);
}

View file

@ -0,0 +1,184 @@
/*
* 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 {
EuiColorPickerSwatch,
EuiPopover,
euiShadowSmall,
isColorDark,
useEuiTheme,
} from '@elastic/eui';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { ColorPicker } from './color_picker';
import { getAssignmentColor } from '../../color/color_handling';
import { ColorMapping } from '../../config';
import { getPalette } from '../../palettes';
import { removeGradientColorStep } from '../../state/color_mapping';
import { selectColorPickerVisibility } from '../../state/selectors';
import { colorPickerVisibility, hideColorPickerVisibility } from '../../state/ui';
import { getValidColor } from '../../color/color_math';
interface ColorPickerSwatchProps {
colorMode: ColorMapping.Config['colorMode'];
assignmentColor:
| ColorMapping.Config['assignments'][number]['color']
| ColorMapping.Config['specialAssignments'][number]['color'];
getPaletteFn: ReturnType<typeof getPalette>;
canPickColor: boolean;
index: number;
total: number;
palette: ColorMapping.CategoricalPalette;
onColorChange: (color: ColorMapping.CategoricalColor | ColorMapping.ColorCode) => void;
swatchShape: 'square' | 'round';
isDarkMode: boolean;
forType: 'assignment' | 'specialAssignment' | 'gradient';
}
export const ColorSwatch = ({
colorMode,
assignmentColor,
getPaletteFn,
canPickColor,
index,
total,
palette,
onColorChange,
swatchShape,
isDarkMode,
forType,
}: ColorPickerSwatchProps) => {
const colorPickerState = useSelector(selectColorPickerVisibility);
const dispatch = useDispatch();
const colorPickerVisible =
colorPickerState.index === index &&
colorPickerState.type === forType &&
colorPickerState.visibile;
const colorHex = getAssignmentColor(
colorMode,
assignmentColor,
getPaletteFn,
isDarkMode,
index,
total
);
const colorIsDark = isColorDark(...getValidColor(colorHex).rgb());
const euiTheme = useEuiTheme();
return canPickColor && assignmentColor.type !== 'gradient' ? (
<EuiPopover
panelPaddingSize="none"
isOpen={colorPickerVisible}
repositionOnScroll={true}
closePopover={() => dispatch(hideColorPickerVisibility())}
anchorPosition="upLeft"
button={
swatchShape === 'round' ? (
<button
aria-label={i18n.translate('coloring.colorMapping.colorPicker.pickAColorAriaLabel', {
defaultMessage: 'Pick a color',
})}
data-test-subj={`lns-colorMapping-colorSwatch-${index}`}
onClick={() =>
dispatch(
colorPickerVisible
? hideColorPickerVisibility()
: colorPickerVisibility({ index, visible: true, type: forType })
)
}
css={css`
background: ${colorHex};
width: 16px;
height: 16px;
border-radius: 50%;
top: 8px;
border: 3px solid white;
${euiShadowSmall(euiTheme)};
backgroundcolor: ${colorHex};
cursor: ${canPickColor ? 'pointer' : 'not-allowed'};
`}
/>
) : (
<EuiColorPickerSwatch
color={colorHex}
aria-label={i18n.translate('coloring.colorMapping.colorPicker.pickAColorAriaLabel', {
defaultMessage: 'Pick a color',
})}
data-test-subj={`lns-colorMapping-colorSwatch-${index}`}
onClick={() =>
dispatch(
colorPickerVisible
? hideColorPickerVisibility()
: colorPickerVisibility({ index, visible: true, type: forType })
)
}
style={{
// the color swatch can't pickup colors written in rgb/css standard
backgroundColor: colorHex,
cursor: canPickColor ? 'pointer' : 'not-allowed',
width: 32,
height: 32,
}}
css={css`
&::after {
content: '';
width: 0;
height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
border-top: 4px solid ${colorIsDark ? 'white' : 'black'};
margin: 0;
bottom: 2px;
position: absolute;
right: 2px;
}
`}
/>
)
}
>
<ColorPicker
key={
assignmentColor.type === 'categorical'
? `${assignmentColor.colorIndex}-${assignmentColor.paletteId}`
: assignmentColor.colorCode
}
color={assignmentColor}
palette={palette}
getPaletteFn={getPaletteFn}
close={() => dispatch(hideColorPickerVisibility())}
isDarkMode={isDarkMode}
selectColor={(color) => {
// dispatch update
onColorChange(color);
}}
deleteStep={
colorMode.type === 'gradient' && total > 1
? () => dispatch(removeGradientColorStep(index))
: undefined
}
/>
</EuiPopover>
) : (
<EuiColorPickerSwatch
color={colorHex}
aria-label={i18n.translate('coloring.colorMapping.colorPicker.newColorAriaLabel', {
defaultMessage: 'Select a new color',
})}
disabled
style={{
// the color swatch can't pickup colors written in rgb/css standard
backgroundColor: colorHex,
cursor: 'not-allowed',
width: 32,
height: 32,
}}
/>
);
};

View file

@ -0,0 +1,133 @@
/*
* 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 {
EuiColorPickerSwatch,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ColorMapping } from '../../config';
import { getPalette } from '../../palettes';
import { isSameColor } from '../../color/color_math';
import { NeutralPalette } from '../../palettes/neutral';
export function PaletteColors({
palette,
isDarkMode,
color,
getPaletteFn,
selectColor,
}: {
palette: ColorMapping.CategoricalPalette;
isDarkMode: boolean;
color: ColorMapping.CategoricalColor | ColorMapping.ColorCode;
getPaletteFn: ReturnType<typeof getPalette>;
selectColor: (color: ColorMapping.CategoricalColor | ColorMapping.ColorCode) => void;
}) {
const colors = Array.from({ length: palette.colorCount }, (d, i) => {
return palette.getColor(i, isDarkMode);
});
const neutralColors = Array.from({ length: NeutralPalette.colorCount }, (d, i) => {
return NeutralPalette.getColor(i, isDarkMode);
});
const originalColor =
color.type === 'categorical'
? color.paletteId === NeutralPalette.id
? NeutralPalette.getColor(color.colorIndex, isDarkMode)
: getPaletteFn(color.paletteId).getColor(color.colorIndex, isDarkMode)
: color.colorCode;
return (
<>
<EuiFlexGroup direction="column" style={{ padding: 8 }}>
<EuiFlexItem>
<EuiText size="s">
<strong>
{i18n.translate('coloring.colorMapping.colorPicker.paletteColorsLabel', {
defaultMessage: 'Palette colors',
})}
</strong>
</EuiText>
<EuiFlexGroup
direction="row"
gutterSize="s"
wrap={true}
alignItems="center"
justifyContent="flexStart"
>
{colors.map((c, index) => (
<EuiFlexItem key={c} grow={0}>
<EuiColorPickerSwatch
data-test-subj={`lns-colorMapping-colorPicker-staticColor-${index}`}
style={{
border: isSameColor(c, originalColor) ? '2px solid black' : 'transparent',
}}
color={c}
onClick={() =>
selectColor({ type: 'categorical', paletteId: palette.id, colorIndex: index })
}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
<EuiFlexGroup style={{ padding: 8, paddingTop: 0 }}>
<EuiFlexItem>
<EuiText size="s">
<strong>
{i18n.translate('coloring.colorMapping.colorPicker.themeAwareColorsLabel', {
defaultMessage: 'Neutral colors',
})}
</strong>
<EuiToolTip
position="bottom"
content={i18n.translate('coloring.colorMapping.colorPicker.themeAwareColorsTooltip', {
defaultMessage:
'The provided neutral colors are theme-aware and will change appropriately when switching between light and dark themes',
})}
>
<EuiIcon tabIndex={0} type="questionInCircle" />
</EuiToolTip>
</EuiText>
<EuiFlexGroup
direction="row"
gutterSize="s"
wrap={true}
alignItems="center"
justifyContent="flexStart"
>
{neutralColors.map((c, index) => (
<EuiFlexItem key={c} grow={0}>
<EuiColorPickerSwatch
style={{
border: isSameColor(c, originalColor) ? '2px solid black' : 'transparent',
}}
data-test-subj={`lns-colorMapping-colorPicker-neutralColor-${index}`}
color={c}
onClick={() =>
selectColor({
type: 'categorical',
paletteId: NeutralPalette.id,
colorIndex: index,
})
}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}

View file

@ -0,0 +1,144 @@
/*
* 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 { EuiColorPicker, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
import React, { useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import chromajs from 'chroma-js';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { ColorMapping } from '../../config';
import { hasEnoughContrast } from '../../color/color_math';
import { getPalette } from '../../palettes';
export function RGBPicker({
isDarkMode,
color,
getPaletteFn,
selectColor,
close,
}: {
palette: ColorMapping.CategoricalPalette;
isDarkMode: boolean;
color: ColorMapping.CategoricalColor | ColorMapping.ColorCode;
getPaletteFn: ReturnType<typeof getPalette>;
selectColor: (color: ColorMapping.CategoricalColor | ColorMapping.ColorCode) => void;
close: () => void;
}) {
const [customColorMappingColor, setCustomColorMappingColor] = useState<
ColorMapping.CategoricalColor | ColorMapping.ColorCode
>(color);
const customColorHex =
customColorMappingColor.type === 'categorical'
? getPaletteFn(customColorMappingColor.paletteId).getColor(
customColorMappingColor.colorIndex,
isDarkMode
)
: customColorMappingColor.colorCode;
const [colorTextInput, setColorTextInput] = useState<string>(customColorHex);
// check contrasts with WCAG 2.1 with a min contrast ratio of 3
const lightContrast = hasEnoughContrast(customColorHex, false, 3);
const darkContrast = hasEnoughContrast(customColorHex, true, 3);
const errorMessage = [
lightContrast === false ? 'light' : undefined,
darkContrast === false ? 'dark' : undefined,
].filter(Boolean);
const isColorTextValid = chromajs.valid(colorTextInput);
const colorHasContrast = lightContrast && darkContrast;
// debounce setting the color from the rgb picker by 500ms
useDebounce(
() => {
if (color !== customColorMappingColor) {
selectColor(customColorMappingColor);
}
},
500,
[color, customColorMappingColor]
);
return (
<EuiFlexGroup direction="column" gutterSize="s" style={{ padding: 8 }}>
<EuiFlexItem>
<EuiColorPicker
onChange={(c) => {
setCustomColorMappingColor({
type: 'colorCode',
colorCode: c,
});
setColorTextInput(c);
}}
color={customColorHex}
display="inline"
swatches={[]}
/>
</EuiFlexItem>
<EuiFlexItem>
<div
css={
!colorHasContrast && isColorTextValid
? css`
svg {
fill: ${euiThemeVars.euiColorWarningText} !important;
}
input {
background-image: linear-gradient(
to top,
${euiThemeVars.euiColorWarning},
${euiThemeVars.euiColorWarning} 2px,
transparent 2px,
transparent 100%
) !important;
}
.euiFormErrorText {
color: ${euiThemeVars.euiColorWarningText} !important;
}
`
: undefined
}
>
<EuiFormRow
isInvalid={!isColorTextValid || !colorHasContrast}
error={
!isColorTextValid
? `Please input a valid color hex code`
: !colorHasContrast
? `This color has a low contrast in ${errorMessage} mode${
errorMessage.length > 1 ? 's' : ''
}`
: undefined
}
>
<EuiFieldText
placeholder="Please enter an hex color code"
value={colorTextInput}
compressed
isInvalid={!isColorTextValid || !colorHasContrast}
onChange={(e) => {
const textColor = e.currentTarget.value;
setColorTextInput(textColor);
if (chromajs.valid(textColor)) {
setCustomColorMappingColor({
type: 'colorCode',
colorCode: chromajs(textColor).hex(),
});
}
}}
aria-label="hex color input"
/>
</EuiFormRow>
</div>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,252 @@
/*
* 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 { useSelector, useDispatch } from 'react-redux';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFormLabel,
EuiHorizontalRule,
EuiPanel,
EuiSwitch,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { Assignment } from '../assignment/assignment';
import { SpecialAssignment } from '../assignment/special_assignment';
import { PaletteSelector } from '../palette_selector/palette_selector';
import {
RootState,
addNewAssignment,
assignAutomatically,
assignStatically,
changeGradientSortOrder,
} from '../../state/color_mapping';
import { generateAutoAssignmentsForCategories } from '../../config/assignment_from_categories';
import { ColorMapping } from '../../config';
import { getPalette } from '../../palettes';
import { getUnusedColorForNewAssignment } from '../../config/assignments';
import {
selectColorMode,
selectPalette,
selectSpecialAssignments,
selectIsAutoAssignmentMode,
} from '../../state/selectors';
import { ColorMappingInputData } from '../../categorical_color_mapping';
import { Gradient } from '../palette_selector/gradient';
import { NeutralPalette } from '../../palettes/neutral';
export const MAX_ASSIGNABLE_COLORS = 10;
function selectComputedAssignments(
data: ColorMappingInputData,
palette: ColorMapping.CategoricalPalette,
colorMode: ColorMapping.Config['colorMode']
) {
return (state: RootState) =>
state.colorMapping.assignmentMode === 'auto'
? generateAutoAssignmentsForCategories(data, palette, colorMode)
: state.colorMapping.assignments;
}
export function Container(props: {
palettes: Map<string, ColorMapping.CategoricalPalette>;
data: ColorMappingInputData;
isDarkMode: boolean;
/** map between original and formatted tokens used to handle special cases, like the Other bucket and the empty bucket */
specialTokens: Map<string, string>;
}) {
const dispatch = useDispatch();
const getPaletteFn = getPalette(props.palettes, NeutralPalette);
const palette = useSelector(selectPalette(getPaletteFn));
const colorMode = useSelector(selectColorMode);
const autoAssignmentMode = useSelector(selectIsAutoAssignmentMode);
const assignments = useSelector(selectComputedAssignments(props.data, palette, colorMode));
const specialAssignments = useSelector(selectSpecialAssignments);
const canAddNewAssignment = !autoAssignmentMode && assignments.length < MAX_ASSIGNABLE_COLORS;
const assignmentValuesCounter = assignments.reduce<Map<string | string[], number>>(
(acc, assignment) => {
const values = assignment.rule.type === 'matchExactly' ? assignment.rule.values : [];
values.forEach((value) => {
acc.set(value, (acc.get(value) ?? 0) + 1);
});
return acc;
},
new Map()
);
return (
<EuiFlexGroup direction="column" gutterSize="s" justifyContent="flexStart">
<EuiFlexItem>
<PaletteSelector
palettes={props.palettes}
getPaletteFn={getPaletteFn}
isDarkMode={props.isDarkMode}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiFormLabel>
{i18n.translate('coloring.colorMapping.container.mappingAssignmentHeader', {
defaultMessage: 'Mapping assignments',
})}
</EuiFormLabel>
</EuiFlexItem>
<EuiFlexItem grow={0}>
<EuiSwitch
data-test-subj="lns-colorMapping-autoAssignSwitch"
label={
<EuiText size="xs">
{i18n.translate('coloring.colorMapping.container.autoAssignLabel', {
defaultMessage: 'Auto assign',
})}
</EuiText>
}
checked={autoAssignmentMode}
compressed
onChange={() => {
if (autoAssignmentMode) {
dispatch(assignStatically(assignments));
} else {
dispatch(assignAutomatically());
}
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel color="subdued" borderRadius="none" hasShadow={false} paddingSize="s">
<div
data-test-subj="lns-colorMapping-assignmentsList"
css={css`
display: grid;
grid-template-columns: ${colorMode.type === 'gradient' ? '[gradient] 16px' : ''} [assignment] auto;
gap: 8px;
`}
>
{colorMode.type !== 'gradient' ? null : (
<Gradient
colorMode={colorMode}
getPaletteFn={getPaletteFn}
isDarkMode={props.isDarkMode}
paletteId={palette.id}
assignmentsSize={assignments.length}
/>
)}
{assignments.map((assignment, i) => {
return (
<div
key={i}
css={css`
position: relative;
grid-column: ${colorMode.type === 'gradient' ? 2 : 1};
grid-row: ${i + 1};
width: 100%;
`}
>
<Assignment
data={props.data}
index={i}
total={assignments.length}
colorMode={colorMode}
editable={!autoAssignmentMode}
canPickColor={!autoAssignmentMode && colorMode.type !== 'gradient'}
palette={palette}
isDarkMode={props.isDarkMode}
getPaletteFn={getPaletteFn}
assignment={assignment}
disableDelete={assignments.length <= 1 || autoAssignmentMode}
specialTokens={props.specialTokens}
assignmentValuesCounter={assignmentValuesCounter}
/>
</div>
);
})}
</div>
<EuiHorizontalRule margin="xs" />
<EuiFlexGroup direction="row" gutterSize="s">
<EuiFlexItem data-test-subj="lns-colorMapping-specialAssignmentsList">
{props.data.type === 'categories' &&
specialAssignments.map((assignment, i) => {
return (
<SpecialAssignment
key={i}
index={i}
palette={palette}
isDarkMode={props.isDarkMode}
getPaletteFn={getPaletteFn}
assignment={assignment}
total={specialAssignments.length}
/>
);
})}
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiFlexGroup direction="row">
<EuiFlexItem style={{ display: 'block' }}>
<EuiButtonEmpty
data-test-subj="lns-colorMapping-addNewAssignment"
iconType="plusInCircleFilled"
size="xs"
flush="both"
onClick={() => {
dispatch(
addNewAssignment({
rule:
props.data.type === 'categories'
? {
type: 'matchExactly',
values: [],
}
: { type: 'range', min: 0, max: 0, minInclusive: true, maxInclusive: true },
color: getUnusedColorForNewAssignment(palette, colorMode, assignments),
touched: false,
})
);
}}
disabled={!canAddNewAssignment}
css={css`
margin-right: 8px;
`}
>
{i18n.translate('coloring.colorMapping.container.addAssignmentButtonLabel', {
defaultMessage: 'Add assignment',
})}
</EuiButtonEmpty>
{colorMode.type === 'gradient' && (
<EuiButtonEmpty
flush="both"
data-test-subj="lns-colorMapping-invertGradient"
iconType="sortable"
size="xs"
onClick={() => {
dispatch(changeGradientSortOrder(colorMode.sort === 'asc' ? 'desc' : 'asc'));
}}
>
{i18n.translate('coloring.colorMapping.container.invertGradientButtonLabel', {
defaultMessage: 'Invert gradient',
})}
</EuiButtonEmpty>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,336 @@
/*
* 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 { euiFocusRing, EuiIcon, euiShadowSmall, useEuiTheme } from '@elastic/eui';
import React from 'react';
import { useDispatch } from 'react-redux';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { changeAlpha } from '../../color/color_math';
import { ColorMapping } from '../../config';
import { ColorSwatch } from '../color_picker/color_swatch';
import { getPalette } from '../../palettes';
import { addGradientColorStep, updateGradientColorStep } from '../../state/color_mapping';
import { colorPickerVisibility } from '../../state/ui';
import { getGradientColorScale } from '../../color/color_handling';
export function Gradient({
paletteId,
colorMode,
getPaletteFn,
isDarkMode,
assignmentsSize,
}: {
paletteId: string;
isDarkMode: boolean;
colorMode: ColorMapping.Config['colorMode'];
getPaletteFn: ReturnType<typeof getPalette>;
assignmentsSize: number;
}) {
if (colorMode.type === 'categorical') {
return null;
}
const currentPalette = getPaletteFn(paletteId);
const gradientColorScale = getGradientColorScale(colorMode, getPaletteFn, isDarkMode);
const topMostColorStop =
colorMode.sort === 'asc'
? colorMode.steps.length === 1
? undefined
: colorMode.steps.at(-1)
: colorMode.steps.at(0);
const topMostColorStopIndex =
colorMode.sort === 'asc'
? colorMode.steps.length === 1
? NaN
: colorMode.steps.length - 1
: 0;
const bottomMostColorStop =
colorMode.sort === 'asc'
? colorMode.steps.at(0)
: colorMode.steps.length === 1
? undefined
: colorMode.steps.at(-1);
const bottomMostColorStopIndex =
colorMode.sort === 'asc' ? 0 : colorMode.steps.length === 1 ? NaN : colorMode.steps.length - 1;
const middleMostColorSep = colorMode.steps.length === 3 ? colorMode.steps[1] : undefined;
const middleMostColorStopIndex = colorMode.steps.length === 3 ? 1 : NaN;
return (
<>
{assignmentsSize > 1 && (
<div
className="gradientLine"
css={css`
position: relative;
grid-column: 1;
grid-row: 1;
width: 6px;
margin-left: 5px;
top: 16px;
height: calc(100% - 12px);
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background-image: linear-gradient(
to bottom,
${[gradientColorScale(0), gradientColorScale(1 / assignmentsSize)].join(',')}
);
border-left: 1px solid ${changeAlpha(euiThemeVars.euiColorDarkestShade, 0.2)};
border-top: 1px solid ${changeAlpha(euiThemeVars.euiColorDarkestShade, 0.2)};
border-right: 1px solid ${changeAlpha(euiThemeVars.euiColorDarkestShade, 0.2)};
`}
/>
)}
<div
className="gradientStop"
css={css`
position: relative;
grid-column: 1;
grid-row: 1;
margin-top: 8px;
`}
>
{topMostColorStop ? (
<ColorStop
colorMode={colorMode}
step={topMostColorStop}
index={topMostColorStopIndex}
currentPalette={currentPalette}
getPaletteFn={getPaletteFn}
isDarkMode={isDarkMode}
/>
) : (
<AddStop colorMode={colorMode} currentPalette={currentPalette} at={1} />
)}
</div>
{assignmentsSize > 1 && (
<div
className="gradientLine"
css={css`
position: relative;
z-index: 1;
grid-column: 1;
grid-row-start: 2;
grid-row-end: ${assignmentsSize};
background-image: linear-gradient(
to bottom,
${[
gradientColorScale(1 / assignmentsSize),
gradientColorScale((assignmentsSize - 1) / assignmentsSize),
].join(',')}
);
margin: -4px 0;
width: 6px;
margin-left: 5px;
${assignmentsSize === 2 ? 'height: 0;' : ''};
border-left: 1px solid ${changeAlpha(euiThemeVars.euiColorDarkestShade, 0.2)};
border-right: 1px solid ${changeAlpha(euiThemeVars.euiColorDarkestShade, 0.2)};
`}
>
<div
css={css`
position: absolute;
width: 16px;
height: 16px;
top: calc(50% - 5px);
margin-left: -6px;
margin-top: -3px;
`}
>
{middleMostColorSep ? (
<ColorStop
colorMode={colorMode}
step={middleMostColorSep}
index={middleMostColorStopIndex}
currentPalette={currentPalette}
getPaletteFn={getPaletteFn}
isDarkMode={isDarkMode}
/>
) : colorMode.steps.length === 2 ? (
<AddStop colorMode={colorMode} currentPalette={currentPalette} at={1} />
) : undefined}
</div>
</div>
)}
{assignmentsSize > 1 && (
<div
className="gradientLine"
css={css`
position: relative;
grid-column: 1;
grid-row: ${assignmentsSize};
background-image: linear-gradient(
to bottom,
${[
gradientColorScale((assignmentsSize - 1) / assignmentsSize),
gradientColorScale(1),
].join(',')}
);
top: -4px;
height: 24px;
width: 6px;
margin-left: 5px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border-left: 1px solid ${changeAlpha(euiThemeVars.euiColorDarkestShade, 0.2)};
border-bottom: 1px solid ${changeAlpha(euiThemeVars.euiColorDarkestShade, 0.2)};
border-right: 1px solid ${changeAlpha(euiThemeVars.euiColorDarkestShade, 0.2)};
`}
/>
)}
<div
css={css`
position: relative;
grid-column: 1;
grid-row: ${assignmentsSize};
width: 16px;
height: 16px;
margin-top: 8px;
`}
>
{bottomMostColorStop ? (
<ColorStop
colorMode={colorMode}
step={bottomMostColorStop}
index={bottomMostColorStopIndex}
currentPalette={currentPalette}
getPaletteFn={getPaletteFn}
isDarkMode={isDarkMode}
/>
) : (
<AddStop colorMode={colorMode} currentPalette={currentPalette} at={1} />
)}
</div>
</>
);
}
function AddStop({
colorMode,
currentPalette,
at,
}: {
colorMode: {
type: 'gradient';
steps: Array<(ColorMapping.CategoricalColor | ColorMapping.ColorCode) & { touched: boolean }>;
};
currentPalette: ColorMapping.CategoricalPalette;
at: number;
}) {
const euiTheme = useEuiTheme();
const dispatch = useDispatch();
return (
<button
css={css`
position: relative;
border-radius: 50%;
width: 17px;
height: 17px;
padding: 0 0.5px;
${euiFocusRing(euiTheme)};
`}
onClick={() => {
dispatch(
addGradientColorStep({
color: {
type: 'categorical',
// TODO assign the next available color or a better one
colorIndex: colorMode.steps.length,
paletteId: currentPalette.id,
},
at,
})
);
dispatch(
colorPickerVisibility({
index: at,
type: 'gradient',
visible: true,
})
);
}}
>
<div
css={css`
width: 15px;
height: 15px;
border-radius: 50%;
transition: 200ms background-color;
background-color: lightgrey;
&:hover {
background-color: #696f7d;
}
${euiShadowSmall(euiTheme)}
`}
>
<EuiIcon
type="plus"
css={css`
position: absolute;
top: 0.5px;
left: 0;
transition: 200ms fill;
&:hover {
fill: white;
}
`}
color={'#696f7d'}
/>
</div>
</button>
);
}
function ColorStop({
colorMode,
step,
index,
currentPalette,
getPaletteFn,
isDarkMode,
}: {
colorMode: ColorMapping.GradientColorMode;
step: ColorMapping.CategoricalColor | ColorMapping.ColorCode;
index: number;
currentPalette: ColorMapping.CategoricalPalette;
getPaletteFn: ReturnType<typeof getPalette>;
isDarkMode: boolean;
}) {
const dispatch = useDispatch();
return (
<ColorSwatch
canPickColor={true}
colorMode={colorMode}
assignmentColor={step}
getPaletteFn={getPaletteFn}
index={index}
palette={currentPalette}
total={colorMode.steps.length}
swatchShape="round"
isDarkMode={isDarkMode}
onColorChange={(color) => {
dispatch(
updateGradientColorStep({
index,
color,
})
);
}}
forType="gradient"
/>
);
}

View file

@ -0,0 +1,263 @@
/*
* 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, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
EuiButtonGroup,
EuiColorPalettePicker,
EuiConfirmModal,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ScaleCategoricalIcon } from './scale_categorical';
import { ScaleSequentialIcon } from './scale_sequential';
import { RootState, updatePalette } from '../../state/color_mapping';
import { ColorMapping } from '../../config';
import { updateAssignmentsPalette, updateColorModePalette } from '../../config/assignments';
import { getPalette } from '../../palettes';
export function PaletteSelector({
palettes,
getPaletteFn,
isDarkMode,
}: {
getPaletteFn: ReturnType<typeof getPalette>;
palettes: Map<string, ColorMapping.CategoricalPalette>;
isDarkMode: boolean;
}) {
const dispatch = useDispatch();
const colorMode = useSelector((state: RootState) => state.colorMapping.colorMode);
const model = useSelector((state: RootState) => state.colorMapping);
const { paletteId } = model;
const switchPaletteFn = useCallback(
(selectedPaletteId: string, preserveColorChanges: boolean) => {
dispatch(
updatePalette({
paletteId: selectedPaletteId,
assignments: updateAssignmentsPalette(
model.assignments,
model.assignmentMode,
model.colorMode,
selectedPaletteId,
getPaletteFn,
preserveColorChanges
),
colorMode: updateColorModePalette(
model.colorMode,
selectedPaletteId,
preserveColorChanges
),
})
);
},
[getPaletteFn, model, dispatch]
);
const updateColorMode = useCallback(
(type: 'gradient' | 'categorical', preserveColorChanges: boolean) => {
const updatedColorMode: ColorMapping.Config['colorMode'] =
type === 'gradient'
? {
type: 'gradient',
steps: [
{
type: 'categorical',
paletteId,
colorIndex: 0,
touched: false,
},
],
sort: 'desc',
}
: { type: 'categorical' };
const assignments = updateAssignmentsPalette(
model.assignments,
model.assignmentMode,
updatedColorMode,
paletteId,
getPaletteFn,
preserveColorChanges
);
dispatch(updatePalette({ paletteId, assignments, colorMode: updatedColorMode }));
},
[getPaletteFn, model, dispatch, paletteId]
);
const [preserveModalPaletteId, setPreserveModalPaletteId] = useState<string | null>(null);
const preserveChangesModal =
preserveModalPaletteId !== null ? (
<EuiConfirmModal
title={i18n.translate('coloring.colorMapping.colorChangesModal.title', {
defaultMessage: 'Color changes detected',
})}
onCancel={() => {
if (preserveModalPaletteId) switchPaletteFn(preserveModalPaletteId, true);
setPreserveModalPaletteId(null);
}}
onConfirm={() => {
if (preserveModalPaletteId) switchPaletteFn(preserveModalPaletteId, false);
setPreserveModalPaletteId(null);
}}
confirmButtonText={i18n.translate('coloring.colorMapping.colorChangesModal.discardButton', {
defaultMessage: 'Discard changes',
})}
cancelButtonText={i18n.translate('coloring.colorMapping.colorChangesModal.preserveButton', {
defaultMessage: 'Preserve changes',
})}
buttonColor="danger"
defaultFocusedButton="confirm"
>
<p>
{i18n.translate('coloring.colorMapping.colorChangesModal.switchPaletteDescription', {
defaultMessage: 'Switching palette will discard all your custom color changes',
})}
</p>
</EuiConfirmModal>
) : null;
const [colorScaleModalId, setColorScaleModalId] = useState<'gradient' | 'categorical' | null>(
null
);
const colorScaleModal =
colorScaleModalId !== null ? (
<EuiConfirmModal
title={i18n.translate('coloring.colorMapping.colorChangesModal.modalTitle', {
defaultMessage: 'Color changes detected',
})}
onCancel={() => {
setColorScaleModalId(null);
}}
onConfirm={() => {
if (colorScaleModalId) updateColorMode(colorScaleModalId, false);
setColorScaleModalId(null);
}}
cancelButtonText={i18n.translate(
'coloring.colorMapping.colorChangesModal.goBackButtonLabel',
{
defaultMessage: 'Go back',
}
)}
confirmButtonText={i18n.translate(
'coloring.colorMapping.colorChangesModal.discardButtonLabel',
{
defaultMessage: 'Discard changes',
}
)}
defaultFocusedButton="confirm"
buttonColor="danger"
>
<p>
{colorScaleModalId === 'categorical'
? i18n.translate('coloring.colorMapping.colorChangesModal.categoricalModeDescription', {
defaultMessage: `Switching to a categorical mode will discard all your custom color changes`,
})
: i18n.translate('coloring.colorMapping.colorChangesModal.sequentialModeDescription', {
defaultMessage: `Switching to a sequential mode will discard all your custom color changes`,
})}
</p>
</EuiConfirmModal>
) : null;
return (
<>
{preserveChangesModal}
{colorScaleModal}
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('coloring.colorMapping.paletteSelector.paletteLabel', {
defaultMessage: `Color palette`,
})}
>
<EuiColorPalettePicker
data-test-subj="kbnColoring_ColorMapping_PalettePicker"
palettes={[...palettes.values()]
.filter((d) => d.name !== 'Neutral')
.map((palette) => ({
'data-test-subj': `kbnColoring_ColorMapping_Palette-${palette.id}`,
value: palette.id,
title: palette.name,
palette: Array.from({ length: palette.colorCount }, (_, i) => {
return palette.getColor(i, isDarkMode);
}),
type: 'fixed',
}))}
onChange={(selectedPaletteId) => {
const hasChanges = model.assignments.some((a) => a.touched);
const hasGradientChanges =
model.colorMode.type === 'gradient' &&
model.colorMode.steps.some((a) => a.touched);
if (hasChanges || hasGradientChanges) {
setPreserveModalPaletteId(selectedPaletteId);
} else {
switchPaletteFn(selectedPaletteId, false);
}
}}
valueOfSelected={model.paletteId}
selectionDisplay={'palette'}
compressed={true}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={0}>
<EuiFormRow
label={i18n.translate('coloring.colorMapping.paletteSelector.scaleLabel', {
defaultMessage: `Scale`,
})}
>
<EuiButtonGroup
legend="Scale"
data-test-subj="lns_colorMapping_scaleSwitch"
options={[
{
id: `categorical`,
label: i18n.translate('coloring.colorMapping.paletteSelector.categoricalLabel', {
defaultMessage: `Categorical`,
}),
iconType: ScaleCategoricalIcon,
},
{
id: `gradient`,
label: i18n.translate('coloring.colorMapping.paletteSelector.sequentialLabel', {
defaultMessage: `Sequential`,
}),
iconType: ScaleSequentialIcon,
},
]}
isFullWidth
buttonSize="compressed"
idSelected={colorMode.type}
onChange={(id) => {
const hasChanges = model.assignments.some((a) => a.touched);
const hasGradientChanges =
model.colorMode.type === 'gradient' &&
model.colorMode.steps.some((a) => a.touched);
if (hasChanges || hasGradientChanges) {
setColorScaleModalId(id as 'gradient' | 'categorical');
} else {
updateColorMode(id as 'gradient' | 'categorical', false);
}
}}
isIconOnly
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}

View file

@ -0,0 +1,17 @@
/*
* 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';
export function ScaleCategoricalIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="M4 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm2 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM5 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm4-2a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm2 2a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" />
<path d="M8 1a7 7 0 0 0 0 14h2a2 2 0 1 0 0-4 1 1 0 1 1 0-2h3.98C14.515 9 15 8.583 15 8a7 7 0 0 0-7-7ZM2 8a6 6 0 0 1 12-.005.035.035 0 0 1-.02.005H10a2 2 0 1 0 0 4 1 1 0 1 1 0 2H8a6 6 0 0 1-6-6Z" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
/*
* 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';
export function ScaleSequentialIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path
fillRule="evenodd"
d="M1 2a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2Zm4 0h1v12H5V2Zm3 12V2h2v12H8Zm3 0h3V2h-3v12Z"
/>
</svg>
);
}

View file

@ -0,0 +1,65 @@
/*
* 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 { ColorMapping } from '.';
import { ColorMappingInputData } from '../categorical_color_mapping';
import { MAX_ASSIGNABLE_COLORS } from '../components/container/container';
export function generateAutoAssignmentsForCategories(
data: ColorMappingInputData,
palette: ColorMapping.CategoricalPalette,
colorMode: ColorMapping.Config['colorMode']
): ColorMapping.Config['assignments'] {
const isCategorical = colorMode.type === 'categorical';
const maxColorAssignable = data.type === 'categories' ? data.categories.length : data.bins;
const assignableColors = isCategorical
? Math.min(palette.colorCount, maxColorAssignable)
: Math.min(MAX_ASSIGNABLE_COLORS, maxColorAssignable);
const autoRules: Array<ColorMapping.RuleMatchExactly | ColorMapping.RuleRange> =
data.type === 'categories'
? data.categories.map((c) => ({ type: 'matchExactly', values: [c] }))
: Array.from({ length: data.bins }, (d, i) => {
const step = (data.max - data.min) / data.bins;
return {
type: 'range',
min: data.max - i * step - step,
max: data.max - i * step,
minInclusive: true,
maxInclusive: false,
};
});
const assignments = autoRules
.slice(0, assignableColors)
.map<ColorMapping.Config['assignments'][number]>((rule, colorIndex) => {
if (isCategorical) {
return {
rule,
color: {
type: 'categorical',
paletteId: palette.id,
colorIndex,
},
touched: false,
};
} else {
return {
rule,
color: {
type: 'gradient',
},
touched: false,
};
}
});
return assignments;
}

View file

@ -0,0 +1,91 @@
/*
* 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 { ColorMapping } from '.';
import { MAX_ASSIGNABLE_COLORS } from '../components/container/container';
import { getPalette, NeutralPalette } from '../palettes';
import { DEFAULT_NEUTRAL_PALETTE_INDEX } from './default_color_mapping';
export function updateAssignmentsPalette(
assignments: ColorMapping.Config['assignments'],
assignmentMode: ColorMapping.Config['assignmentMode'],
colorMode: ColorMapping.Config['colorMode'],
paletteId: string,
getPaletteFn: ReturnType<typeof getPalette>,
preserveColorChanges: boolean
): ColorMapping.Config['assignments'] {
const palette = getPaletteFn(paletteId);
const maxColors = palette.type === 'categorical' ? palette.colorCount : MAX_ASSIGNABLE_COLORS;
return assignmentMode === 'auto'
? []
: assignments.map(({ rule, color, touched }, index) => {
if (preserveColorChanges && touched) {
return { rule, color, touched };
} else {
const newColor: ColorMapping.Config['assignments'][number]['color'] =
colorMode.type === 'categorical'
? {
type: 'categorical',
paletteId: index < maxColors ? paletteId : NeutralPalette.id,
colorIndex: index < maxColors ? index : 0,
}
: { type: 'gradient' };
return {
rule,
color: newColor,
touched: false,
};
}
});
}
export function updateColorModePalette(
colorMode: ColorMapping.Config['colorMode'],
paletteId: string,
preserveColorChanges: boolean
): ColorMapping.Config['colorMode'] {
return colorMode.type === 'categorical'
? colorMode
: {
type: 'gradient',
steps: colorMode.steps.map((step, stepIndex) => {
return preserveColorChanges
? step
: { type: 'categorical', paletteId, colorIndex: stepIndex, touched: false };
}),
sort: colorMode.sort,
};
}
export function getUnusedColorForNewAssignment(
palette: ColorMapping.CategoricalPalette,
colorMode: ColorMapping.Config['colorMode'],
assignments: ColorMapping.Config['assignments']
): ColorMapping.Config['assignments'][number]['color'] {
if (colorMode.type === 'categorical') {
// TODO: change the type of color assignment depending on palette
// compute the next unused color index in the palette.
const maxColors = palette.type === 'categorical' ? palette.colorCount : MAX_ASSIGNABLE_COLORS;
const colorIndices = new Set(Array.from({ length: maxColors }, (d, i) => i));
assignments.forEach(({ color }) => {
if (color.type === 'categorical' && color.paletteId === palette.id) {
colorIndices.delete(color.colorIndex);
}
});
const paletteForNextUnusedColorIndex = colorIndices.size > 0 ? palette.id : NeutralPalette.id;
const nextUnusedColorIndex =
colorIndices.size > 0 ? [...colorIndices][0] : DEFAULT_NEUTRAL_PALETTE_INDEX;
return {
type: 'categorical',
paletteId: paletteForNextUnusedColorIndex,
colorIndex: nextUnusedColorIndex,
};
} else {
return { type: 'gradient' };
}
}

View file

@ -0,0 +1,79 @@
/*
* 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 { ColorMapping } from '.';
import { AVAILABLE_PALETTES, getPalette } from '../palettes';
import { EUIAmsterdamColorBlindPalette } from '../palettes/eui_amsterdam';
import { NeutralPalette } from '../palettes/neutral';
import { getColor, getGradientColorScale } from '../color/color_handling';
export const DEFAULT_NEUTRAL_PALETTE_INDEX = 1;
/**
* The default color mapping used in Kibana, starts with the EUI color palette
*/
export const DEFAULT_COLOR_MAPPING_CONFIG: ColorMapping.Config = {
assignmentMode: 'auto',
assignments: [],
specialAssignments: [
{
rule: {
type: 'other',
},
color: {
type: 'categorical',
paletteId: NeutralPalette.id,
colorIndex: DEFAULT_NEUTRAL_PALETTE_INDEX,
},
touched: false,
},
],
paletteId: EUIAmsterdamColorBlindPalette.id,
colorMode: {
type: 'categorical',
},
};
export function getPaletteColors(
isDarkMode: boolean,
colorMappings?: ColorMapping.Config
): string[] {
const colorMappingModel = colorMappings ?? { ...DEFAULT_COLOR_MAPPING_CONFIG };
const palette = getPalette(AVAILABLE_PALETTES, NeutralPalette)(colorMappingModel.paletteId);
return Array.from({ length: palette.colorCount }, (d, i) => palette.getColor(i, isDarkMode));
}
export function getColorsFromMapping(
isDarkMode: boolean,
colorMappings?: ColorMapping.Config
): string[] {
const { colorMode, paletteId, assignmentMode, assignments, specialAssignments } =
colorMappings ?? {
...DEFAULT_COLOR_MAPPING_CONFIG,
};
const getPaletteFn = getPalette(AVAILABLE_PALETTES, NeutralPalette);
if (colorMode.type === 'gradient') {
const colorScale = getGradientColorScale(colorMode, getPaletteFn, isDarkMode);
return Array.from({ length: 6 }, (d, i) => colorScale(i / 6));
} else {
const palette = getPaletteFn(paletteId);
if (assignmentMode === 'auto') {
return Array.from({ length: palette.colorCount }, (d, i) => palette.getColor(i, isDarkMode));
} else {
return [
...assignments.map((a) => {
return a.color.type === 'gradient' ? '' : getColor(a.color, getPaletteFn, isDarkMode);
}),
...specialAssignments.map((a) => {
return getColor(a.color, getPaletteFn, isDarkMode);
}),
].filter((color) => color !== '');
}
}
}

View file

@ -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 * as ColorMapping from './types';

View file

@ -0,0 +1,153 @@
/*
* 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.
*/
/**
* A color specified as a CSS color datatype (rgb/a,hex,keywords,lab,lch etc)
*/
export interface ColorCode {
type: 'colorCode';
colorCode: string;
}
/**
* An index specified categorical color, coming from paletteId
*/
export interface CategoricalColor {
type: 'categorical';
paletteId: string;
colorIndex: number;
}
/**
* Specify that the Color in an Assignment needs to be taken from a gradient defined in the `Config.colorMode`
*/
export interface GradientColor {
type: 'gradient';
}
/**
* A special rule that match automatically, in order, all the categories that are not matching a specified rule
*/
export interface RuleAuto {
/* tag */
type: 'auto';
}
/**
* A rule that match exactly, case sensitive, with the provided strings
*/
export interface RuleMatchExactly {
/* tag */
type: 'matchExactly';
values: Array<string | string[]>;
}
/**
* A Match rule to match the values case insensitive
* @ignore not used yet
*/
export interface RuleMatchExactlyCI {
/* tag */
type: 'matchExactlyCI';
values: string[];
}
/**
* A range rule, not used yet, but can be used for numerical data assignments
*/
export interface RuleRange {
/* tag */
type: 'range';
/**
* The min value of the range
*/
min: number;
/**
* The max value of the range
*/
max: number;
/**
* `true` if the range is left-closed (the `min` value is considered within the range), false otherwise (only values that are
* greater than the `min` are considered within the range)
*/
minInclusive: boolean;
/**
* `true` if the range is right-closed (the `max` value is considered within the range), false otherwise (only values less than
* the `max` are considered within the range)
*/
maxInclusive: boolean;
}
/**
* Regex rule.
* @ignore not used yet
*/
export interface RuleRegExp {
/* tag */
type: 'regex';
/**
* TODO: not sure how we can store a regexp
*/
values: string;
}
/**
* A specific catch-everything-else rule
*/
export interface RuleOthers {
/* tag */
type: 'other';
}
/**
* An assignment is the connection link between a rule and a color
*/
export interface Assignment<R, C> {
/**
* Describe the rule used to assign the color.
*/
rule: R;
/**
* The color definition
*/
color: C;
/**
* Specify if the color was changed from the original one
* TODO: rename
*/
touched: boolean;
}
export interface CategoricalColorMode {
type: 'categorical';
}
export interface GradientColorMode {
type: 'gradient';
steps: Array<(CategoricalColor | ColorCode) & { touched: boolean }>;
sort: 'asc' | 'desc';
}
export interface Config {
paletteId: string;
colorMode: CategoricalColorMode | GradientColorMode;
assignmentMode: 'auto' | 'manual';
assignments: Array<
Assignment<
RuleAuto | RuleMatchExactly | RuleMatchExactlyCI | RuleRange | RuleRegExp,
CategoricalColor | ColorCode | GradientColor
>
>;
specialAssignments: Array<Assignment<RuleOthers, CategoricalColor | ColorCode>>;
}
export interface CategoricalPalette {
id: string;
name: string;
type: 'categorical';
colorCount: number;
getColor: (valueInRange: number, isDarkMode: boolean) => string;
}

View file

@ -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.
*/
export { CategoricalColorMapping, type ColorMappingProps } from './categorical_color_mapping';
export type { ColorMappingInputData } from './categorical_color_mapping';
export type { ColorMapping } from './config';
export * from './palettes';
export * from './color/color_handling';
export { SPECIAL_TOKENS_STRING_CONVERTION } from './color/rule_matching';
export {
DEFAULT_COLOR_MAPPING_CONFIG,
getPaletteColors,
getColorsFromMapping,
} from './config/default_color_mapping';

View file

@ -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.
*/
import { ColorMapping } from '../config';
export const ELASTIC_BRAND_PALETTE_COLORS = [
'#20377d',
'#7de2d1',
'#ff957d',
'#f04e98',
'#0077cc',
'#fec514',
];
export const ElasticBrandPalette: ColorMapping.CategoricalPalette = {
id: 'elastic_brand_2023',
name: 'Elastic Brand',
colorCount: ELASTIC_BRAND_PALETTE_COLORS.length,
type: 'categorical',
getColor(valueInRange) {
return ELASTIC_BRAND_PALETTE_COLORS[valueInRange];
},
};

View file

@ -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 { ColorMapping } from '../config';
export const EUI_AMSTERDAM_PALETTE_COLORS = [
'#54b399',
'#6092c0',
'#d36086',
'#9170b8',
'#ca8eae',
'#d6bf57',
'#b9a888',
'#da8b45',
'#aa6556',
'#e7664c',
];
export const EUIAmsterdamColorBlindPalette: ColorMapping.CategoricalPalette = {
id: 'eui_amsterdam_color_blind',
name: 'Default',
colorCount: EUI_AMSTERDAM_PALETTE_COLORS.length,
type: 'categorical',
getColor(valueInRange) {
return EUI_AMSTERDAM_PALETTE_COLORS[valueInRange];
},
};

View file

@ -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 { ColorMapping } from '../config';
import { ElasticBrandPalette } from './elastic_brand';
import { EUIAmsterdamColorBlindPalette } from './eui_amsterdam';
import { KibanaV7LegacyPalette } from './kibana_legacy';
import { NeutralPalette } from './neutral';
export const AVAILABLE_PALETTES = new Map<string, ColorMapping.CategoricalPalette>([
[EUIAmsterdamColorBlindPalette.id, EUIAmsterdamColorBlindPalette],
[ElasticBrandPalette.id, ElasticBrandPalette],
[KibanaV7LegacyPalette.id, KibanaV7LegacyPalette],
[NeutralPalette.id, NeutralPalette],
]);
/**
* This function should be instanciated once at the root of the component with the available palettes and
* a choosed default one and shared across components to keep a single point of truth of the available palettes and the default
* one.
*/
export function getPalette(
palettes: Map<string, ColorMapping.CategoricalPalette>,
defaultPalette: ColorMapping.CategoricalPalette
): (paletteId: string) => ColorMapping.CategoricalPalette {
return (paletteId) => palettes.get(paletteId) ?? defaultPalette;
}
export * from './eui_amsterdam';
export * from './elastic_brand';
export * from './kibana_legacy';
export * from './neutral';

View file

@ -0,0 +1,29 @@
/*
* 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 { ColorMapping } from '../config';
export const KIBANA_V7_LEGACY_PALETTE_COLORS = [
'#00a69b',
'#57c17b',
'#6f87d8',
'#663db8',
'#bc52bc',
'#9e3533',
'#daa05d',
];
export const KibanaV7LegacyPalette: ColorMapping.CategoricalPalette = {
id: 'kibana_v7_legacy',
name: 'Kibana Legacy',
colorCount: KIBANA_V7_LEGACY_PALETTE_COLORS.length,
type: 'categorical',
getColor(valueInRange) {
return KIBANA_V7_LEGACY_PALETTE_COLORS[valueInRange];
},
};

View file

@ -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 { ColorMapping } from '../config';
const schemeGreys = ['#f2f4fb', '#d4d9e5', '#98a2b3', '#696f7d', '#353642'];
export const NEUTRAL_COLOR_LIGHT = schemeGreys.slice();
export const NEUTRAL_COLOR_DARK = schemeGreys.slice().reverse();
export const NeutralPalette: ColorMapping.CategoricalPalette = {
id: 'neutral',
name: 'Neutral',
colorCount: NEUTRAL_COLOR_LIGHT.length,
type: 'categorical',
getColor(valueInRange, isDarkMode) {
return isDarkMode ? NEUTRAL_COLOR_DARK[valueInRange] : NEUTRAL_COLOR_LIGHT[valueInRange];
},
};

View file

@ -0,0 +1,225 @@
/*
* 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 { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { ColorMapping } from '../config';
export interface RootState {
colorMapping: ColorMapping.Config;
ui: {
colorPicker: {
index: number;
visibile: boolean;
type: 'gradient' | 'assignment' | 'specialAssignment';
};
};
}
const initialState: RootState['colorMapping'] = {
assignmentMode: 'auto',
assignments: [],
specialAssignments: [],
paletteId: 'eui',
colorMode: { type: 'categorical' },
};
export const colorMappingSlice = createSlice({
name: 'colorMapping',
initialState,
reducers: {
updateModel: (state, action: PayloadAction<ColorMapping.Config>) => {
state.assignmentMode = action.payload.assignmentMode;
state.assignments = [...action.payload.assignments];
state.specialAssignments = [...action.payload.specialAssignments];
state.paletteId = action.payload.paletteId;
state.colorMode = { ...action.payload.colorMode };
},
updatePalette: (
state,
action: PayloadAction<{
assignments: ColorMapping.Config['assignments'];
paletteId: ColorMapping.Config['paletteId'];
colorMode: ColorMapping.Config['colorMode'];
}>
) => {
state.paletteId = action.payload.paletteId;
state.assignments = [...action.payload.assignments];
state.colorMode = { ...action.payload.colorMode };
},
assignStatically: (state, action: PayloadAction<ColorMapping.Config['assignments']>) => {
state.assignmentMode = 'manual';
state.assignments = [...action.payload];
},
assignAutomatically: (state) => {
state.assignmentMode = 'auto';
state.assignments = [];
},
addNewAssignment: (
state,
action: PayloadAction<ColorMapping.Config['assignments'][number]>
) => {
state.assignments.push({ ...action.payload });
},
updateAssignment: (
state,
action: PayloadAction<{
assignmentIndex: number;
assignment: ColorMapping.Config['assignments'][number];
}>
) => {
state.assignments[action.payload.assignmentIndex] = {
...action.payload.assignment,
touched: true,
};
},
updateAssignmentRule: (
state,
action: PayloadAction<{
assignmentIndex: number;
rule: ColorMapping.Config['assignments'][number]['rule'];
}>
) => {
state.assignments[action.payload.assignmentIndex] = {
...state.assignments[action.payload.assignmentIndex],
rule: action.payload.rule,
};
},
updateAssignmentColor: (
state,
action: PayloadAction<{
assignmentIndex: number;
color: ColorMapping.Config['assignments'][number]['color'];
}>
) => {
state.assignments[action.payload.assignmentIndex] = {
...state.assignments[action.payload.assignmentIndex],
color: action.payload.color,
touched: true,
};
},
updateSpecialAssignmentColor: (
state,
action: PayloadAction<{
assignmentIndex: number;
color: ColorMapping.Config['specialAssignments'][number]['color'];
}>
) => {
state.specialAssignments[action.payload.assignmentIndex] = {
...state.specialAssignments[action.payload.assignmentIndex],
color: action.payload.color,
touched: true,
};
},
removeAssignment: (state, action: PayloadAction<number>) => {
state.assignments.splice(action.payload, 1);
},
changeColorMode: (state, action: PayloadAction<ColorMapping.Config['colorMode']>) => {
state.colorMode = { ...action.payload };
},
updateGradientColorStep: (
state,
action: PayloadAction<{
index: number;
color: ColorMapping.CategoricalColor | ColorMapping.ColorCode;
}>
) => {
if (state.colorMode.type !== 'gradient') {
return;
}
state.colorMode = {
...state.colorMode,
steps: state.colorMode.steps.map((step, index) => {
return index === action.payload.index
? { ...action.payload.color, touched: true }
: { ...step, touched: false };
}),
};
},
removeGradientColorStep: (state, action: PayloadAction<number>) => {
if (state.colorMode.type !== 'gradient') {
return;
}
const steps = [...state.colorMode.steps];
steps.splice(action.payload, 1);
// this maintain the correct sort direciton depending on which step
// gets removed from the array when only 2 steps are left.
const sort =
state.colorMode.steps.length === 2
? state.colorMode.sort === 'desc'
? action.payload === 0
? 'asc'
: 'desc'
: action.payload === 0
? 'desc'
: 'asc'
: state.colorMode.sort;
state.colorMode = {
...state.colorMode,
steps: [...steps],
sort,
};
},
addGradientColorStep: (
state,
action: PayloadAction<{
color: ColorMapping.CategoricalColor | ColorMapping.ColorCode;
at: number;
}>
) => {
if (state.colorMode.type !== 'gradient') {
return;
}
state.colorMode = {
...state.colorMode,
steps: [
...state.colorMode.steps.slice(0, action.payload.at),
{ ...action.payload.color, touched: false },
...state.colorMode.steps.slice(action.payload.at),
],
};
},
changeGradientSortOrder: (state, action: PayloadAction<'asc' | 'desc'>) => {
if (state.colorMode.type !== 'gradient') {
return;
}
state.colorMode = {
...state.colorMode,
sort: action.payload,
};
},
},
});
// Action creators are generated for each case reducer function
export const {
updatePalette,
assignStatically,
assignAutomatically,
addNewAssignment,
updateAssignment,
updateAssignmentColor,
updateSpecialAssignmentColor,
updateAssignmentRule,
removeAssignment,
changeColorMode,
updateGradientColorStep,
removeGradientColorStep,
addGradientColorStep,
changeGradientSortOrder,
updateModel,
} = colorMappingSlice.actions;
export const colorMappingReducer = colorMappingSlice.reducer;

View file

@ -0,0 +1,26 @@
/*
* 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 { getPalette } from '../palettes';
import { RootState } from './color_mapping';
export function selectPalette(getPaletteFn: ReturnType<typeof getPalette>) {
return (state: RootState) => getPaletteFn(state.colorMapping.paletteId);
}
export function selectColorMode(state: RootState) {
return state.colorMapping.colorMode;
}
export function selectSpecialAssignments(state: RootState) {
return state.colorMapping.specialAssignments;
}
export function selectIsAutoAssignmentMode(state: RootState) {
return state.colorMapping.assignmentMode === 'auto';
}
export function selectColorPickerVisibility(state: RootState) {
return state.ui.colorPicker;
}

View file

@ -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 PayloadAction, createSlice } from '@reduxjs/toolkit';
import { RootState } from './color_mapping';
const initialState: RootState['ui'] = {
colorPicker: {
index: 0,
visibile: false,
type: 'assignment',
},
};
export const uiSlice = createSlice({
name: 'colorMapping',
initialState,
reducers: {
colorPickerVisibility: (
state,
action: PayloadAction<{
index: number;
type: RootState['ui']['colorPicker']['type'];
visible: boolean;
}>
) => {
state.colorPicker.visibile = action.payload.visible;
state.colorPicker.index = action.payload.index;
state.colorPicker.type = action.payload.type;
},
switchColorPickerVisibility: (state) => {
state.colorPicker.visibile = !state.colorPicker.visibile;
},
showColorPickerVisibility: (state) => {
state.colorPicker.visibile = true;
},
hideColorPickerVisibility: (state) => {
state.colorPicker.visibile = false;
},
},
});
export const {
colorPickerVisibility,
switchColorPickerVisibility,
showColorPickerVisibility,
hideColorPickerVisibility,
} = uiSlice.actions;
export const uiReducer = uiSlice.reducer;

View file

@ -21,3 +21,5 @@ export const CustomizablePaletteLazy = React.lazy(() => import('./coloring'));
* a predefined fallback and error boundary.
*/
export const CustomizablePalette = withSuspense(CustomizablePaletteLazy);
export * from './color_mapping';

View file

@ -10,7 +10,6 @@
]
},
"include": [
"**/*.scss",
"**/*.ts",
"**/*.tsx"
],
@ -21,6 +20,8 @@
"@kbn/utility-types",
"@kbn/shared-ux-utility",
"@kbn/test-jest-helpers",
"@kbn/data-plugin",
"@kbn/ui-theme",
],
"exclude": [
"target/**/*",

View file

@ -50,13 +50,13 @@ pageLoadAssetSize:
expressionLegacyMetricVis: 23121
expressionMetric: 22238
expressionMetricVis: 23121
expressionPartitionVis: 26338
expressionPartitionVis: 28000
expressionRepeatImage: 22341
expressionRevealImage: 25675
expressions: 140958
expressionShape: 34008
expressionTagcloud: 27505
expressionXY: 39500
expressionXY: 45000
features: 21723
fieldFormats: 65209
files: 22673

View file

@ -0,0 +1,41 @@
/*
* 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 { DatatableRow } from '@kbn/expressions-plugin/common';
import { isMultiFieldKey } from '@kbn/data-plugin/common';
/**
* Get the stringified version of all the categories that needs to be colored in the chart.
* Multifield keys will return as array of string and simple fields (numeric, string) will be returned as a plain unformatted string.
*/
export function getColorCategories(
rows: DatatableRow[],
accessor?: string
): Array<string | string[]> {
return accessor
? rows.reduce<{ keys: Set<string>; categories: Array<string | string[]> }>(
(acc, r) => {
const value = r[accessor];
if (value === undefined) {
return acc;
}
// The categories needs to be stringified in their unformatted version.
// We can't distinguish between a number and a string from a text input and the match should
// work with both numeric field values and string values.
const key = (isMultiFieldKey(value) ? [...value.keys] : [value]).map(String);
const stringifiedKeys = key.join(',');
if (!acc.keys.has(stringifiedKeys)) {
acc.keys.add(stringifiedKeys);
acc.categories.push(key.length === 1 ? key[0] : key);
}
return acc;
},
{ keys: new Set(), categories: [] }
).categories
: [];
}

View file

@ -13,3 +13,4 @@ export {
isOnAggBasedEditor,
} from './utils';
export type { Simplify, MakeOverridesSerializable } from './types';
export { getColorCategories } from './color_categories';

View file

@ -15,5 +15,7 @@
],
"kbn_references": [
"@kbn/core-execution-context-common",
"@kbn/expressions-plugin",
"@kbn/data-plugin",
]
}

View file

@ -12,7 +12,8 @@
"fieldFormats",
"charts",
"visualizations",
"presentationUtil"
"presentationUtil",
"data"
],
"optionalPlugins": [
"usageCollection"

View file

@ -12,7 +12,8 @@
"fieldFormats",
"charts",
"visualizations",
"presentationUtil"
"presentationUtil",
"data"
],
"optionalPlugins": [
"usageCollection"

View file

@ -71,6 +71,7 @@ Object {
"type": "vis_dimension",
},
],
"colorMapping": undefined,
"dimensions": Object {
"buckets": Array [
Object {

View file

@ -61,6 +61,7 @@ Object {
"type": "vis_dimension",
},
],
"colorMapping": undefined,
"dimensions": Object {
"buckets": Array [
Object {
@ -203,6 +204,7 @@ Object {
"type": "vis_dimension",
},
],
"colorMapping": undefined,
"dimensions": Object {
"buckets": Array [
Object {

View file

@ -71,6 +71,7 @@ Object {
"type": "vis_dimension",
},
],
"colorMapping": undefined,
"dimensions": Object {
"buckets": Array [
Object {

View file

@ -53,6 +53,7 @@ Object {
},
"type": "vis_dimension",
},
"colorMapping": undefined,
"dimensions": Object {
"buckets": Array [
Object {

View file

@ -122,6 +122,10 @@ export const strings = {
i18n.translate('expressionPartitionVis.reusable.function.dimension.splitrow', {
defaultMessage: 'Row split',
}),
getColorMappingHelp: () =>
i18n.translate('expressionPartitionVis.layer.colorMapping.help', {
defaultMessage: 'JSON key-value pairs of the color mapping model',
}),
};
export const errors = {

View file

@ -110,6 +110,10 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({
help: strings.getAriaLabelHelp(),
required: false,
},
colorMapping: {
types: ['string'],
help: strings.getColorMappingHelp(),
},
},
fn(context, args, handlers) {
const maxSupportedBuckets = 2;
@ -146,6 +150,7 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({
splitColumn: args.splitColumn,
splitRow: args.splitRow,
},
colorMapping: args.colorMapping,
};
if (handlers?.inspectorAdapters?.tables) {

View file

@ -141,6 +141,10 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({
help: strings.getAriaLabelHelp(),
required: false,
},
colorMapping: {
types: ['string'],
help: strings.getColorMappingHelp(),
},
},
fn(context, args, handlers) {
if (args.splitColumn && args.splitRow) {
@ -173,6 +177,7 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({
splitColumn: args.splitColumn,
splitRow: args.splitRow,
},
colorMapping: args.colorMapping,
};
if (handlers?.inspectorAdapters?.tables) {

View file

@ -115,6 +115,10 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition =>
help: strings.getAriaLabelHelp(),
required: false,
},
colorMapping: {
types: ['string'],
help: strings.getColorMappingHelp(),
},
},
fn(context, args, handlers) {
const maxSupportedBuckets = 2;
@ -152,6 +156,7 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition =>
splitColumn: args.splitColumn,
splitRow: args.splitRow,
},
colorMapping: args.colorMapping,
};
if (handlers?.inspectorAdapters?.tables) {

View file

@ -114,6 +114,10 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({
help: strings.getAriaLabelHelp(),
required: false,
},
colorMapping: {
types: ['string'],
help: strings.getColorMappingHelp(),
},
},
fn(context, args, handlers) {
if (args.splitColumn && args.splitRow) {
@ -147,6 +151,7 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({
splitColumn: args.splitColumn,
splitRow: args.splitRow,
},
colorMapping: args.colorMapping,
};
if (handlers?.inspectorAdapters?.tables) {

View file

@ -61,6 +61,7 @@ interface VisCommonParams {
maxLegendLines: number;
legendSize?: LegendSize;
ariaLabel?: string;
colorMapping?: string; // JSON stringified object of the color mapping
}
interface VisCommonConfig extends VisCommonParams {

View file

@ -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 { NodeColorAccessor, PATH_KEY } from '@elastic/charts';
import { lightenColor } from '@kbn/charts-plugin/public';
import { MultiFieldKey } from '@kbn/data-plugin/common';
import { getColorFactory } from '@kbn/coloring';
import { isMultiFieldKey } from '@kbn/data-plugin/common';
import { ChartTypes } from '../../../common/types';
export function getCategoryKeys(category: string | MultiFieldKey): string | string[] {
return isMultiFieldKey(category) ? category.keys.map(String) : `${category}`;
}
/**
* Get the color of a specific slice/section in Pie,donut,waffle and treemap.
* These chart type shares the same color assignment mechanism.
*/
const getPieFillColor =
(
layerIndex: number,
numOfLayers: number,
getColorFn: ReturnType<typeof getColorFactory>
): NodeColorAccessor =>
(_key, _sortIndex, node) => {
const path = node[PATH_KEY];
// the category used to color the pie/donut is at the third level of the path
// first two are: small multiple and pie whole center.
const category = getCategoryKeys(path[2].value);
const color = getColorFn(category);
// increase the lightness of the color on each layer.
return lightenColor(color, layerIndex + 1, numOfLayers);
};
/**
* Get the color of a section in a Mosaic chart.
* This chart has a slight variation in the way color are applied. Mosaic can represent up to 2 layers,
* described in lens as the horizontal and vertical axes.
* With a single layer the color is simply applied per each category, with 2 layer, the color is applied only
* to the category that describe a row, not by column.
*/
const getMosaicFillColor =
(
layerIndex: number,
numOfLayers: number,
getColorFn: ReturnType<typeof getColorFactory>
): NodeColorAccessor =>
(_key, _sortIndex, node) => {
// Special case for 2 layer mosaic where the color is per rows and the columns are not colored
if (numOfLayers === 2 && layerIndex === 0) {
// transparent color will fallback to the kibana/context background
return 'rgba(0,0,0,0)';
}
const path = node[PATH_KEY];
// the category used to color the pie/donut is at the third level of the `path` when using a single layer mosaic
// and are at fourth level of `path` when using 2 layer mosaic
// first two are: small multiple and pie whole center.
const category = getCategoryKeys(numOfLayers === 2 ? path[3].value : path[2].value);
return getColorFn(category);
};
export const getPartitionFillColor = (
chartType: ChartTypes,
layerIndex: number,
numOfLayers: number,
getColorFn: ReturnType<typeof getColorFactory>
): NodeColorAccessor => {
return chartType === ChartTypes.MOSAIC
? getMosaicFillColor(layerIndex, numOfLayers, getColorFn)
: getPieFillColor(layerIndex, numOfLayers, getColorFn);
};

View file

@ -7,16 +7,25 @@
*/
import { Datum, PartitionLayer } from '@elastic/charts';
import type { PaletteRegistry } from '@kbn/coloring';
import {
PaletteRegistry,
getColorFactory,
getPalette,
AVAILABLE_PALETTES,
NeutralPalette,
} from '@kbn/coloring';
import { i18n } from '@kbn/i18n';
import { FieldFormat } from '@kbn/field-formats-plugin/common';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { Datatable, DatatableRow } from '@kbn/expressions-plugin/public';
import { getColorCategories } from '@kbn/chart-expressions-common';
import { getDistinctSeries } from '..';
import { BucketColumns, ChartTypes, PartitionVisParams } from '../../../common/types';
import { sortPredicateByType, sortPredicateSaveSourceOrder } from './sort_predicate';
import { byDataColorPaletteMap, getColor } from './get_color';
import { getNodeLabel } from './get_node_labels';
import { getPartitionFillColor } from '../colors/color_mapping_accessors';
// This is particularly useful in case of a text based languages where
// it's no possible to use a missingBucketLabel
@ -62,6 +71,15 @@ export const getLayers = (
const distinctSeries = getDistinctSeries(rows, columns);
// return a fn only if color mapping is available in visParams
const getColorFromMappingFn = getColorFromMappingFactory(
chartType,
columns,
rows,
isDarkMode,
visParams
);
return columns.map((col, layerIndex) => {
return {
groupByRollup: (d: Datum) => (col.id ? d[col.id] ?? emptySliceLabel : col.name),
@ -75,26 +93,74 @@ export const getLayers = (
? sortPredicateSaveSourceOrder()
: sortPredicateForType,
shape: {
fillColor: (key, sortIndex, node) =>
getColor(
chartType,
key,
node,
layerIndex,
isSplitChart,
overwriteColors,
distinctSeries,
{ columnsLength: columns.length, rowsLength: rows.length },
visParams,
palettes,
byDataPalette,
syncColors,
isDarkMode,
formatter,
col,
formatters
),
// this applies color mapping only if visParams.colorMapping is available
fillColor: getColorFromMappingFn
? getPartitionFillColor(chartType, layerIndex, columns.length, getColorFromMappingFn)
: (key, sortIndex, node) =>
getColor(
chartType,
key,
node,
layerIndex,
isSplitChart,
overwriteColors,
distinctSeries,
{ columnsLength: columns.length, rowsLength: rows.length },
visParams,
palettes,
byDataPalette,
syncColors,
isDarkMode,
formatter,
col,
formatters
),
},
};
});
};
/**
* If colorMapping is available, returns a function that accept a string or an array of strings (used in case of multi-field-key)
* and returns a color specified in the provided mapping
*/
function getColorFromMappingFactory(
chartType: ChartTypes,
columns: Array<Partial<BucketColumns>>,
rows: DatatableRow[],
isDarkMode: boolean,
visParams: PartitionVisParams
): undefined | ((category: string | string[]) => string) {
const { colorMapping, dimensions } = visParams;
if (!colorMapping) {
// return undefined, we will use the legacy color mapping instead
return undefined;
}
// if pie/donut/treemap with no buckets use the default color mode
if (
(chartType === ChartTypes.DONUT ||
chartType === ChartTypes.PIE ||
chartType === ChartTypes.TREEMAP) &&
(!dimensions.buckets || dimensions.buckets?.length === 0)
) {
return undefined;
}
// the mosaic configures the main categories in the second column, instead of the first
// as it happens in all the other partition types.
// Independentely from the bucket aggregation used, the categories will always be casted
// as string to make it nicely working with a text input field, avoiding a field
const categories =
chartType === ChartTypes.MOSAIC && columns.length === 2
? getColorCategories(rows, columns[1]?.id)
: getColorCategories(rows, columns[0]?.id);
return getColorFactory(
JSON.parse(colorMapping),
getPalette(AVAILABLE_PALETTES, NeutralPalette),
isDarkMode,
{
type: 'categories',
categories,
}
);
}

View file

@ -66,6 +66,7 @@ Object {
"bucket": Object {
"accessor": 1,
},
"colorMapping": undefined,
"isPreview": false,
"maxFontSize": 72,
"metric": Object {
@ -126,6 +127,7 @@ Object {
},
"type": "vis_dimension",
},
"colorMapping": undefined,
"isPreview": false,
"maxFontSize": 72,
"metric": Object {

View file

@ -51,6 +51,9 @@ const strings = {
isPreview: i18n.translate('expressionTagcloud.functions.tagcloud.args.isPreviewHelpText', {
defaultMessage: 'Set isPreview to true to avoid showing out of room warnings',
}),
colorMapping: i18n.translate('expressionTagcloud.layer.colorMapping.help', {
defaultMessage: 'JSON key-value pairs of the color mapping model',
}),
},
dimension: {
tags: i18n.translate('expressionTagcloud.functions.tagcloud.dimension.tags', {
@ -146,6 +149,10 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => {
default: false,
required: false,
},
colorMapping: {
types: ['string'],
help: argHelp.colorMapping,
},
},
fn(input, args, handlers) {
validateAccessor(args.metric, input.columns);
@ -167,6 +174,7 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => {
(handlers.variables?.embeddableTitle as string) ??
handlers.getExecutionContext?.()?.description,
isPreview: Boolean(args.isPreview),
colorMapping: args.colorMapping,
};
if (handlers?.inspectorAdapters?.tables) {

View file

@ -27,6 +27,7 @@ interface TagCloudCommonParams {
metric: ExpressionValueVisDimension | string;
bucket?: ExpressionValueVisDimension | string;
palette: PaletteOutput;
colorMapping?: string; // JSON stringified object of the color mapping
}
export interface TagCloudVisConfig extends TagCloudCommonParams {

View file

@ -8,6 +8,7 @@
"server": true,
"browser": true,
"requiredPlugins": [
"data",
"expressions",
"visualizations",
"charts",

View file

@ -105,6 +105,7 @@ describe('TagCloudChart', function () {
renderComplete: jest.fn(),
syncColors: false,
visType: 'tagcloud',
isDarkMode: false,
};
wrapperPropsWithColumnNames = {
@ -135,6 +136,7 @@ describe('TagCloudChart', function () {
renderComplete: jest.fn(),
syncColors: false,
visType: 'tagcloud',
isDarkMode: false,
};
});

View file

@ -13,11 +13,19 @@ import { EuiIconTip, EuiResizeObserver } from '@elastic/eui';
import { IconChartTagcloud } from '@kbn/chart-icons';
import { Chart, Settings, Wordcloud, RenderChangeListener } from '@elastic/charts';
import { EmptyPlaceholder } from '@kbn/charts-plugin/public';
import type { PaletteRegistry, PaletteOutput } from '@kbn/coloring';
import { IInterpreterRenderHandlers } from '@kbn/expressions-plugin/public';
import { getOverridesFor } from '@kbn/chart-expressions-common';
import {
PaletteRegistry,
PaletteOutput,
getColorFactory,
getPalette,
AVAILABLE_PALETTES,
NeutralPalette,
} from '@kbn/coloring';
import { IInterpreterRenderHandlers, DatatableRow } from '@kbn/expressions-plugin/public';
import { getColorCategories, getOverridesFor } from '@kbn/chart-expressions-common';
import type { AllowedSettingsOverrides, AllowedChartOverrides } from '@kbn/charts-plugin/common';
import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils';
import { isMultiFieldKey } from '@kbn/data-plugin/common';
import { getFormatService } from '../format_service';
import { TagcloudRendererConfig } from '../../common/types';
import { ScaleOptions, Orientation } from '../../common/constants';
@ -31,6 +39,7 @@ export type TagCloudChartProps = TagcloudRendererConfig & {
renderComplete: IInterpreterRenderHandlers['done'];
palettesRegistry: PaletteRegistry;
overrides?: AllowedSettingsOverrides & AllowedChartOverrides;
isDarkMode: boolean;
};
const calculateWeight = (value: number, x1: number, y1: number, x2: number, y2: number) =>
@ -84,9 +93,10 @@ export const TagCloudChart = ({
renderComplete,
syncColors,
overrides,
isDarkMode,
}: TagCloudChartProps) => {
const [warning, setWarning] = useState(false);
const { bucket, metric, scale, palette, showLabel, orientation } = visParams;
const { bucket, metric, scale, palette, showLabel, orientation, colorMapping } = visParams;
const bucketFormatter = useMemo(() => {
return bucket
@ -96,23 +106,35 @@ export const TagCloudChart = ({
const tagCloudData = useMemo(() => {
const bucketColumn = bucket ? getColumnByAccessor(bucket, visData.columns)! : null;
const tagColumn = bucket ? bucketColumn!.id : null;
const tagColumn = bucket ? bucketColumn!.id : undefined;
const metricColumn = getColumnByAccessor(metric, visData.columns)!.id;
const metrics = visData.rows.map((row) => row[metricColumn]);
const values = bucket && tagColumn !== null ? visData.rows.map((row) => row[tagColumn]) : [];
const values =
bucket && tagColumn !== undefined ? visData.rows.map((row) => row[tagColumn]) : [];
const maxValue = Math.max(...metrics);
const minValue = Math.min(...metrics);
const colorFromMappingFn = getColorFromMappingFactory(
tagColumn,
visData.rows,
isDarkMode,
colorMapping
);
return visData.rows.map((row) => {
const tag = tagColumn === null ? 'all' : row[tagColumn];
const tag = tagColumn === undefined ? 'all' : row[tagColumn];
const category = isMultiFieldKey(tag) ? tag.keys.map(String) : `${tag}`;
return {
text: bucketFormatter ? bucketFormatter.convert(tag, 'text') : tag,
weight:
tag === 'all' || visData.rows.length <= 1
? 1
: calculateWeight(row[metricColumn], minValue, maxValue, 0, 1) || 0,
color: getColor(palettesRegistry, palette, tag, values, syncColors) || 'rgba(0,0,0,0)',
color: colorFromMappingFn
? colorFromMappingFn(category)
: getColor(palettesRegistry, palette, tag, values, syncColors) || 'rgba(0,0,0,0)',
};
});
}, [
@ -124,6 +146,8 @@ export const TagCloudChart = ({
syncColors,
visData.columns,
visData.rows,
colorMapping,
isDarkMode,
]);
useEffect(() => {
@ -278,3 +302,28 @@ export const TagCloudChart = ({
// eslint-disable-next-line import/no-default-export
export { TagCloudChart as default };
/**
* If colorMapping is available, returns a function that accept a string or an array of strings (used in case of multi-field-key)
* and returns a color specified in the provided mapping
*/
function getColorFromMappingFactory(
tagColumn: string | undefined,
rows: DatatableRow[],
isDarkMode: boolean,
colorMapping?: string
): undefined | ((category: string | string[]) => string) {
if (!colorMapping) {
// return undefined, we will use the legacy color mapping instead
return undefined;
}
return getColorFactory(
JSON.parse(colorMapping),
getPalette(AVAILABLE_PALETTES, NeutralPalette),
isDarkMode,
{
type: 'categories',
categories: getColorCategories(rows, tagColumn),
}
);
}

View file

@ -67,6 +67,12 @@ export const tagcloudRenderer: (
};
const palettesRegistry = await plugins.charts.palettes.getPalettes();
let isDarkMode = false;
plugins.charts.theme.darkModeEnabled$
.subscribe((val) => {
isDarkMode = val.darkMode;
})
.unsubscribe();
render(
<KibanaThemeProvider theme$={core.theme.theme$}>
@ -87,6 +93,7 @@ export const tagcloudRenderer: (
fireEvent={handlers.event}
syncColors={config.syncColors}
overrides={config.overrides}
isDarkMode={isDarkMode}
/>
</VisualizationContainer>
)}

View file

@ -27,6 +27,7 @@
"@kbn/analytics",
"@kbn/chart-expressions-common",
"@kbn/chart-icons",
"@kbn/data-plugin",
],
"exclude": [
"target/**/*",

View file

@ -94,4 +94,8 @@ export const commonDataLayerArgs: Omit<
help: strings.getPaletteHelp(),
default: '{palette}',
},
colorMapping: {
types: ['string'],
help: strings.getColorMappingHelp(),
},
};

View file

@ -52,6 +52,7 @@ const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult
layerType: LayerTypes.DATA,
table: normalizedTable,
showLines: args.showLines,
colorMapping: args.colorMapping,
...accessors,
};
};

View file

@ -209,6 +209,10 @@ export const strings = {
i18n.translate('expressionXY.dataLayer.palette.help', {
defaultMessage: 'Palette',
}),
getColorMappingHelp: () =>
i18n.translate('expressionXY.layer.colorMapping.help', {
defaultMessage: 'JSON key-value pairs of the color mapping model',
}),
getTableHelp: () =>
i18n.translate('expressionXY.layers.table.help', {
defaultMessage: 'Table',

View file

@ -136,6 +136,7 @@ export interface DataLayerArgs {
isStacked: boolean;
isHorizontal: boolean;
palette: PaletteOutput;
colorMapping?: string; // JSON stringified object of the color mapping
decorations?: DataDecorationConfigResult[];
curveType?: XYCurveType;
}
@ -163,6 +164,7 @@ export interface ExtendedDataLayerArgs {
isStacked: boolean;
isHorizontal: boolean;
palette: PaletteOutput;
colorMapping?: string;
// palette will always be set on the expression
decorations?: DataDecorationConfigResult[];
curveType?: XYCurveType;

View file

@ -1099,6 +1099,7 @@ exports[`XYChart component it renders area 1`] = `
},
}
}
isDarkMode={false}
layers={
Array [
Object {
@ -2107,6 +2108,7 @@ exports[`XYChart component it renders bar 1`] = `
},
}
}
isDarkMode={false}
layers={
Array [
Object {
@ -3115,6 +3117,7 @@ exports[`XYChart component it renders horizontal bar 1`] = `
},
}
}
isDarkMode={false}
layers={
Array [
Object {
@ -4123,6 +4126,7 @@ exports[`XYChart component it renders line 1`] = `
},
}
}
isDarkMode={false}
layers={
Array [
Object {
@ -5131,6 +5135,7 @@ exports[`XYChart component it renders stacked area 1`] = `
},
}
}
isDarkMode={false}
layers={
Array [
Object {
@ -6139,6 +6144,7 @@ exports[`XYChart component it renders stacked bar 1`] = `
},
}
}
isDarkMode={false}
layers={
Array [
Object {
@ -7147,6 +7153,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = `
},
}
}
isDarkMode={false}
layers={
Array [
Object {
@ -8381,6 +8388,7 @@ exports[`XYChart component split chart should render split chart if both, splitR
},
}
}
isDarkMode={false}
layers={
Array [
Object {
@ -9622,6 +9630,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA
},
}
}
isDarkMode={false}
layers={
Array [
Object {
@ -10861,6 +10870,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce
},
}
}
isDarkMode={false}
layers={
Array [
Object {

View file

@ -57,6 +57,7 @@ interface Props {
fieldFormats: LayersFieldFormats;
uiState?: PersistedState;
singleTable?: boolean;
isDarkMode: boolean;
}
export const DataLayers: FC<Props> = ({
@ -80,6 +81,7 @@ export const DataLayers: FC<Props> = ({
fieldFormats,
uiState,
singleTable,
isDarkMode,
}) => {
// for singleTable mode we should use y accessors from all layers for creating correct series name and getting color
const allYAccessors = layers.flatMap((layer) => layer.accessors);
@ -169,6 +171,7 @@ export const DataLayers: FC<Props> = ({
allYAccessors,
singleTable,
multipleLayersWithSplits,
isDarkMode,
});
const index = `${layer.layerId}-${accessorIndex}`;

View file

@ -964,6 +964,7 @@ export function XYChart({
fieldFormats={fieldFormats}
uiState={uiState}
singleTable={singleTable}
isDarkMode={darkMode}
/>
)}
{referenceLineLayers.length ? (

View file

@ -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 { SeriesColorAccessorFn } from '@elastic/charts';
import { getColorFactory, type ColorMapping, type ColorMappingInputData } from '@kbn/coloring';
import { MULTI_FIELD_KEY_SEPARATOR } from '@kbn/data-plugin/common';
/**
* Return a color accessor function for XY charts depending on the split accessors received.
*/
export function getColorSeriesAccessorFn(
config: ColorMapping.Config,
getPaletteFn: (paletteId: string) => ColorMapping.CategoricalPalette,
isDarkMode: boolean,
mappingData: ColorMappingInputData,
fieldId: string,
specialTokens: Map<string, string>
): SeriesColorAccessorFn {
// inverse map to handle the conversion between the formatted string and their original format
// for any specified special tokens
const specialHandlingInverseMap: Map<string, string> = new Map(
[...specialTokens.entries()].map((d) => [d[1], d[0]])
);
const getColor = getColorFactory(config, getPaletteFn, isDarkMode, mappingData);
return ({ splitAccessors }) => {
const splitValue = splitAccessors.get(fieldId);
// if there isn't a category associated in the split accessor, let's use the default color
if (splitValue === undefined) {
return null;
}
// category can be also a number, range, ip, multi-field. We need to stringify it to be sure
// we can correctly match it a with user string
// if the separator exist, we de-construct it into a multifieldkey into values.
const categories = `${splitValue}`.split(MULTI_FIELD_KEY_SEPARATOR).map((category) => {
return specialHandlingInverseMap.get(category) ?? category;
});
// we must keep the array nature of a multi-field key or just use a single string
// This is required because the rule stored are checked differently for single values or multi-values
return getColor(categories.length > 1 ? categories : categories[0]);
};
}

View file

@ -95,6 +95,11 @@ export const getAllSeries = (
return allSeries;
};
/**
* This function joins every data series name available on each layer by the same color palette.
* The returned function `getRank` should return the position of a series name in this unified list by palette.
*
*/
export function getColorAssignments(
layers: CommonXYLayerConfig[],
titles: LayersAccessorsTitles,

View file

@ -16,6 +16,7 @@ import {
SeriesName,
StackMode,
XYChartSeriesIdentifier,
SeriesColorAccessorFn,
} from '@elastic/charts';
import { IFieldFormat } from '@kbn/field-formats-plugin/common';
import type { PersistedState } from '@kbn/visualizations-plugin/public';
@ -23,6 +24,13 @@ import { Datatable } from '@kbn/expressions-plugin/common';
import { getAccessorByDimension } from '@kbn/visualizations-plugin/common/utils';
import type { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions';
import { PaletteRegistry, SeriesLayer } from '@kbn/coloring';
import {
getPalette,
AVAILABLE_PALETTES,
NeutralPalette,
SPECIAL_TOKENS_STRING_CONVERTION,
} from '@kbn/coloring';
import { getColorCategories } from '@kbn/chart-expressions-common';
import { isDataLayer } from '../../common/utils/layer_types_guards';
import { CommonXYDataLayerConfig, CommonXYLayerConfig, XScaleType } from '../../common';
import { AxisModes, SeriesTypes } from '../../common/constants';
@ -32,6 +40,7 @@ import { ColorAssignments } from './color_assignment';
import { GroupsConfiguration } from './axes_configuration';
import { LayerAccessorsTitles, LayerFieldFormats, LayersFieldFormats } from './layers';
import { getFormat } from './format';
import { getColorSeriesAccessorFn } from './color/color_mapping_accessor';
type SeriesSpec = LineSeriesProps & BarSeriesProps & AreaSeriesProps;
@ -57,6 +66,7 @@ type GetSeriesPropsFn = (config: {
allYAccessors: Array<string | ExpressionValueVisDimension>;
singleTable?: boolean;
multipleLayersWithSplits: boolean;
isDarkMode: boolean;
}) => SeriesSpec;
type GetSeriesNameFn = (
@ -399,6 +409,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({
allYAccessors,
singleTable,
multipleLayersWithSplits,
isDarkMode,
}): SeriesSpec => {
const { table, isStacked, markSizeAccessor } = layer;
const isPercentage = layer.isPercentage;
@ -478,6 +489,34 @@ export const getSeriesProps: GetSeriesPropsFn = ({
);
};
const colorAccessorFn: SeriesColorAccessorFn =
// if colorMapping exist then we can apply it, if not let's use the legacy coloring method
layer.colorMapping && splitColumnIds.length > 0
? getColorSeriesAccessorFn(
JSON.parse(layer.colorMapping), // the color mapping is at this point just a strinfigied JSON
getPalette(AVAILABLE_PALETTES, NeutralPalette),
isDarkMode,
{
type: 'categories',
categories: getColorCategories(table.rows, splitColumnIds[0]),
},
splitColumnIds[0],
SPECIAL_TOKENS_STRING_CONVERTION
)
: (series) =>
getColor(
series,
{
layer,
colorAssignments,
paletteService,
getSeriesNameFn,
syncColors,
},
uiState,
singleTable
);
return {
splitSeriesAccessors: splitColumnIds.length ? splitColumnIds : [],
stackAccessors: isStacked ? [xColumnId || 'unifiedX'] : [],
@ -497,19 +536,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({
formatter?.id === 'bytes' && scaleType === ScaleType.Linear
? ScaleType.LinearBinary
: scaleType,
color: (series) =>
getColor(
series,
{
layer,
colorAssignments,
paletteService,
getSeriesNameFn,
syncColors,
},
uiState,
singleTable
),
color: colorAccessorFn,
groupId: yAxis?.groupId,
enableHistogramMode,
stackMode,

View file

@ -7,7 +7,8 @@
"server": true,
"browser": true,
"requiredPlugins": [
"expressions"
"expressions",
"data"
],
"extraPublicDirs": [
"common"

View file

@ -37,7 +37,7 @@ export * from './significant_text_fn';
export * from './significant_text';
export * from './terms_fn';
export * from './terms';
export { MultiFieldKey } from './multi_field_key';
export { MultiFieldKey, isMultiFieldKey, MULTI_FIELD_KEY_SEPARATOR } from './multi_field_key';
export * from './multi_terms_fn';
export * from './multi_terms';
export * from './rare_terms_fn';

View file

@ -38,3 +38,13 @@ export class MultiFieldKey {
return this[id];
}
}
export function isMultiFieldKey(field: unknown): field is MultiFieldKey {
return field instanceof MultiFieldKey;
}
/**
* Multi-field key separator used in Visualizations (Lens, AggBased, TSVB).
* This differs from the separator used in the toString method of the MultiFieldKey
*/
export const MULTI_FIELD_KEY_SEPARATOR = ' ';

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"isPreview":false,"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"isPreview":false,"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"colorMapping":null,"isPreview":false,"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"isPreview":false,"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"colorMapping":null,"isPreview":false,"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}}

View file

@ -1 +1 @@
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}}
{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"excludeIsRegex":true,"field":"response.raw","includeIsRegex":true,"missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"colorMapping":null,"isPreview":false,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}}

View file

@ -8,7 +8,7 @@
import type { Filter, FilterMeta } from '@kbn/es-query';
import type { Position } from '@elastic/charts';
import type { $Values } from '@kbn/utility-types';
import type { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { CustomPaletteParams, PaletteOutput, ColorMapping } from '@kbn/coloring';
import type { ColorMode } from '@kbn/charts-plugin/common';
import type { LegendSize } from '@kbn/visualizations-plugin/common';
import { CategoryDisplay, LegendDisplay, NumberDisplay, PieChartTypes } from './constants';
@ -71,6 +71,7 @@ export interface SharedPieLayerState {
legendMaxLines?: number;
legendSize?: LegendSize;
truncateLegend?: boolean;
colorMapping?: ColorMapping.Config;
}
export type PieLayerState = SharedPieLayerState & {

View file

@ -154,7 +154,7 @@ export function App({
useExecutionContext(executionContext, {
type: 'application',
id: savedObjectId || 'new',
id: savedObjectId || 'new', // TODO: this doesn't consider when lens is saved by value
page: 'editor',
});

View file

@ -18,6 +18,7 @@ import {
type EventAnnotationGroupConfig,
EVENT_ANNOTATION_GROUP_TYPE,
} from '@kbn/event-annotation-common';
import { DEFAULT_COLOR_MAPPING_CONFIG } from '@kbn/coloring';
import type {
Datasource,
DatasourceMap,
@ -289,7 +290,8 @@ export function initializeVisualization({
visualizationMap[visualizationState.activeId]?.initialize(
() => '',
visualizationState.state,
undefined,
// initialize a new visualization always with the new color mapping
{ type: 'colorMapping', value: { ...DEFAULT_COLOR_MAPPING_CONFIG } },
annotationGroups,
references
) ?? visualizationState.state

View file

@ -457,7 +457,13 @@ describe('suggestion helpers', () => {
it('should pass passed in main palette if specified', () => {
const mockVisualization1 = createMockVisualization();
const mockVisualization2 = createMockVisualization();
const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' };
const mainPalette: { type: 'legacyPalette'; value: PaletteOutput } = {
type: 'legacyPalette',
value: {
type: 'palette',
name: 'mock',
},
};
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
generateSuggestion(0),
generateSuggestion(1),
@ -490,7 +496,13 @@ describe('suggestion helpers', () => {
it('should query active visualization for main palette if not specified', () => {
const mockVisualization1 = createMockVisualization();
const mockVisualization2 = createMockVisualization();
const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' };
const mainPalette: { type: 'legacyPalette'; value: PaletteOutput } = {
type: 'legacyPalette',
value: {
type: 'palette',
name: 'mock',
},
};
mockVisualization1.getMainPalette = jest.fn(() => mainPalette);
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
generateSuggestion(0),

View file

@ -6,12 +6,11 @@
*/
import type { Datatable } from '@kbn/expressions-plugin/common';
import type { PaletteOutput } from '@kbn/coloring';
import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { DragDropIdentifier } from '@kbn/dom-drag-drop';
import { showMemoizedErrorNotification } from '../../lens_ui_errors';
import type {
import {
Visualization,
Datasource,
TableSuggestion,
@ -21,6 +20,7 @@ import type {
VisualizeEditorContext,
Suggestion,
DatasourceLayers,
SuggestionRequest,
} from '../../types';
import type { LayerType } from '../../../common/types';
import {
@ -64,7 +64,7 @@ export function getSuggestions({
visualizeTriggerFieldContext?: VisualizeFieldContext | VisualizeEditorContext;
activeData?: Record<string, Datatable>;
dataViews: DataViewsState;
mainPalette?: PaletteOutput;
mainPalette?: SuggestionRequest['mainPalette'];
allowMixed?: boolean;
}): Suggestion[] {
const datasources = Object.entries(datasourceMap).filter(
@ -237,7 +237,7 @@ function getVisualizationSuggestions(
datasourceSuggestion: DatasourceSuggestion & { datasourceId: string },
currentVisualizationState: unknown,
subVisualizationId?: string,
mainPalette?: PaletteOutput,
mainPalette?: SuggestionRequest['mainPalette'],
isFromContext?: boolean,
activeData?: Record<string, Datatable>,
allowMixed?: boolean

View file

@ -519,7 +519,10 @@ describe('chart_switch', () => {
it('should query main palette from active chart and pass into suggestions', async () => {
const visualizationMap = mockVisualizationMap();
const mockPalette: PaletteOutput = { type: 'palette', name: 'mock' };
visualizationMap.visA.getMainPalette = jest.fn(() => mockPalette);
visualizationMap.visA.getMainPalette = jest.fn(() => ({
type: 'legacyPalette',
value: mockPalette,
}));
visualizationMap.visB.getSuggestions.mockReturnValueOnce([]);
const frame = mockFrame(['a', 'b', 'c']);
const currentVisState = {};
@ -550,7 +553,7 @@ describe('chart_switch', () => {
expect(visualizationMap.visB.getSuggestions).toHaveBeenCalledWith(
expect.objectContaining({
keptLayerIds: ['a'],
mainPalette: mockPalette,
mainPalette: { type: 'legacyPalette', value: mockPalette },
})
);
});

View file

@ -27,8 +27,10 @@ export function PalettePanelContainer({
handleClose,
siblingRef,
children,
title,
}: {
isOpen: boolean;
title: string;
handleClose: () => void;
siblingRef: MutableRefObject<HTMLDivElement | null>;
children?: React.ReactElement | React.ReactElement[];
@ -76,16 +78,12 @@ export function PalettePanelContainer({
<EuiFlexItem>
<EuiTitle size="xs">
<h2
<h3
id="lnsPalettePanelContainerTitle"
className="lnsPalettePanelContainer__headerTitle"
>
<strong>
{i18n.translate('xpack.lens.table.palettePanelTitle', {
defaultMessage: 'Color',
})}
</strong>
</h2>
{title}
</h3>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -36,28 +36,24 @@ export function PalettePicker({
});
return (
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.palettePicker.label', {
defaultMessage: 'Color palette',
defaultMessage: 'Palette',
})}
>
<>
<EuiColorPalettePicker
fullWidth
data-test-subj="lns-palettePicker"
compressed
palettes={palettesToShow}
onChange={(newPalette) => {
setPalette({
type: 'palette',
name: newPalette,
});
}}
valueOfSelected={activePalette?.name || 'default'}
selectionDisplay={'palette'}
/>
</>
<EuiColorPalettePicker
fullWidth
data-test-subj="lns-palettePicker"
palettes={palettesToShow}
onChange={(newPalette) => {
setPalette({
type: 'palette',
name: newPalette,
});
}}
valueOfSelected={activePalette?.name || 'default'}
selectionDisplay={'palette'}
/>
</EuiFormRow>
);
}

View file

@ -7,7 +7,7 @@
import type { Ast } from '@kbn/interpreter';
import type { IconType } from '@elastic/eui/src/components/icon/icon';
import type { CoreStart, SavedObjectReference, ResolvedSimpleSavedObject } from '@kbn/core/public';
import type { PaletteOutput } from '@kbn/coloring';
import type { ColorMapping, PaletteOutput } from '@kbn/coloring';
import type { TopNavMenuData } from '@kbn/navigation-plugin/public';
import type { MutableRefObject, ReactElement } from 'react';
import type { Filter, TimeRange } from '@kbn/es-query';
@ -863,7 +863,12 @@ export interface SuggestionRequest<T = unknown> {
* State is only passed if the visualization is active.
*/
state?: T;
mainPalette?: PaletteOutput;
/**
* Passing the legacy palette or the new color mapping if available
*/
mainPalette?:
| { type: 'legacyPalette'; value: PaletteOutput }
| { type: 'colorMapping'; value: ColorMapping.Config };
isFromContext?: boolean;
/**
* The visualization needs to know which table is being suggested
@ -1026,11 +1031,15 @@ export interface Visualization<T = unknown, P = T, ExtraAppendLayerArg = unknown
* - When using suggestions, the suggested state is passed in
*/
initialize: {
(addNewLayer: () => string, nonPersistedState?: T, mainPalette?: PaletteOutput): T;
(
addNewLayer: () => string,
nonPersistedState?: T,
mainPalette?: SuggestionRequest['mainPalette']
): T;
(
addNewLayer: () => string,
persistedState: P,
mainPalette?: PaletteOutput,
mainPalette?: SuggestionRequest['mainPalette'],
annotationGroups?: AnnotationGroups,
references?: SavedObjectReference[]
): T;
@ -1042,7 +1051,7 @@ export interface Visualization<T = unknown, P = T, ExtraAppendLayerArg = unknown
*/
getUsedDataViews?: (state?: T) => string[];
getMainPalette?: (state: T) => undefined | PaletteOutput;
getMainPalette?: (state: T) => undefined | SuggestionRequest['mainPalette'];
/**
* Supported triggers of this visualization type when embedded somewhere

View file

@ -253,6 +253,9 @@ export function TableDimensionEditor(
siblingRef={props.panelRef}
isOpen={isPaletteOpen}
handleClose={() => setIsPaletteOpen(!isPaletteOpen)}
title={i18n.translate('xpack.lens.table.colorByRangePanelTitle', {
defaultMessage: 'Color',
})}
>
<CustomizablePalette
palettes={props.paletteService}

View file

@ -159,6 +159,9 @@ export function GaugeDimensionEditor(
siblingRef={props.panelRef}
isOpen={isPaletteOpen}
handleClose={togglePalette}
title={i18n.translate('xpack.lens.table.colorByRangePanelTitle', {
defaultMessage: 'Color',
})}
>
<CustomizablePalette
palettes={props.paletteService}

View file

@ -224,7 +224,7 @@ export const getGaugeVisualization = ({
layerId: addNewLayer(),
layerType: LayerTypes.DATA,
shape: GaugeShapes.HORIZONTAL_BULLET,
palette: mainPalette,
palette: mainPalette?.type === 'legacyPalette' ? mainPalette.value : undefined,
ticksPosition: 'auto',
labelMajorMode: 'auto',
}

Some files were not shown because too many files have changed in this diff Show more