diff --git a/changelogs/fragments/10909.yml b/changelogs/fragments/10909.yml new file mode 100644 index 000000000000..5615779cc6ac --- /dev/null +++ b/changelogs/fragments/10909.yml @@ -0,0 +1,2 @@ +feat: +- Simplify threshold logic ([#10909](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10909)) \ No newline at end of file diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.test.ts b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.test.ts index df4a650ccf40..da945c955ba4 100644 --- a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.test.ts +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.test.ts @@ -8,9 +8,12 @@ import { thresholdsToGradient, symbolOpposite, getGradientConfig, + generateThresholds, + generateValueThresholds, } from './bar_gauge_utils'; import { AxisColumnMappings, Threshold, VisFieldType } from '../types'; import { BarGaugeChartStyle } from './bar_gauge_vis_config'; +import { getColors } from '../theme/default_colors'; describe('bar_gauge_utils', () => { describe('getBarOrientation', () => { @@ -123,4 +126,158 @@ describe('bar_gauge_utils', () => { expect(result).toBeUndefined(); }); }); + + describe('generateThresholds', () => { + const mockThresholds: Threshold[] = [ + { value: 10, color: '#FF0000' }, + { value: 50, color: '#FFFF00' }, + { value: 80, color: '#0037ffff' }, + ]; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Basic functionality', () => { + it('should handle empty thresholds array', () => { + const result = generateThresholds(0, 100, [], '#BLUE'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ value: 0, color: '#BLUE' }); + }); + + it('should use default color when baseColor is undefined', () => { + const result = generateThresholds(0, 100, [], undefined); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ value: 0, color: '#00BD6B' }); + }); + + it('should process normal thresholds correctly', () => { + const result = generateThresholds(0, 100, mockThresholds, '#BLUE'); + + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ value: 0, color: '#BLUE' }); + expect(result[1]).toEqual({ value: 10, color: '#FF0000' }); + expect(result[2]).toEqual({ value: 50, color: '#FFFF00' }); + expect(result[3]).toEqual({ value: 80, color: '#0037ffff' }); + }); + }); + + describe('Threshold filtering and range handling', () => { + it('should filter thresholds above maxBase', () => { + const result = generateThresholds(0, 60, mockThresholds, '#BLUE'); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ value: 0, color: '#BLUE' }); + expect(result[1]).toEqual({ value: 10, color: '#FF0000' }); + expect(result[2]).toEqual({ value: 50, color: '#FFFF00' }); + // Should not include the threshold with value 80 + }); + + it('should handle minBase higher than first threshold', () => { + const result = generateThresholds(25, 100, mockThresholds, '#BLUE'); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ value: 25, color: '#FF0000' }); + expect(result[1]).toEqual({ value: 50, color: '#FFFF00' }); + expect(result[2]).toEqual({ value: 80, color: '#0037ffff' }); + }); + + it('should handle minBase higher than all thresholds', () => { + const result = generateThresholds(90, 100, mockThresholds, '#BLUE'); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ value: 90, color: '#0037ffff' }); + }); + }); + + describe('Duplicate threshold handling', () => { + it('should handle duplicate threshold values by keeping the latest', () => { + const duplicateThresholds: Threshold[] = [ + { value: 10, color: '#FF0000' }, + { value: 50, color: '#FFFF00' }, + { value: 50, color: '#00FFFF' }, // Duplicate value, different color + { value: 80, color: '#00FF00' }, + ]; + + const result = generateThresholds(0, 100, duplicateThresholds, '#BLUE'); + + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ value: 0, color: '#BLUE' }); + expect(result[1]).toEqual({ value: 10, color: '#FF0000' }); + expect(result[2]).toEqual({ value: 50, color: '#00FFFF' }); // Latest color + expect(result[3]).toEqual({ value: 80, color: '#00FF00' }); + }); + }); + }); + + describe('generateValueThresholds', () => { + const mockThresholds: Threshold[] = [ + { value: 10, color: '#FF0000' }, + { value: 50, color: '#FFFF00' }, + { value: 80, color: '#0037ffff' }, + ]; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Value stops processing', () => { + it('should process value stops correctly', () => { + const valueStops = [15, 45, 75]; + const result = generateValueThresholds(0, 100, valueStops, mockThresholds); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ value: 15, color: '#FF0000' }); // Uses threshold at 10 + expect(result[1]).toEqual({ value: 45, color: '#FF0000' }); // Uses threshold at 10 + expect(result[2]).toEqual({ value: 75, color: '#FFFF00' }); // Uses threshold at 50 + }); + + it('should filter value stops outside range', () => { + const valueStops = [5, 15, 45, 95, 105]; // 105 is above maxBase, 5 is below minBase (when minBase > 0) + const result = generateValueThresholds(10, 90, valueStops, mockThresholds); + + // Should only include stops between minBase (10) and maxBase (90) + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ value: 15, color: '#FF0000' }); + expect(result[1]).toEqual({ value: 45, color: '#FF0000' }); + }); + + it('should handle duplicate value stops', () => { + const valueStops = [15, 15, 45, 45, 75]; // Duplicates + const result = generateValueThresholds(0, 100, valueStops, mockThresholds); + expect(result).toHaveLength(3); // Should deduplicate + expect(result[0]).toEqual({ value: 15, color: '#FF0000' }); + expect(result[1]).toEqual({ value: 45, color: '#FF0000' }); + expect(result[2]).toEqual({ value: 75, color: '#FFFF00' }); + }); + + it('should handle unsorted value stops', () => { + const valueStops = [75, 15, 45]; // Unsorted + const result = generateValueThresholds(0, 100, valueStops, mockThresholds); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ value: 15, color: '#FF0000' }); + expect(result[1]).toEqual({ value: 45, color: '#FF0000' }); + expect(result[2]).toEqual({ value: 75, color: '#FFFF00' }); + }); + + it('should handle empty value stops array', () => { + const result = generateValueThresholds(0, 100, [], mockThresholds); + + expect(result).toEqual([]); + }); + + it('should handle value stops equal to threshold values', () => { + const valueStops = [10, 50, 80]; // Exact threshold values + const result = generateValueThresholds(0, 100, valueStops, mockThresholds); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ value: 10, color: '#FF0000' }); + expect(result[1]).toEqual({ value: 50, color: '#FFFF00' }); + expect(result[2]).toEqual({ value: 80, color: '#0037ffff' }); + }); + }); + }); }); diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.ts b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.ts index b2058503fc36..4abcc14085c1 100644 --- a/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.ts +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/bar_gauge_utils.ts @@ -5,6 +5,7 @@ import { AxisColumnMappings, Threshold, VisFieldType } from '../types'; import { BarGaugeChartStyle } from './bar_gauge_vis_config'; +import { getColors } from '../theme/default_colors'; export const getBarOrientation = ( styles: BarGaugeChartStyle, @@ -80,22 +81,6 @@ export const getGradientConfig = ( }; }; -export const processThresholds = (thresholds: Threshold[]) => { - const result: Threshold[] = []; - - for (let i = 0; i < thresholds.length; i++) { - const current = thresholds[i]; - const next = thresholds[i + 1]; - - // if the next threshold has the same value, use next - if (next && next.value === current.value) continue; - - result.push(current); - } - - return result; -}; - export const normalizeData = (data: number, start: number, end: number) => { if (start === end) return null; // normalize data value between start and end into 0–1 range @@ -157,3 +142,75 @@ export const generateParams = ( return result; }; + +export const generateThresholds = ( + minBase: number, + maxBase: number, + thresholds: Threshold[], + baseColor: string | undefined +) => { + const defaultColor = baseColor ?? getColors().statusGreen; + + // sort thresholds by value and dedupe threshold by value + thresholds = thresholds + .sort((t1, t2) => t1.value - t2.value) + .reduce((acc, t) => { + const last = acc.pop(); + if (last) { + if (last.value === t.value) { + return [...acc, t]; + } else { + return [...acc, last, t]; + } + } + return [...acc, t]; + }, [] as Threshold[]); + + const result: Threshold[] = []; + + const minThreshold: Threshold = { value: minBase, color: defaultColor }; + for (const threshold of thresholds) { + if (minThreshold.value >= threshold.value) { + minThreshold.color = threshold.color; + } + if (threshold.value > minThreshold.value && threshold.value <= maxBase) { + result.push(threshold); + } + } + result.unshift(minThreshold); + + return result; +}; + +export const generateValueThresholds = ( + minBase: number, + maxBase: number, + valueStops: number[], + thresholds: Threshold[] +) => { + const filteredValueStops = valueStops + .filter((v) => v <= maxBase && v >= minBase) + .sort((a, b) => a - b); + + const valueThresholds: Threshold[] = []; + if (filteredValueStops.length > 0 && thresholds.length > 0) { + const stops = [...new Set(filteredValueStops)]; + + let thresholdIndex = 0; + + for (const stop of stops) { + while ( + thresholdIndex < thresholds.length - 1 && + thresholds[thresholdIndex + 1].value <= stop + ) { + thresholdIndex++; + } + + // Add valid threshold for this stop + if (thresholds[thresholdIndex].value <= stop) { + valueThresholds.push({ value: stop, color: thresholds[thresholdIndex].color }); + } + } + } + return valueThresholds; +}; diff --git a/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.ts b/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.ts index fb96cb56550d..fd8d0972860d 100644 --- a/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.ts +++ b/src/plugins/explore/public/components/visualizations/bar_gauge/to_expression.ts @@ -13,11 +13,11 @@ import { getBarOrientation, thresholdsToGradient, symbolOpposite, - processThresholds, generateParams, + generateThresholds, + generateValueThresholds, } from './bar_gauge_utils'; import { getUnitById, showDisplayValue } from '../style_panel/unit/collection'; -import { mergeThresholdsWithBase } from '../style_panel/threshold/threshold_utils'; export const createBarGaugeSpec = ( transformedData: Array>, @@ -51,6 +51,7 @@ export const createBarGaugeSpec = ( let maxNumber: number = -Infinity; let minNumber: number = Infinity; let maxTextLength: number = 0; + const valueStops: number[] = []; const selectedUnit = getUnitById(styleOptions?.unitId); @@ -75,6 +76,7 @@ export const createBarGaugeSpec = ( [numericField]: isValidNumber ? calculate : null, displayValue, }); + valueStops.push(...(isValidNumber ? [calculate] : [])); } } @@ -98,36 +100,19 @@ export const createBarGaugeSpec = ( ] : styleOptions?.thresholdOptions?.thresholds; - const { textColor, mergedThresholds } = mergeThresholdsWithBase( + const mergedThresholds = generateThresholds( minBase, maxBase, - styleOptions?.thresholdOptions?.baseColor, - styleOptions?.thresholdOptions?.thresholds + styleOptions?.thresholdOptions?.thresholds ?? [], + styleOptions?.thresholdOptions?.baseColor ); - // transfer value to threshold - const valueToThreshold = []; - - for (const record of newRecord) { - for (let i = mergedThresholds.length - 1; i >= 0; i--) { - if (numericField && record[numericField] >= mergedThresholds[i].value) { - valueToThreshold.push({ value: record[numericField], color: mergedThresholds[i].color }); - break; - } - } - } - - // only use value-based thresholds in gradient mode - const finalThreshold = styleOptions?.exclusive.displayMode === 'gradient' ? valueToThreshold : []; - - const completeThreshold = [...mergedThresholds, ...(invalidCase ? [] : finalThreshold)].sort( - (a, b) => a.value - b.value - ); - - // filter out value thresholds that are beyond maxBase, this ensures that the gradient mode on different bar is always aligned. - const processedThresholds = processThresholds( - completeThreshold.filter((t) => t.value <= maxBase) - ); + const processedThresholds = [ + ...mergedThresholds, + ...(styleOptions?.exclusive.displayMode === 'gradient' + ? generateValueThresholds(minBase, maxBase, valueStops, mergedThresholds) + : []), + ].sort((a, b) => a.value - b.value); const gradientParams = generateParams(processedThresholds, styleOptions, isXaxisNumerical); diff --git a/src/plugins/explore/public/components/visualizations/gauge/to_expression.ts b/src/plugins/explore/public/components/visualizations/gauge/to_expression.ts index d76a1d03af3e..d7c70d5deecc 100644 --- a/src/plugins/explore/public/components/visualizations/gauge/to_expression.ts +++ b/src/plugins/explore/public/components/visualizations/gauge/to_expression.ts @@ -64,7 +64,7 @@ export const createGauge = ( minBase, maxBase, // TODO: update to use the color from color palette - styleOptions?.thresholdOptions?.baseColor || colors.statusGreen, + styleOptions?.thresholdOptions?.baseColor, styleOptions?.thresholdOptions?.thresholds, calculatedValue ); diff --git a/src/plugins/explore/public/components/visualizations/metric/to_expression.ts b/src/plugins/explore/public/components/visualizations/metric/to_expression.ts index a0a737bb3e1b..b67bf1799fdf 100644 --- a/src/plugins/explore/public/components/visualizations/metric/to_expression.ts +++ b/src/plugins/explore/public/components/visualizations/metric/to_expression.ts @@ -7,13 +7,9 @@ import { MetricChartStyle } from './metric_vis_config'; import { VisColumn, VEGASCHEMA, AxisRole, AxisColumnMappings, Threshold } from '../types'; import { getTooltipFormat } from '../utils/utils'; import { calculatePercentage, calculateValue } from '../utils/calculation'; -import { getColors } from '../theme/default_colors'; +import { getColors, DEFAULT_GREY } from '../theme/default_colors'; import { DEFAULT_OPACITY } from '../constants'; import { getUnitById, showDisplayValue } from '../style_panel/unit/collection'; -import { - mergeThresholdsWithBase, - getMaxAndMinBase, -} from '../style_panel/threshold/threshold_utils'; export const createSingleMetric = ( transformedData: Array>, @@ -39,12 +35,9 @@ export const createSingleMetric = ( const percentageSize = styles.percentageSize; let numericalValues: number[] = []; - let maxNumber: number = 0; - let minNumber: number = 0; + if (numericField) { numericalValues = transformedData.map((d) => d[numericField]); - maxNumber = Math.max(...numericalValues); - minNumber = Math.min(...numericalValues); } const calculatedValue = calculateValue(numericalValues, styles.valueCalculation); @@ -55,16 +48,6 @@ export const createSingleMetric = ( const displayValue = showDisplayValue(isValidNumber, selectedUnit, calculatedValue); - const { minBase, maxBase } = getMaxAndMinBase( - minNumber, - maxNumber, - styles?.min, - styles?.max, - calculatedValue - ); - - const targetValue = calculatedValue ?? 0; - function targetFillColor( useThresholdColor: boolean, threshold?: Threshold[], @@ -72,15 +55,16 @@ export const createSingleMetric = ( ) { const newThreshold = threshold ?? []; - const newBaseColor = baseColor ?? getColors().statusGreen; + if (calculatedValue === undefined) { + return useThresholdColor ? DEFAULT_GREY : colorPalette.text; + } + + let textColor = baseColor ?? getColors().statusGreen; - const { textColor, mergedThresholds } = mergeThresholdsWithBase( - minBase, - maxBase, - newBaseColor, - newThreshold, - calculatedValue - ); + for (let i = 0; i < newThreshold.length; i++) { + const { value, color } = newThreshold[i]; + if (calculatedValue !== undefined && calculatedValue >= value) textColor = color; + } const fillColor = useThresholdColor ? textColor : colorPalette.text; diff --git a/src/plugins/explore/public/components/visualizations/style_panel/threshold/threshold_utils.ts b/src/plugins/explore/public/components/visualizations/style_panel/threshold/threshold_utils.ts index c9d6dbeea7dc..f542d5b14c8e 100644 --- a/src/plugins/explore/public/components/visualizations/style_panel/threshold/threshold_utils.ts +++ b/src/plugins/explore/public/components/visualizations/style_panel/threshold/threshold_utils.ts @@ -25,7 +25,7 @@ export function mergeThresholdsWithBase( // Handle empty thresholds if (!validThresholds || validThresholds.length === 0) { return { - textColor: !targetValue || targetValue < minBase ? DEFAULT_GREY : defaultColor, + textColor: !targetValue ? DEFAULT_GREY : defaultColor, mergedThresholds: [{ value: minBase, color: defaultColor }], }; }