diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx index b725f9eed3555a..da548c3bd1fa39 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx @@ -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', }); diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index f1fbbccc3c20fd..6af0c36323d452 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -269,8 +269,10 @@ export const HeatmapComponent: FC = 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) => { diff --git a/src/plugins/charts/common/index.ts b/src/plugins/charts/common/index.ts index 825fd74e24041d..2b8f252f892a59 100644 --- a/src/plugins/charts/common/index.ts +++ b/src/plugins/charts/common/index.ts @@ -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'; diff --git a/src/plugins/charts/common/palette.ts b/src/plugins/charts/common/palette.ts index 8cd449fe99f99b..55cd2f32e0aee7 100644 --- a/src/plugins/charts/common/palette.ts +++ b/src/plugins/charts/common/palette.ts @@ -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), }, }; }, diff --git a/src/plugins/charts/common/static/index.ts b/src/plugins/charts/common/static/index.ts index 4a6b3ec2b52bb3..67da5f8ca9e9b4 100644 --- a/src/plugins/charts/common/static/index.ts +++ b/src/plugins/charts/common/static/index.ts @@ -17,5 +17,5 @@ export { } from './color_maps'; export { ColorMode, LabelRotation, defaultCountLabel } from './components'; - +export { checkIsMaxContinuity, checkIsMinContinuity } from './palette'; export * from './styles'; diff --git a/src/plugins/charts/common/static/palette/index.ts b/src/plugins/charts/common/static/palette/index.ts new file mode 100644 index 00000000000000..d28862b9a02e1c --- /dev/null +++ b/src/plugins/charts/common/static/palette/index.ts @@ -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)); diff --git a/src/plugins/charts/common/types.ts b/src/plugins/charts/common/types.ts index 841494c2edb8af..ce1e1adc3218ac 100644 --- a/src/plugins/charts/common/types.ts +++ b/src/plugins/charts/common/types.ts @@ -8,6 +8,8 @@ import { ColorSchemas, LabelRotation } from './static'; +export type PaletteContinuity = 'above' | 'below' | 'none' | 'all'; + export interface ColorSchemaParams { colorSchema: ColorSchemas; invertColors: boolean; diff --git a/src/plugins/charts/public/services/palettes/helpers.test.ts b/src/plugins/charts/public/services/palettes/helpers.test.ts index 90f5745570cc8b..a8bf9b9df8e194 100644 --- a/src/plugins/charts/public/services/palettes/helpers.test.ts +++ b/src/plugins/charts/public/services/palettes/helpers.test.ts @@ -73,7 +73,7 @@ describe('workoutColorForValue', () => { { ...DEFAULT_PROPS, continuity: 'all', - rangeMax: 100, + rangeMax: Infinity, stops: [20, 40, 60, 80], }, { min: 0, max: 200 } diff --git a/src/plugins/charts/public/services/palettes/helpers.ts b/src/plugins/charts/public/services/palettes/helpers.ts index bd1f8350ba9f39..8d22b1ee42129c 100644 --- a/src/plugins/charts/public/services/palettes/helpers.ts +++ b/src/plugins/charts/public/services/palettes/helpers.ts @@ -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, + 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, + 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 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]; + const [min, max]: [number, number] = range === 'percent' ? [0, 100] : [minMax.min, minMax.max]; - // 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]; + 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 (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); } diff --git a/test/interpreter_functional/snapshots/baseline/partial_test_1.json b/test/interpreter_functional/snapshots/baseline/partial_test_1.json index 5b081f4d0713e7..6bbd5ea72015d6 100644 --- a/test/interpreter_functional/snapshots/baseline/partial_test_1.json +++ b/test/interpreter_functional/snapshots/baseline/partial_test_1.json @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json index 8f079b49ed98da..b1e76880dc912c 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json index e0026b189949d9..ce352d1f63c28c 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json index 4eef2bcb1fc483..90cc06d2088c87 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json index 26ca82acd75630..7850768e9466c2 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json index d13cc180e1e7db..19b76ac66efcfc 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 152eef4ebd3fe0..0243aeef41c2d2 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -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; diff --git a/x-pack/plugins/lens/public/assets/distribute_equally.tsx b/x-pack/plugins/lens/public/assets/distribute_equally.tsx new file mode 100644 index 00000000000000..775ccaca5cdb67 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/distribute_equally.tsx @@ -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) => ( + + + +); diff --git a/x-pack/plugins/lens/public/assets/related.tsx b/x-pack/plugins/lens/public/assets/related.tsx new file mode 100644 index 00000000000000..221ef86ec74eb1 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/related.tsx @@ -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) => ( + + + +); diff --git a/x-pack/plugins/lens/public/assets/value_max.tsx b/x-pack/plugins/lens/public/assets/value_max.tsx new file mode 100644 index 00000000000000..791822998bdda7 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/value_max.tsx @@ -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) => ( + + + + + +); diff --git a/x-pack/plugins/lens/public/assets/value_min.tsx b/x-pack/plugins/lens/public/assets/value_min.tsx new file mode 100644 index 00000000000000..1bced92537740a --- /dev/null +++ b/x-pack/plugins/lens/public/assets/value_min.tsx @@ -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) => ( + + + +); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx index 6840f4f13450c7..fc0fa9b5d8ac6d 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -27,7 +27,6 @@ import { applyPaletteParams, defaultPaletteParams, FIXED_PROGRESSION, - getStopsForFixedMode, useDebouncedValue, PalettePanelContainer, findMinMaxByColumnId, @@ -352,7 +351,7 @@ export function TableDimensionEditor( color)} type={FIXED_PROGRESSION} onClick={() => { setIsPaletteOpen(!isPaletteOpen); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 108d01acd33a9d..0115a8c5b39c7f 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -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, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/dimension_editor.tsx b/x-pack/plugins/lens/public/heatmap_visualization/dimension_editor.tsx index 8adcf3ef79122f..78f10056ac60c0 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/dimension_editor.tsx @@ -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( color)} type={FIXED_PROGRESSION} onClick={() => { setIsPaletteOpen(!isPaletteOpen); @@ -93,23 +92,24 @@ export function HeatmapDimensionEditor( isOpen={isPaletteOpen} handleClose={() => setIsPaletteOpen(!isPaletteOpen)} > - { - // 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 && ( + { + // 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'], + }); + }} + /> + )} diff --git a/x-pack/plugins/lens/public/heatmap_visualization/types.ts b/x-pack/plugins/lens/public/heatmap_visualization/types.ts index 5f57c779929001..8c94dc7254d9ca 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/types.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/types.ts @@ -19,7 +19,9 @@ export type HeatmapLayerState = HeatmapArguments & { shape: ChartShapes; }; +export type Palette = PaletteOutput & { accessor: string }; + export type HeatmapVisualizationState = HeatmapLayerState & { // need to store the current accessor to reset the color stops at accessor change - palette?: PaletteOutput & { accessor: string }; + palette?: Palette; }; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/utils.ts b/x-pack/plugins/lens/public/heatmap_visualization/utils.ts index 3f860be646f35c..da7f1141046c95 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/utils.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/utils.ts @@ -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, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts index 9a463efae6a2de..17ee15faf8f3be 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -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', diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index 108e9b3ffb9523..82305d293fe4d7 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -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 diff --git a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx index fd804ee5a82ad5..77c6e909bc671f 100644 --- a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx @@ -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( color) - } + palette={displayStops.map(({ color }) => color)} type={FIXED_PROGRESSION} onClick={togglePalette} /> diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx index aee83fe4fcae94..3ec2f4c285c4e5 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx @@ -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( diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.tsx index 38bb92bb342ef0..d84abcc0b10051 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.tsx @@ -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, diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index 920c594952ed03..19d5a9c7e340a1 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -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, }, ] : [], diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.test.tsx new file mode 100644 index 00000000000000..872bff882fbff1 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.test.tsx @@ -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( + + + + ); +} + +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: {} }, + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx new file mode 100644 index 00000000000000..76cab5ba743d3f --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx @@ -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; +} + +export function ColorRanges({ + colorRanges, + paletteConfiguration, + showExtraActions = true, + dispatch, +}: ColorRangesProps) { + const { dataBounds } = useContext(ColorRangesContext); + const [colorRangesValidity, setColorRangesValidity] = useState< + Record + >({}); + + 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 ( + + {colorRanges.map((colorRange, index) => ( + + + + ))} + {lastColorRange ? ( + + + + ) : null} + + {errors.map((error) => ( + {error} + ))} + + {showExtraActions ? ( + + = paletteConfiguration?.maxSteps) || + errors.length + )} + shouldDisableDistribute={Boolean(colorRanges.length === 1)} + shouldDisableReverse={Boolean(colorRanges.length === 1)} + /> + + ) : null} + + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_context.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_context.ts new file mode 100644 index 00000000000000..368abf6a167019 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_context.ts @@ -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( + {} as ColorRangesContextType +); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_extra_actions.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_extra_actions.tsx new file mode 100644 index 00000000000000..7756922bfa883c --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_extra_actions.tsx @@ -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; + 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 ( + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item.tsx new file mode 100644 index 00000000000000..a6d66a9177ad53 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item.tsx @@ -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; + 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( + + + + ); + } + + return items; +}; + +export function ColorRangeItem({ + accessor, + index, + colorRange, + rangeType, + colorRanges, + validation, + continuity, + dispatch, +}: ColorRangesItemProps) { + const { dataBounds, palettes } = useContext(ColorRangesContext); + const [popoverInFocus, setPopoverInFocus] = useState(false); + const [localValue, setLocalValue] = useState(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) => { + 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 ( + + + {!isLast ? ( + + ) : ( + + ) + } + secondaryInputDisplay="top" + color={colorRange.color} + onFocus={() => setPopoverInFocus(true)} + onBlur={() => { + setPopoverInFocus(false); + }} + isInvalid={!isColorValid} + /> + ) : ( + + )} + + + {isLast ? '\u2264' : '\u2265'}} + aria-label={i18n.translate('xpack.lens.dynamicColoring.customPalette.rangeAriaLabel', { + defaultMessage: 'Range {index}', + values: { + index: index + 1, + }, + })} + /> + + {ActionButton ? ( + + + + ) : null} + + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item_buttons.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item_buttons.tsx new file mode 100644 index 00000000000000..3f289395f7b7d7 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_item_buttons.tsx @@ -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; + 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 ( + + ); +} + +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 ( + + + + ); +} + +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 ( + + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.test.ts new file mode 100644 index 00000000000000..a645d637bc6a58 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.test.ts @@ -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(); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.tsx new file mode 100644 index 00000000000000..30cfe380663780 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges_validation.tsx @@ -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) => { + 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 => { + 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>( + (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 + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/index.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/index.tsx new file mode 100644 index 00000000000000..0b6f90de1d9d08 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/index.tsx @@ -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'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/types.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/types.ts new file mode 100644 index 00000000000000..02e673b15462f1 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/types.ts @@ -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'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.test.ts new file mode 100644 index 00000000000000..837c66eeb1e5e6 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.test.ts @@ -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 }, + ]); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.ts new file mode 100644 index 00000000000000..6a2e92d284f01a --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_crud.ts @@ -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[] => { + 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 }); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.test.ts new file mode 100644 index 00000000000000..14150022395c8a --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.test.ts @@ -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 }, + ]); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.ts new file mode 100644 index 00000000000000..b2477f1ad510a6 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/color_ranges_extra_actions.ts @@ -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(); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/index.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/index.ts new file mode 100644 index 00000000000000..e868198d8e4062 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/index.ts @@ -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'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.test.ts new file mode 100644 index 00000000000000..daebb02e44e462 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.test.ts @@ -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); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.ts b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.ts new file mode 100644 index 00000000000000..300df9b3b317b2 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/utils/utils.ts @@ -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; +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx deleted file mode 100644 index 5489c0cbd96933..00000000000000 --- a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx +++ /dev/null @@ -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(); - 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(); - 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(); - const componentWithMaxSteps = mount( - - ); - 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(); - 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(); - 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(); - 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'); - }); -}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx deleted file mode 100644 index 65f07351021b7f..00000000000000 --- a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx +++ /dev/null @@ -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(''); - const shouldEnableDelete = localColorStops.length > 2; - const shouldDisableAdd = Boolean( - paletteConfiguration?.maxSteps && localColorStops.length >= paletteConfiguration?.maxSteps - ); - - const [popoverInFocus, setPopoverInFocus] = useState(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 ? ( - -

- {i18n.translate('xpack.lens.dynamicColoring.customPalette.sortReason', { - defaultMessage: 'Color stops have been sorted due to new stop value {value}', - values: { - value: sortedReason, - }, - })} -

-
- ) : null} - - - {localColorStops.map(({ color, stop, id }, index) => { - const prevStopValue = Number(localColorStops[index - 1]?.stop ?? -Infinity); - const nextStopValue = Number(localColorStops[index + 1]?.stop ?? Infinity); - - return ( - ) => { - // 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); - } - }} - > - - - { - 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, - }, - } - )} - /> - - - { - // make sure that the popover is closed - if (color === '' && !popoverInFocus) { - const newColorStops = [...localColorStops]; - newColorStops[index] = { color: colorStops[index].color, stop, id }; - setLocalColorStops(newColorStops); - } - }} - > - { - 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=" " - /> - - - - - { - const newColorStops = localColorStops.filter((_, i) => i !== index); - setLocalColorStops(newColorStops); - }} - data-test-subj={`${dataTestPrefix}_dynamicColoring_removeStop_${index}`} - isDisabled={!shouldEnableDelete} - /> - - - - - ); - })} - - - - - - { - 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', - })} - - - - ); -}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/constants.ts b/x-pack/plugins/lens/public/shared_components/coloring/constants.ts index fafa2cd6139303..86b6379a2748f8 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/constants.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/constants.ts @@ -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, diff --git a/x-pack/plugins/lens/public/shared_components/coloring/index.ts b/x-pack/plugins/lens/public/shared_components/coloring/index.ts index 7cbf79ac43b1e7..93583f6148e095 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/index.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/index.ts @@ -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'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx index 0840c19495eaa2..a97f3d3f041122 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx @@ -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; setPalette: (palette: PaletteOutput) => 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(); - 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(); - 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(); - 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(); - - 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(); - - 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(); 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); }); }); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx index d1f1bc813deab9..104b8e4319e40a 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx @@ -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 ( - - - - - {children} - - ); -}; - -/** - * 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; + activePalette: PaletteOutput; setPalette: (palette: PaletteOutput) => 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 [localState, dispatch] = useReducer(paletteConfigurationReducer, { + activePalette, + colorRanges: colorRangesToShow, + }); - const colorStopsToShow = roundStopValues( - getColorStops(palettes, activePalette?.params?.colorStops || [], activePalette, dataBounds) + 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 ( - <> -
+
+ + { + const isPaletteChanged = newPalette.name !== activePalette.name; + if (isPaletteChanged) { + dispatch({ + type: 'changeColorPalette', + payload: { palette: newPalette, dataBounds, palettes, disableSwitchingContinuity }, + }); + } + }} + showCustomPalette + showDynamicColorOnly + /> + + {showRangeTypeSelector && ( + {i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', { + defaultMessage: 'Value type', + })}{' '} + + + } display="rowCompressed" - fullWidth - label={i18n.translate('xpack.lens.palettePicker.label', { - defaultMessage: 'Color palette', - })} > - { - 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; + { + const newRangeType = id.replace( + idPrefix, + '' + ) as RequiredPaletteParamTypes['rangeType']; - setPalette({ - ...newPalette, - params: newParams, + dispatch({ + type: 'updateRangeType', + payload: { rangeType: newRangeType, dataBounds, palettes }, }); }} - showCustomPalette - showDynamicColorOnly + isFullWidth /> - {showContinuity && ( - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.label', { - defaultMessage: 'Color continuity', - })}{' '} - - - } - display="rowCompressed" - > - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.aboveLabel', { - defaultMessage: 'Above range', - })} - - ), - 'data-test-subj': 'continuity-above', - }, - { - value: 'below', - inputDisplay: ( - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.belowLabel', { - defaultMessage: 'Below range', - })} - - ), - 'data-test-subj': 'continuity-below', - }, - { - value: 'all', - inputDisplay: ( - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.allLabel', { - defaultMessage: 'Above and below range', - })} - - ), - 'data-test-subj': 'continuity-all', - }, - { - value: 'none', - inputDisplay: ( - - {i18n.translate('xpack.lens.table.dynamicColoring.continuity.noneLabel', { - defaultMessage: 'Within range', - })} - - ), - 'data-test-subj': 'continuity-none', - }, - ]} - valueOfSelected={activePalette.params?.continuity || defaultPaletteParams.continuity} - onChange={(continuity: Required['continuity']) => - setPalette( - mergePaletteParams(activePalette, { - continuity, - }) - ) - } - /> - - )} - {showRangeTypeSelector && ( - - {i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', { - defaultMessage: 'Value type', - })}{' '} - - - } - display="rowCompressed" - > - { - 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)); - }} - /> - - )} - - { - // 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); - }} - > - - - - - - {i18n.translate('xpack.lens.table.dynamicColoring.reverse.label', { - defaultMessage: 'Reverse colors', - })} - - - - - } + )} + + - { - 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); - }} + - -
- + + +
); } diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration_reducer.ts b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration_reducer.ts new file mode 100644 index 00000000000000..efdfc104ddfea1 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration_reducer.ts @@ -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'); + } +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx index abcd714b3af97c..cf01b60b1c42cb 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_panel_container.tsx @@ -25,13 +25,13 @@ import { export function PalettePanelContainer({ isOpen, handleClose, - children, siblingRef, + children, }: { isOpen: boolean; handleClose: () => void; - children: React.ReactElement | React.ReactElement[]; siblingRef: MutableRefObject; + children?: React.ReactElement | React.ReactElement[]; }) { const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false); @@ -91,7 +91,7 @@ export function PalettePanelContainer({ -
{children}
+ {children &&
{children}
} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx index 19da4eef29969d..5b3b514bcb23a7 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx @@ -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 + ), }; } diff --git a/x-pack/plugins/lens/public/shared_components/coloring/types.ts b/x-pack/plugins/lens/public/shared_components/coloring/types.ts new file mode 100644 index 00000000000000..00ffb12e70715a --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/types.ts @@ -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; + 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; + palettes: PaletteRegistry; + dataBounds: DataBounds; + disableSwitchingContinuity: boolean; +} + +export type PaletteConfigurationActions = + | ColorRangesActions + | { type: 'updateRangeType'; payload: UpdateRangeTypePayload } + | { type: 'changeColorPalette'; payload: ChangeColorPalettePayload }; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts index 07d93ca5c40c6b..8a01e2a12c2c53 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts @@ -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, + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts index 010f6e99e39bc5..16cb843f3dfb4a 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts @@ -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>( 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, 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['stops'], activePalette: PaletteOutput, - 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, + 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 = {}; + const minMax: Record = {}; 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(); } } } diff --git a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx index f5031242d268c8..18084a8c3db518 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx @@ -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( 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 ( diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index 94a2ec2eaac1c1..670f44e47270db 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -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) || {}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2a18190d0b2a55..4ed57d7b8aaf70 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -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": "戻る", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index dfc4576371768d..7129a804f921fe 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "返回", diff --git a/x-pack/test/functional/apps/lens/heatmap.ts b/x-pack/test/functional/apps/lens/heatmap.ts index e4f20d075541fd..0318f544a45663 100644 --- a/x-pack/test/functional/apps/lens/heatmap.ts +++ b/x-pack/test/functional/apps/lens/heatmap.ts @@ -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' }, diff --git a/x-pack/test/functional/apps/lens/metrics.ts b/x-pack/test/functional/apps/lens/metrics.ts index 79f37df60cccfd..19f463e3569d8b 100644 --- a/x-pack/test/functional/apps/lens/metrics.ts +++ b/x-pack/test/functional/apps/lens/metrics.ts @@ -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)'); diff --git a/x-pack/test/functional/apps/lens/table.ts b/x-pack/test/functional/apps/lens/table.ts index 94bc5e8b266ea5..2070eb047ef614 100644 --- a/x-pack/test/functional/apps/lens/table.ts +++ b/x-pack/test/functional/apps/lens/table.ts @@ -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)');