mirror of https://github.com/grafana/grafana
FieldColor: Adds new standard color option for color (#28039)
* FieldColor: Added field color option * Progress * FieldColor: Added custom schemes * move to fieldColor * move to fieldColor * add back the standard color picker * FieldColor: Added registry for field color modes * wip refactor * Seperate scale from color mode * Progress * schemes working * Stuff is working * Added fallback * Updated threshold tests * Added unit tests * added more tests * Made it work with new graph panel * Use scale calculator from graph panel * Updates * Updated test * Renaming things * Update packages/grafana-data/src/field/displayProcessor.ts Co-authored-by: Ryan McKinley <ryantxu@gmail.com> * updated according to feedback, added docs * Updated docs * Updated * Update docs/sources/panels/field-options/standard-field-options.md Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com> * Update docs/sources/panels/field-options/standard-field-options.md Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com> * Updated docs according to feedback * fixed test * Updated * Updated wording * Change to fieldState.seriesIndex * Updated tests * Updates * New names * More work needed to support bar gauge and showing the color modes in the picker * Now correct gradients work in bar gauge * before rename * Unifying the concept * Updates * review feedback * Updated * Skip minification * Updated * UI improvements Co-authored-by: Ryan McKinley <ryantxu@gmail.com> Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>pull/28071/head^2
parent
c052abed87
commit
d181782943
@ -0,0 +1,32 @@ |
||||
import { Field, GrafanaThemeType, GrafanaTheme, FieldColorModeId } from '../types'; |
||||
import { fieldColorModeRegistry, FieldValueColorCalculator } from './fieldColor'; |
||||
|
||||
describe('fieldColorModeRegistry', () => { |
||||
interface GetCalcOptions { |
||||
mode: FieldColorModeId; |
||||
seriesIndex?: number; |
||||
} |
||||
|
||||
function getCalculator(options: GetCalcOptions): FieldValueColorCalculator { |
||||
const mode = fieldColorModeRegistry.get(options.mode); |
||||
return mode.getCalculator( |
||||
{ state: { seriesIndex: options.seriesIndex } } as Field, |
||||
{ type: GrafanaThemeType.Dark } as GrafanaTheme |
||||
); |
||||
} |
||||
|
||||
it('Schemes should interpolate', () => { |
||||
const calcFn = getCalculator({ mode: FieldColorModeId.ContinousGrYlRd }); |
||||
expect(calcFn(70, 0.5, undefined)).toEqual('rgb(226, 192, 61)'); |
||||
}); |
||||
|
||||
it('Palette classic with series index 0', () => { |
||||
const calcFn = getCalculator({ mode: FieldColorModeId.PaletteClassic, seriesIndex: 0 }); |
||||
expect(calcFn(70, 0, undefined)).toEqual('#7EB26D'); |
||||
}); |
||||
|
||||
it('Palette classic with series index 1', () => { |
||||
const calcFn = getCalculator({ mode: FieldColorModeId.PaletteClassic, seriesIndex: 1 }); |
||||
expect(calcFn(70, 0, undefined)).toEqual('#EAB839'); |
||||
}); |
||||
}); |
@ -0,0 +1,162 @@ |
||||
import { FALLBACK_COLOR, Field, FieldColorModeId, GrafanaTheme, Threshold } from '../types'; |
||||
import { classicColors, getColorFromHexRgbOrName, RegistryItem } from '../utils'; |
||||
import { Registry } from '../utils/Registry'; |
||||
import { interpolateRgbBasis } from 'd3-interpolate'; |
||||
import { fallBackTreshold } from './thresholds'; |
||||
|
||||
export type FieldValueColorCalculator = (value: number, percent: number, Threshold?: Threshold) => string; |
||||
|
||||
export interface FieldColorMode extends RegistryItem { |
||||
getCalculator: (field: Field, theme: GrafanaTheme) => FieldValueColorCalculator; |
||||
colors?: string[]; |
||||
isContinuous?: boolean; |
||||
isByValue?: boolean; |
||||
} |
||||
|
||||
export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => { |
||||
return [ |
||||
{ |
||||
id: FieldColorModeId.Fixed, |
||||
name: 'Single color', |
||||
description: 'Set a specific color', |
||||
getCalculator: getFixedColor, |
||||
}, |
||||
{ |
||||
id: FieldColorModeId.Thresholds, |
||||
name: 'Color by thresholds', |
||||
description: 'Derive colors from thresholds', |
||||
getCalculator: (_field, theme) => { |
||||
return (_value, _percent, threshold) => { |
||||
const thresholdSafe = threshold ?? fallBackTreshold; |
||||
return getColorFromHexRgbOrName(thresholdSafe.color, theme.type); |
||||
}; |
||||
}, |
||||
}, |
||||
new FieldColorSchemeMode({ |
||||
id: FieldColorModeId.PaletteSaturated, |
||||
name: 'Color by series / Saturated palette', |
||||
//description: 'Assigns color based on series or field index',
|
||||
isContinuous: false, |
||||
isByValue: false, |
||||
colors: [ |
||||
'blue', |
||||
'red', |
||||
'green', |
||||
'yellow', |
||||
'purple', |
||||
'orange', |
||||
'dark-blue', |
||||
'dark-red', |
||||
'dark-yellow', |
||||
'dark-purple', |
||||
'dark-orange', |
||||
], |
||||
}), |
||||
new FieldColorSchemeMode({ |
||||
id: FieldColorModeId.PaletteClassic, |
||||
name: 'Color by series / Classic palette', |
||||
//description: 'Assigns color based on series or field index',
|
||||
isContinuous: false, |
||||
isByValue: false, |
||||
colors: classicColors, |
||||
}), |
||||
new FieldColorSchemeMode({ |
||||
id: FieldColorModeId.ContinousGrYlRd, |
||||
name: 'Color by value / Green-Yellow-Red / Continouous', |
||||
//description: 'Interpolated colors based value, min and max',
|
||||
isContinuous: true, |
||||
isByValue: true, |
||||
colors: ['green', 'yellow', 'red'], |
||||
}), |
||||
new FieldColorSchemeMode({ |
||||
id: FieldColorModeId.ContinousBlGrOr, |
||||
name: 'Color by value / Blue-Green-Orange / Continouous', |
||||
//description: 'Interpolated colors based value, min and max',
|
||||
isContinuous: true, |
||||
isByValue: true, |
||||
colors: ['blue', 'green', 'orange'], |
||||
}), |
||||
]; |
||||
}); |
||||
|
||||
interface FieldColorSchemeModeOptions { |
||||
id: FieldColorModeId; |
||||
name: string; |
||||
description?: string; |
||||
colors: string[]; |
||||
isContinuous: boolean; |
||||
isByValue: boolean; |
||||
} |
||||
|
||||
export class FieldColorSchemeMode implements FieldColorMode { |
||||
id: string; |
||||
name: string; |
||||
description?: string; |
||||
colors: string[]; |
||||
isContinuous: boolean; |
||||
isByValue: boolean; |
||||
colorCache?: string[]; |
||||
interpolator?: (value: number) => string; |
||||
|
||||
constructor(options: FieldColorSchemeModeOptions) { |
||||
this.id = options.id; |
||||
this.name = options.name; |
||||
this.description = options.description; |
||||
this.colors = options.colors; |
||||
this.isContinuous = options.isContinuous; |
||||
this.isByValue = options.isByValue; |
||||
} |
||||
|
||||
private getColors(theme: GrafanaTheme) { |
||||
if (this.colorCache) { |
||||
return this.colorCache; |
||||
} |
||||
|
||||
this.colorCache = this.colors.map(c => getColorFromHexRgbOrName(c, theme.type)); |
||||
return this.colorCache; |
||||
} |
||||
|
||||
private getInterpolator() { |
||||
if (!this.interpolator) { |
||||
this.interpolator = interpolateRgbBasis(this.colorCache!); |
||||
} |
||||
|
||||
return this.interpolator; |
||||
} |
||||
|
||||
getCalculator(field: Field, theme: GrafanaTheme) { |
||||
const colors = this.getColors(theme); |
||||
|
||||
if (this.isByValue) { |
||||
if (this.isContinuous) { |
||||
return (_: number, percent: number, _threshold?: Threshold) => { |
||||
return this.getInterpolator()(percent); |
||||
}; |
||||
} else { |
||||
return (_: number, percent: number, _threshold?: Threshold) => { |
||||
return colors[percent * (colors.length - 1)]; |
||||
}; |
||||
} |
||||
} else { |
||||
const seriesIndex = field.state?.seriesIndex ?? 0; |
||||
|
||||
return (_: number, _percent: number, _threshold?: Threshold) => { |
||||
return colors[seriesIndex % colors.length]; |
||||
}; |
||||
} |
||||
} |
||||
} |
||||
|
||||
export function getFieldColorModeForField(field: Field): FieldColorMode { |
||||
return fieldColorModeRegistry.get(field.config.color?.mode ?? FieldColorModeId.Thresholds); |
||||
} |
||||
|
||||
export function getFieldColorMode(mode?: FieldColorModeId): FieldColorMode { |
||||
return fieldColorModeRegistry.get(mode ?? FieldColorModeId.Thresholds); |
||||
} |
||||
|
||||
function getFixedColor(field: Field, theme: GrafanaTheme) { |
||||
return () => { |
||||
return getColorFromHexRgbOrName(field.config.color?.fixedColor ?? FALLBACK_COLOR, theme.type); |
||||
}; |
||||
} |
@ -1,10 +1,12 @@ |
||||
export * from './fieldDisplay'; |
||||
export * from './displayProcessor'; |
||||
export * from './scale'; |
||||
export * from './standardFieldConfigEditorRegistry'; |
||||
export * from './overrides/processors'; |
||||
export { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; |
||||
|
||||
export { getFieldColorModeForField, getFieldColorMode, fieldColorModeRegistry, FieldColorMode } from './fieldColor'; |
||||
export { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; |
||||
export { sortThresholds, getActiveThreshold } from './thresholds'; |
||||
export { applyFieldOverrides, validateFieldConfig, applyRawFieldOverrides } from './fieldOverrides'; |
||||
export { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy'; |
||||
export { getFieldDisplayName, getFrameDisplayName } from './fieldState'; |
||||
export { getScaleCalculator } from './scale'; |
||||
|
@ -1,195 +1,28 @@ |
||||
import { Field, FieldType, ColorScheme, ThresholdsConfig, ThresholdsMode, FieldColorMode, FieldConfig } from '../types'; |
||||
import { sortThresholds, getScaleCalculator, getActiveThreshold } from './scale'; |
||||
import { ArrayVector } from '../vector'; |
||||
import { validateFieldConfig } from './fieldOverrides'; |
||||
import { ThresholdsMode, Field, FieldType, GrafanaThemeType, GrafanaTheme } from '../types'; |
||||
import { sortThresholds } from './thresholds'; |
||||
import { ArrayVector } from '../vector/ArrayVector'; |
||||
import { getScaleCalculator } from './scale'; |
||||
|
||||
describe('scale', () => { |
||||
test('sort thresholds', () => { |
||||
const thresholds: ThresholdsConfig = { |
||||
steps: [ |
||||
{ color: 'TEN', value: 10 }, |
||||
{ color: 'HHH', value: 100 }, |
||||
{ color: 'ONE', value: 1 }, |
||||
], |
||||
mode: ThresholdsMode.Absolute, |
||||
}; |
||||
const sorted = sortThresholds(thresholds.steps).map(t => t.value); |
||||
expect(sorted).toEqual([1, 10, 100]); |
||||
const config: FieldConfig = { thresholds }; |
||||
|
||||
// Mutates and sorts the
|
||||
validateFieldConfig(config); |
||||
expect(getActiveThreshold(10, thresholds.steps).color).toEqual('TEN'); |
||||
}); |
||||
describe('getScaleCalculator', () => { |
||||
it('should return percent, threshold and color', () => { |
||||
const thresholds = [ |
||||
{ index: 2, value: 75, color: '#6ED0E0' }, |
||||
{ index: 1, value: 50, color: '#EAB839' }, |
||||
{ index: 0, value: -Infinity, color: '#7EB26D' }, |
||||
]; |
||||
|
||||
test('find active', () => { |
||||
const thresholds: ThresholdsConfig = { |
||||
steps: [ |
||||
{ color: 'ONE', value: 1 }, |
||||
{ color: 'TEN', value: 10 }, |
||||
{ color: 'HHH', value: 100 }, |
||||
], |
||||
mode: ThresholdsMode.Absolute, |
||||
}; |
||||
const config: FieldConfig = { thresholds }; |
||||
// Mutates and sets ONE to -Infinity
|
||||
validateFieldConfig(config); |
||||
expect(getActiveThreshold(-1, thresholds.steps).color).toEqual('ONE'); |
||||
expect(getActiveThreshold(1, thresholds.steps).color).toEqual('ONE'); |
||||
expect(getActiveThreshold(5, thresholds.steps).color).toEqual('ONE'); |
||||
expect(getActiveThreshold(10, thresholds.steps).color).toEqual('TEN'); |
||||
expect(getActiveThreshold(11, thresholds.steps).color).toEqual('TEN'); |
||||
expect(getActiveThreshold(99, thresholds.steps).color).toEqual('TEN'); |
||||
expect(getActiveThreshold(100, thresholds.steps).color).toEqual('HHH'); |
||||
expect(getActiveThreshold(1000, thresholds.steps).color).toEqual('HHH'); |
||||
}); |
||||
|
||||
test('absolute thresholds', () => { |
||||
const thresholds: ThresholdsConfig = { |
||||
steps: [ |
||||
// Colors are ignored when 'scheme' exists
|
||||
{ color: '#F00', state: 'LowLow', value: -Infinity }, |
||||
{ color: '#F00', state: 'Low', value: -50 }, |
||||
{ color: '#F00', state: 'OK', value: 0 }, |
||||
{ color: '#F00', state: 'High', value: 50 }, |
||||
{ color: '#F00', state: 'HighHigh', value: 100 }, |
||||
], |
||||
mode: ThresholdsMode.Absolute, |
||||
}; |
||||
|
||||
const field: Field<number> = { |
||||
const field: Field = { |
||||
name: 'test', |
||||
config: { thresholds: { mode: ThresholdsMode.Absolute, steps: sortThresholds(thresholds) } }, |
||||
type: FieldType.number, |
||||
config: { |
||||
min: -100, // explicit range
|
||||
max: 100, // note less then range of actual data
|
||||
thresholds, |
||||
color: { |
||||
mode: FieldColorMode.Scheme, |
||||
schemeName: ColorScheme.Greens, |
||||
}, |
||||
}, |
||||
values: new ArrayVector([ |
||||
-1000, |
||||
-100, |
||||
-75, |
||||
-50, |
||||
-25, |
||||
0, // middle
|
||||
25, |
||||
50, |
||||
75, |
||||
100, |
||||
1000, |
||||
]), |
||||
values: new ArrayVector([0, 50, 100]), |
||||
}; |
||||
validateFieldConfig(field.config); |
||||
const calc = getScaleCalculator(field); |
||||
const mapped = field.values.toArray().map(v => { |
||||
return calc(v); |
||||
|
||||
const calc = getScaleCalculator(field, { type: GrafanaThemeType.Dark } as GrafanaTheme); |
||||
expect(calc(70)).toEqual({ |
||||
percent: 0.7, |
||||
threshold: thresholds[1], |
||||
color: '#EAB839', |
||||
}); |
||||
expect(mapped).toMatchInlineSnapshot(` |
||||
Array [ |
||||
Object { |
||||
"color": "rgb(247, 252, 245)", |
||||
"percent": -4.5, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "LowLow", |
||||
"value": -Infinity, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(247, 252, 245)", |
||||
"percent": 0, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "LowLow", |
||||
"value": -Infinity, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(227, 244, 222)", |
||||
"percent": 0.125, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "LowLow", |
||||
"value": -Infinity, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(198, 232, 191)", |
||||
"percent": 0.25, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "Low", |
||||
"value": -50, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(160, 216, 155)", |
||||
"percent": 0.375, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "Low", |
||||
"value": -50, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(115, 195, 120)", |
||||
"percent": 0.5, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "OK", |
||||
"value": 0, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(69, 170, 93)", |
||||
"percent": 0.625, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "OK", |
||||
"value": 0, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(34, 139, 69)", |
||||
"percent": 0.75, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "High", |
||||
"value": 50, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(6, 107, 45)", |
||||
"percent": 0.875, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "High", |
||||
"value": 50, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(0, 68, 27)", |
||||
"percent": 1, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "HighHigh", |
||||
"value": 100, |
||||
}, |
||||
}, |
||||
Object { |
||||
"color": "rgb(0, 68, 27)", |
||||
"percent": 5.5, |
||||
"threshold": Object { |
||||
"color": "#F00", |
||||
"state": "HighHigh", |
||||
"value": 100, |
||||
}, |
||||
}, |
||||
] |
||||
`);
|
||||
}); |
||||
}); |
||||
|
@ -1,127 +1,67 @@ |
||||
import { Field, Threshold, GrafanaTheme, GrafanaThemeType, ThresholdsMode, FieldColorMode } from '../types'; |
||||
import { reduceField, ReducerID } from '../transformations'; |
||||
import { getColorFromHexRgbOrName } from '../utils/namedColorsPalette'; |
||||
import * as d3 from 'd3-scale-chromatic'; |
||||
import isNumber from 'lodash/isNumber'; |
||||
import { isNumber } from 'lodash'; |
||||
import { reduceField, ReducerID } from '../transformations/fieldReducer'; |
||||
import { Field, FieldType, GrafanaTheme, Threshold } from '../types'; |
||||
import { getFieldColorModeForField } from './fieldColor'; |
||||
import { getActiveThresholdForValue } from './thresholds'; |
||||
|
||||
export interface ScaledValue { |
||||
percent?: number; // 0-1
|
||||
threshold?: Threshold; // the selected step
|
||||
color?: string; // Selected color (may be range based on threshold)
|
||||
percent: number; // 0-1
|
||||
threshold: Threshold; |
||||
color: string; |
||||
} |
||||
|
||||
export type ScaleCalculator = (value: number) => ScaledValue; |
||||
|
||||
/** |
||||
* @param t Number in the range [0, 1]. |
||||
*/ |
||||
type colorInterpolator = (t: number) => string; |
||||
export function getScaleCalculator(field: Field, theme: GrafanaTheme): ScaleCalculator { |
||||
const mode = getFieldColorModeForField(field); |
||||
const getColor = mode.getCalculator(field, theme); |
||||
const info = getMinMaxAndDelta(field); |
||||
|
||||
export function getScaleCalculator(field: Field, theme?: GrafanaTheme): ScaleCalculator { |
||||
const themeType = theme ? theme.type : GrafanaThemeType.Dark; |
||||
const config = field.config || {}; |
||||
const { thresholds, color } = config; |
||||
|
||||
const fixedColor = |
||||
color && color.mode === FieldColorMode.Fixed && color.fixedColor |
||||
? getColorFromHexRgbOrName(color.fixedColor, themeType) |
||||
: undefined; |
||||
|
||||
// Should we calculate the percentage
|
||||
const percentThresholds = thresholds && thresholds.mode === ThresholdsMode.Percentage; |
||||
const useColorScheme = color && color.mode === FieldColorMode.Scheme; |
||||
|
||||
if (percentThresholds || useColorScheme) { |
||||
// Calculate min/max if required
|
||||
let min = config.min; |
||||
let max = config.max; |
||||
|
||||
if (!isNumber(min) || !isNumber(max)) { |
||||
if (field.values && field.values.length) { |
||||
const stats = reduceField({ field, reducers: [ReducerID.min, ReducerID.max] }); |
||||
if (!isNumber(min)) { |
||||
min = stats[ReducerID.min]; |
||||
} |
||||
if (!isNumber(max)) { |
||||
max = stats[ReducerID.max]; |
||||
} |
||||
} else { |
||||
min = 0; |
||||
max = 100; |
||||
} |
||||
} |
||||
|
||||
const delta = max! - min!; |
||||
|
||||
// Use a d3 color scale
|
||||
let interpolator: colorInterpolator | undefined; |
||||
|
||||
if (useColorScheme) { |
||||
interpolator = (d3 as any)[`interpolate${color!.schemeName}`] as colorInterpolator; |
||||
} |
||||
|
||||
return (value: number) => { |
||||
const percent = (value - min!) / delta; |
||||
const threshold = thresholds |
||||
? getActiveThreshold(percentThresholds ? percent * 100 : value, thresholds.steps) |
||||
: undefined; // 0-100
|
||||
let color = fixedColor; |
||||
|
||||
if (interpolator) { |
||||
color = interpolator(percent); |
||||
} else if (threshold) { |
||||
color = getColorFromHexRgbOrName(threshold!.color, themeType); |
||||
} |
||||
return (value: number) => { |
||||
const percent = (value - info.min!) / info.delta; |
||||
const threshold = getActiveThresholdForValue(field, value, percent); |
||||
|
||||
return { |
||||
percent, |
||||
threshold, |
||||
color, |
||||
}; |
||||
return { |
||||
percent, |
||||
threshold, |
||||
color: getColor(value, percent, threshold), |
||||
}; |
||||
} |
||||
}; |
||||
} |
||||
|
||||
if (thresholds) { |
||||
return (value: number) => { |
||||
const threshold = getActiveThreshold(value, thresholds.steps); |
||||
const color = fixedColor ?? (threshold ? getColorFromHexRgbOrName(threshold.color, themeType) : undefined); |
||||
return { |
||||
threshold, |
||||
color, |
||||
}; |
||||
}; |
||||
} |
||||
interface FieldMinMaxInfo { |
||||
min?: number | null; |
||||
max?: number | null; |
||||
delta: number; |
||||
} |
||||
|
||||
// Constant color
|
||||
if (fixedColor) { |
||||
return (value: number) => { |
||||
return { color: fixedColor }; |
||||
}; |
||||
function getMinMaxAndDelta(field: Field): FieldMinMaxInfo { |
||||
if (field.type !== FieldType.number) { |
||||
return { min: 0, max: 100, delta: 100 }; |
||||
} |
||||
|
||||
// NO-OP
|
||||
return (value: number) => { |
||||
return {}; |
||||
}; |
||||
} |
||||
// Calculate min/max if required
|
||||
let min = field.config.min; |
||||
let max = field.config.max; |
||||
|
||||
export function getActiveThreshold(value: number, thresholds: Threshold[]): Threshold { |
||||
let active = thresholds[0]; |
||||
for (const threshold of thresholds) { |
||||
if (value >= threshold.value) { |
||||
active = threshold; |
||||
if (!isNumber(min) || !isNumber(max)) { |
||||
if (field.values && field.values.length) { |
||||
const stats = reduceField({ field, reducers: [ReducerID.min, ReducerID.max] }); |
||||
if (!isNumber(min)) { |
||||
min = stats[ReducerID.min]; |
||||
} |
||||
if (!isNumber(max)) { |
||||
max = stats[ReducerID.max]; |
||||
} |
||||
} else { |
||||
break; |
||||
min = 0; |
||||
max = 100; |
||||
} |
||||
} |
||||
return active; |
||||
} |
||||
|
||||
/** |
||||
* Sorts the thresholds |
||||
*/ |
||||
export function sortThresholds(thresholds: Threshold[]) { |
||||
return thresholds.sort((t1, t2) => { |
||||
return t1.value - t2.value; |
||||
}); |
||||
return { |
||||
min, |
||||
max, |
||||
delta: max! - min!, |
||||
}; |
||||
} |
||||
|
@ -0,0 +1,93 @@ |
||||
import { ThresholdsConfig, ThresholdsMode, FieldConfig, Threshold, Field, FieldType } from '../types'; |
||||
import { sortThresholds, getActiveThreshold, getActiveThresholdForValue } from './thresholds'; |
||||
import { validateFieldConfig } from './fieldOverrides'; |
||||
import { ArrayVector } from '../vector/ArrayVector'; |
||||
|
||||
describe('thresholds', () => { |
||||
test('sort thresholds', () => { |
||||
const thresholds: ThresholdsConfig = { |
||||
steps: [ |
||||
{ color: 'TEN', value: 10 }, |
||||
{ color: 'HHH', value: 100 }, |
||||
{ color: 'ONE', value: 1 }, |
||||
], |
||||
mode: ThresholdsMode.Absolute, |
||||
}; |
||||
const sorted = sortThresholds(thresholds.steps).map(t => t.value); |
||||
expect(sorted).toEqual([1, 10, 100]); |
||||
const config: FieldConfig = { thresholds }; |
||||
|
||||
// Mutates and sorts the
|
||||
validateFieldConfig(config); |
||||
expect(getActiveThreshold(10, thresholds.steps).color).toEqual('TEN'); |
||||
}); |
||||
|
||||
test('find active', () => { |
||||
const thresholds: ThresholdsConfig = { |
||||
steps: [ |
||||
{ color: 'ONE', value: 1 }, |
||||
{ color: 'TEN', value: 10 }, |
||||
{ color: 'HHH', value: 100 }, |
||||
], |
||||
mode: ThresholdsMode.Absolute, |
||||
}; |
||||
const config: FieldConfig = { thresholds }; |
||||
// Mutates and sets ONE to -Infinity
|
||||
validateFieldConfig(config); |
||||
expect(getActiveThreshold(-1, thresholds.steps).color).toEqual('ONE'); |
||||
expect(getActiveThreshold(1, thresholds.steps).color).toEqual('ONE'); |
||||
expect(getActiveThreshold(5, thresholds.steps).color).toEqual('ONE'); |
||||
expect(getActiveThreshold(10, thresholds.steps).color).toEqual('TEN'); |
||||
expect(getActiveThreshold(11, thresholds.steps).color).toEqual('TEN'); |
||||
expect(getActiveThreshold(99, thresholds.steps).color).toEqual('TEN'); |
||||
expect(getActiveThreshold(100, thresholds.steps).color).toEqual('HHH'); |
||||
expect(getActiveThreshold(1000, thresholds.steps).color).toEqual('HHH'); |
||||
}); |
||||
|
||||
function getThreshold(value: number, steps: Threshold[], mode: ThresholdsMode, percent = 1): Threshold { |
||||
const field: Field = { |
||||
name: 'test', |
||||
config: { thresholds: { mode: mode, steps: sortThresholds(steps) } }, |
||||
type: FieldType.number, |
||||
values: new ArrayVector([]), |
||||
}; |
||||
validateFieldConfig(field.config!); |
||||
return getActiveThresholdForValue(field, value, percent); |
||||
} |
||||
|
||||
describe('Get color from threshold', () => { |
||||
it('should get first threshold color when only one threshold', () => { |
||||
const thresholds = [{ index: 0, value: -Infinity, color: '#7EB26D' }]; |
||||
expect(getThreshold(49, thresholds, ThresholdsMode.Absolute)).toEqual(thresholds[0]); |
||||
}); |
||||
|
||||
it('should get the threshold color if value is same as a threshold', () => { |
||||
const thresholds = [ |
||||
{ index: 0, value: -Infinity, color: '#7EB26D' }, |
||||
{ index: 1, value: 50, color: '#EAB839' }, |
||||
{ index: 2, value: 75, color: '#6ED0E0' }, |
||||
]; |
||||
expect(getThreshold(50, thresholds, ThresholdsMode.Absolute)).toEqual(thresholds[1]); |
||||
}); |
||||
|
||||
it('should get the nearest threshold color between thresholds', () => { |
||||
const thresholds = [ |
||||
{ index: 0, value: -Infinity, color: '#7EB26D' }, |
||||
{ index: 1, value: 50, color: '#EAB839' }, |
||||
{ index: 2, value: 75, color: '#6ED0E0' }, |
||||
]; |
||||
expect(getThreshold(55, thresholds, ThresholdsMode.Absolute)).toEqual(thresholds[1]); |
||||
}); |
||||
|
||||
it('should be able to get percent based threshold', () => { |
||||
const thresholds = [ |
||||
{ index: 0, value: 0, color: '#7EB26D' }, |
||||
{ index: 1, value: 50, color: '#EAB839' }, |
||||
{ index: 2, value: 75, color: '#6ED0E0' }, |
||||
]; |
||||
expect(getThreshold(55, thresholds, ThresholdsMode.Percentage, 0.9)).toEqual(thresholds[2]); |
||||
expect(getThreshold(55, thresholds, ThresholdsMode.Percentage, 0.5)).toEqual(thresholds[1]); |
||||
expect(getThreshold(55, thresholds, ThresholdsMode.Percentage, 0.2)).toEqual(thresholds[0]); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,38 @@ |
||||
import { Threshold, FALLBACK_COLOR, Field, ThresholdsMode } from '../types'; |
||||
|
||||
export const fallBackTreshold: Threshold = { value: 0, color: FALLBACK_COLOR }; |
||||
|
||||
export function getActiveThreshold(value: number, thresholds: Threshold[] | undefined): Threshold { |
||||
if (!thresholds || thresholds.length === 0) { |
||||
return fallBackTreshold; |
||||
} |
||||
|
||||
let active = thresholds[0]; |
||||
|
||||
for (const threshold of thresholds) { |
||||
if (value >= threshold.value) { |
||||
active = threshold; |
||||
} else { |
||||
break; |
||||
} |
||||
} |
||||
|
||||
return active; |
||||
} |
||||
|
||||
export function getActiveThresholdForValue(field: Field, value: number, percent: number): Threshold { |
||||
const { thresholds } = field.config; |
||||
|
||||
if (thresholds?.mode === ThresholdsMode.Percentage) { |
||||
return getActiveThreshold(percent * 100, thresholds?.steps); |
||||
} |
||||
|
||||
return getActiveThreshold(value, thresholds?.steps); |
||||
} |
||||
|
||||
/** |
||||
* Sorts the thresholds |
||||
*/ |
||||
export function sortThresholds(thresholds: Threshold[]) { |
||||
return thresholds.sort((t1, t2) => t1.value - t2.value); |
||||
} |
@ -1,52 +1,15 @@ |
||||
export enum FieldColorMode { |
||||
export enum FieldColorModeId { |
||||
Thresholds = 'thresholds', |
||||
Scheme = 'scheme', |
||||
ContinousGrYlRd = 'continuous-GrYlRd', |
||||
ContinousBlGrOr = 'continuous-BlGrOr', |
||||
PaletteClassic = 'palette-classic', |
||||
PaletteSaturated = 'palette-saturated', |
||||
Fixed = 'fixed', |
||||
} |
||||
|
||||
export interface FieldColor { |
||||
mode: FieldColorMode; |
||||
schemeName?: ColorScheme; |
||||
mode: FieldColorModeId; |
||||
fixedColor?: string; |
||||
} |
||||
|
||||
// https://github.com/d3/d3-scale-chromatic
|
||||
export enum ColorScheme { |
||||
BrBG = 'BrBG', |
||||
PRGn = 'PRGn', |
||||
PiYG = 'PiYG', |
||||
PuOr = 'PuOr', |
||||
RdBu = 'RdBu', |
||||
RdGy = 'RdGy', |
||||
RdYlBu = 'RdYlBu', |
||||
RdYlGn = 'RdYlGn', |
||||
Spectral = 'Spectral', |
||||
BuGn = 'BuGn', |
||||
BuPu = 'BuPu', |
||||
GnBu = 'GnBu', |
||||
OrRd = 'OrRd', |
||||
PuBuGn = 'PuBuGn', |
||||
PuBu = 'PuBu', |
||||
PuRd = 'PuRd', |
||||
RdPu = 'RdPu', |
||||
YlGnBu = 'YlGnBu', |
||||
YlGn = 'YlGn', |
||||
YlOrBr = 'YlOrBr', |
||||
YlOrRd = 'YlOrRd', |
||||
Blues = 'Blues', |
||||
Greens = 'Greens', |
||||
Greys = 'Greys', |
||||
Purples = 'Purples', |
||||
Reds = 'Reds', |
||||
Oranges = 'Oranges', |
||||
|
||||
// interpolateCubehelix
|
||||
// interpolateRainbow,
|
||||
// interpolateWarm
|
||||
// interpolateCool
|
||||
// interpolateSinebow
|
||||
// interpolateViridis
|
||||
// interpolateMagma
|
||||
// interpolateInferno
|
||||
// interpolatePlasma
|
||||
} |
||||
export const FALLBACK_COLOR = 'gray'; |
||||
|
@ -0,0 +1,115 @@ |
||||
import React, { CSSProperties, FC } from 'react'; |
||||
import { |
||||
FieldConfigEditorProps, |
||||
FieldColorModeId, |
||||
SelectableValue, |
||||
FieldColor, |
||||
fieldColorModeRegistry, |
||||
FieldColorMode, |
||||
GrafanaTheme, |
||||
getColorFromHexRgbOrName, |
||||
} from '@grafana/data'; |
||||
import { Select } from '../Select/Select'; |
||||
import { ColorValueEditor } from './color'; |
||||
import { useStyles, useTheme } from '../../themes/ThemeContext'; |
||||
import { css } from 'emotion'; |
||||
|
||||
export const FieldColorEditor: React.FC<FieldConfigEditorProps<FieldColor | undefined, {}>> = ({ |
||||
value, |
||||
onChange, |
||||
item, |
||||
}) => { |
||||
const theme = useTheme(); |
||||
const styles = useStyles(getStyles); |
||||
|
||||
const options = fieldColorModeRegistry.list().map(mode => { |
||||
return { |
||||
value: mode.id, |
||||
label: mode.name, |
||||
description: mode.description, |
||||
isContinuous: mode.isContinuous, |
||||
isByValue: mode.isByValue, |
||||
component: () => <FieldColorModeViz mode={mode} theme={theme} />, |
||||
}; |
||||
}); |
||||
|
||||
const onModeChange = (newMode: SelectableValue<string>) => { |
||||
onChange({ |
||||
mode: newMode.value! as FieldColorModeId, |
||||
}); |
||||
}; |
||||
|
||||
const onColorChange = (color?: string) => { |
||||
onChange({ |
||||
mode, |
||||
fixedColor: color, |
||||
}); |
||||
}; |
||||
|
||||
const mode = value?.mode ?? FieldColorModeId.Thresholds; |
||||
|
||||
if (mode === FieldColorModeId.Fixed) { |
||||
return ( |
||||
<div className={styles.group}> |
||||
<Select minMenuHeight={200} options={options} value={mode} onChange={onModeChange} className={styles.select} /> |
||||
<ColorValueEditor value={value?.fixedColor} onChange={onColorChange} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return <Select minMenuHeight={200} options={options} value={mode} onChange={onModeChange} />; |
||||
}; |
||||
|
||||
interface ModeProps { |
||||
mode: FieldColorMode; |
||||
theme: GrafanaTheme; |
||||
} |
||||
|
||||
const FieldColorModeViz: FC<ModeProps> = ({ mode, theme }) => { |
||||
if (!mode.colors) { |
||||
return null; |
||||
} |
||||
|
||||
const colors = mode.colors.map(item => getColorFromHexRgbOrName(item, theme.type)); |
||||
const style: CSSProperties = { |
||||
height: '8px', |
||||
width: '100%', |
||||
margin: '2px 0', |
||||
borderRadius: '3px', |
||||
opacity: 1, |
||||
}; |
||||
|
||||
if (mode.isContinuous) { |
||||
style.background = `linear-gradient(90deg, ${colors.join(',')})`; |
||||
} else { |
||||
let gradient = ''; |
||||
let lastColor = ''; |
||||
|
||||
for (let i = 0; i < colors.length; i++) { |
||||
const color = colors[i]; |
||||
if (gradient === '') { |
||||
gradient = `linear-gradient(90deg, ${color} 0%`; |
||||
} else { |
||||
const valuePercent = i / (colors.length - 1); |
||||
const pos = valuePercent * 100; |
||||
gradient += `, ${lastColor} ${pos}%, ${color} ${pos}%`; |
||||
} |
||||
lastColor = color; |
||||
} |
||||
style.background = gradient; |
||||
} |
||||
|
||||
return <div style={style} />; |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme) => { |
||||
return { |
||||
group: css` |
||||
display: flex; |
||||
`,
|
||||
select: css` |
||||
margin-right: 8px; |
||||
flex-grow: 1; |
||||
`,
|
||||
}; |
||||
}; |
Loading…
Reference in new issue