Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/10909.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Simplify threshold logic ([#10909](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10909))
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ import {
thresholdsToGradient,
symbolOpposite,
getGradientConfig,
generateThresholds,
} from './bar_gauge_utils';
import { AxisColumnMappings, Threshold, VisFieldType } from '../types';
import { BarGaugeChartStyle } from './bar_gauge_vis_config';

jest.mock('../theme/default_colors', () => ({
getColors: jest.fn(() => ({ statusGreen: '#00FF00' })),
}));

describe('bar_gauge_utils', () => {
describe('getBarOrientation', () => {
const mockAxisColumnMappings: AxisColumnMappings = {
Expand Down Expand Up @@ -123,4 +128,186 @@ 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.mergedThresholds).toHaveLength(1);
expect(result.mergedThresholds[0]).toEqual({ value: 0, color: '#BLUE' });
expect(result.valueThresholds).toEqual([]);
});

it('should use default color when baseColor is undefined', () => {
const result = generateThresholds(0, 100, [], undefined, []);

expect(result.mergedThresholds).toHaveLength(1);
expect(result.mergedThresholds[0]).toEqual({ value: 0, color: '#00FF00' });
});

it('should process normal thresholds correctly', () => {
const result = generateThresholds(0, 100, mockThresholds, '#BLUE', []);

expect(result.mergedThresholds).toHaveLength(4);
expect(result.mergedThresholds[0]).toEqual({ value: 0, color: '#BLUE' });
expect(result.mergedThresholds[1]).toEqual({ value: 10, color: '#FF0000' });
expect(result.mergedThresholds[2]).toEqual({ value: 50, color: '#FFFF00' });
expect(result.mergedThresholds[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.mergedThresholds).toHaveLength(3);
expect(result.mergedThresholds[0]).toEqual({ value: 0, color: '#BLUE' });
expect(result.mergedThresholds[1]).toEqual({ value: 10, color: '#FF0000' });
expect(result.mergedThresholds[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.mergedThresholds).toHaveLength(3);
expect(result.mergedThresholds[0]).toEqual({ value: 25, color: '#FF0000' });
expect(result.mergedThresholds[1]).toEqual({ value: 50, color: '#FFFF00' });
expect(result.mergedThresholds[2]).toEqual({ value: 80, color: '#0037ffff' });
});

it('should handle minBase higher than all thresholds', () => {
const result = generateThresholds(90, 100, mockThresholds, '#BLUE', []);

expect(result.mergedThresholds).toHaveLength(1);
expect(result.mergedThresholds[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.mergedThresholds).toHaveLength(4);
expect(result.mergedThresholds[0]).toEqual({ value: 0, color: '#BLUE' });
expect(result.mergedThresholds[1]).toEqual({ value: 10, color: '#FF0000' });
expect(result.mergedThresholds[2]).toEqual({ value: 50, color: '#00FFFF' }); // Latest color
expect(result.mergedThresholds[3]).toEqual({ value: 80, color: '#00FF00' });
});
});

describe('Value stops processing', () => {
it('should process value stops correctly', () => {
const valueStops = [15, 45, 75];
const result = generateThresholds(0, 100, mockThresholds, '#BLUE', valueStops);

expect(result.valueThresholds).toHaveLength(3);
expect(result.valueThresholds[0]).toEqual({ value: 15, color: '#FF0000' }); // Uses threshold at 10
expect(result.valueThresholds[1]).toEqual({ value: 45, color: '#FF0000' }); // Uses threshold at 10
expect(result.valueThresholds[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 = generateThresholds(10, 90, mockThresholds, '#BLUE', valueStops);

// Should only include stops between minBase (10) and maxBase (90)
expect(result.valueThresholds).toHaveLength(2);
expect(result.valueThresholds[0]).toEqual({ value: 15, color: '#FF0000' });
expect(result.valueThresholds[1]).toEqual({ value: 45, color: '#FF0000' });
});

it('should handle duplicate value stops', () => {
const valueStops = [15, 15, 45, 45, 75]; // Duplicates
const result = generateThresholds(0, 100, mockThresholds, '#BLUE', valueStops);

expect(result.valueThresholds).toHaveLength(3); // Should deduplicate
expect(result.valueThresholds[0]).toEqual({ value: 15, color: '#FF0000' });
expect(result.valueThresholds[1]).toEqual({ value: 45, color: '#FF0000' });
expect(result.valueThresholds[2]).toEqual({ value: 75, color: '#FFFF00' });
});

it('should handle unsorted value stops', () => {
const valueStops = [75, 15, 45]; // Unsorted
const result = generateThresholds(0, 100, mockThresholds, '#BLUE', valueStops);

expect(result.valueThresholds).toHaveLength(3);
expect(result.valueThresholds[0]).toEqual({ value: 15, color: '#FF0000' });
expect(result.valueThresholds[1]).toEqual({ value: 45, color: '#FF0000' });
expect(result.valueThresholds[2]).toEqual({ value: 75, color: '#FFFF00' });
});

it('should handle empty value stops array', () => {
const result = generateThresholds(0, 100, mockThresholds, '#BLUE', []);

expect(result.valueThresholds).toEqual([]);
});
});

describe('Edge cases', () => {
it('should handle single threshold', () => {
const singleThreshold: Threshold[] = [{ value: 50, color: '#FF0000' }];
const result = generateThresholds(0, 100, singleThreshold, '#BLUE', [25, 75]);

expect(result.mergedThresholds).toHaveLength(2);
expect(result.mergedThresholds[0]).toEqual({ value: 0, color: '#BLUE' });
expect(result.mergedThresholds[1]).toEqual({ value: 50, color: '#FF0000' });

expect(result.valueThresholds).toHaveLength(2);
expect(result.valueThresholds[0]).toEqual({ value: 25, color: '#BLUE' });
expect(result.valueThresholds[1]).toEqual({ value: 75, color: '#FF0000' });
});

it('should handle minBase equal to maxBase', () => {
const result = generateThresholds(50, 50, mockThresholds, '#BLUE', []);

expect(result.mergedThresholds).toHaveLength(1);
expect(result.mergedThresholds[0]).toEqual({ value: 50, color: '#FFFF00' });
});

it('should handle value stops equal to threshold values', () => {
const valueStops = [10, 50, 80]; // Exact threshold values
const result = generateThresholds(0, 100, mockThresholds, '#BLUE', valueStops);

expect(result.valueThresholds).toHaveLength(3);
expect(result.valueThresholds[0]).toEqual({ value: 10, color: '#FF0000' });
expect(result.valueThresholds[1]).toEqual({ value: 50, color: '#FFFF00' });
expect(result.valueThresholds[2]).toEqual({ value: 80, color: '#0037ffff' });
});

it('should handle negative values', () => {
const negativeThresholds: Threshold[] = [
{ value: -50, color: '#FF0000' },
{ value: 0, color: '#FFFF00' },
{ value: 50, color: '#00FF00' },
];

const result = generateThresholds(-100, 100, negativeThresholds, '#BLUE', [-25, 25]);

expect(result.mergedThresholds).toHaveLength(4);
expect(result.mergedThresholds[0]).toEqual({ value: -100, color: '#BLUE' });
expect(result.valueThresholds).toHaveLength(2);
expect(result.valueThresholds[0]).toEqual({ value: -25, color: '#FF0000' });
expect(result.valueThresholds[1]).toEqual({ value: 25, color: '#FFFF00' });
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -157,3 +142,67 @@ export const generateParams = (

return result;
};

export const generateThresholds = (
minBase: number,
maxBase: number,
thresholds: Threshold[],
baseColor: string | undefined,
valueStops: number[]
) => {
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 filteredValueStops = valueStops
.filter((v) => v <= maxBase && v >= minBase)
.sort((a, b) => a - b);
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);

const valueResults: Threshold[] = [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would create another function to calculate the valueResults separately

if (filteredValueStops.length > 0 && result.length > 0) {
const stops = [...new Set(filteredValueStops)];

let thresholdIndex = 0;

for (const stop of stops) {
while (thresholdIndex < result.length - 1 && result[thresholdIndex + 1].value <= stop) {
thresholdIndex++;
}

// Add valid threshold for this stop
if (result[thresholdIndex].value <= stop) {
valueResults.push({ value: stop, color: result[thresholdIndex].color });
}
}
}

return {
mergedThresholds: result,
valueThresholds: valueResults,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ import {
getBarOrientation,
thresholdsToGradient,
symbolOpposite,
processThresholds,
generateParams,
generateThresholds,
} 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<Record<string, any>>,
Expand Down Expand Up @@ -51,6 +50,7 @@ export const createBarGaugeSpec = (
let maxNumber: number = -Infinity;
let minNumber: number = Infinity;
let maxTextLength: number = 0;
const valueStops: number[] = [];

const selectedUnit = getUnitById(styleOptions?.unitId);

Expand All @@ -75,6 +75,7 @@ export const createBarGaugeSpec = (
[numericField]: isValidNumber ? calculate : null,
displayValue,
});
valueStops.push(...(isValidNumber ? [calculate] : []));
}
}

Expand All @@ -98,36 +99,18 @@ export const createBarGaugeSpec = (
]
: styleOptions?.thresholdOptions?.thresholds;

const { textColor, mergedThresholds } = mergeThresholdsWithBase(
const { mergedThresholds, valueThresholds } = generateThresholds(
minBase,
maxBase,
styleOptions?.thresholdOptions?.thresholds ?? [],
styleOptions?.thresholdOptions?.baseColor,
styleOptions?.thresholdOptions?.thresholds
valueStops
);

// 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' ? valueThresholds : []),
].sort((a, b) => a.value - b.value);

const gradientParams = generateParams(processedThresholds, styleOptions, isXaxisNumerical);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
Loading
Loading