[Lens] Improve color stop UI (#119165)

* First very draft version

* Added validation, clean up code

* Some fixes

* Adapt components to the new UI design

* Some fixes

* Fix validation

* Fix lint errors

* Fix metric vis for new color stop UI

* Fix problems with keeping state of auto detecting max/min value

* Add tests

* Fix CI

* Fix tests

* Fix some lint problems

* Fix CI

* Fix min/max behavior for heatmap

* Fix checks.

* Fix auto value when we add new color range

* Fix check task

* Fix some issues

* Some fixes

* Fix functional tests

* small fix for heatmap

* Fix test

* Update comment-description

* fix PR comments

* do some refactoring (work in progress)

* do some refactoring (work in progress)

* some cleanup

* some cleanup

* wp: fix validation

* wip: fix validation

* push some refactoring

* do some refactoring

* add useDebounce

* add useReducer

* remove autoValue

* fix validation

* update validation logic

* revert getStopsForFixedMode

* some updates

* update EuiColorPaletteDisplay palette arg

* push some logic

* push some logic

* update validation messages

* push some updates

* fix some logic

* fix some cases

* fix JES

* fix CI

* reset continuity

* fix functional tests

* fix issue with -infinite/+infinite

* fix CI

* push some updates

* Update x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_reducer.tsx

Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>

* Update x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.tsx

Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>

* fix some comments

* make color ranges crud methods "immutable"

* fix Max. value input size

* fix PR comment

* fix tests

* Fix edit/min/max buttons behavior

* Fix entering decimal values and max/min value behavior

* Fix lint

* Fix getNormalizedValueByRange for case when min == max

* Fix table cell coloring

* add warning messages

* Move color ranges reducer upper to palette_configuration (#3)

* Move color ranges reducer upper to palette_configuration

* Remove from local state unnecessary params

* Fix some cases

* Fix lint

* use one dataBounds type across palette configuration

* cleanup

* Fix some behavior

* Fix checks

* Some clean up

* Some fixes

* Some fixes

* Fix validation

* Fix CI

* Add unit tests for color_ranges_crud util

* Fix unit test

* Add unit tests for color ranges utils.ts

* Add allowEditMinMaxValues props and fix validation

* Fix CI

* Rename allowEditMinMaxValues to disableSwitchingContinuity

* Add unit tests for color_ranges_validation

* Add unit tests for updateRangeType and changeColorPalette functions

* Add unit tests for color_ranges_extra_actions

* Fix checks

* Clean up code

* Some fixes

* Fix unit-tests

* Fix comments

* Some changes

* Fix tests

* Fix all comments

* Fix Checks

* Fix CI

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Alexey Antonov <alexwizp@gmail.com>
Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>
This commit is contained in:
Uladzislau Lasitsa 2022-01-25 17:27:43 +03:00 committed by GitHub
parent bf2626f101
commit 95f2967c24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 3084 additions and 1180 deletions

View file

@ -156,7 +156,7 @@ describe('HeatmapComponent', function () {
expect(component.find(Heatmap).prop('colorScale')).toEqual({
bands: [
{ color: 'rgb(0, 0, 0)', end: 0, start: 0 },
{ color: 'rgb(112, 38, 231)', end: 150, start: 0 },
{ color: 'rgb(112, 38, 231)', end: 150.00001, start: 0 },
],
type: 'bands',
});

View file

@ -269,8 +269,10 @@ export const HeatmapComponent: FC<HeatmapRenderProps> = memo(
);
// adds a very small number to the max value to make sure the max value will be included
const smattering = 0.00001;
const endValue =
paletteParams && paletteParams.range === 'number' ? paletteParams.rangeMax : max + 0.00000001;
(paletteParams?.range === 'number' ? paletteParams.rangeMax : max) + smattering;
const overwriteColors = uiState?.get('vis.colors') ?? null;
const bands = ranges.map((start, index, array) => {

View file

@ -18,7 +18,6 @@ export type {
export { defaultCustomColors, palette, systemPalette } from './palette';
export { paletteIds } from './constants';
export type { ColorSchema, RawColorSchema, ColorMap } from './static';
export {
ColorSchemas,
@ -31,6 +30,8 @@ export {
LabelRotation,
defaultCountLabel,
MULTILAYER_TIME_AXIS_STYLE,
checkIsMinContinuity,
checkIsMaxContinuity,
} from './static';
export type { ColorSchemaParams, Labels, Style } from './types';
export type { ColorSchemaParams, Labels, Style, PaletteContinuity } from './types';

View file

@ -6,10 +6,13 @@
* Side Public License, v 1.
*/
import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common';
import type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common';
import { i18n } from '@kbn/i18n';
import { last } from 'lodash';
import { paletteIds } from './constants';
import { checkIsMaxContinuity, checkIsMinContinuity } from './static';
import type { PaletteContinuity } from './types';
export interface CustomPaletteArguments {
color?: string[];
@ -19,7 +22,7 @@ export interface CustomPaletteArguments {
range?: 'number' | 'percent';
rangeMin?: number;
rangeMax?: number;
continuity?: 'above' | 'below' | 'all' | 'none';
continuity?: PaletteContinuity;
}
export interface CustomPaletteState {
@ -29,7 +32,7 @@ export interface CustomPaletteState {
range: 'number' | 'percent';
rangeMin: number;
rangeMax: number;
continuity?: 'above' | 'below' | 'all' | 'none';
continuity?: PaletteContinuity;
}
export interface SystemPaletteArguments {
@ -169,8 +172,12 @@ export function palette(): ExpressionFunctionDefinition<
range: range ?? 'percent',
gradient,
continuity,
rangeMin: calculateRange(rangeMin, stops[0], rangeMinDefault),
rangeMax: calculateRange(rangeMax, last(stops), rangeMaxDefault),
rangeMin: checkIsMinContinuity(continuity)
? Number.NEGATIVE_INFINITY
: calculateRange(rangeMin, stops[0], rangeMinDefault),
rangeMax: checkIsMaxContinuity(continuity)
? Number.POSITIVE_INFINITY
: calculateRange(rangeMax, last(stops), rangeMaxDefault),
},
};
},

View file

@ -17,5 +17,5 @@ export {
} from './color_maps';
export { ColorMode, LabelRotation, defaultCountLabel } from './components';
export { checkIsMaxContinuity, checkIsMinContinuity } from './palette';
export * from './styles';

View file

@ -0,0 +1,15 @@
/*
* 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 { PaletteContinuity } from '../../types';
export const checkIsMinContinuity = (continuity: PaletteContinuity | undefined) =>
Boolean(continuity && ['below', 'all'].includes(continuity));
export const checkIsMaxContinuity = (continuity: PaletteContinuity | undefined) =>
Boolean(continuity && ['above', 'all'].includes(continuity));

View file

@ -8,6 +8,8 @@
import { ColorSchemas, LabelRotation } from './static';
export type PaletteContinuity = 'above' | 'below' | 'none' | 'all';
export interface ColorSchemaParams {
colorSchema: ColorSchemas;
invertColors: boolean;

View file

@ -73,7 +73,7 @@ describe('workoutColorForValue', () => {
{
...DEFAULT_PROPS,
continuity: 'all',
rangeMax: 100,
rangeMax: Infinity,
stops: [20, 40, 60, 80],
},
{ min: 0, max: 200 }

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
import { CustomPaletteState } from '../..';
import { checkIsMinContinuity, checkIsMaxContinuity } from '../../../common';
import type { CustomPaletteState } from '../..';
function findColorSegment(
value: number,
@ -20,7 +21,11 @@ function findColorSegment(
// what about values in range
const index = colors.findIndex((c, i) => comparison(value, rangeMin + (1 + i) * step) <= 0);
return colors[index] || colors[0];
// see comment below in function 'findColorsByStops'
return (
colors[index] ??
(value >= rangeMin + colors.length * step ? colors[colors.length - 1] : colors[0])
);
}
function findColorsByStops(
@ -30,25 +35,62 @@ function findColorsByStops(
stops: number[]
) {
const index = stops.findIndex((s) => comparison(value, s) < 0);
return colors[index] || colors[0];
// as we now we can provide 'rangeMax' as end for last interval (iterval [lastStop, rangeMax]),
// value can be more that last stop but will be valid
// because of this we should provide for that value the last color.
// (For example, value = 100, last stop = 80, rangeMax = 120, before we was return the first color,
// but now we will return the last one)
return (
colors[index] ?? (value >= stops[stops.length - 1] ? colors[colors.length - 1] : colors[0])
);
}
function getNormalizedValueByRange(
value: number,
{ range }: CustomPaletteState,
{ range, rangeMin }: CustomPaletteState,
minMax: { min: number; max: number }
) {
let result = value;
if (range === 'percent') {
result = (100 * (value - minMax.min)) / (minMax.max - minMax.min);
// for a range of 1 value the formulas above will divide by 0, so here's a safety guard
if (Number.isNaN(result)) {
return rangeMin;
}
}
// for a range of 1 value the formulas above will divide by 0, so here's a safety guard
if (Number.isNaN(result)) {
return 1;
}
return result;
}
const getNormalizedMaxRange = (
{
stops,
colors,
rangeMax,
}: Pick<CustomPaletteState, 'stops' | 'continuity' | 'colors' | 'rangeMax'>,
isMaxContinuity: boolean,
[min, max]: [number, number]
) => {
if (isMaxContinuity) {
return Number.POSITIVE_INFINITY;
}
return stops.length ? rangeMax : max - (max - min) / colors.length;
};
const getNormalizedMinRange = (
{ stops, rangeMin }: Pick<CustomPaletteState, 'stops' | 'continuity' | 'rangeMin'>,
isMinContinuity: boolean,
min: number
) => {
if (isMinContinuity) {
return Number.NEGATIVE_INFINITY;
}
return stops.length ? rangeMin : min;
};
/**
* When stops are empty, it is assumed a predefined palette, so colors are distributed uniformly in the whole data range
* When stops are passed, then rangeMin/rangeMax are used as reference for user defined limits:
@ -63,29 +105,30 @@ export function workoutColorForValue(
return;
}
const { colors, stops, range = 'percent', continuity = 'above', rangeMax, rangeMin } = params;
const isMinContinuity = checkIsMinContinuity(continuity);
const isMaxContinuity = checkIsMaxContinuity(continuity);
// ranges can be absolute numbers or percentages
// normalized the incoming value to the same format as range to make easier comparisons
const normalizedValue = getNormalizedValueByRange(value, params, minMax);
const dataRangeArguments = range === 'percent' ? [0, 100] : [minMax.min, minMax.max];
const [min, max]: [number, number] = range === 'percent' ? [0, 100] : [minMax.min, minMax.max];
const minRange = getNormalizedMinRange({ stops, rangeMin }, isMinContinuity, min);
const maxRange = getNormalizedMaxRange({ stops, colors, rangeMax }, isMaxContinuity, [min, max]);
const comparisonFn = (v: number, threshold: number) => v - threshold;
// if steps are defined consider the specific rangeMax/Min as data boundaries
// as of max reduce its value by 1/colors.length for correct continuity checks
const maxRange = stops.length
? rangeMax
: dataRangeArguments[1] - (dataRangeArguments[1] - dataRangeArguments[0]) / colors.length;
const minRange = stops.length ? rangeMin : dataRangeArguments[0];
// in case of shorter rangers, extends the steps on the sides to cover the whole set
if (comparisonFn(normalizedValue, maxRange) > 0) {
if (continuity === 'above' || continuity === 'all') {
return colors[colors.length - 1];
if (comparisonFn(normalizedValue, minRange) < 0) {
if (isMinContinuity) {
return colors[0];
}
return;
}
if (comparisonFn(normalizedValue, minRange) < 0) {
if (continuity === 'below' || continuity === 'all') {
return colors[0];
if (comparisonFn(normalizedValue, maxRange) > 0) {
if (isMaxContinuity) {
return colors[colors.length - 1];
}
return;
}
@ -94,11 +137,5 @@ export function workoutColorForValue(
return findColorsByStops(normalizedValue, comparisonFn, colors, stops);
}
return findColorSegment(
normalizedValue,
comparisonFn,
colors,
dataRangeArguments[0],
dataRangeArguments[1]
);
return findColorSegment(normalizedValue, comparisonFn, colors, min, max);
}

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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"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":100,"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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"bucket":{"accessor":0,"format":{"id":"string","params":{}},"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"}}

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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","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":100,"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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","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":100,"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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"string","params":{}},"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":100,"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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"string","params":{}},"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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","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":100,"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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"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":100,"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":{"field":"response.raw","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":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"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":{"bucket":{"accessor":1,"format":{"id":"string","params":{}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"string","params":{}},"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

@ -11,6 +11,7 @@ import type {
SerializedFieldFormat,
} from '../../../../src/plugins/field_formats/common';
import type { Datatable } from '../../../../src/plugins/expressions/common';
import type { PaletteContinuity } from '../../../../src/plugins/charts/common';
export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat;
@ -50,7 +51,7 @@ export interface CustomPaletteParams {
name?: string;
reverse?: boolean;
rangeType?: 'number' | 'percent';
continuity?: 'above' | 'below' | 'all' | 'none';
continuity?: PaletteContinuity;
progression?: 'fixed';
rangeMin?: number;
rangeMax?: number;

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiIconProps } from '@elastic/eui';
export const DistributeEquallyIcon = (props: Omit<EuiIconProps, 'type'>) => (
<svg
width="15"
height="12"
viewBox="0 0 15 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M4 .5a.5.5 0 0 1 1 0v11a.5.5 0 0 1-1 0V.5ZM0 3a1 1 0 0 1 1-1h2v8H1a1 1 0 0 1-1-1V3ZM10.5 0a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 1 0V.5a.5.5 0 0 0-.5-.5ZM6 2h3v8H6V2ZM14 2h-2v8h2a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Z" />
</svg>
);

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiIconProps } from '@elastic/eui';
export const RelatedIcon = (props: Omit<EuiIconProps, 'type'>) => (
<svg width="24" height="24" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M6 .5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5V8h4V6.5a.5.5 0 0 1 .82-.384l3 2.5a.5.5 0 0 1 0 .768l-3 2.5A.5.5 0 0 1 12 11.5V10H6.5a.5.5 0 0 1-.5-.5v-9Z" />
</svg>
);

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiIconProps } from '@elastic/eui';
export const ValueMaxIcon = (props: Omit<EuiIconProps, 'type'>) => (
<svg width="16" height="14" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M8 0a.5.5 0 0 1 .384.18l2.5 3A.5.5 0 0 1 10.5 4h-5a.5.5 0 0 1-.384-.82l2.5-3A.5.5 0 0 1 8 0ZM.916 5.223A.5.5 0 0 0 0 5.5v5a.5.5 0 0 0 1 0V7.151l1.084 1.626a.5.5 0 0 0 .832 0L4 7.151V10.5a.5.5 0 0 0 1 0v-5a.5.5 0 0 0-.916-.277L2.5 7.599.916 5.223Z" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.5 5h-.545l-.098.006a3.572 3.572 0 0 0-1.33.36 2.693 2.693 0 0 0-1.052.911C6.18 6.72 6 7.287 6 8v2.5a.5.5 0 0 0 1 0V9h2v1.5a.5.5 0 0 0 1 0v-5a.5.5 0 0 0-.5-.5Zm-.5.5V5v.5ZM7 8h2V6h-.011a2.57 2.57 0 0 0-.297.032c-.202.034-.463.1-.718.228a1.695 1.695 0 0 0-.667.572C7.133 7.092 7 7.462 7 8Z"
/>
<path d="M11.188 5.11a.5.5 0 0 1 .702.078L13.5 7.2l1.61-2.012a.5.5 0 1 1 .78.624L14.14 8l1.75 2.188a.5.5 0 1 1-.78.624L13.5 8.8l-1.61 2.012a.5.5 0 0 1-.78-.624L12.86 8l-1.75-2.188a.5.5 0 0 1 .078-.702Z" />
</svg>
);

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiIconProps } from '@elastic/eui';
export const ValueMinIcon = (props: Omit<EuiIconProps, 'type'>) => (
<svg width="16" height="14" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M10.367.018a.5.5 0 0 1 .562.225L13 3.695V.5a.5.5 0 0 1 1 0v5a.5.5 0 0 1-.929.257L11 2.305V5.5a.5.5 0 0 1-1 0v-5a.5.5 0 0 1 .367-.482ZM.916.223A.5.5 0 0 0 0 .5v5a.5.5 0 0 0 1 0V2.151l1.084 1.626a.5.5 0 0 0 .832 0L4 2.151V5.5a.5.5 0 0 0 1 0v-5a.5.5 0 0 0-.916-.277L2.5 2.599.916.223ZM7 11a.5.5 0 0 0 .384-.18l2.5-3A.5.5 0 0 0 9.5 7h-5a.5.5 0 0 0-.384.82l2.5 3A.5.5 0 0 0 7 11ZM6.5 0a.5.5 0 0 0 0 1H7v4h-.5a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1H8V1h.5a.5.5 0 0 0 0-1h-2Z" />
</svg>
);

View file

@ -27,7 +27,6 @@ import {
applyPaletteParams,
defaultPaletteParams,
FIXED_PROGRESSION,
getStopsForFixedMode,
useDebouncedValue,
PalettePanelContainer,
findMinMaxByColumnId,
@ -352,7 +351,7 @@ export function TableDimensionEditor(
<EuiFlexItem>
<EuiColorPaletteDisplay
data-test-subj="lnsDatatable_dynamicColoring_palette"
palette={getStopsForFixedMode(displayStops, activePalette.params?.colorStops)}
palette={displayStops.map(({ color }) => color)}
type={FIXED_PROGRESSION}
onClick={() => {
setIsPaletteOpen(!isPaletteOpen);

View file

@ -22,7 +22,6 @@ import type {
import { LensIconChartDatatable } from '../assets/chart_datatable';
import { TableDimensionEditor } from './components/dimension_editor';
import { CUSTOM_PALETTE } from '../shared_components/coloring/constants';
import { getStopsForFixedMode } from '../shared_components';
import { LayerType, layerTypes } from '../../common';
import { getDefaultSummaryLabel, PagingState } from '../../common/expressions';
import type { ColumnState, SortingState } from '../../common/expressions';
@ -241,9 +240,9 @@ export const getDatatableVisualization = ({
.filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed)
.map((accessor) => {
const columnConfig = columnMap[accessor];
const hasColoring = Boolean(
columnConfig.colorMode !== 'none' && columnConfig.palette?.params?.stops
);
const stops = columnConfig.palette?.params?.stops;
const hasColoring = Boolean(columnConfig.colorMode !== 'none' && stops);
return {
columnId: accessor,
triggerIcon: columnConfig.hidden
@ -251,12 +250,7 @@ export const getDatatableVisualization = ({
: hasColoring
? 'colorBy'
: undefined,
palette: hasColoring
? getStopsForFixedMode(
columnConfig.palette?.params?.stops || [],
columnConfig.palette?.params?.colorStops
)
: undefined,
palette: hasColoring && stops ? stops.map(({ color }) => color) : undefined,
};
}),
supportsMoreColumns: true,

View file

@ -19,7 +19,6 @@ import type { VisualizationDimensionEditorProps } from '../types';
import {
CustomizablePalette,
FIXED_PROGRESSION,
getStopsForFixedMode,
PalettePanelContainer,
} from '../shared_components/';
import './dimension_editor.scss';
@ -64,7 +63,7 @@ export function HeatmapDimensionEditor(
<EuiFlexItem>
<EuiColorPaletteDisplay
data-test-subj="lnsHeatmap_dynamicColoring_palette"
palette={getStopsForFixedMode(displayStops, activePalette?.params?.colorStops)}
palette={displayStops.map(({ color }) => color)}
type={FIXED_PROGRESSION}
onClick={() => {
setIsPaletteOpen(!isPaletteOpen);
@ -93,23 +92,24 @@ export function HeatmapDimensionEditor(
isOpen={isPaletteOpen}
handleClose={() => setIsPaletteOpen(!isPaletteOpen)}
>
<CustomizablePalette
palettes={props.paletteService}
activePalette={activePalette}
dataBounds={currentMinMax}
showContinuity={false}
setPalette={(newPalette) => {
// make sure to always have a list of stops
if (newPalette.params && !newPalette.params.stops) {
newPalette.params.stops = displayStops;
}
(newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor;
setState({
...state,
palette: newPalette as HeatmapVisualizationState['palette'],
});
}}
/>
{activePalette && (
<CustomizablePalette
palettes={props.paletteService}
activePalette={activePalette}
dataBounds={currentMinMax}
setPalette={(newPalette) => {
// make sure to always have a list of stops
if (newPalette.params && !newPalette.params.stops) {
newPalette.params.stops = displayStops;
}
(newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor;
setState({
...state,
palette: newPalette as HeatmapVisualizationState['palette'],
});
}}
/>
)}
</PalettePanelContainer>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -19,7 +19,9 @@ export type HeatmapLayerState = HeatmapArguments & {
shape: ChartShapes;
};
export type Palette = PaletteOutput<CustomPaletteParams> & { accessor: string };
export type HeatmapVisualizationState = HeatmapLayerState & {
// need to store the current accessor to reset the color stops at accessor change
palette?: PaletteOutput<CustomPaletteParams> & { accessor: string };
palette?: Palette;
};

View file

@ -9,7 +9,7 @@ import type { PaletteRegistry } from 'src/plugins/charts/public';
import type { Datatable } from 'src/plugins/expressions';
import { applyPaletteParams, findMinMaxByColumnId } from '../shared_components';
import { DEFAULT_PALETTE_NAME } from './constants';
import type { HeatmapVisualizationState } from './types';
import type { HeatmapVisualizationState, Palette } from './types';
export function getSafePaletteParams(
paletteService: PaletteRegistry,
@ -18,9 +18,9 @@ export function getSafePaletteParams(
activePalette?: HeatmapVisualizationState['palette']
) {
if (currentData == null || accessor == null) {
return { displayStops: [], activePalette: {} as HeatmapVisualizationState['palette'] };
return { displayStops: [], activePalette };
}
const finalActivePalette: HeatmapVisualizationState['palette'] = activePalette ?? {
const finalActivePalette: Palette = activePalette ?? {
type: 'palette',
name: DEFAULT_PALETTE_NAME,
accessor,

View file

@ -153,10 +153,7 @@ describe('heatmap', () => {
{
columnId: 'v-accessor',
triggerIcon: 'colorBy',
palette: [
{ color: 'blue', stop: 100 },
{ color: 'yellow', stop: 350 },
],
palette: ['blue', 'yellow'],
},
],
filterOperations: isCellValueSupported,
@ -406,6 +403,7 @@ describe('heatmap', () => {
],
},
],
lastRangeIsRightOpen: [true],
legend: [
{
type: 'expression',

View file

@ -29,7 +29,7 @@ import {
LENS_HEATMAP_ID,
} from './constants';
import { HeatmapToolbar } from './toolbar_component';
import { CUSTOM_PALETTE, getStopsForFixedMode } from '../shared_components';
import { CUSTOM_PALETTE } from '../shared_components';
import { HeatmapDimensionEditor } from './dimension_editor';
import { getSafePaletteParams } from './utils';
import type { CustomPaletteParams } from '../../common';
@ -205,10 +205,7 @@ export const getHeatmapVisualization = ({
? {
columnId: state.valueAccessor,
triggerIcon: 'colorBy',
palette: getStopsForFixedMode(
displayStops,
activePalette?.params?.colorStops
),
palette: displayStops.map(({ color }) => color),
}
: {
columnId: state.valueAccessor,
@ -317,6 +314,11 @@ export const getHeatmapVisualization = ({
xAccessor: [state.xAccessor ?? ''],
yAccessor: [state.yAccessor ?? ''],
valueAccessor: [state.valueAccessor ?? ''],
lastRangeIsRightOpen: [
state.palette?.params?.continuity
? ['above', 'all'].includes(state.palette.params.continuity)
: true,
],
palette: state.palette?.params
? [
paletteService

View file

@ -23,7 +23,6 @@ import {
CustomizablePalette,
CUSTOM_PALETTE,
FIXED_PROGRESSION,
getStopsForFixedMode,
PalettePanelContainer,
} from '../shared_components';
import type { VisualizationDimensionEditorProps } from '../types';
@ -165,14 +164,7 @@ export function MetricDimensionEditor(
<EuiFlexItem>
<EuiColorPaletteDisplay
data-test-subj="lnsMetric_dynamicColoring_palette"
palette={
activePalette.params?.name === CUSTOM_PALETTE
? getStopsForFixedMode(
activePalette.params.stops!,
activePalette.params.colorStops
)
: displayStops.map(({ color }) => color)
}
palette={displayStops.map(({ color }) => color)}
type={FIXED_PROGRESSION}
onClick={togglePalette}
/>

View file

@ -420,7 +420,7 @@ describe('metric_expression', () => {
);
});
test('it renders no color styling for numeric value if value is higher than rangeMax and continuity is "none"', () => {
test('it renders no color styling for numeric value if value is higher than rangeMax', () => {
const { data, args } = sampleArgs();
data.tables.l1.rows[0].c = 500;
@ -432,7 +432,6 @@ describe('metric_expression', () => {
gradient: false,
range: 'number',
colors: ['red', 'yellow', 'green'],
continuity: 'none',
};
const instance = mount(
@ -453,7 +452,7 @@ describe('metric_expression', () => {
);
});
test('it renders no color styling for numeric value if value is lower than rangeMin and continuity is "none"', () => {
test('it renders no color styling for numeric value if value is lower than rangeMin', () => {
const { data, args } = sampleArgs();
data.tables.l1.rows[0].c = -1;
@ -465,7 +464,6 @@ describe('metric_expression', () => {
gradient: false,
range: 'number',
colors: ['red', 'yellow', 'green'],
continuity: 'none',
};
const instance = mount(
@ -486,19 +484,18 @@ describe('metric_expression', () => {
);
});
test('it renders the color styling for numeric value if value is higher than rangeMax and continuity is "all"', () => {
test('it renders the correct color styling for numeric value if user select auto detect max value', () => {
const { data, args } = sampleArgs();
data.tables.l1.rows[0].c = 500;
args.colorMode = ColorMode.Labels;
args.palette.params = {
rangeMin: 0,
rangeMax: 400,
rangeMin: 20,
rangeMax: Infinity,
stops: [100, 200, 400],
gradient: false,
range: 'number',
colors: ['red', 'yellow', 'green'],
continuity: 'all',
};
const instance = mount(
@ -519,19 +516,18 @@ describe('metric_expression', () => {
);
});
test('it renders the color styling for numeric value if value is lower than rangeMin and continuity is "all"', () => {
test('it renders the correct color styling for numeric value if user select auto detect min value', () => {
const { data, args } = sampleArgs();
data.tables.l1.rows[0].c = -1;
args.colorMode = ColorMode.Labels;
args.palette.params = {
rangeMin: 0,
rangeMin: -Infinity,
rangeMax: 400,
stops: [100, 200, 400],
stops: [-Infinity, 200, 400],
gradient: false,
range: 'number',
colors: ['red', 'yellow', 'green'],
continuity: 'all',
};
const instance = mount(

View file

@ -70,29 +70,28 @@ function getColorStyling(
return {};
}
const { continuity = 'above', rangeMin, stops, colors } = palette.params;
const penultimateStop = stops[stops.length - 2];
const { rangeMin, rangeMax, stops, colors } = palette.params;
if (continuity === 'none' && (value < rangeMin || value > penultimateStop)) {
if (value > rangeMax) {
return {};
}
if (continuity === 'below' && value > penultimateStop) {
return {};
}
if (continuity === 'above' && value < rangeMin) {
if (value < rangeMin) {
return {};
}
const cssProp = colorMode === ColorMode.Background ? 'backgroundColor' : 'color';
const rawIndex = stops.findIndex((v) => v > value);
let rawIndex = stops.findIndex((v) => v > value);
let colorIndex = rawIndex;
if (['all', 'below'].includes(continuity) && value < rangeMin && colorIndex < 0) {
colorIndex = 0;
if (!isFinite(rangeMax) && value > stops[stops.length - 1]) {
rawIndex = stops.length - 1;
}
if (['all', 'above'].includes(continuity) && value > penultimateStop && colorIndex < 0) {
colorIndex = stops.length - 1;
// in this case first stop is -Infinity
if (!isFinite(rangeMin) && value < (isFinite(stops[0]) ? stops[0] : stops[1])) {
rawIndex = 0;
}
const colorIndex = rawIndex;
const color = colors[colorIndex];
const styling = {
[cssProp]: color,

View file

@ -19,7 +19,7 @@ import { LensIconChartMetric } from '../assets/chart_metric';
import { Visualization, OperationMetadata, DatasourcePublicAPI } from '../types';
import type { MetricConfig, MetricState } from '../../common/expressions';
import { layerTypes } from '../../common';
import { CUSTOM_PALETTE, getStopsForFixedMode, shiftPalette } from '../shared_components';
import { CUSTOM_PALETTE, shiftPalette } from '../shared_components';
import { MetricDimensionEditor } from './dimension_editor';
const toExpression = (
@ -146,11 +146,7 @@ export const getMetricVisualization = ({
{
columnId: props.state.accessor,
triggerIcon: hasColoring ? 'colorBy' : undefined,
palette: hasColoring
? props.state.palette?.params?.name === CUSTOM_PALETTE
? getStopsForFixedMode(stops, props.state.palette?.params.colorStops)
: stops.map(({ color }) => color)
: undefined,
palette: hasColoring ? stops.map(({ color }) => color) : undefined,
},
]
: [],

View file

@ -0,0 +1,166 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mountWithIntl } from '@kbn/test/jest';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { ColorRanges, ColorRangesProps } from './color_ranges';
import { ReactWrapper } from 'enzyme';
import { PaletteRegistry } from 'src/plugins/charts/public';
import { ColorRangesContext } from './color_ranges_context';
const extraActionSelectors = {
addColorRange: '[data-test-subj^="lnsPalettePanel_dynamicColoring_addColorRange"]',
reverseColors: '[data-test-subj^="lnsPalettePanel_dynamicColoring_reverseColors"]',
distributeEqually: '[data-test-subj="lnsPalettePanel_dynamicColoring_distributeEqually"]',
};
const pageObjects = {
getAddColorRangeButton: (component: ReactWrapper) =>
component.find(extraActionSelectors.addColorRange).first(),
reverseColors: (component: ReactWrapper) =>
component.find(extraActionSelectors.reverseColors).first(),
distributeEqually: (component: ReactWrapper) =>
component.find(extraActionSelectors.distributeEqually).first(),
};
function renderColorRanges(props: ColorRangesProps) {
return mountWithIntl(
<ColorRangesContext.Provider
value={{
dataBounds: { min: 0, max: 100 },
palettes: {} as PaletteRegistry,
}}
>
<ColorRanges {...props} />
</ColorRangesContext.Provider>
);
}
describe('Color Ranges', () => {
let props: ColorRangesProps;
const dispatch = jest.fn();
beforeEach(() => {
dispatch.mockClear();
props = {
colorRanges: [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
],
paletteConfiguration: {
rangeType: 'number',
continuity: 'none',
},
showExtraActions: true,
dispatch,
};
});
it('should display all the color ranges passed', () => {
const component = renderColorRanges(props);
expect(component.find('ColorRangeItem')).toHaveLength(4);
});
it('should disable "add new" button if there is maxStops configured', () => {
props.colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
{ color: '#ccc', start: 80, end: 90 },
{ color: '#ccc', start: 90, end: 100 },
];
const component = renderColorRanges({ ...props, paletteConfiguration: { maxSteps: 5 } });
expect(pageObjects.getAddColorRangeButton(component).prop('disabled')).toBe(true);
});
it('should add a new range with default color and reasonable distance from last one', () => {
const component = renderColorRanges(props);
act(() => {
pageObjects.getAddColorRangeButton(component).simulate('click');
});
component.update();
expect(dispatch).toHaveBeenCalledWith({
type: 'addColorRange',
payload: { dataBounds: { min: 0, max: 100 }, palettes: {} },
});
});
it('should sort ranges value on whole component blur', () => {
props.colorRanges = [
{ color: '#aaa', start: 65, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
const component = renderColorRanges(props);
const firstInput = component.find('ColorRangeItem').first().find('input').first();
act(() => {
firstInput.simulate('blur');
});
expect(dispatch).toHaveBeenCalledWith({
type: 'sortColorRanges',
payload: {
dataBounds: { min: 0, max: 100 },
palettes: {},
},
});
});
it('should reverse colors when user click "reverse"', () => {
props.colorRanges = [
{ color: '#aaa', start: 10, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 90 },
{ color: '#ddd', start: 90, end: 130 },
];
const component = renderColorRanges(props);
act(() => {
pageObjects.reverseColors(component).simulate('click');
});
component.update();
expect(dispatch).toHaveBeenCalledWith({
type: 'reversePalette',
payload: {
dataBounds: { min: 0, max: 100 },
palettes: {},
},
});
});
it('should distribute equally ranges when use click on "Distribute equally" button', () => {
props.colorRanges = [
{ color: '#aaa', start: 0, end: 2 },
{ color: '#bbb', start: 3, end: 4 },
{ color: '#ccc', start: 5, end: 6 },
{ color: '#ccc', start: 7, end: 8 },
];
const component = renderColorRanges(props);
act(() => {
pageObjects.distributeEqually(component).simulate('click');
});
component.update();
expect(dispatch).toHaveBeenCalledWith({
type: 'distributeEqually',
payload: { dataBounds: { min: 0, max: 100 }, palettes: {} },
});
});
});

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useEffect, Dispatch, useContext } from 'react';
import { EuiFlexGroup, EuiTextColor, EuiFlexItem } from '@elastic/eui';
import { ColorRangesExtraActions } from './color_ranges_extra_actions';
import { ColorRangeItem } from './color_ranges_item';
import {
validateColorRanges,
getErrorMessages,
ColorRangeValidation,
} from './color_ranges_validation';
import type { CustomPaletteParamsConfig } from '../../../../common';
import type { ColorRange } from './types';
import type { PaletteConfigurationActions } from '../types';
import { defaultPaletteParams } from '../constants';
import { ColorRangesContext } from './color_ranges_context';
export interface ColorRangesProps {
colorRanges: ColorRange[];
paletteConfiguration: CustomPaletteParamsConfig | undefined;
showExtraActions?: boolean;
dispatch: Dispatch<PaletteConfigurationActions>;
}
export function ColorRanges({
colorRanges,
paletteConfiguration,
showExtraActions = true,
dispatch,
}: ColorRangesProps) {
const { dataBounds } = useContext(ColorRangesContext);
const [colorRangesValidity, setColorRangesValidity] = useState<
Record<string, ColorRangeValidation>
>({});
const lastColorRange = colorRanges[colorRanges.length - 1];
const errors = getErrorMessages(colorRangesValidity);
const continuity = paletteConfiguration?.continuity ?? defaultPaletteParams.continuity;
const rangeType = paletteConfiguration?.rangeType ?? defaultPaletteParams.rangeType;
useEffect(() => {
setColorRangesValidity(validateColorRanges(colorRanges, dataBounds, rangeType));
}, [colorRanges, rangeType, dataBounds]);
return (
<EuiFlexGroup
data-test-subj={`lnsPalettePanel_dynamicColoring_custom_color_ranges`}
direction="column"
gutterSize="s"
>
{colorRanges.map((colorRange, index) => (
<EuiFlexItem grow={false} key={`${colorRange.end ?? 0 + colorRange.start ?? 0}${index}`}>
<ColorRangeItem
colorRange={colorRange}
dispatch={dispatch}
colorRanges={colorRanges}
continuity={continuity}
rangeType={rangeType}
index={index}
validation={colorRangesValidity[index]}
accessor="start"
/>
</EuiFlexItem>
))}
{lastColorRange ? (
<EuiFlexItem grow={false}>
<ColorRangeItem
colorRange={lastColorRange}
dispatch={dispatch}
colorRanges={colorRanges}
continuity={continuity}
rangeType={rangeType}
index={colorRanges.length - 1}
validation={colorRangesValidity.last}
accessor="end"
/>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
{errors.map((error) => (
<EuiTextColor color="danger">{error}</EuiTextColor>
))}
</EuiFlexItem>
{showExtraActions ? (
<EuiFlexItem grow={false}>
<ColorRangesExtraActions
dispatch={dispatch}
shouldDisableAdd={Boolean(
(paletteConfiguration?.maxSteps &&
colorRanges.length >= paletteConfiguration?.maxSteps) ||
errors.length
)}
shouldDisableDistribute={Boolean(colorRanges.length === 1)}
shouldDisableReverse={Boolean(colorRanges.length === 1)}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
);
}

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { PaletteRegistry } from 'src/plugins/charts/public';
import type { DataBounds } from '../types';
interface ColorRangesContextType {
dataBounds: DataBounds;
palettes: PaletteRegistry;
disableSwitchingContinuity?: boolean;
}
export const ColorRangesContext = React.createContext<ColorRangesContextType>(
{} as ColorRangesContextType
);

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, Dispatch, useContext } from 'react';
import { EuiFlexGroup, EuiButtonEmpty, EuiFlexItem } from '@elastic/eui';
import { DistributeEquallyIcon } from '../../../assets/distribute_equally';
import { TooltipWrapper } from '../../index';
import type { ColorRangesActions } from './types';
import { ColorRangesContext } from './color_ranges_context';
export interface ColorRangesExtraActionsProps {
dispatch: Dispatch<ColorRangesActions>;
shouldDisableAdd?: boolean;
shouldDisableReverse?: boolean;
shouldDisableDistribute?: boolean;
}
export function ColorRangesExtraActions({
dispatch,
shouldDisableAdd = false,
shouldDisableReverse = false,
shouldDisableDistribute = false,
}: ColorRangesExtraActionsProps) {
const { dataBounds, palettes } = useContext(ColorRangesContext);
const onAddColorRange = useCallback(() => {
dispatch({
type: 'addColorRange',
payload: { dataBounds, palettes },
});
}, [dataBounds, dispatch, palettes]);
const onReversePalette = useCallback(() => {
dispatch({ type: 'reversePalette', payload: { dataBounds, palettes } });
}, [dispatch, dataBounds, palettes]);
const onDistributeEqually = useCallback(() => {
dispatch({ type: 'distributeEqually', payload: { dataBounds, palettes } });
}, [dataBounds, dispatch, palettes]);
return (
<EuiFlexGroup justifyContent="flexStart" gutterSize="none" wrap={true}>
<EuiFlexItem grow={false}>
<TooltipWrapper
tooltipContent={i18n.translate(
'xpack.lens.dynamicColoring.customPalette.maximumStepsApplied',
{
defaultMessage: `You've applied the maximum number of steps`,
}
)}
condition={shouldDisableAdd}
position="top"
delay="regular"
>
<EuiButtonEmpty
data-test-subj={`lnsPalettePanel_dynamicColoring_addColorRange`}
iconType="plusInCircle"
color="primary"
aria-label={i18n.translate(
'xpack.lens.dynamicColoring.customPalette.addColorRangeAriaLabel',
{
defaultMessage: 'Add color range',
}
)}
size="xs"
flush="left"
disabled={shouldDisableAdd}
onClick={onAddColorRange}
>
<FormattedMessage
id="xpack.lens.dynamicColoring.customPalette.addColorRange"
defaultMessage="Add color range"
/>
</EuiButtonEmpty>
</TooltipWrapper>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj={`lnsPalettePanel_dynamicColoring_reverseColors`}
iconType="sortable"
color="primary"
aria-label={i18n.translate('xpack.lens.dynamicColoring.customPaletteAriaLabel', {
defaultMessage: 'Reverse colors',
})}
size="xs"
flush="left"
onClick={onReversePalette}
disabled={shouldDisableReverse}
>
<FormattedMessage
id="xpack.lens.dynamicColoring.customPalette.reverseColors"
defaultMessage="Reverse colors"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj={`lnsPalettePanel_dynamicColoring_distributeEqually`}
iconType={DistributeEquallyIcon}
color="primary"
aria-label={i18n.translate(
'xpack.lens.dynamicColoring.customPalette.distributeEquallyAriaLabel',
{
defaultMessage: 'Distribute equally',
}
)}
size="xs"
flush="left"
disabled={shouldDisableDistribute}
onClick={onDistributeEqually}
>
<FormattedMessage
id="xpack.lens.dynamicColoring.customPalette.distributeEqually"
defaultMessage="Distribute equally"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,238 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import useUpdateEffect from 'react-use/lib/useUpdateEffect';
import React, { useState, useCallback, Dispatch, FocusEvent, useContext } from 'react';
import {
EuiFieldNumber,
EuiColorPicker,
EuiFlexItem,
EuiFlexGroup,
EuiIcon,
EuiColorPickerSwatch,
EuiButtonIcon,
EuiToolTip,
EuiFieldNumberProps,
} from '@elastic/eui';
import { RelatedIcon } from '../../../assets/related';
import { isLastItem } from './utils';
import { isValidColor } from '../utils';
import {
ColorRangeDeleteButton,
ColorRangeAutoDetectButton,
ColorRangeEditButton,
} from './color_ranges_item_buttons';
import type { ColorRange, ColorRangeAccessor, ColorRangesActions } from './types';
import { ColorRangesContext } from './color_ranges_context';
import type { ColorRangeValidation } from './color_ranges_validation';
import type { CustomPaletteParams } from '../../../../common';
import {
PaletteContinuity,
checkIsMaxContinuity,
checkIsMinContinuity,
} from '../../../../../../../src/plugins/charts/common';
import { getOutsideDataBoundsWarningMessage } from './color_ranges_validation';
export interface ColorRangesItemProps {
colorRange: ColorRange;
index: number;
colorRanges: ColorRange[];
dispatch: Dispatch<ColorRangesActions>;
rangeType: CustomPaletteParams['rangeType'];
continuity: PaletteContinuity;
accessor: ColorRangeAccessor;
validation?: ColorRangeValidation;
}
type ColorRangeItemMode = 'value' | 'auto' | 'edit';
const getMode = (
index: ColorRangesItemProps['index'],
isLast: boolean,
continuity: PaletteContinuity
): ColorRangeItemMode => {
if (!isLast && index > 0) {
return 'value';
}
return (isLast ? checkIsMaxContinuity : checkIsMinContinuity)(continuity) ? 'auto' : 'edit';
};
const getPlaceholderForAutoMode = (isLast: boolean) =>
isLast
? i18n.translate('xpack.lens.dynamicColoring.customPalette.maxValuePlaceholder', {
defaultMessage: 'Max. value',
})
: i18n.translate('xpack.lens.dynamicColoring.customPalette.minValuePlaceholder', {
defaultMessage: 'Min. value',
});
const getActionButton = (mode: ColorRangeItemMode) => {
if (mode === 'value') {
return ColorRangeDeleteButton;
}
return mode === 'edit' ? ColorRangeAutoDetectButton : ColorRangeEditButton;
};
const getAppend = (
rangeType: CustomPaletteParams['rangeType'],
mode: ColorRangeItemMode,
validation?: ColorRangeValidation
) => {
const items: EuiFieldNumberProps['append'] = [];
if (rangeType === 'percent') {
items.push('%');
}
if (mode !== 'auto' && validation?.warnings.length) {
items.push(
<EuiToolTip position="top" content={getOutsideDataBoundsWarningMessage(validation.warnings)}>
<EuiIcon type="alert" size="m" color="warning" />
</EuiToolTip>
);
}
return items;
};
export function ColorRangeItem({
accessor,
index,
colorRange,
rangeType,
colorRanges,
validation,
continuity,
dispatch,
}: ColorRangesItemProps) {
const { dataBounds, palettes } = useContext(ColorRangesContext);
const [popoverInFocus, setPopoverInFocus] = useState<boolean>(false);
const [localValue, setLocalValue] = useState<number | undefined>(colorRange[accessor]);
const isLast = isLastItem(accessor);
const mode = getMode(index, isLast, continuity);
const isDisabled = mode === 'auto';
const isColorValid = isValidColor(colorRange.color);
const ActionButton = getActionButton(mode);
const isValid = validation?.isValid ?? true;
const onLeaveFocus = useCallback(
(e: FocusEvent<HTMLDivElement>) => {
const prevStartValue = colorRanges[index - 1]?.start ?? Number.NEGATIVE_INFINITY;
const nextStartValue = colorRanges[index + 1]?.start ?? Number.POSITIVE_INFINITY;
const shouldSort = colorRange.start > nextStartValue || prevStartValue > colorRange.start;
const isFocusStillInContent =
(e.currentTarget as Node)?.contains(e.relatedTarget as Node) || popoverInFocus;
if (shouldSort && !isFocusStillInContent) {
dispatch({ type: 'sortColorRanges', payload: { dataBounds, palettes } });
}
},
[colorRange.start, colorRanges, dispatch, index, popoverInFocus, dataBounds, palettes]
);
const onValueChange = useCallback(
({ target: { value: targetValue } }) => {
setLocalValue(targetValue);
dispatch({
type: 'updateValue',
payload: { index, value: targetValue, accessor, dataBounds, palettes },
});
},
[dispatch, index, accessor, dataBounds, palettes]
);
const onUpdateColor = useCallback(
(color) => {
dispatch({ type: 'updateColor', payload: { index, color, dataBounds, palettes } });
},
[dispatch, index, dataBounds, palettes]
);
useUpdateEffect(() => {
if (!Number.isNaN(colorRange[accessor]) && colorRange[accessor] !== localValue) {
setLocalValue(colorRange[accessor]);
}
}, [localValue, colorRange, accessor]);
const selectNewColorText = i18n.translate(
'xpack.lens.dynamicColoring.customPalette.selectNewColor',
{
defaultMessage: 'Select a new color',
}
);
return (
<EuiFlexGroup alignItems="center" gutterSize="s" wrap={false}>
<EuiFlexItem grow={false}>
{!isLast ? (
<EuiColorPicker
onChange={onUpdateColor}
button={
isColorValid ? (
<EuiColorPickerSwatch color={colorRange.color} aria-label={selectNewColorText} />
) : (
<EuiButtonIcon
color="danger"
iconType="stopSlash"
iconSize="l"
aria-label={selectNewColorText}
/>
)
}
secondaryInputDisplay="top"
color={colorRange.color}
onFocus={() => setPopoverInFocus(true)}
onBlur={() => {
setPopoverInFocus(false);
}}
isInvalid={!isColorValid}
/>
) : (
<EuiIcon type={RelatedIcon} size="l" />
)}
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiFieldNumber
compressed
fullWidth={true}
isInvalid={!isValid}
value={mode !== 'auto' ? localValue : ''}
disabled={isDisabled}
onChange={onValueChange}
placeholder={mode === 'auto' ? getPlaceholderForAutoMode(isLast) : ''}
append={getAppend(rangeType, mode, validation)}
onBlur={onLeaveFocus}
data-test-subj={`lnsPalettePanel_dynamicColoring_range_value_${index}`}
prepend={<span className="euiFormLabel">{isLast ? '\u2264' : '\u2265'}</span>}
aria-label={i18n.translate('xpack.lens.dynamicColoring.customPalette.rangeAriaLabel', {
defaultMessage: 'Range {index}',
values: {
index: index + 1,
},
})}
/>
</EuiFlexItem>
{ActionButton ? (
<EuiFlexItem grow={false}>
<ActionButton
index={index}
continuity={continuity}
rangeType={rangeType}
colorRanges={colorRanges}
dispatch={dispatch}
accessor={accessor}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Dispatch, useCallback, useContext } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon } from '@elastic/eui';
import { ValueMaxIcon } from '../../../assets/value_max';
import { ValueMinIcon } from '../../../assets/value_min';
import { isLastItem } from './utils';
import { TooltipWrapper } from '../../index';
import type { ColorRangesActions, ColorRange, ColorRangeAccessor } from './types';
import { ColorRangesContext } from './color_ranges_context';
import type { CustomPaletteParams } from '../../../../common';
import type { PaletteContinuity } from '../../../../../../../src/plugins/charts/common';
export interface ColorRangesItemButtonProps {
index: number;
colorRanges: ColorRange[];
rangeType: CustomPaletteParams['rangeType'];
continuity: PaletteContinuity;
dispatch: Dispatch<ColorRangesActions>;
accessor: ColorRangeAccessor;
}
const switchContinuity = (isLast: boolean, continuity: PaletteContinuity) => {
switch (continuity) {
case 'none':
return isLast ? 'above' : 'below';
case 'above':
return isLast ? 'none' : 'all';
case 'below':
return isLast ? 'all' : 'none';
case 'all':
return isLast ? 'below' : 'above';
}
};
export function ColorRangeDeleteButton({ index, dispatch }: ColorRangesItemButtonProps) {
const { dataBounds, palettes } = useContext(ColorRangesContext);
const onExecuteAction = useCallback(() => {
dispatch({ type: 'deleteColorRange', payload: { index, dataBounds, palettes } });
}, [dispatch, index, dataBounds, palettes]);
const title = i18n.translate('xpack.lens.dynamicColoring.customPalette.deleteButtonAriaLabel', {
defaultMessage: 'Delete',
});
return (
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label={title}
title={title}
onClick={onExecuteAction}
data-test-subj={`lnsPalettePanel_dynamicColoring_removeColorRange_${index}`}
/>
);
}
export function ColorRangeEditButton({
index,
continuity,
dispatch,
accessor,
}: ColorRangesItemButtonProps) {
const { dataBounds, palettes, disableSwitchingContinuity } = useContext(ColorRangesContext);
const isLast = isLastItem(accessor);
const onExecuteAction = useCallback(() => {
const newContinuity = switchContinuity(isLast, continuity);
dispatch({
type: 'updateContinuity',
payload: { isLast, continuity: newContinuity, dataBounds, palettes },
});
}, [isLast, dispatch, continuity, dataBounds, palettes]);
const title = i18n.translate('xpack.lens.dynamicColoring.customPalette.editButtonAriaLabel', {
defaultMessage: 'Edit',
});
return (
<TooltipWrapper
tooltipContent={i18n.translate(
'xpack.lens.dynamicColoring.customPalette.disallowedEditMinMaxValues',
{
defaultMessage: `For current configuration you can not set custom value`,
}
)}
condition={Boolean(disableSwitchingContinuity)}
position="top"
delay="regular"
>
<EuiButtonIcon
iconType="pencil"
aria-label={title}
title={title}
disabled={disableSwitchingContinuity}
onClick={onExecuteAction}
data-test-subj={`lnsPalettePanel_dynamicColoring_editValue_${index}`}
/>
</TooltipWrapper>
);
}
export function ColorRangeAutoDetectButton({
continuity,
dispatch,
accessor,
}: ColorRangesItemButtonProps) {
const { dataBounds, palettes } = useContext(ColorRangesContext);
const isLast = isLastItem(accessor);
const onExecuteAction = useCallback(() => {
const newContinuity = switchContinuity(isLast, continuity);
dispatch({
type: 'updateContinuity',
payload: { isLast, continuity: newContinuity, dataBounds, palettes },
});
}, [continuity, dataBounds, dispatch, isLast, palettes]);
const title = isLast
? i18n.translate('xpack.lens.dynamicColoring.customPalette.autoDetectMaximumAriaLabel', {
defaultMessage: 'Auto detect maximum value',
})
: i18n.translate('xpack.lens.dynamicColoring.customPalette.autoDetectMinimumAriaLabel', {
defaultMessage: 'Auto detect minimum value',
});
return (
<EuiButtonIcon
iconType={isLast ? ValueMaxIcon : ValueMinIcon}
aria-label={title}
title={title}
onClick={onExecuteAction}
data-test-subj={`lnsPalettePanel_dynamicColoring_autoDetect_${
isLast ? 'maximum' : 'minimum'
}`}
/>
);
}

View file

@ -0,0 +1,151 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { validateColorRanges, isAllColorRangesValid } from './color_ranges_validation';
describe('Color ranges validation', () => {
describe('validateColorRanges', () => {
it('should return correct valid state for color ranges', () => {
const colorRanges = [
{
start: 0,
end: 10,
color: '#aaa',
},
{
start: 10,
end: 20,
color: '',
},
{
start: 20,
end: 15,
color: '#aaa',
},
];
const validation = validateColorRanges(colorRanges, { min: 0, max: 100 }, 'number');
expect(validation['0']).toEqual({
errors: [],
warnings: [],
isValid: true,
});
expect(validation['1']).toEqual({
errors: ['invalidColor'],
warnings: [],
isValid: false,
});
expect(validation.last).toEqual({
errors: ['greaterThanMaxValue'],
warnings: [],
isValid: false,
});
});
it('should return correct warnings for color ranges', () => {
const colorRanges = [
{
start: 0,
end: 10,
color: '#aaa',
},
{
start: 10,
end: 20,
color: '#bbb',
},
{
start: 20,
end: 35,
color: '#ccc',
},
];
const validation = validateColorRanges(colorRanges, { min: 5, max: 30 }, 'number');
expect(validation['0']).toEqual({
errors: [],
warnings: ['lowerThanDataBounds'],
isValid: true,
});
expect(validation['1']).toEqual({
errors: [],
warnings: [],
isValid: true,
});
expect(validation.last).toEqual({
errors: [],
warnings: ['greaterThanDataBounds'],
isValid: true,
});
});
it('should not return warnings for color ranges in number mode if we get fallback as data bounds', () => {
const colorRanges = [
{
start: 0,
end: 10,
color: '#aaa',
},
{
start: 10,
end: 20,
color: '#bbb',
},
{
start: 20,
end: 35,
color: '#ccc',
},
];
const validation = validateColorRanges(
colorRanges,
{ min: 5, max: 30, fallback: true },
'number'
);
expect(validation['0']).toEqual({
errors: [],
warnings: [],
isValid: true,
});
expect(validation['1']).toEqual({
errors: [],
warnings: [],
isValid: true,
});
expect(validation.last).toEqual({
errors: [],
warnings: [],
isValid: true,
});
});
});
describe('isAllColorRangesValid', () => {
it('should return true if all color ranges is valid', () => {
const colorRanges = [
{
start: 0,
end: 10,
color: '#aaa',
},
{
start: 10,
end: 20,
color: '#bbb',
},
{
start: 20,
end: 15,
color: '#ccc',
},
];
let isValid = isAllColorRangesValid(colorRanges, { min: 5, max: 40 }, 'number');
expect(isValid).toBeFalsy();
colorRanges[colorRanges.length - 1].end = 30;
isValid = isAllColorRangesValid(colorRanges, { min: 5, max: 40 }, 'number');
expect(isValid).toBeTruthy();
});
});
});

View file

@ -0,0 +1,154 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { getDataMinMax, isValidColor } from '../utils';
import type { ColorRange, ColorRangeAccessor } from './types';
import type { DataBounds } from '../types';
import { CustomPaletteParams } from '../../../../common';
/** @internal **/
type ColorRangeValidationErrors = 'invalidColor' | 'invalidValue' | 'greaterThanMaxValue';
/** @internal **/
type ColorRangeValidationWarnings = 'lowerThanDataBounds' | 'greaterThanDataBounds';
/** @internal **/
export interface ColorRangeValidation {
errors: ColorRangeValidationErrors[];
warnings: ColorRangeValidationWarnings[];
isValid: boolean;
}
/** @internal **/
export const getErrorMessages = (colorRangesValidity: Record<string, ColorRangeValidation>) => {
return [
...new Set(
Object.values(colorRangesValidity)
.map((item) => item.errors)
.flat()
.map((item) => {
switch (item) {
case 'invalidColor':
case 'invalidValue':
return i18n.translate(
'xpack.lens.dynamicColoring.customPalette.invalidValueOrColor',
{
defaultMessage: 'At least one color range contains the wrong value or color',
}
);
case 'greaterThanMaxValue':
return i18n.translate('xpack.lens.dynamicColoring.customPalette.invalidMaxValue', {
defaultMessage: 'Maximum value should be greater than preceding values',
});
default:
return '';
}
})
),
];
};
export const getOutsideDataBoundsWarningMessage = (warnings: ColorRangeValidation['warnings']) => {
for (const warning of warnings) {
switch (warning) {
case 'lowerThanDataBounds':
return i18n.translate('xpack.lens.dynamicColoring.customPalette.lowerThanDataBounds', {
defaultMessage: 'This value is outside the minimum data bound',
});
case 'greaterThanDataBounds':
return i18n.translate('xpack.lens.dynamicColoring.customPalette.greaterThanDataBounds', {
defaultMessage: 'This value is outside the maximum data bound',
});
}
}
};
const checkForComplianceWithDataBounds = (value: number, minMax?: [number, number]) => {
const warnings: ColorRangeValidationWarnings[] = [];
if (minMax) {
const [min, max] = minMax;
if (value < min) {
warnings.push('lowerThanDataBounds');
}
if (value > max) {
warnings.push('greaterThanDataBounds');
}
}
return warnings;
};
/** @internal **/
export const validateColorRange = (
colorRange: ColorRange,
accessor: ColorRangeAccessor,
minMax?: [number, number]
) => {
const errors: ColorRangeValidationErrors[] = [];
let warnings: ColorRangeValidationWarnings[] = [];
if (Number.isNaN(colorRange[accessor])) {
errors.push('invalidValue');
}
if (accessor === 'end') {
if (colorRange.start > colorRange.end) {
errors.push('greaterThanMaxValue');
}
warnings = [...warnings, ...checkForComplianceWithDataBounds(colorRange.end, minMax)];
} else {
if (!isValidColor(colorRange.color)) {
errors.push('invalidColor');
}
warnings = [...warnings, ...checkForComplianceWithDataBounds(colorRange.start, minMax)];
}
return {
isValid: !errors.length,
errors,
warnings,
} as ColorRangeValidation;
};
export const validateColorRanges = (
colorRanges: ColorRange[],
dataBounds: DataBounds,
rangeType: CustomPaletteParams['rangeType']
): Record<string, ColorRangeValidation> => {
let minMax: [number, number] | undefined;
if ((dataBounds.fallback && rangeType === 'percent') || !dataBounds.fallback) {
const { min, max } = getDataMinMax(rangeType, dataBounds);
minMax = [min, max];
}
const validations = colorRanges.reduce<Record<string, ColorRangeValidation>>(
(acc, item, index) => ({
...acc,
[index]: validateColorRange(item, 'start', minMax),
}),
{}
);
return {
...validations,
last: validateColorRange(colorRanges[colorRanges.length - 1], 'end', minMax),
};
};
export const isAllColorRangesValid = (
colorRanges: ColorRange[],
dataBounds: DataBounds,
rangeType: CustomPaletteParams['rangeType']
) => {
return Object.values(validateColorRanges(colorRanges, dataBounds, rangeType)).every(
(colorRange) => colorRange.isValid
);
};

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { ColorRanges } from './color_ranges';
export { ColorRangesContext } from './color_ranges_context';
export type { ColorRange, ColorRangesActions } from './types';

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PaletteRegistry } from 'src/plugins/charts/public';
import type { CustomPaletteParams } from '../../../../common';
import type { PaletteContinuity } from '../../../../../../../src/plugins/charts/common';
import type { DataBounds } from '../types';
export interface ColorRange {
color: string;
start: number;
end: number;
}
/** @internal **/
export interface ColorRangesState {
colorRanges: ColorRange[];
rangeType: CustomPaletteParams['rangeType'];
continuity: PaletteContinuity;
}
/** @internal **/
interface BasicPayload {
dataBounds: DataBounds;
palettes?: PaletteRegistry;
}
/** @internal **/
export interface UpdateColorPayload extends BasicPayload {
index: number;
color: string;
}
/** @internal **/
export interface UpdateColorRangeValuePayload extends BasicPayload {
index: number;
value: string;
accessor: ColorRangeAccessor;
}
/** @internal **/
export interface DeleteColorRangePayload extends BasicPayload {
index: number;
}
/** @internal **/
export interface UpdateContinuityPayload extends BasicPayload {
isLast: boolean;
continuity: PaletteContinuity;
}
/** @internal **/
export type ColorRangesActions =
| { type: 'reversePalette'; payload: BasicPayload }
| { type: 'sortColorRanges'; payload: BasicPayload }
| { type: 'distributeEqually'; payload: BasicPayload }
| { type: 'updateContinuity'; payload: UpdateContinuityPayload }
| { type: 'deleteColorRange'; payload: DeleteColorRangePayload }
| {
type: 'addColorRange';
payload: BasicPayload;
}
| { type: 'updateColor'; payload: UpdateColorPayload }
| {
type: 'updateValue';
payload: UpdateColorRangeValuePayload;
};
/** @internal **/
export type ColorRangeAccessor = 'start' | 'end';

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
addColorRange,
deleteColorRange,
updateColorRangeValue,
updateColorRangeColor,
} from './color_ranges_crud';
import type { ColorRange } from '../types';
describe('addColorRange', () => {
let colorRanges: ColorRange[];
beforeEach(() => {
colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 81 },
];
});
it('should add new color range with the corresponding interval', () => {
expect(addColorRange(colorRanges, 'number', { min: 0, max: 81 })).toEqual([
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
{ color: '#ccc', start: 80, end: 81 },
]);
});
it('should add new color range with the interval equal 1 if new range out of max bound', () => {
colorRanges[colorRanges.length - 1].end = 80;
expect(addColorRange(colorRanges, 'number', { min: 0, max: 80 })).toEqual([
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 61 },
{ color: '#ccc', start: 61, end: 80 },
]);
});
});
describe('deleteColorRange', () => {
let colorRanges: ColorRange[];
beforeEach(() => {
colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
});
it('delete the last range', () => {
expect(deleteColorRange(colorRanges.length - 1, colorRanges)).toEqual([
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 80 },
]);
});
it('delete the another range', () => {
expect(deleteColorRange(1, colorRanges)).toEqual([
{ color: '#aaa', start: 20, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
]);
});
});
describe('updateColorRangeValue', () => {
let colorRanges: ColorRange[];
beforeEach(() => {
colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
});
it('update the last end color range value', () => {
expect(updateColorRangeValue(colorRanges.length - 1, '90', 'end', colorRanges)).toEqual([
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 90 },
]);
});
it('update the first start color range value', () => {
expect(updateColorRangeValue(0, '10', 'start', colorRanges)).toEqual([
{ color: '#aaa', start: 10, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
]);
});
it('update the color range value between the first and last color ranges', () => {
expect(updateColorRangeValue(1, '50', 'start', colorRanges)).toEqual([
{ color: '#aaa', start: 20, end: 50 },
{ color: '#bbb', start: 50, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
]);
});
});
describe('updateColorRangeColor', () => {
let colorRanges: ColorRange[];
beforeEach(() => {
colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
});
it('update color for color range', () => {
expect(updateColorRangeColor(0, '#ddd', colorRanges)).toEqual([
{ color: '#ddd', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
]);
});
});

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getDataMinMax, roundValue } from '../../utils';
import { calculateMaxStep } from './utils';
import type { ColorRange, ColorRangeAccessor } from '../types';
import type { DataBounds } from '../../types';
import type { CustomPaletteParamsConfig } from '../../../../../common';
/**
* Allows to update a ColorRange
* @private
*/
const updateColorRangeItem = (
colorRanges: ColorRange[],
index: number,
payload: Partial<ColorRange>
): ColorRange[] => {
const ranges = [...colorRanges];
ranges[index] = { ...ranges[index], ...payload };
return ranges;
};
/**
* Add new color range after the last item
* @internal
*/
export const addColorRange = (
colorRanges: ColorRange[],
rangeType: CustomPaletteParamsConfig['rangeType'],
dataBounds: DataBounds
) => {
let newColorRanges = [...colorRanges];
const lastIndex = newColorRanges.length - 1;
const lastStart = newColorRanges[lastIndex].start;
const lastEnd = newColorRanges[lastIndex].end;
const lastColor = newColorRanges[lastIndex].color;
const { max: dataMax } = getDataMinMax(rangeType, dataBounds);
const max = Math.max(dataMax, lastEnd);
const step = calculateMaxStep(
newColorRanges.map((item) => item.start),
max
);
let insertEnd = roundValue(Math.min(lastStart + step, max));
if (insertEnd === Number.NEGATIVE_INFINITY) {
insertEnd = 1;
}
newColorRanges = updateColorRangeItem(newColorRanges, lastIndex, { end: insertEnd });
newColorRanges.push({
color: lastColor,
start: insertEnd,
end: lastEnd === insertEnd ? lastEnd + 1 : lastEnd,
});
return newColorRanges;
};
/**
* Delete ColorRange
* @internal
*/
export const deleteColorRange = (index: number, colorRanges: ColorRange[]) => {
const lastIndex = colorRanges.length - 1;
let ranges = colorRanges;
if (index !== 0) {
if (index !== lastIndex) {
ranges = updateColorRangeItem(ranges, index - 1, { end: ranges[index + 1].start });
}
if (index === lastIndex) {
ranges = updateColorRangeItem(ranges, index - 1, { end: colorRanges[index].end });
}
}
return ranges.filter((item, i) => i !== index);
};
/**
* Update ColorRange value
* @internal
*/
export const updateColorRangeValue = (
index: number,
value: string,
accessor: ColorRangeAccessor,
colorRanges: ColorRange[]
) => {
const parsedValue = value ? parseFloat(value) : Number.NaN;
let ranges = colorRanges;
if (accessor === 'end') {
ranges = updateColorRangeItem(ranges, index, { end: parsedValue });
} else {
ranges = updateColorRangeItem(ranges, index, { start: parsedValue });
if (index > 0) {
ranges = updateColorRangeItem(ranges, index - 1, { end: parsedValue });
}
}
return ranges;
};
/**
* Update ColorRange color
* @internal
*/
export const updateColorRangeColor = (index: number, color: string, colorRanges: ColorRange[]) =>
updateColorRangeItem(colorRanges, index, { color });

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { distributeEqually, reversePalette } from './color_ranges_extra_actions';
import type { ColorRange } from '../types';
describe('distributeEqually', () => {
let colorRanges: ColorRange[];
beforeEach(() => {
colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
{ color: '#ddd', start: 80, end: 100 },
];
});
it('should equally distribute the color ranges', () => {
expect(distributeEqually(colorRanges, 'number', 'none', { min: 0, max: 4000 })).toEqual([
{ color: '#aaa', start: 0, end: 1000 },
{ color: '#bbb', start: 1000, end: 2000 },
{ color: '#ccc', start: 2000, end: 3000 },
{ color: '#ddd', start: 3000, end: 4000 },
]);
});
it('should work correctly with continuity to both sides', () => {
expect(distributeEqually(colorRanges, 'percent', 'all', { min: 0, max: 5000 })).toEqual([
{ color: '#aaa', start: Number.NEGATIVE_INFINITY, end: 25 },
{ color: '#bbb', start: 25, end: 50 },
{ color: '#ccc', start: 50, end: 75 },
{ color: '#ddd', start: 75, end: Number.POSITIVE_INFINITY },
]);
});
});
describe('reversePalette', () => {
let colorRanges: ColorRange[];
beforeEach(() => {
colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 81 },
];
});
it('should return reversed color palette of given color range', () => {
expect(reversePalette(colorRanges)).toEqual([
{ color: '#ccc', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#aaa', start: 60, end: 81 },
]);
});
});

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getDataMinMax, roundValue } from '../../utils';
import type { ColorRange } from '../types';
import type { DataBounds } from '../../types';
import type { CustomPaletteParamsConfig } from '../../../../../common';
import {
PaletteContinuity,
checkIsMinContinuity,
checkIsMaxContinuity,
} from '../../../../../../../../src/plugins/charts/common';
/**
* Distribute equally
* @internal
*/
export const distributeEqually = (
colorRanges: ColorRange[],
rangeType: CustomPaletteParamsConfig['rangeType'],
continuity: PaletteContinuity,
dataBounds: DataBounds
) => {
const items = colorRanges.length;
const lastIndex = colorRanges.length - 1;
const { min, max } = getDataMinMax(rangeType, dataBounds);
const step = roundValue((max - min) / items);
const getValueForIndex = (index: number) => roundValue(min + step * index);
const getStartValue = (index: number) => {
if (index === 0) {
return checkIsMinContinuity(continuity) ? Number.NEGATIVE_INFINITY : roundValue(min);
}
return getValueForIndex(index);
};
const getEndValue = (index: number) => {
if (index === lastIndex) {
return checkIsMaxContinuity(continuity) ? Number.POSITIVE_INFINITY : roundValue(max);
}
return getValueForIndex(index + 1);
};
return colorRanges.map((colorRange, index) => ({
color: colorRange.color,
start: getStartValue(index),
end: getEndValue(index),
}));
};
/**
* Reverse Palette
* @internal
*/
export const reversePalette = (colorRanges: ColorRange[]) =>
colorRanges
.map(({ color }, i) => ({
color,
start: colorRanges[colorRanges.length - i - 1].start,
end: colorRanges[colorRanges.length - i - 1].end,
}))
.reverse();

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './utils';
export * from './color_ranges_crud';
export * from './color_ranges_extra_actions';

View file

@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { sortColorRanges, calculateMaxStep, toColorStops, getValueForContinuity } from './utils';
describe('utils', () => {
it('sortColorRanges', () => {
const colorRanges = [
{ color: '#aaa', start: 55, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
expect(sortColorRanges(colorRanges)).toEqual([
{ color: '#bbb', start: 40, end: 55 },
{ color: '#aaa', start: 55, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
]);
});
it('calculateMaxStep', () => {
const stops = [20, 40, 60];
expect(calculateMaxStep(stops, 90)).toEqual(20);
// should return 1 if the last stop with calculated interval more than max
expect(calculateMaxStep(stops, 75)).toEqual(1);
// should return 1 if we don't provide stops
expect(calculateMaxStep([], 75)).toEqual(1);
});
it('toColorStops', () => {
const colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
const colorStops = [
{
color: '#aaa',
stop: 20,
},
{
color: '#bbb',
stop: 40,
},
{
color: '#ccc',
stop: 60,
},
];
// if continuity is none then min should be the first range value
// and max should be the last range value
expect(toColorStops(colorRanges, 'none')).toEqual({
min: 20,
max: 80,
colorStops,
});
colorStops[0].stop = Number.NEGATIVE_INFINITY;
// if continuity is below then min should be -Infinity
expect(toColorStops(colorRanges, 'below')).toEqual({
min: Number.NEGATIVE_INFINITY,
max: 80,
colorStops,
});
colorStops[0].stop = 20;
// if continuity is above then max should be Infinity
expect(toColorStops(colorRanges, 'above')).toEqual({
min: 20,
max: Number.POSITIVE_INFINITY,
colorStops,
});
colorStops[0].stop = Number.NEGATIVE_INFINITY;
// if continuity is all then max should be Infinity and min should be -Infinity
expect(toColorStops(colorRanges, 'all')).toEqual({
min: Number.NEGATIVE_INFINITY,
max: Number.POSITIVE_INFINITY,
colorStops,
});
});
describe('getValueForContinuity', () => {
it('should return Infinity if continuity is all or above and that last range', () => {
const colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
expect(
getValueForContinuity(colorRanges, 'above', true, 'number', { min: 0, max: 100 })
).toEqual(Number.POSITIVE_INFINITY);
expect(
getValueForContinuity(colorRanges, 'all', true, 'number', { min: 0, max: 100 })
).toEqual(Number.POSITIVE_INFINITY);
});
it('should return -Infinity if continuity is all or below and that first range', () => {
const colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
expect(
getValueForContinuity(colorRanges, 'below', false, 'number', { min: 0, max: 100 })
).toEqual(Number.NEGATIVE_INFINITY);
expect(
getValueForContinuity(colorRanges, 'all', false, 'number', { min: 0, max: 100 })
).toEqual(Number.NEGATIVE_INFINITY);
});
it('should return new max if continuity is none or below and that last range', () => {
const colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
expect(
getValueForContinuity(colorRanges, 'below', true, 'number', { min: 0, max: 100 })
).toEqual(100);
expect(
getValueForContinuity(colorRanges, 'none', true, 'number', { min: 0, max: 55 })
).toEqual(61);
});
it('should return new min if continuity is none or above and that first range', () => {
const colorRanges = [
{ color: '#aaa', start: 20, end: 40 },
{ color: '#bbb', start: 40, end: 60 },
{ color: '#ccc', start: 60, end: 80 },
];
expect(
getValueForContinuity(colorRanges, 'above', false, 'number', { min: 0, max: 100 })
).toEqual(0);
expect(
getValueForContinuity(colorRanges, 'none', false, 'number', { min: 45, max: 100 })
).toEqual(39);
});
});
});

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { roundValue, getDataMinMax } from '../../utils';
import {
PaletteContinuity,
checkIsMaxContinuity,
checkIsMinContinuity,
} from '../../../../../../../../src/plugins/charts/common';
import type { CustomPaletteParams } from '../../../../../common';
import type { ColorRange, ColorRangeAccessor } from '../types';
import type { DataBounds } from '../../types';
/**
* Check if item is last
* @internal
*/
export const isLastItem = (accessor: ColorRangeAccessor) => accessor === 'end';
/**
* Sort Color ranges array
* @internal
*/
export const sortColorRanges = (colorRanges: ColorRange[]) => {
const maxValue = colorRanges[colorRanges.length - 1].end;
return [...colorRanges]
.sort(({ start: startA }, { start: startB }) => Number(startA) - Number(startB))
.map((newColorRange, i, array) => ({
color: newColorRange.color,
start: newColorRange.start,
end: i !== array.length - 1 ? array[i + 1].start : maxValue,
}));
};
/**
* Calculate max step
* @internal
*/
export const calculateMaxStep = (stops: number[], max: number) => {
let step = 1;
if (stops.length > 1) {
const last = stops[stops.length - 1];
const last2step = stops[stops.length - 1] - stops[stops.length - 2];
if (last + last2step < max) {
step = last2step;
}
}
return roundValue(step);
};
/**
* Convert ColorRange to ColorStops
* @internal
*/
export const toColorStops = (colorRanges: ColorRange[], continuity: PaletteContinuity) => {
const min = checkIsMinContinuity(continuity) ? Number.NEGATIVE_INFINITY : colorRanges[0].start;
const max = checkIsMaxContinuity(continuity)
? Number.POSITIVE_INFINITY
: colorRanges[colorRanges.length - 1].end;
return {
min,
max,
colorStops: colorRanges.map((colorRange, i) => ({
color: colorRange.color,
stop: i === 0 ? min : colorRange.start,
})),
};
};
/**
* Calculate right max or min value for new continuity
*/
export const getValueForContinuity = (
colorRanges: ColorRange[],
continuity: PaletteContinuity,
isLast: boolean,
rangeType: CustomPaletteParams['rangeType'],
dataBounds: DataBounds
) => {
const { max, min } = getDataMinMax(rangeType, dataBounds);
let value;
if (isLast) {
if (checkIsMaxContinuity(continuity)) {
value = Number.POSITIVE_INFINITY;
} else {
value =
colorRanges[colorRanges.length - 1].start > max
? colorRanges[colorRanges.length - 1].start + 1
: max;
}
} else {
if (checkIsMinContinuity(continuity)) {
value = Number.NEGATIVE_INFINITY;
} else {
value = colorRanges[0].end < min ? colorRanges[0].end - 1 : min;
}
}
return value;
};

View file

@ -1,191 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiColorPicker } from '@elastic/eui';
import { mount } from 'enzyme';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { CustomStops, CustomStopsProps } from './color_stops';
// mocking random id generator function
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
...original,
htmlIdGenerator: (fn: unknown) => {
let counter = 0;
return () => counter++;
},
};
});
describe('Color Stops component', () => {
let props: CustomStopsProps;
beforeEach(() => {
props = {
colorStops: [
{ color: '#aaa', stop: 20 },
{ color: '#bbb', stop: 40 },
{ color: '#ccc', stop: 60 },
],
paletteConfiguration: {},
dataBounds: { min: 0, max: 200 },
onChange: jest.fn(),
'data-test-prefix': 'my-test',
};
});
it('should display all the color stops passed', () => {
const component = mount(<CustomStops {...props} />);
expect(
component.find('input[data-test-subj^="my-test_dynamicColoring_stop_value_"]')
).toHaveLength(3);
});
it('should disable the delete buttons when there are 2 stops or less', () => {
// reduce to 2 stops
props.colorStops = props.colorStops.slice(0, 2);
const component = mount(<CustomStops {...props} />);
expect(
component
.find('[data-test-subj="my-test_dynamicColoring_removeStop_0"]')
.first()
.prop('isDisabled')
).toBe(true);
});
it('should disable "add new" button if there is maxStops configured', () => {
props.colorStops = [
{ color: '#aaa', stop: 20 },
{ color: '#bbb', stop: 40 },
{ color: '#ccc', stop: 60 },
{ color: '#ccc', stop: 80 },
{ color: '#ccc', stop: 90 },
];
const component = mount(<CustomStops {...props} />);
const componentWithMaxSteps = mount(
<CustomStops {...props} paletteConfiguration={{ maxSteps: 5 }} />
);
expect(
component
.find('[data-test-subj="my-test_dynamicColoring_addStop"]')
.first()
.prop('isDisabled')
).toBe(false);
expect(
componentWithMaxSteps
.find('[data-test-subj="my-test_dynamicColoring_addStop"]')
.first()
.prop('isDisabled')
).toBe(true);
});
it('should add a new stop with default color and reasonable distance from last one', () => {
let component = mount(<CustomStops {...props} />);
const addStopButton = component
.find('[data-test-subj="my-test_dynamicColoring_addStop"]')
.first();
act(() => {
addStopButton.simulate('click');
});
component = component.update();
expect(
component.find('input[data-test-subj^="my-test_dynamicColoring_stop_value_"]')
).toHaveLength(4);
expect(
component.find('input[data-test-subj="my-test_dynamicColoring_stop_value_3"]').prop('value')
).toBe('80'); // 60-40 + 60
expect(
component
// workaround for https://github.com/elastic/eui/issues/4792
.find('[data-test-subj="my-test_dynamicColoring_stop_color_3"]')
.last() // pick the inner element
.childAt(0)
.prop('color')
).toBe('#ccc'); // pick previous color
});
it('should restore previous color when abandoning the field with an empty color', () => {
let component = mount(<CustomStops {...props} />);
expect(
component
.find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]')
.first()
.find(EuiColorPicker)
.first()
.prop('color')
).toBe('#aaa');
act(() => {
component
.find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]')
.first()
.find(EuiColorPicker)
.first()
.prop('onChange')!('', {
rgba: [NaN, NaN, NaN, NaN],
hex: '',
isValid: false,
});
});
component = component.update();
expect(
component
.find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]')
.first()
.find(EuiColorPicker)
.first()
.prop('color')
).toBe('');
act(() => {
component
.find('[data-test-subj="my-test_dynamicColoring_stop_color_0"]')
.first()
.simulate('blur');
});
component = component.update();
expect(
component
.find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]')
.first()
.find(EuiColorPicker)
.first()
.prop('color')
).toBe('#aaa');
});
it('should sort stops value on whole component blur', () => {
let component = mount(<CustomStops {...props} />);
let firstStopValueInput = component.find(
'[data-test-subj="my-test_dynamicColoring_stop_value_0"] input[type="number"]'
);
act(() => {
firstStopValueInput.simulate('change', { target: { value: ' 90' } });
});
act(() => {
component
.find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]')
.first()
.simulate('blur');
});
component = component.update();
// retrieve again the input
firstStopValueInput = component.find(
'[data-test-subj="my-test_dynamicColoring_stop_value_0"] input[type="number"]'
);
expect(firstStopValueInput.prop('value')).toBe('40');
// the previous one move at the bottom
expect(
component
.find('[data-test-subj="my-test_dynamicColoring_stop_value_2"] input[type="number"]')
.prop('value')
).toBe('90');
});
});

View file

@ -1,310 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useCallback, useMemo } from 'react';
import type { FocusEvent } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFieldNumber,
EuiColorPicker,
EuiButtonIcon,
EuiFlexItem,
EuiFlexGroup,
EuiButtonEmpty,
EuiSpacer,
EuiScreenReaderOnly,
htmlIdGenerator,
} from '@elastic/eui';
import useUnmount from 'react-use/lib/useUnmount';
import { DEFAULT_COLOR } from './constants';
import { getDataMinMax, getStepValue, isValidColor } from './utils';
import { TooltipWrapper, useDebouncedValue } from '../index';
import type { ColorStop, CustomPaletteParamsConfig } from '../../../common';
const idGeneratorFn = htmlIdGenerator();
function areStopsValid(colorStops: Array<{ color: string; stop: string }>) {
return colorStops.every(
({ color, stop }) => isValidColor(color) && !Number.isNaN(parseFloat(stop))
);
}
function shouldSortStops(colorStops: Array<{ color: string; stop: string | number }>) {
return colorStops.some(({ stop }, i) => {
const numberStop = Number(stop);
const prevNumberStop = Number(colorStops[i - 1]?.stop ?? -Infinity);
return numberStop < prevNumberStop;
});
}
export interface CustomStopsProps {
colorStops: ColorStop[];
onChange: (colorStops: ColorStop[]) => void;
dataBounds: { min: number; max: number };
paletteConfiguration: CustomPaletteParamsConfig | undefined;
'data-test-prefix': string;
}
export const CustomStops = ({
colorStops,
onChange,
paletteConfiguration,
dataBounds,
['data-test-prefix']: dataTestPrefix,
}: CustomStopsProps) => {
const onChangeWithValidation = useCallback(
(newColorStops: Array<{ color: string; stop: string }>) => {
const areStopsValuesValid = areStopsValid(newColorStops);
const shouldSort = shouldSortStops(newColorStops);
if (areStopsValuesValid && !shouldSort) {
onChange(newColorStops.map(({ color, stop }) => ({ color, stop: Number(stop) })));
}
},
[onChange]
);
const memoizedValues = useMemo(() => {
return colorStops.map(({ color, stop }, i) => ({
color,
stop: String(stop),
id: idGeneratorFn(),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paletteConfiguration?.name, paletteConfiguration?.reverse, paletteConfiguration?.rangeType]);
const { inputValue: localColorStops, handleInputChange: setLocalColorStops } = useDebouncedValue({
onChange: onChangeWithValidation,
value: memoizedValues,
});
const [sortedReason, setSortReason] = useState<string>('');
const shouldEnableDelete = localColorStops.length > 2;
const shouldDisableAdd = Boolean(
paletteConfiguration?.maxSteps && localColorStops.length >= paletteConfiguration?.maxSteps
);
const [popoverInFocus, setPopoverInFocus] = useState<boolean>(false);
// refresh on unmount:
// the onChange logic here is a bit different than the one above as it has to actively sort if required
useUnmount(() => {
const areStopsValuesValid = areStopsValid(localColorStops);
const shouldSort = shouldSortStops(localColorStops);
if (areStopsValuesValid && shouldSort) {
onChange(
localColorStops
.map(({ color, stop }) => ({ color, stop: Number(stop) }))
.sort(({ stop: stopA }, { stop: stopB }) => Number(stopA) - Number(stopB))
);
}
});
const rangeType = paletteConfiguration?.rangeType || 'percent';
return (
<>
{sortedReason ? (
<EuiScreenReaderOnly>
<p aria-live="assertive">
{i18n.translate('xpack.lens.dynamicColoring.customPalette.sortReason', {
defaultMessage: 'Color stops have been sorted due to new stop value {value}',
values: {
value: sortedReason,
},
})}
</p>
</EuiScreenReaderOnly>
) : null}
<EuiFlexGroup
data-test-subj={`${dataTestPrefix}_dynamicColoring_custom_stops`}
direction="column"
gutterSize="s"
>
{localColorStops.map(({ color, stop, id }, index) => {
const prevStopValue = Number(localColorStops[index - 1]?.stop ?? -Infinity);
const nextStopValue = Number(localColorStops[index + 1]?.stop ?? Infinity);
return (
<EuiFlexItem
key={id}
data-test-subj={`${dataTestPrefix}_dynamicColoring_stop_row_${index}`}
onBlur={(e: FocusEvent<HTMLDivElement>) => {
// sort the stops when the focus leaves the row container
const shouldSort = Number(stop) > nextStopValue || prevStopValue > Number(stop);
const isFocusStillInContent =
(e.currentTarget as Node)?.contains(e.relatedTarget as Node) || popoverInFocus;
const hasInvalidColor = !isValidColor(color);
if ((shouldSort && !isFocusStillInContent) || hasInvalidColor) {
// replace invalid color with previous valid one
const lastValidColor = hasInvalidColor ? colorStops[index].color : color;
const localColorStopsCopy = localColorStops.map((item, i) =>
i === index ? { color: lastValidColor, stop, id } : item
);
setLocalColorStops(
localColorStopsCopy.sort(
({ stop: stopA }, { stop: stopB }) => Number(stopA) - Number(stopB)
)
);
setSortReason(stop);
}
}}
>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem>
<EuiFieldNumber
compressed
data-test-subj={`${dataTestPrefix}_dynamicColoring_stop_value_${index}`}
value={stop}
min={-Infinity}
onChange={({ target }) => {
const newStopString = target.value.trim();
const newColorStops = [...localColorStops];
newColorStops[index] = {
color,
stop: newStopString,
id,
};
setLocalColorStops(newColorStops);
}}
append={rangeType === 'percent' ? '%' : undefined}
aria-label={i18n.translate(
'xpack.lens.dynamicColoring.customPalette.stopAriaLabel',
{
defaultMessage: 'Stop {index}',
values: {
index: index + 1,
},
}
)}
/>
</EuiFlexItem>
<EuiFlexItem
data-test-subj={`${dataTestPrefix}_dynamicColoring_stop_color_${index}`}
onBlur={() => {
// make sure that the popover is closed
if (color === '' && !popoverInFocus) {
const newColorStops = [...localColorStops];
newColorStops[index] = { color: colorStops[index].color, stop, id };
setLocalColorStops(newColorStops);
}
}}
>
<EuiColorPicker
key={stop}
onChange={(newColor) => {
const newColorStops = [...localColorStops];
newColorStops[index] = { color: newColor, stop, id };
setLocalColorStops(newColorStops);
}}
secondaryInputDisplay="top"
color={color}
isInvalid={!isValidColor(color)}
showAlpha
compressed
onFocus={() => setPopoverInFocus(true)}
onBlur={() => {
setPopoverInFocus(false);
if (color === '') {
const newColorStops = [...localColorStops];
newColorStops[index] = { color: colorStops[index].color, stop, id };
setLocalColorStops(newColorStops);
}
}}
placeholder=" "
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TooltipWrapper
tooltipContent={i18n.translate(
'xpack.lens.dynamicColoring.customPalette.deleteButtonDisabled',
{
defaultMessage:
'This color stop cannot be deleted, as two or more stops are required',
}
)}
condition={!shouldEnableDelete}
>
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label={i18n.translate(
'xpack.lens.dynamicColoring.customPalette.deleteButtonAriaLabel',
{
defaultMessage: 'Delete',
}
)}
title={i18n.translate(
'xpack.lens.dynamicColoring.customPalette.deleteButtonLabel',
{
defaultMessage: 'Delete',
}
)}
onClick={() => {
const newColorStops = localColorStops.filter((_, i) => i !== index);
setLocalColorStops(newColorStops);
}}
data-test-subj={`${dataTestPrefix}_dynamicColoring_removeStop_${index}`}
isDisabled={!shouldEnableDelete}
/>
</TooltipWrapper>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
<EuiSpacer size="s" />
<TooltipWrapper
tooltipContent={i18n.translate(
'xpack.lens.dynamicColoring.customPalette.maximumStepsApplied',
{
defaultMessage: `You've applied the maximum number of steps`,
}
)}
condition={shouldDisableAdd}
position="top"
delay="regular"
>
<EuiButtonEmpty
data-test-subj={`${dataTestPrefix}_dynamicColoring_addStop`}
iconType="plusInCircle"
color="primary"
aria-label={i18n.translate('xpack.lens.dynamicColoring.customPalette.addColorStop', {
defaultMessage: 'Add color stop',
})}
size="xs"
isDisabled={shouldDisableAdd}
flush="left"
onClick={() => {
const newColorStops = [...localColorStops];
const length = newColorStops.length;
const { max } = getDataMinMax(rangeType, dataBounds);
const step = getStepValue(
colorStops,
newColorStops.map(({ color, stop }) => ({ color, stop: Number(stop) })),
max
);
const prevColor = localColorStops[length - 1].color || DEFAULT_COLOR;
const newStop = step + Number(localColorStops[length - 1].stop);
newColorStops.push({
color: prevColor,
stop: String(newStop),
id: idGeneratorFn(),
});
setLocalColorStops(newColorStops);
}}
>
{i18n.translate('xpack.lens.dynamicColoring.customPalette.addColorStop', {
defaultMessage: 'Add color stop',
})}
</EuiButtonEmpty>
</TooltipWrapper>
</>
);
};

View file

@ -11,15 +11,17 @@ export const DEFAULT_PALETTE_NAME = 'positive';
export const FIXED_PROGRESSION = 'fixed' as const;
export const CUSTOM_PALETTE = 'custom';
export const DEFAULT_CONTINUITY = 'above';
export const DEFAULT_RANGE_TYPE = 'percent';
export const DEFAULT_MIN_STOP = 0;
export const DEFAULT_MAX_STOP = 100;
export const DEFAULT_COLOR_STEPS = 5;
export const DEFAULT_COLOR = '#6092C0'; // Same as EUI ColorStops default for new stops
export const defaultPaletteParams: RequiredPaletteParamTypes = {
maxSteps: undefined,
name: DEFAULT_PALETTE_NAME,
reverse: false,
rangeType: 'percent',
rangeType: DEFAULT_RANGE_TYPE,
rangeMin: DEFAULT_MIN_STOP,
rangeMax: DEFAULT_MAX_STOP,
progression: FIXED_PROGRESSION,

View file

@ -7,6 +7,7 @@
export { CustomizablePalette } from './palette_configuration';
export { PalettePanelContainer } from './palette_panel_container';
export { CustomStops } from './color_stops';
export { ColorRanges } from './color_ranges';
export * from './utils';
export * from './constants';

View file

@ -12,10 +12,9 @@ import { chartPluginMock } from 'src/plugins/charts/public/mocks';
import type { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public';
import { ReactWrapper } from 'enzyme';
import type { CustomPaletteParams } from '../../../common';
import { applyPaletteParams } from './utils';
import { CustomizablePalette } from './palette_configuration';
import { CUSTOM_PALETTE } from './constants';
import { act } from 'react-dom/test-utils';
import type { DataBounds } from './types';
// mocking random id generator function
jest.mock('@elastic/eui', () => {
@ -30,39 +29,14 @@ jest.mock('@elastic/eui', () => {
};
});
describe('palette utilities', () => {
const paletteRegistry = chartPluginMock.createPaletteRegistry();
describe('applyPaletteParams', () => {
it('should return a set of colors for a basic configuration', () => {
expect(
applyPaletteParams(
paletteRegistry,
{ type: 'palette', name: 'positive' },
{ min: 0, max: 100 }
)
).toEqual([
{ color: 'blue', stop: 20 },
{ color: 'yellow', stop: 70 },
]);
});
// mocking isAllColorRangesValid function
jest.mock('./color_ranges/color_ranges_validation', () => {
const original = jest.requireActual('./color_ranges/color_ranges_validation');
it('should reverse the palette color stops correctly', () => {
expect(
applyPaletteParams(
paletteRegistry,
{
type: 'palette',
name: 'positive',
params: { reverse: true },
},
{ min: 0, max: 100 }
)
).toEqual([
{ color: 'yellow', stop: 20 },
{ color: 'blue', stop: 70 },
]);
});
});
return {
...original,
isAllColorRangesValid: () => true,
};
});
describe('palette panel', () => {
@ -71,7 +45,7 @@ describe('palette panel', () => {
palettes: PaletteRegistry;
activePalette: PaletteOutput<CustomPaletteParams>;
setPalette: (palette: PaletteOutput<CustomPaletteParams>) => void;
dataBounds: { min: number; max: number };
dataBounds: DataBounds;
};
describe('palette picker', () => {
@ -82,6 +56,8 @@ describe('palette panel', () => {
setPalette: jest.fn(),
dataBounds: { min: 0, max: 100 },
};
jest.useFakeTimers();
});
function changePaletteIn(instance: ReactWrapper, newPaletteName: string) {
@ -113,7 +89,11 @@ describe('palette panel', () => {
it('should set the colorStops and stops when selecting the Custom palette from the list', () => {
const instance = mountWithIntl(<CustomizablePalette {...props} />);
changePaletteIn(instance, 'custom');
act(() => {
changePaletteIn(instance, 'custom');
});
jest.advanceTimersByTime(250);
expect(props.setPalette).toHaveBeenCalledWith({
type: 'palette',
@ -135,7 +115,11 @@ describe('palette panel', () => {
it('should restore the reverse initial state on transitioning', () => {
const instance = mountWithIntl(<CustomizablePalette {...props} />);
changePaletteIn(instance, 'negative');
act(() => {
changePaletteIn(instance, 'negative');
});
jest.advanceTimersByTime(250);
expect(props.setPalette).toHaveBeenCalledWith({
type: 'palette',
@ -150,69 +134,27 @@ describe('palette panel', () => {
it('should rewrite the min/max range values on palette change', () => {
const instance = mountWithIntl(<CustomizablePalette {...props} />);
changePaletteIn(instance, 'custom');
act(() => {
changePaletteIn(instance, 'custom');
});
jest.advanceTimersByTime(250);
expect(props.setPalette).toHaveBeenCalledWith({
type: 'palette',
name: 'custom',
params: expect.objectContaining({
rangeMin: 0,
rangeMax: 50,
rangeMax: Number.POSITIVE_INFINITY,
}),
});
});
});
describe('reverse option', () => {
beforeEach(() => {
props = {
activePalette: { type: 'palette', name: 'positive' },
palettes: paletteRegistry,
setPalette: jest.fn(),
dataBounds: { min: 0, max: 100 },
};
});
function toggleReverse(instance: ReactWrapper, checked: boolean) {
return instance
.find('[data-test-subj="lnsPalettePanel_dynamicColoring_reverse"]')
.first()
.prop('onClick')!({} as React.MouseEvent);
}
it('should reverse the colorStops on click', () => {
const instance = mountWithIntl(<CustomizablePalette {...props} />);
toggleReverse(instance, true);
expect(props.setPalette).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
reverse: true,
}),
})
);
});
it('should transition a predefined palette to a custom one on reverse click', () => {
const instance = mountWithIntl(<CustomizablePalette {...props} />);
toggleReverse(instance, true);
expect(props.setPalette).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
name: CUSTOM_PALETTE,
}),
})
);
});
});
describe('percentage / number modes', () => {
beforeEach(() => {
props = {
activePalette: { type: 'palette', name: 'positive' },
activePalette: { type: 'palette', name: 'custom' },
palettes: paletteRegistry,
setPalette: jest.fn(),
dataBounds: { min: 5, max: 200 },
@ -228,6 +170,8 @@ describe('palette panel', () => {
.prop('onChange')!('number');
});
jest.advanceTimersByTime(250);
act(() => {
instance
.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_range_groups"]')
@ -235,13 +179,15 @@ describe('palette panel', () => {
.prop('onChange')!('percent');
});
jest.advanceTimersByTime(250);
expect(props.setPalette).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
params: expect.objectContaining({
rangeType: 'number',
rangeMin: 5,
rangeMax: 102.5 /* (200 - (200-5)/ colors.length: 2) */,
rangeMax: Number.POSITIVE_INFINITY,
}),
})
);
@ -252,7 +198,7 @@ describe('palette panel', () => {
params: expect.objectContaining({
rangeType: 'percent',
rangeMin: 0,
rangeMax: 50 /* 100 - (100-0)/ colors.length: 2 */,
rangeMax: Number.POSITIVE_INFINITY,
}),
})
);
@ -282,7 +228,9 @@ describe('palette panel', () => {
it('should be visible for predefined palettes', () => {
const instance = mountWithIntl(<CustomizablePalette {...props} />);
expect(
instance.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_stops"]').exists()
instance
.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_color_ranges"]')
.exists()
).toEqual(true);
});
@ -300,7 +248,9 @@ describe('palette panel', () => {
/>
);
expect(
instance.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_stops"]').exists()
instance
.find('[data-test-subj="lnsPalettePanel_dynamicColoring_custom_color_ranges"]')
.exists()
).toEqual(true);
});
});

View file

@ -5,378 +5,179 @@
* 2.0.
*/
import React, { FC } from 'react';
import React, { useReducer, useMemo } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import type { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public';
import {
EuiFormRow,
htmlIdGenerator,
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiSuperSelect,
EuiIcon,
EuiIconTip,
EuiLink,
EuiText,
} from '@elastic/eui';
import { EuiFormRow, htmlIdGenerator, EuiButtonGroup, EuiIconTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { PalettePicker } from './palette_picker';
import type { DataBounds } from './types';
import './palette_configuration.scss';
import { CustomStops } from './color_stops';
import { defaultPaletteParams, CUSTOM_PALETTE, DEFAULT_COLOR_STEPS } from './constants';
import type { CustomPaletteParams, RequiredPaletteParamTypes } from '../../../common';
import {
getColorStops,
getPaletteStops,
mergePaletteParams,
getDataMinMax,
remapStopsByNewInterval,
getSwitchToCustomParams,
reversePalette,
roundStopValues,
} from './utils';
const idPrefix = htmlIdGenerator()();
const ContinuityOption: FC<{ iconType: string }> = ({ children, iconType }) => {
return (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon type={iconType} />
</EuiFlexItem>
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
</EuiFlexGroup>
);
};
/**
* Some name conventions here:
* * `displayStops` => It's an additional transformation of `stops` into a [0, N] domain for the EUIPaletteDisplay component.
* * `stops` => final steps used to table coloring. It is a rightShift of the colorStops
* * `colorStops` => user's color stop inputs. Used to compute range min.
*
* When the user inputs the colorStops, they are designed to be the initial part of the color segment,
* so the next stops indicate where the previous stop ends.
* Both table coloring logic and EuiPaletteDisplay format implementation works differently than our current `colorStops`,
* by having the stop values at the end of each color segment rather than at the beginning: `stops` values are computed by a rightShift of `colorStops`.
* EuiPaletteDisplay has an additional requirement as it is always mapped against a domain [0, N]: from `stops` the `displayStops` are computed with
* some continuity enrichment and a remap against a [0, 100] domain to make the palette component work ok.
*
* These naming conventions would be useful to track the code flow in this feature as multiple transformations are happening
* for a single change.
*/
import { toColorRanges, getFallbackDataBounds } from './utils';
import { defaultPaletteParams } from './constants';
import { ColorRanges, ColorRangesContext } from './color_ranges';
import { isAllColorRangesValid } from './color_ranges/color_ranges_validation';
import { paletteConfigurationReducer } from './palette_configuration_reducer';
export function CustomizablePalette({
palettes,
activePalette,
setPalette,
dataBounds,
showContinuity = true,
dataBounds = getFallbackDataBounds(activePalette.params?.rangeType),
showRangeTypeSelector = true,
disableSwitchingContinuity = false,
}: {
palettes: PaletteRegistry;
activePalette?: PaletteOutput<CustomPaletteParams>;
activePalette: PaletteOutput<CustomPaletteParams>;
setPalette: (palette: PaletteOutput<CustomPaletteParams>) => void;
dataBounds?: { min: number; max: number };
showContinuity?: boolean;
dataBounds?: DataBounds;
showRangeTypeSelector?: boolean;
disableSwitchingContinuity?: boolean;
}) {
if (!dataBounds || !activePalette) {
return null;
}
const isCurrentPaletteCustom = activePalette.params?.name === CUSTOM_PALETTE;
const idPrefix = useMemo(() => htmlIdGenerator()(), []);
const colorRangesToShow = toColorRanges(
palettes,
activePalette.params?.colorStops || [],
activePalette,
dataBounds
);
const colorStopsToShow = roundStopValues(
getColorStops(palettes, activePalette?.params?.colorStops || [], activePalette, dataBounds)
const [localState, dispatch] = useReducer(paletteConfigurationReducer, {
activePalette,
colorRanges: colorRangesToShow,
});
useDebounce(
() => {
const rangeType =
localState.activePalette?.params?.rangeType ?? defaultPaletteParams.rangeType;
if (
(localState.activePalette !== activePalette ||
colorRangesToShow !== localState.colorRanges) &&
isAllColorRangesValid(localState.colorRanges, dataBounds, rangeType)
) {
setPalette(localState.activePalette);
}
},
250,
[localState]
);
return (
<>
<div className="lnsPalettePanel__section lnsPalettePanel__section--shaded">
<div className="lnsPalettePanel__section lnsPalettePanel__section--shaded">
<EuiFormRow
display="rowCompressed"
label={i18n.translate('xpack.lens.palettePicker.label', {
defaultMessage: 'Color palette',
})}
fullWidth
>
<PalettePicker
data-test-subj="lnsPalettePanel_dynamicColoring_palette_picker"
palettes={palettes}
activePalette={localState.activePalette}
setPalette={(newPalette) => {
const isPaletteChanged = newPalette.name !== activePalette.name;
if (isPaletteChanged) {
dispatch({
type: 'changeColorPalette',
payload: { palette: newPalette, dataBounds, palettes, disableSwitchingContinuity },
});
}
}}
showCustomPalette
showDynamicColorOnly
/>
</EuiFormRow>
{showRangeTypeSelector && (
<EuiFormRow
display="rowCompressed"
fullWidth
label={i18n.translate('xpack.lens.palettePicker.label', {
defaultMessage: 'Color palette',
})}
>
<PalettePicker
data-test-subj="lnsPalettePanel_dynamicColoring_palette_picker"
palettes={palettes}
activePalette={activePalette}
setPalette={(newPalette) => {
const isNewPaletteCustom = newPalette.name === CUSTOM_PALETTE;
const newParams: CustomPaletteParams = {
...activePalette.params,
name: newPalette.name,
colorStops: undefined,
reverse: false, // restore the reverse flag
};
const newColorStops = getColorStops(palettes, [], activePalette, dataBounds);
if (isNewPaletteCustom) {
newParams.colorStops = newColorStops;
}
newParams.stops = getPaletteStops(palettes, newParams, {
prevPalette:
isNewPaletteCustom || isCurrentPaletteCustom ? undefined : newPalette.name,
dataBounds,
mapFromMinValue: true,
});
newParams.rangeMin = newColorStops[0].stop;
newParams.rangeMax = newColorStops[newColorStops.length - 1].stop;
setPalette({
...newPalette,
params: newParams,
});
}}
showCustomPalette
showDynamicColorOnly
/>
</EuiFormRow>
{showContinuity && (
<EuiFormRow
label={
<>
{i18n.translate('xpack.lens.table.dynamicColoring.continuity.label', {
defaultMessage: 'Color continuity',
})}{' '}
<EuiIconTip
content={i18n.translate(
'xpack.lens.table.dynamicColoring.customPalette.continuityHelp',
{
defaultMessage:
'Specify how colors appear before the first color stop, and after the last color stop.',
}
)}
position="top"
size="s"
/>
</>
}
display="rowCompressed"
>
<EuiSuperSelect
data-test-subj="lnsPalettePanel_dynamicColoring_continuity"
compressed
options={[
{
value: 'above',
inputDisplay: (
<ContinuityOption iconType="continuityAbove">
{i18n.translate('xpack.lens.table.dynamicColoring.continuity.aboveLabel', {
defaultMessage: 'Above range',
})}
</ContinuityOption>
),
'data-test-subj': 'continuity-above',
},
{
value: 'below',
inputDisplay: (
<ContinuityOption iconType="continuityBelow">
{i18n.translate('xpack.lens.table.dynamicColoring.continuity.belowLabel', {
defaultMessage: 'Below range',
})}
</ContinuityOption>
),
'data-test-subj': 'continuity-below',
},
{
value: 'all',
inputDisplay: (
<ContinuityOption iconType="continuityAboveBelow">
{i18n.translate('xpack.lens.table.dynamicColoring.continuity.allLabel', {
defaultMessage: 'Above and below range',
})}
</ContinuityOption>
),
'data-test-subj': 'continuity-all',
},
{
value: 'none',
inputDisplay: (
<ContinuityOption iconType="continuityWithin">
{i18n.translate('xpack.lens.table.dynamicColoring.continuity.noneLabel', {
defaultMessage: 'Within range',
})}
</ContinuityOption>
),
'data-test-subj': 'continuity-none',
},
]}
valueOfSelected={activePalette.params?.continuity || defaultPaletteParams.continuity}
onChange={(continuity: Required<CustomPaletteParams>['continuity']) =>
setPalette(
mergePaletteParams(activePalette, {
continuity,
})
)
}
/>
</EuiFormRow>
)}
{showRangeTypeSelector && (
<EuiFormRow
label={
<>
{i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', {
defaultMessage: 'Value type',
})}{' '}
<EuiIconTip
content={i18n.translate(
'xpack.lens.table.dynamicColoring.customPalette.colorStopsHelpPercentage',
{
defaultMessage:
'Percent value types are relative to the full range of available data values.',
}
)}
position="top"
size="s"
/>
</>
}
display="rowCompressed"
>
<EuiButtonGroup
isFullWidth
legend={i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', {
label={
<>
{i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', {
defaultMessage: 'Value type',
})}
data-test-subj="lnsPalettePanel_dynamicColoring_custom_range_groups"
name="dynamicColoringRangeType"
buttonSize="compressed"
options={[
{
id: `${idPrefix}percent`,
label: i18n.translate('xpack.lens.table.dynamicColoring.rangeType.percent', {
defaultMessage: 'Percent',
}),
'data-test-subj': 'lnsPalettePanel_dynamicColoring_rangeType_groups_percent',
},
{
id: `${idPrefix}number`,
label: i18n.translate('xpack.lens.table.dynamicColoring.rangeType.number', {
defaultMessage: 'Number',
}),
'data-test-subj': 'lnsPalettePanel_dynamicColoring_rangeType_groups_number',
},
]}
idSelected={
activePalette.params?.rangeType
? `${idPrefix}${activePalette.params?.rangeType}`
: `${idPrefix}percent`
}
onChange={(id) => {
const newRangeType = id.replace(
idPrefix,
''
) as RequiredPaletteParamTypes['rangeType'];
const params: CustomPaletteParams = { rangeType: newRangeType };
const { min: newMin, max: newMax } = getDataMinMax(newRangeType, dataBounds);
const { min: oldMin, max: oldMax } = getDataMinMax(
activePalette.params?.rangeType,
dataBounds
);
const newColorStops = remapStopsByNewInterval(colorStopsToShow, {
oldInterval: oldMax - oldMin,
newInterval: newMax - newMin,
newMin,
oldMin,
});
if (isCurrentPaletteCustom) {
const stops = getPaletteStops(
palettes,
{ ...activePalette.params, colorStops: newColorStops, ...params },
{ dataBounds }
);
params.colorStops = newColorStops;
params.stops = stops;
} else {
params.stops = getPaletteStops(
palettes,
{ ...activePalette.params, ...params },
{ prevPalette: activePalette.name, dataBounds }
);
}
// why not use newMin/newMax here?
// That's because there's the concept of continuity to accomodate, where in some scenarios it has to
// take into account the stop value rather than the data value
params.rangeMin = newColorStops[0].stop;
params.rangeMax = newColorStops[newColorStops.length - 1].stop;
setPalette(mergePaletteParams(activePalette, params));
}}
/>
</EuiFormRow>
)}
<EuiFormRow
display="rowCompressed"
label={i18n.translate('xpack.lens.table.dynamicColoring.customPalette.colorStopsLabel', {
defaultMessage: 'Color stops',
})}
labelAppend={
<EuiText size="xs">
<EuiLink
className="lnsPalettePanel__reverseButton"
data-test-subj="lnsPalettePanel_dynamicColoring_reverse"
onClick={() => {
// when reversing a palette, the palette is automatically transitioned to a custom palette
const newParams = getSwitchToCustomParams(
palettes,
activePalette,
{
colorStops: reversePalette(colorStopsToShow),
steps: activePalette.params?.steps || DEFAULT_COLOR_STEPS,
reverse: !activePalette.params?.reverse, // Store the reverse state
rangeMin: colorStopsToShow[0]?.stop,
rangeMax: colorStopsToShow[colorStopsToShow.length - 1]?.stop,
},
dataBounds
);
setPalette(newParams);
}}
>
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon size="s" type="sortable" />
</EuiFlexItem>
<EuiFlexItem>
{i18n.translate('xpack.lens.table.dynamicColoring.reverse.label', {
defaultMessage: 'Reverse colors',
})}
</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
</EuiText>
})}{' '}
<EuiIconTip
content={i18n.translate(
'xpack.lens.table.dynamicColoring.customPalette.colorStopsHelpPercentage',
{
defaultMessage:
'Percent value types are relative to the full range of available data values.',
}
)}
position="top"
size="s"
/>
</>
}
display="rowCompressed"
>
<CustomStops
paletteConfiguration={activePalette?.params}
data-test-prefix="lnsPalettePanel"
colorStops={colorStopsToShow}
dataBounds={dataBounds}
onChange={(colorStops) => {
const newParams = getSwitchToCustomParams(
palettes,
activePalette,
{
colorStops,
steps: activePalette.params?.steps || DEFAULT_COLOR_STEPS,
rangeMin: colorStops[0]?.stop,
rangeMax: colorStops[colorStops.length - 1]?.stop,
},
dataBounds
);
return setPalette(newParams);
<EuiButtonGroup
legend={i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', {
defaultMessage: 'Value type',
})}
data-test-subj="lnsPalettePanel_dynamicColoring_custom_range_groups"
name="dynamicColoringRangeType"
buttonSize="compressed"
options={[
{
id: `${idPrefix}percent`,
label: i18n.translate('xpack.lens.table.dynamicColoring.rangeType.percent', {
defaultMessage: 'Percent',
}),
'data-test-subj': 'lnsPalettePanel_dynamicColoring_rangeType_groups_percent',
},
{
id: `${idPrefix}number`,
label: i18n.translate('xpack.lens.table.dynamicColoring.rangeType.number', {
defaultMessage: 'Number',
}),
'data-test-subj': 'lnsPalettePanel_dynamicColoring_rangeType_groups_number',
},
]}
idSelected={
localState.activePalette.params?.rangeType
? `${idPrefix}${localState.activePalette.params?.rangeType}`
: `${idPrefix}percent`
}
onChange={(id) => {
const newRangeType = id.replace(
idPrefix,
''
) as RequiredPaletteParamTypes['rangeType'];
dispatch({
type: 'updateRangeType',
payload: { rangeType: newRangeType, dataBounds, palettes },
});
}}
isFullWidth
/>
</EuiFormRow>
</div>
</>
)}
<EuiFormRow
label={i18n.translate('xpack.lens.palettePicker.colorRangesLabel', {
defaultMessage: 'Color Ranges',
})}
display="rowCompressed"
fullWidth
>
<ColorRangesContext.Provider
value={{
dataBounds,
palettes,
disableSwitchingContinuity,
}}
>
<ColorRanges
paletteConfiguration={localState.activePalette?.params}
colorRanges={localState.colorRanges}
dispatch={dispatch}
/>
</ColorRangesContext.Provider>
</EuiFormRow>
</div>
);
}

View file

@ -0,0 +1,161 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Reducer } from 'react';
import {
addColorRange,
deleteColorRange,
distributeEqually,
reversePalette,
sortColorRanges,
updateColorRangeColor,
updateColorRangeValue,
getValueForContinuity,
} from './color_ranges/utils';
import { DEFAULT_CONTINUITY, DEFAULT_RANGE_TYPE } from './constants';
import {
mergePaletteParams,
updateRangeType,
changeColorPalette,
withUpdatingPalette,
withUpdatingColorRanges,
} from './utils';
import type { PaletteConfigurationState, PaletteConfigurationActions } from './types';
export const paletteConfigurationReducer: Reducer<
PaletteConfigurationState,
PaletteConfigurationActions
> = (state, action) => {
switch (action.type) {
case 'updateContinuity': {
const { continuity, isLast, dataBounds, palettes } = action.payload;
const rangeType = state.activePalette.params?.rangeType ?? DEFAULT_RANGE_TYPE;
const value = getValueForContinuity(
state.colorRanges,
continuity,
isLast,
rangeType,
dataBounds
);
return withUpdatingPalette(
palettes!,
state.activePalette,
updateColorRangeValue(
isLast ? state.colorRanges.length - 1 : 0,
`${value}`,
isLast ? 'end' : 'start',
state.colorRanges
),
dataBounds,
continuity
);
}
case 'addColorRange': {
const { dataBounds, palettes } = action.payload;
return withUpdatingPalette(
palettes!,
state.activePalette,
addColorRange(
state.colorRanges,
state.activePalette.params?.rangeType ?? DEFAULT_RANGE_TYPE,
dataBounds
),
dataBounds
);
}
case 'reversePalette': {
const { dataBounds, palettes } = action.payload;
return withUpdatingPalette(
palettes!,
state.activePalette,
reversePalette(state.colorRanges),
dataBounds
);
}
case 'distributeEqually': {
const { dataBounds, palettes } = action.payload;
return withUpdatingPalette(
palettes!,
state.activePalette,
distributeEqually(
state.colorRanges,
state.activePalette.params?.rangeType,
state.activePalette.params?.continuity ?? DEFAULT_CONTINUITY,
dataBounds
),
dataBounds
);
}
case 'updateColor': {
const { index, color, palettes, dataBounds } = action.payload;
return withUpdatingPalette(
palettes!,
state.activePalette,
updateColorRangeColor(index, color, state.colorRanges),
dataBounds
);
}
case 'sortColorRanges': {
const { dataBounds, palettes } = action.payload;
return withUpdatingPalette(
palettes!,
state.activePalette,
sortColorRanges(state.colorRanges),
dataBounds
);
}
case 'updateValue': {
const { index, value, accessor, dataBounds, palettes } = action.payload;
return withUpdatingPalette(
palettes!,
state.activePalette,
updateColorRangeValue(index, value, accessor, state.colorRanges),
dataBounds
);
}
case 'deleteColorRange': {
const { index, dataBounds, palettes } = action.payload;
return withUpdatingPalette(
palettes!,
state.activePalette,
deleteColorRange(index, state.colorRanges),
dataBounds
);
}
case 'updateRangeType': {
const { dataBounds, palettes, rangeType } = action.payload;
const paletteParams = updateRangeType(
rangeType,
state.activePalette,
dataBounds,
palettes,
state.colorRanges
);
const newPalette = mergePaletteParams(state.activePalette, paletteParams);
return withUpdatingColorRanges(palettes, newPalette, dataBounds);
}
case 'changeColorPalette': {
const { dataBounds, palettes, palette, disableSwitchingContinuity } = action.payload;
const newPalette = changeColorPalette(
palette,
state.activePalette,
palettes,
dataBounds,
disableSwitchingContinuity
);
return withUpdatingColorRanges(palettes, newPalette, dataBounds);
}
default:
throw new Error('wrong action');
}
};

View file

@ -25,13 +25,13 @@ import {
export function PalettePanelContainer({
isOpen,
handleClose,
children,
siblingRef,
children,
}: {
isOpen: boolean;
handleClose: () => void;
children: React.ReactElement | React.ReactElement[];
siblingRef: MutableRefObject<HTMLDivElement | null>;
children?: React.ReactElement | React.ReactElement[];
}) {
const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false);
@ -91,7 +91,7 @@ export function PalettePanelContainer({
</EuiFlexGroup>
</EuiFlyoutHeader>
<div className="lnsPalettePanelContainer__content">{children}</div>
{children && <div className="lnsPalettePanelContainer__content">{children}</div>}
<EuiFlyoutFooter className="lnsPalettePanelContainer__footer">
<EuiButtonEmpty flush="left" size="s" iconType="sortLeft" onClick={closeFlyout}>

View file

@ -15,7 +15,6 @@ import {
defaultPaletteParams,
} from './constants';
import type { CustomPaletteParams } from '../../../common';
import { getStopsForFixedMode } from './utils';
function getCustomPaletteConfig(
palettes: PaletteRegistry,
@ -52,7 +51,9 @@ function getCustomPaletteConfig(
title,
type: FIXED_PROGRESSION,
'data-test-subj': `custom-palette`,
palette: getStopsForFixedMode(activePalette.params.stops, activePalette.params.colorStops),
palette: (activePalette.params.colorStops || activePalette.params.stops).map(
(colorStop) => colorStop.color
),
};
}

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public';
import type { CustomPaletteParams } from '../../../common';
import type { ColorRange, ColorRangesActions } from './color_ranges';
export interface PaletteConfigurationState {
activePalette: PaletteOutput<CustomPaletteParams>;
colorRanges: ColorRange[];
}
/** @internal **/
export interface DataBounds {
min: number;
max: number;
fallback?: boolean;
}
/** @internal **/
export interface UpdateRangeTypePayload {
rangeType: CustomPaletteParams['rangeType'];
palettes: PaletteRegistry;
dataBounds: DataBounds;
}
/** @internal **/
export interface ChangeColorPalettePayload {
palette: PaletteOutput<CustomPaletteParams>;
palettes: PaletteRegistry;
dataBounds: DataBounds;
disableSwitchingContinuity: boolean;
}
export type PaletteConfigurationActions =
| ColorRangesActions
| { type: 'updateRangeType'; payload: UpdateRangeTypePayload }
| { type: 'changeColorPalette'; payload: ChangeColorPalettePayload };

View file

@ -17,7 +17,8 @@ import {
mergePaletteParams,
remapStopsByNewInterval,
reversePalette,
roundStopValues,
updateRangeType,
changeColorPalette,
} from './utils';
describe('applyPaletteParams', () => {
@ -411,14 +412,6 @@ describe('isValidColor', () => {
});
});
describe('roundStopValues', () => {
it('should round very long values', () => {
expect(roundStopValues([{ color: 'red', stop: 0.1515 }])).toEqual([
{ color: 'red', stop: 0.15 },
]);
});
});
describe('getStepValue', () => {
it('should compute the next step based on the last 2 stops', () => {
expect(
@ -490,3 +483,310 @@ describe('getContrastColor', () => {
expect(getContrastColor('rgba(255,255,255,0)', false)).toBe('#000000');
});
});
describe('updateRangeType', () => {
const paletteRegistry = chartPluginMock.createPaletteRegistry();
const colorRanges = [
{
start: 0,
end: 40,
color: 'green',
},
{
start: 40,
end: 80,
color: 'blue',
},
{
start: 80,
end: 100,
color: 'red',
},
];
it('should correctly update palette params with new range type if continuity is none', () => {
const newPaletteParams = updateRangeType(
'number',
{
type: 'palette',
name: 'custom',
params: {
continuity: 'none',
name: 'custom',
rangeType: 'percent',
rangeMax: 100,
rangeMin: 0,
colorStops: [
{ color: 'green', stop: 0 },
{ color: 'blue', stop: 40 },
{ color: 'red', stop: 80 },
],
},
},
{ min: 0, max: 200 },
paletteRegistry,
colorRanges
);
expect(newPaletteParams).toEqual({
rangeType: 'number',
rangeMin: 0,
rangeMax: 200,
colorStops: [
{
color: 'green',
stop: 0,
},
{
color: 'blue',
stop: 80,
},
{
color: 'red',
stop: 160,
},
],
stops: [
{
color: 'green',
stop: 80,
},
{
color: 'blue',
stop: 160,
},
{
color: 'red',
stop: 200,
},
],
});
});
it('should correctly update palette params with new range type if continuity is all', () => {
const newPaletteParams = updateRangeType(
'number',
{
type: 'palette',
name: 'custom',
params: {
continuity: 'all',
name: 'custom',
rangeType: 'percent',
rangeMax: 100,
rangeMin: 0,
colorStops: [
{ color: 'green', stop: 0 },
{ color: 'blue', stop: 40 },
{ color: 'red', stop: 80 },
],
},
},
{ min: 0, max: 200 },
paletteRegistry,
colorRanges
);
expect(newPaletteParams).toEqual({
rangeType: 'number',
rangeMin: Number.NEGATIVE_INFINITY,
rangeMax: Number.POSITIVE_INFINITY,
colorStops: [
{
color: 'green',
stop: 0,
},
{
color: 'blue',
stop: 80,
},
{
color: 'red',
stop: 160,
},
],
stops: [
{
color: 'green',
stop: 80,
},
{
color: 'blue',
stop: 160,
},
{
color: 'red',
stop: 200,
},
],
});
});
it('should correctly update palette params with new range type if continuity is below', () => {
const newPaletteParams = updateRangeType(
'number',
{
type: 'palette',
name: 'custom',
params: {
continuity: 'below',
name: 'custom',
rangeType: 'percent',
rangeMax: 100,
rangeMin: 0,
colorStops: [
{ color: 'green', stop: 0 },
{ color: 'blue', stop: 40 },
{ color: 'red', stop: 80 },
],
},
},
{ min: 0, max: 200 },
paletteRegistry,
colorRanges
);
expect(newPaletteParams).toEqual({
rangeType: 'number',
rangeMin: Number.NEGATIVE_INFINITY,
rangeMax: 200,
colorStops: [
{
color: 'green',
stop: 0,
},
{
color: 'blue',
stop: 80,
},
{
color: 'red',
stop: 160,
},
],
stops: [
{
color: 'green',
stop: 80,
},
{
color: 'blue',
stop: 160,
},
{
color: 'red',
stop: 200,
},
],
});
});
it('should correctly update palette params with new range type if continuity is above', () => {
const newPaletteParams = updateRangeType(
'number',
{
type: 'palette',
name: 'custom',
params: {
continuity: 'above',
name: 'custom',
rangeType: 'percent',
rangeMax: 100,
rangeMin: 0,
colorStops: [
{ color: 'green', stop: 0 },
{ color: 'blue', stop: 40 },
{ color: 'red', stop: 80 },
],
},
},
{ min: 0, max: 200 },
paletteRegistry,
colorRanges
);
expect(newPaletteParams).toEqual({
rangeType: 'number',
rangeMin: 0,
rangeMax: Number.POSITIVE_INFINITY,
colorStops: [
{
color: 'green',
stop: 0,
},
{
color: 'blue',
stop: 80,
},
{
color: 'red',
stop: 160,
},
],
stops: [
{
color: 'green',
stop: 80,
},
{
color: 'blue',
stop: 160,
},
{
color: 'red',
stop: 200,
},
],
});
});
});
describe('changeColorPalette', () => {
const paletteRegistry = chartPluginMock.createPaletteRegistry();
it('should correct update params for new palette', () => {
const newPaletteParams = changeColorPalette(
{
type: 'palette',
name: 'default',
},
{
type: 'palette',
name: 'custom',
params: {
continuity: 'above',
name: 'custom',
rangeType: 'percent',
rangeMax: 100,
rangeMin: 0,
colorStops: [
{ color: 'green', stop: 0 },
{ color: 'blue', stop: 40 },
{ color: 'red', stop: 80 },
],
},
},
paletteRegistry,
{ min: 0, max: 200 },
false
);
expect(newPaletteParams).toEqual({
name: 'default',
type: 'palette',
params: {
rangeType: 'percent',
name: 'default',
continuity: 'above',
rangeMin: 0,
rangeMax: Number.POSITIVE_INFINITY,
reverse: false,
colorStops: undefined,
stops: [
{
color: 'red',
stop: 0,
},
{
color: 'black',
stop: 50,
},
],
},
});
});
});

View file

@ -16,8 +16,16 @@ import {
DEFAULT_COLOR_STEPS,
DEFAULT_MAX_STOP,
DEFAULT_MIN_STOP,
DEFAULT_CONTINUITY,
} from './constants';
import type { ColorRange } from './color_ranges';
import { toColorStops, sortColorRanges } from './color_ranges/utils';
import type { PaletteConfigurationState, DataBounds } from './types';
import type { CustomPaletteParams, ColorStop } from '../../../common';
import {
checkIsMinContinuity,
checkIsMaxContinuity,
} from '../../../../../../src/plugins/charts/common';
/**
* Some name conventions here:
@ -36,10 +44,171 @@ import type { CustomPaletteParams, ColorStop } from '../../../common';
* for a single change.
*/
export function updateRangeType(
newRangeType: CustomPaletteParams['rangeType'],
activePalette: PaletteConfigurationState['activePalette'],
dataBounds: DataBounds,
palettes: PaletteRegistry,
colorRanges: PaletteConfigurationState['colorRanges']
) {
const continuity = activePalette.params?.continuity ?? DEFAULT_CONTINUITY;
const params: CustomPaletteParams = { rangeType: newRangeType };
const { min: newMin, max: newMax } = getDataMinMax(newRangeType, dataBounds);
const { min: oldMin, max: oldMax } = getDataMinMax(activePalette.params?.rangeType, dataBounds);
const newColorStops = getStopsFromColorRangesByNewInterval(colorRanges, {
oldInterval: oldMax - oldMin,
newInterval: newMax - newMin,
newMin,
oldMin,
});
if (activePalette.name === CUSTOM_PALETTE) {
const stops = getPaletteStops(
palettes,
{ ...activePalette.params, colorStops: newColorStops, ...params },
{ dataBounds }
);
params.colorStops = newColorStops;
params.stops = stops;
} else {
params.stops = getPaletteStops(
palettes,
{ ...activePalette.params, ...params },
{ prevPalette: activePalette.name, dataBounds }
);
}
const lastStop =
activePalette.name === CUSTOM_PALETTE
? newColorStops[newColorStops.length - 1].stop
: params.stops[params.stops.length - 1].stop;
params.rangeMin = checkIsMinContinuity(continuity)
? Number.NEGATIVE_INFINITY
: activePalette.name === CUSTOM_PALETTE
? newColorStops[0].stop
: params.stops[0].stop;
params.rangeMax = checkIsMaxContinuity(continuity)
? Number.POSITIVE_INFINITY
: activePalette.params?.rangeMax
? calculateStop(activePalette.params.rangeMax, newMin, oldMin, oldMax - oldMin, newMax - newMin)
: lastStop > newMax
? lastStop + 1
: newMax;
return params;
}
export function changeColorPalette(
newPalette: PaletteConfigurationState['activePalette'],
activePalette: PaletteConfigurationState['activePalette'],
palettes: PaletteRegistry,
dataBounds: DataBounds,
disableSwitchingContinuity: boolean
) {
const isNewPaletteCustom = newPalette.name === CUSTOM_PALETTE;
const newParams: CustomPaletteParams = {
...activePalette.params,
name: newPalette.name,
colorStops: undefined,
continuity: disableSwitchingContinuity
? activePalette.params?.continuity ?? DEFAULT_CONTINUITY
: DEFAULT_CONTINUITY,
reverse: false, // restore the reverse flag
};
// we should pass colorStops so that correct calculate new color stops (if there was before) for custom palette
const newColorStops = getColorStops(
palettes,
activePalette.params?.colorStops || [],
activePalette,
dataBounds
);
if (isNewPaletteCustom) {
newParams.colorStops = newColorStops;
}
return {
...newPalette,
params: {
...newParams,
stops: getPaletteStops(palettes, newParams, {
prevPalette:
isNewPaletteCustom || activePalette.name === CUSTOM_PALETTE ? undefined : newPalette.name,
dataBounds,
mapFromMinValue: true,
}),
rangeMin: checkIsMinContinuity(newParams.continuity)
? Number.NEGATIVE_INFINITY
: Math.min(dataBounds.min, newColorStops[0].stop),
rangeMax: checkIsMaxContinuity(newParams.continuity)
? Number.POSITIVE_INFINITY
: Math.min(dataBounds.max, newColorStops[newColorStops.length - 1].stop),
},
};
}
export function withUpdatingPalette(
palettes: PaletteRegistry,
activePalette: PaletteConfigurationState['activePalette'],
colorRanges: ColorRange[],
dataBounds: DataBounds,
continuity?: CustomPaletteParams['continuity']
) {
const currentContinuity = continuity ?? activePalette.params?.continuity ?? DEFAULT_CONTINUITY;
let sortedColorRanges = colorRanges;
if (
colorRanges.some((value, index) =>
index !== colorRanges.length - 1 ? value.start > colorRanges[index + 1].start : false
)
) {
sortedColorRanges = sortColorRanges(colorRanges);
}
const { max, colorStops } = toColorStops(sortedColorRanges, currentContinuity);
const newPallete = getSwitchToCustomParams(
palettes,
activePalette!,
{
continuity: currentContinuity,
colorStops,
steps: activePalette!.params?.steps || DEFAULT_COLOR_STEPS,
reverse: activePalette!.params?.reverse,
rangeMin: colorStops[0]?.stop,
rangeMax: max,
},
dataBounds!
);
return {
activePalette: newPallete,
colorRanges,
};
}
export function withUpdatingColorRanges(
palettes: PaletteRegistry,
activePalette: PaletteConfigurationState['activePalette'],
dataBounds: DataBounds
) {
return {
colorRanges: toColorRanges(
palettes,
activePalette.params?.colorStops || [],
activePalette,
dataBounds
),
activePalette,
};
}
export function applyPaletteParams<T extends PaletteOutput<CustomPaletteParams>>(
palettes: PaletteRegistry,
activePalette: T,
dataBounds: { min: number; max: number }
dataBounds: DataBounds
) {
// make a copy of it as they have to be manipulated later on
const displayStops = getPaletteStops(palettes, activePalette?.params || {}, {
@ -60,6 +229,7 @@ export function shiftPalette(stops: ColorStop[], max: number) {
...entry,
stop: i + 1 < array.length ? array[i + 1].stop : max,
}));
if (stops[stops.length - 1].stop === max) {
// extends the range by a fair amount to make it work the extra case for the last stop === max
const computedStep = getStepValue(stops, result, max) || 1;
@ -70,6 +240,17 @@ export function shiftPalette(stops: ColorStop[], max: number) {
return result;
}
/** @internal **/
export function calculateStop(
stopValue: number,
newMin: number,
oldMin: number,
oldInterval: number,
newInterval: number
) {
return roundValue(newMin + ((stopValue - oldMin) * newInterval) / oldInterval);
}
// Utility to remap color stops within new domain
export function remapStopsByNewInterval(
controlStops: ColorStop[],
@ -83,18 +264,40 @@ export function remapStopsByNewInterval(
return (controlStops || []).map(({ color, stop }) => {
return {
color,
stop: newMin + ((stop - oldMin) * newInterval) / oldInterval,
stop: calculateStop(stop, newMin, oldMin, oldInterval, newInterval),
};
});
}
function getOverallMinMax(
params: CustomPaletteParams | undefined,
dataBounds: { min: number; max: number }
// Utility to remap color stops within new domain
export function getStopsFromColorRangesByNewInterval(
colorRanges: ColorRange[],
{
newInterval,
oldInterval,
newMin,
oldMin,
}: { newInterval: number; oldInterval: number; newMin: number; oldMin: number }
) {
return (colorRanges || []).map(({ color, start }) => {
let stop = calculateStop(start, newMin, oldMin, oldInterval, newInterval);
if (oldInterval === 0) {
stop = newInterval + newMin;
}
return {
color,
stop: roundValue(stop),
};
});
}
function getOverallMinMax(params: CustomPaletteParams | undefined, dataBounds: DataBounds) {
const { min: dataMin, max: dataMax } = getDataMinMax(params?.rangeType, dataBounds);
const minStopValue = params?.colorStops?.[0]?.stop ?? Infinity;
const maxStopValue = params?.colorStops?.[params.colorStops.length - 1]?.stop ?? -Infinity;
const minStopValue = params?.colorStops?.[0]?.stop ?? Number.POSITIVE_INFINITY;
const maxStopValue =
params?.colorStops?.[params.colorStops.length - 1]?.stop ?? Number.NEGATIVE_INFINITY;
const overallMin = Math.min(dataMin, minStopValue);
const overallMax = Math.max(dataMax, maxStopValue);
return { min: overallMin, max: overallMax };
@ -102,7 +305,7 @@ function getOverallMinMax(
export function getDataMinMax(
rangeType: CustomPaletteParams['rangeType'] | undefined,
dataBounds: { min: number; max: number }
dataBounds: DataBounds
) {
const dataMin = rangeType === 'number' ? dataBounds.min : DEFAULT_MIN_STOP;
const dataMax = rangeType === 'number' ? dataBounds.max : DEFAULT_MAX_STOP;
@ -123,7 +326,7 @@ export function getPaletteStops(
defaultPaletteName,
}: {
prevPalette?: string;
dataBounds: { min: number; max: number };
dataBounds: DataBounds;
mapFromMinValue?: boolean;
defaultPaletteName?: string;
}
@ -145,9 +348,9 @@ export function getPaletteStops(
)
.getCategoricalColors(steps, otherParams);
const newStopsMin = mapFromMinValue ? minValue : interval / steps;
const newStopsMin = mapFromMinValue || interval === 0 ? minValue : interval / steps;
const stops = remapStopsByNewInterval(
return remapStopsByNewInterval(
colorStopsFromPredefined.map((color, index) => ({ color, stop: index })),
{
newInterval: interval,
@ -156,7 +359,6 @@ export function getPaletteStops(
oldMin: 0,
}
);
return stops;
}
export function reversePalette(paletteColorRepresentation: ColorStop[] = []) {
@ -198,12 +400,8 @@ export function isValidColor(colorString: string) {
return colorString !== '' && /^#/.test(colorString) && isValidPonyfill(colorString);
}
export function roundStopValues(colorStops: ColorStop[]) {
return colorStops.map(({ color, stop }) => {
// when rounding mind to not go in excess, rather use the floor function
const roundedStop = Number((Math.floor(stop * 100) / 100).toFixed(2));
return { color, stop: roundedStop };
});
export function roundValue(value: number, fractionDigits: number = 2) {
return Number((Math.floor(value * 100) / 100).toFixed(fractionDigits));
}
// very simple heuristic: pick last two stops and compute a new stop based on the same distance
@ -227,7 +425,7 @@ export function getSwitchToCustomParams(
palettes: PaletteRegistry,
activePalette: PaletteOutput<CustomPaletteParams>,
newParams: CustomPaletteParams,
dataBounds: { min: number; max: number }
dataBounds: DataBounds
) {
// if it's already a custom palette just return the params
if (activePalette?.params?.name === CUSTOM_PALETTE) {
@ -272,7 +470,7 @@ export function getColorStops(
palettes: PaletteRegistry,
colorStops: Required<CustomPaletteParams>['stops'],
activePalette: PaletteOutput<CustomPaletteParams>,
dataBounds: { min: number; max: number }
dataBounds: DataBounds
) {
// just forward the current stops if custom
if (activePalette?.name === CUSTOM_PALETTE && colorStops?.length) {
@ -293,6 +491,47 @@ export function getColorStops(
return freshColorStops;
}
/**
* Both table coloring logic and EuiPaletteDisplay format implementation works differently than our current `colorStops`,
* by having the stop values at the end of each color segment rather than at the beginning: `stops` values are computed by a rightShift of `colorStops`.
* EuiPaletteDisplay has an additional requirement as it is always mapped against a domain [0, N]: from `stops` the `displayStops` are computed with
* some continuity enrichment and a remap against a [0, 100] domain to make the palette component work ok.
*
* These naming conventions would be useful to track the code flow in this feature as multiple transformations are happening
* for a single change.
*/
export function toColorRanges(
palettes: PaletteRegistry,
colorStops: CustomPaletteParams['colorStops'],
activePalette: PaletteOutput<CustomPaletteParams>,
dataBounds: DataBounds
) {
const {
continuity = defaultPaletteParams.continuity,
rangeType = defaultPaletteParams.rangeType,
} = activePalette.params ?? {};
const { min: dataMin, max: dataMax } = getDataMinMax(rangeType, dataBounds);
return getColorStops(palettes, colorStops || [], activePalette, dataBounds).map(
(colorStop, index, array) => {
const isFirst = index === 0;
const isLast = index === array.length - 1;
return {
color: colorStop.color,
start:
isFirst && checkIsMinContinuity(continuity)
? Number.NEGATIVE_INFINITY
: colorStop.stop ?? activePalette.params?.rangeMin ?? dataMin,
end:
isLast && checkIsMaxContinuity(continuity)
? Number.POSITIVE_INFINITY
: array[index + 1]?.stop ?? activePalette.params?.rangeMax ?? dataMax,
};
}
);
}
export function getContrastColor(
color: string,
isDarkTheme: boolean,
@ -312,27 +551,6 @@ export function getContrastColor(
return isColorDark(...finalColor.rgb()) ? lightColor : darkColor;
}
/**
* Same as stops, but remapped against a range 0-100
*/
export function getStopsForFixedMode(stops: ColorStop[], colorStops?: ColorStop[]) {
const referenceStops =
colorStops || stops.map(({ color }, index) => ({ color, stop: 20 * index }));
const fallbackStops = stops;
// what happens when user set two stops with the same value? we'll fallback to the display interval
const oldInterval =
referenceStops[referenceStops.length - 1].stop - referenceStops[0].stop ||
fallbackStops[fallbackStops.length - 1].stop - fallbackStops[0].stop;
return remapStopsByNewInterval(stops, {
newInterval: 100,
oldInterval,
newMin: 0,
oldMin: referenceStops[0].stop,
});
}
function getId(id: string) {
return id;
}
@ -344,17 +562,35 @@ export function getNumericValue(rowValue: number | number[] | undefined) {
return rowValue;
}
export const getFallbackDataBounds = (
rangeType: CustomPaletteParams['rangeType'] = 'percent'
): DataBounds =>
rangeType === 'percent'
? {
min: 0,
max: 100,
fallback: true,
}
: {
min: 1,
max: 1,
fallback: true,
};
export const findMinMaxByColumnId = (
columnIds: string[],
table: Datatable | undefined,
getOriginalId: (id: string) => string = getId
) => {
const minMax: Record<string, { min: number; max: number; fallback?: boolean }> = {};
const minMax: Record<string, DataBounds> = {};
if (table != null) {
for (const columnId of columnIds) {
const originalId = getOriginalId(columnId);
minMax[originalId] = minMax[originalId] || { max: -Infinity, min: Infinity };
minMax[originalId] = minMax[originalId] || {
max: Number.NEGATIVE_INFINITY,
min: Number.POSITIVE_INFINITY,
};
table.rows.forEach((row) => {
const rowValue = row[columnId];
const numericValue = getNumericValue(rowValue);
@ -368,8 +604,8 @@ export const findMinMaxByColumnId = (
}
});
// what happens when there's no data in the table? Fallback to a percent range
if (minMax[originalId].max === -Infinity) {
minMax[originalId] = { max: 100, min: 0, fallback: true };
if (minMax[originalId].max === Number.NEGATIVE_INFINITY) {
minMax[originalId] = getFallbackDataBounds();
}
}
}

View file

@ -32,7 +32,6 @@ import {
CustomizablePalette,
CUSTOM_PALETTE,
FIXED_PROGRESSION,
getStopsForFixedMode,
PalettePanelContainer,
TooltipWrapper,
} from '../../shared_components/';
@ -70,6 +69,7 @@ export function GaugeDimensionEditor(
name: defaultPaletteParams.name,
params: {
...defaultPaletteParams,
continuity: 'all',
colorStops: undefined,
stops: undefined,
rangeMin: currentMinMax.min,
@ -141,14 +141,7 @@ export function GaugeDimensionEditor(
<EuiFlexItem>
<EuiColorPaletteDisplay
data-test-subj="lnsGauge_dynamicColoring_palette"
palette={
activePalette.params?.name === CUSTOM_PALETTE
? getStopsForFixedMode(
activePalette.params.stops!,
activePalette.params.colorStops
)
: displayStops.map(({ color }) => color)
}
palette={displayStops.map(({ color }) => color)}
type={FIXED_PROGRESSION}
onClick={togglePalette}
/>
@ -174,7 +167,7 @@ export function GaugeDimensionEditor(
palettes={props.paletteService}
activePalette={activePalette}
dataBounds={currentMinMax}
showContinuity={false}
disableSwitchingContinuity={true}
setPalette={(newPalette) => {
// if the new palette is not custom, replace the rangeMin with the artificial one
if (

View file

@ -29,7 +29,7 @@ import type { DatasourcePublicAPI, OperationMetadata, Visualization } from '../.
import { getSuggestions } from './suggestions';
import { GROUP_ID, LENS_GAUGE_ID, GaugeVisualizationState } from './constants';
import { GaugeToolbar } from './toolbar_component';
import { applyPaletteParams, CUSTOM_PALETTE, getStopsForFixedMode } from '../../shared_components';
import { applyPaletteParams, CUSTOM_PALETTE } from '../../shared_components';
import { GaugeDimensionEditor } from './dimension_editor';
import { CustomPaletteParams, layerTypes } from '../../../common';
import { generateId } from '../../id_generator';
@ -223,7 +223,7 @@ export const getGaugeVisualization = ({
const currentMinMax = { min: getMinValue(row, state), max: getMaxValue(row, state) };
const displayStops = applyPaletteParams(paletteService, state?.palette, currentMinMax);
palette = getStopsForFixedMode(displayStops, state?.palette?.params?.colorStops);
palette = displayStops.map(({ color }) => color);
}
const invalidProps = checkInvalidConfiguration(row, state) || {};

View file

@ -224,12 +224,7 @@
"xpack.lens.dragDrop.keyboardInstructionsReorder": "スペースまたはEnterを押してドラッグを開始します。ドラッグするときには、上下矢印キーを使用すると、グループの項目を並べ替えます。左右矢印キーを使用すると、グループの外側でドロップ対象を選択します。もう一度スペースまたはEnterを押すと終了します。",
"xpack.lens.dragDrop.shift": "Shift",
"xpack.lens.dragDrop.swap": "入れ替える",
"xpack.lens.dynamicColoring.customPalette.addColorStop": "色経由点を追加",
"xpack.lens.dynamicColoring.customPalette.deleteButtonAriaLabel": "削除",
"xpack.lens.dynamicColoring.customPalette.deleteButtonDisabled": "2つ以上の経由点が必要であるため、この色経由点を削除することはできません",
"xpack.lens.dynamicColoring.customPalette.deleteButtonLabel": "削除",
"xpack.lens.dynamicColoring.customPalette.sortReason": "新しい経由値{value}のため、色経由点が並べ替えられました",
"xpack.lens.dynamicColoring.customPalette.stopAriaLabel": "{index}を停止",
"xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました",
"xpack.lens.editorFrame.colorIndicatorLabel": "このディメンションの色:{hex}",
"xpack.lens.editorFrame.dataFailure": "データの読み込み中にエラーが発生しました。",
@ -712,20 +707,12 @@
"xpack.lens.table.columnVisibilityLabel": "列を非表示",
"xpack.lens.table.defaultAriaLabel": "データ表ビジュアライゼーション",
"xpack.lens.table.dynamicColoring.cell": "セル",
"xpack.lens.table.dynamicColoring.continuity.aboveLabel": "範囲の上",
"xpack.lens.table.dynamicColoring.continuity.allLabel": "範囲の上下",
"xpack.lens.table.dynamicColoring.continuity.belowLabel": "範囲の下",
"xpack.lens.table.dynamicColoring.continuity.label": "色の連続",
"xpack.lens.table.dynamicColoring.continuity.noneLabel": "範囲内",
"xpack.lens.table.dynamicColoring.customPalette.colorStopsHelpPercentage": "割合値は使用可能なデータ値の全範囲に対して相対的です。",
"xpack.lens.table.dynamicColoring.customPalette.colorStopsLabel": "色経由点",
"xpack.lens.table.dynamicColoring.customPalette.continuityHelp": "最初の色経由点の前、最後の色経由点の後に色が表示される方法を指定します。",
"xpack.lens.table.dynamicColoring.label": "値別の色",
"xpack.lens.table.dynamicColoring.none": "なし",
"xpack.lens.table.dynamicColoring.rangeType.label": "値型",
"xpack.lens.table.dynamicColoring.rangeType.number": "数字",
"xpack.lens.table.dynamicColoring.rangeType.percent": "割合(%",
"xpack.lens.table.dynamicColoring.reverse.label": "色を反転",
"xpack.lens.table.dynamicColoring.text": "テキスト",
"xpack.lens.table.hide.hideLabel": "非表示",
"xpack.lens.table.palettePanelContainer.back": "戻る",

View file

@ -228,12 +228,7 @@
"xpack.lens.dragDrop.keyboardInstructionsReorder": "按空格键或 enter 键开始拖动。拖动时,请使用上下箭头键重新排列组中的项目,使用左右箭头键在组之外选择拖动目标。再次按空格键或 enter 键结束操作。",
"xpack.lens.dragDrop.shift": "Shift 键",
"xpack.lens.dragDrop.swap": "交换",
"xpack.lens.dynamicColoring.customPalette.addColorStop": "添加颜色停止",
"xpack.lens.dynamicColoring.customPalette.deleteButtonAriaLabel": "删除",
"xpack.lens.dynamicColoring.customPalette.deleteButtonDisabled": "此颜色停止无法删除,因为需要两个或更多停止",
"xpack.lens.dynamicColoring.customPalette.deleteButtonLabel": "删除",
"xpack.lens.dynamicColoring.customPalette.sortReason": "由于新停止值 {value},颜色停止已排序",
"xpack.lens.dynamicColoring.customPalette.stopAriaLabel": "停止 {index}",
"xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误",
"xpack.lens.editorFrame.colorIndicatorLabel": "此维度的颜色:{hex}",
"xpack.lens.editorFrame.configurationFailureMoreErrors": " +{errors} 个{errors, plural, other {错误}}",
@ -724,20 +719,12 @@
"xpack.lens.table.columnVisibilityLabel": "隐藏列",
"xpack.lens.table.defaultAriaLabel": "数据表可视化",
"xpack.lens.table.dynamicColoring.cell": "单元格",
"xpack.lens.table.dynamicColoring.continuity.aboveLabel": "高于范围",
"xpack.lens.table.dynamicColoring.continuity.allLabel": "高于和低于范围",
"xpack.lens.table.dynamicColoring.continuity.belowLabel": "低于范围",
"xpack.lens.table.dynamicColoring.continuity.label": "颜色连续性",
"xpack.lens.table.dynamicColoring.continuity.noneLabel": "范围内",
"xpack.lens.table.dynamicColoring.customPalette.colorStopsHelpPercentage": "百分比值是相对于全范围可用数据值的类型。",
"xpack.lens.table.dynamicColoring.customPalette.colorStopsLabel": "颜色停止",
"xpack.lens.table.dynamicColoring.customPalette.continuityHelp": "指定颜色在第一个颜色停止之前和最后一个颜色停止之后的出现方式。",
"xpack.lens.table.dynamicColoring.label": "按值上色",
"xpack.lens.table.dynamicColoring.none": "无",
"xpack.lens.table.dynamicColoring.rangeType.label": "值类型",
"xpack.lens.table.dynamicColoring.rangeType.number": "数字",
"xpack.lens.table.dynamicColoring.rangeType.percent": "百分比",
"xpack.lens.table.dynamicColoring.reverse.label": "反转颜色",
"xpack.lens.table.dynamicColoring.text": "文本",
"xpack.lens.table.hide.hideLabel": "隐藏",
"xpack.lens.table.palettePanelContainer.back": "返回",

View file

@ -73,7 +73,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.lens.openDimensionEditor('lnsHeatmap_cellPanel > lns-dimensionTrigger');
await PageObjects.lens.openPalettePanel('lnsHeatmap');
await retry.try(async () => {
await testSubjects.setValue('lnsPalettePanel_dynamicColoring_stop_value_0', '10', {
await testSubjects.setValue('lnsPalettePanel_dynamicColoring_range_value_0', '10', {
clearWithKeyboard: true,
typeCharByChar: true,
});
@ -108,16 +108,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// assert legend has changed
expect(debugState.legend!.items).to.eql([
{ key: '7,126 - 8,529.22', name: '7,126 - 8,529.22', color: '#6092c0' },
{ key: '8,529.22 - 11,335.66', name: '8,529.22 - 11,335.66', color: '#a8bfda' },
{ key: '11,335.66 - 14,142.11', name: '11,335.66 - 14,142.11', color: '#ebeff5' },
{ key: '14,142.11 - 16,948.55', name: '14,142.11 - 16,948.55', color: '#ecb385' },
{ key: '≥ 16,948.55', name: '≥ 16,948.55', color: '#e7664c' },
{ key: '7,125.99 - 8,529.2', name: '7,125.99 - 8,529.2', color: '#6092c0' },
{ key: '8,529.2 - 11,335.66', name: '8,529.2 - 11,335.66', color: '#a8bfda' },
{ key: '11,335.66 - 14,142.1', name: '11,335.66 - 14,142.1', color: '#ebeff5' },
{ key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#ecb385' },
{
color: '#e7664c',
key: '≥ 16,948.55',
name: '≥ 16,948.55',
},
]);
});
it('should reflect stop changes when in number to the chart', async () => {
await testSubjects.setValue('lnsPalettePanel_dynamicColoring_stop_value_0', '0', {
await testSubjects.setValue('lnsPalettePanel_dynamicColoring_range_value_0', '0', {
clearWithKeyboard: true,
});
await PageObjects.header.waitUntilLoadingHasFinished();
@ -130,8 +134,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// assert legend has changed
expect(debugState.legend!.items).to.eql([
{ key: '0 - 8,529.21', name: '0 - 8,529.21', color: '#6092c0' },
{ key: '8,529.21 - 11,335.66', name: '8,529.21 - 11,335.66', color: '#a8bfda' },
{ key: '0 - 8,529.2', name: '0 - 8,529.2', color: '#6092c0' },
{ key: '8,529.2 - 11,335.66', name: '8,529.2 - 11,335.66', color: '#a8bfda' },
{ key: '11,335.66 - 14,142.1', name: '11,335.66 - 14,142.1', color: '#ebeff5' },
{ key: '14,142.1 - 16,948.55', name: '14,142.1 - 16,948.55', color: '#ecb385' },
{ key: '≥ 16,948.55', name: '≥ 16,948.55', color: '#e7664c' },

View file

@ -34,7 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should change the color of the metric when tweaking the values in the panel', async () => {
await PageObjects.lens.openPalettePanel('lnsMetric');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.setValue('lnsPalettePanel_dynamicColoring_stop_value_1', '21000', {
await testSubjects.setValue('lnsPalettePanel_dynamicColoring_range_value_1', '21000', {
clearWithKeyboard: true,
});
await PageObjects.lens.waitForVisualization();
@ -43,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('should change the color when reverting the palette', async () => {
await testSubjects.click('lnsPalettePanel_dynamicColoring_reverse');
await testSubjects.click('lnsPalettePanel_dynamicColoring_reverseColors');
await PageObjects.lens.waitForVisualization();
const styleObj = await PageObjects.lens.getMetricStyle();
expect(styleObj.color).to.be('rgb(204, 86, 66)');

View file

@ -144,11 +144,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.lens.changePaletteTo('temperature');
await testSubjects.click('lnsPalettePanel_dynamicColoring_rangeType_groups_percent');
// now tweak the value
await testSubjects.setValue('lnsPalettePanel_dynamicColoring_stop_value_0', '30', {
await testSubjects.setValue('lnsPalettePanel_dynamicColoring_range_value_0', '30', {
clearWithKeyboard: true,
});
// when clicking on another row will trigger a sorting + update
await testSubjects.click('lnsPalettePanel_dynamicColoring_stop_value_1');
await testSubjects.click('lnsPalettePanel_dynamicColoring_range_value_1');
await PageObjects.header.waitUntilLoadingHasFinished();
// pick a cell without color as is below the range
const styleObj = await PageObjects.lens.getDatatableCellStyle(3, 3);
@ -158,7 +158,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('should allow the user to reverse the palette', async () => {
await testSubjects.click('lnsPalettePanel_dynamicColoring_reverse');
await testSubjects.click('lnsPalettePanel_dynamicColoring_reverseColors');
await PageObjects.header.waitUntilLoadingHasFinished();
const styleObj = await PageObjects.lens.getDatatableCellStyle(1, 1);
expect(styleObj['background-color']).to.be('rgb(168, 191, 218)');