mirror of https://github.com/grafana/grafana
FieldConfig: add thresholds and color modes (#21273)
parent
36aad1c101
commit
d9e9843a10
@ -1,4 +1,5 @@ |
||||
export * from './fieldDisplay'; |
||||
export * from './displayProcessor'; |
||||
export * from './scale'; |
||||
|
||||
export { applyFieldOverrides } from './fieldOverrides'; |
||||
export { applyFieldOverrides, validateFieldConfig } from './fieldOverrides'; |
||||
|
@ -0,0 +1,195 @@ |
||||
import { Field, FieldType, ColorScheme, ThresholdsConfig, ThresholdsMode, FieldColorMode, FieldConfig } from '../types'; |
||||
import { sortThresholds, getScaleCalculator, getActiveThreshold } from './scale'; |
||||
import { ArrayVector } from '../vector'; |
||||
import { validateFieldConfig } from './fieldOverrides'; |
||||
|
||||
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'); |
||||
}); |
||||
|
||||
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> = { |
||||
name: 'test', |
||||
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, |
||||
]), |
||||
}; |
||||
validateFieldConfig(field.config); |
||||
const calc = getScaleCalculator(field); |
||||
const mapped = field.values.toArray().map(v => { |
||||
return calc(v); |
||||
}); |
||||
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, |
||||
}, |
||||
}, |
||||
] |
||||
`);
|
||||
}); |
||||
}); |
@ -0,0 +1,122 @@ |
||||
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'; |
||||
|
||||
export interface ScaledValue { |
||||
percent?: number; // 0-1
|
||||
threshold?: Threshold; // the selected step
|
||||
color?: string; // Selected color (may be range based on threshold)
|
||||
} |
||||
|
||||
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 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 { |
||||
percent, |
||||
threshold, |
||||
color, |
||||
}; |
||||
}; |
||||
} |
||||
|
||||
if (thresholds) { |
||||
return (value: number) => { |
||||
const threshold = getActiveThreshold(value, thresholds.steps); |
||||
const color = fixedColor ?? (threshold ? getColorFromHexRgbOrName(threshold.color, themeType) : undefined); |
||||
return { |
||||
threshold, |
||||
color, |
||||
}; |
||||
}; |
||||
} |
||||
|
||||
// Constant color
|
||||
if (fixedColor) { |
||||
return (value: number) => { |
||||
return { color: fixedColor }; |
||||
}; |
||||
} |
||||
|
||||
// NO-OP
|
||||
return (value: number) => { |
||||
return {}; |
||||
}; |
||||
} |
||||
|
||||
export function getActiveThreshold(value: number, thresholds: Threshold[]): Threshold { |
||||
let active = thresholds[0]; |
||||
for (const threshold of thresholds) { |
||||
if (value >= threshold.value) { |
||||
active = threshold; |
||||
} else { |
||||
break; |
||||
} |
||||
} |
||||
return active; |
||||
} |
||||
|
||||
/** |
||||
* Sorts the thresholds |
||||
*/ |
||||
export function sortThresholds(thresholds: Threshold[]) { |
||||
return thresholds.sort((t1, t2) => { |
||||
return t1.value - t2.value; |
||||
}); |
||||
} |
@ -0,0 +1,52 @@ |
||||
export enum FieldColorMode { |
||||
Thresholds = 'thresholds', |
||||
Scheme = 'scheme', |
||||
Fixed = 'fixed', |
||||
} |
||||
|
||||
export interface FieldColor { |
||||
mode: FieldColorMode; |
||||
schemeName?: ColorScheme; |
||||
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
|
||||
} |
@ -1,4 +0,0 @@ |
||||
export interface Threshold { |
||||
value: number; |
||||
color: string; |
||||
} |
@ -0,0 +1,17 @@ |
||||
export interface Threshold { |
||||
value: number; |
||||
color: string; |
||||
state?: string; // Warning, Error, LowLow, Low, OK, High, HighHigh etc
|
||||
} |
||||
|
||||
export enum ThresholdsMode { |
||||
Absolute = 'absolute', |
||||
Percentage = 'percentage', // between 0 and 1 (based on min/max)
|
||||
} |
||||
|
||||
export interface ThresholdsConfig { |
||||
mode: ThresholdsMode; |
||||
|
||||
// Must be sorted by 'value', first value is always -Infinity
|
||||
steps: Threshold[]; |
||||
} |
@ -1,22 +0,0 @@ |
||||
import { Threshold } from '../types'; |
||||
|
||||
export function getActiveThreshold(value: number, thresholds: Threshold[]): Threshold { |
||||
let active = thresholds[0]; |
||||
for (const threshold of thresholds) { |
||||
if (value >= threshold.value) { |
||||
active = threshold; |
||||
} else { |
||||
break; |
||||
} |
||||
} |
||||
return active; |
||||
} |
||||
|
||||
/** |
||||
* Sorts the thresholds |
||||
*/ |
||||
export function sortThresholds(thresholds: Threshold[]) { |
||||
return thresholds.sort((t1, t2) => { |
||||
return t1.value - t2.value; |
||||
}); |
||||
} |
@ -1,182 +1,156 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`Render should render with base threshold 1`] = ` |
||||
<ThresholdsEditor |
||||
onChange={[MockFunction]} |
||||
thresholds={Array []} |
||||
<div |
||||
className="thresholds" |
||||
> |
||||
<Component |
||||
title="Thresholds" |
||||
<div |
||||
className="thresholds-row" |
||||
key="100" |
||||
> |
||||
<div |
||||
className="panel-options-group" |
||||
className="thresholds-row-add-button" |
||||
onClick={[Function]} |
||||
> |
||||
<i |
||||
className="fa fa-plus" |
||||
/> |
||||
</div> |
||||
<div |
||||
className="thresholds-row-color-indicator" |
||||
style={ |
||||
Object { |
||||
"backgroundColor": "#73BF69", |
||||
} |
||||
} |
||||
/> |
||||
<div |
||||
className="thresholds-row-input" |
||||
> |
||||
<div |
||||
className="panel-options-group__header" |
||||
className="thresholds-row-input-inner" |
||||
> |
||||
<span |
||||
className="panel-options-group__title" |
||||
> |
||||
Thresholds |
||||
</span> |
||||
</div> |
||||
<div |
||||
className="panel-options-group__body" |
||||
> |
||||
className="thresholds-row-input-inner-arrow" |
||||
/> |
||||
<div |
||||
className="thresholds" |
||||
className="thresholds-row-input-inner-color" |
||||
> |
||||
<div |
||||
className="thresholds-row" |
||||
key="100" |
||||
className="thresholds-row-input-inner-color-colorpicker" |
||||
> |
||||
<div |
||||
className="thresholds-row-add-button" |
||||
onClick={[Function]} |
||||
<WithTheme(ColorPicker) |
||||
color="green" |
||||
enableNamedColors={true} |
||||
onChange={[Function]} |
||||
> |
||||
<i |
||||
className="fa fa-plus" |
||||
/> |
||||
</div> |
||||
<div |
||||
className="thresholds-row-color-indicator" |
||||
style={ |
||||
Object { |
||||
"backgroundColor": "#73BF69", |
||||
<ColorPicker |
||||
color="green" |
||||
enableNamedColors={true} |
||||
onChange={[Function]} |
||||
theme={ |
||||
Object { |
||||
"type": "dark", |
||||
} |
||||
} |
||||
} |
||||
/> |
||||
<div |
||||
className="thresholds-row-input" |
||||
> |
||||
<div |
||||
className="thresholds-row-input-inner" |
||||
> |
||||
<span |
||||
className="thresholds-row-input-inner-arrow" |
||||
/> |
||||
<div |
||||
className="thresholds-row-input-inner-color" |
||||
> |
||||
<div |
||||
className="thresholds-row-input-inner-color-colorpicker" |
||||
> |
||||
<WithTheme(ColorPicker) |
||||
<PopoverController |
||||
content={ |
||||
<ColorPickerPopover |
||||
color="green" |
||||
enableNamedColors={true} |
||||
onChange={[Function]} |
||||
> |
||||
<ColorPicker |
||||
color="green" |
||||
enableNamedColors={true} |
||||
onChange={[Function]} |
||||
theme={ |
||||
Object { |
||||
"type": "dark", |
||||
} |
||||
theme={ |
||||
Object { |
||||
"type": "dark", |
||||
} |
||||
> |
||||
<PopoverController |
||||
content={ |
||||
<ColorPickerPopover |
||||
color="green" |
||||
enableNamedColors={true} |
||||
onChange={[Function]} |
||||
theme={ |
||||
Object { |
||||
"type": "dark", |
||||
} |
||||
} |
||||
/> |
||||
} |
||||
hideAfter={300} |
||||
> |
||||
<ForwardRef(ColorPickerTrigger) |
||||
color="#73BF69" |
||||
onClick={[Function]} |
||||
onMouseLeave={[Function]} |
||||
> |
||||
<div |
||||
onClick={[Function]} |
||||
onMouseLeave={[Function]} |
||||
style={ |
||||
Object { |
||||
"background": "inherit", |
||||
"border": "none", |
||||
"borderRadius": 10, |
||||
"color": "inherit", |
||||
"cursor": "pointer", |
||||
"overflow": "hidden", |
||||
"padding": 0, |
||||
} |
||||
} |
||||
> |
||||
<div |
||||
style={ |
||||
Object { |
||||
"backgroundImage": "url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)", |
||||
"border": "none", |
||||
"float": "left", |
||||
"height": 15, |
||||
"margin": 0, |
||||
"position": "relative", |
||||
"width": 15, |
||||
"zIndex": 0, |
||||
} |
||||
} |
||||
> |
||||
<div |
||||
style={ |
||||
Object { |
||||
"backgroundColor": "#73BF69", |
||||
"bottom": 0, |
||||
"display": "block", |
||||
"left": 0, |
||||
"position": "absolute", |
||||
"right": 0, |
||||
"top": 0, |
||||
} |
||||
} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</ForwardRef(ColorPickerTrigger)> |
||||
</PopoverController> |
||||
</ColorPicker> |
||||
</WithTheme(ColorPicker)> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="thresholds-row-input-inner-value" |
||||
} |
||||
/> |
||||
} |
||||
hideAfter={300} |
||||
> |
||||
<Input |
||||
className="" |
||||
readOnly={true} |
||||
type="text" |
||||
value="Base" |
||||
<ForwardRef(ColorPickerTrigger) |
||||
color="#73BF69" |
||||
onClick={[Function]} |
||||
onMouseLeave={[Function]} |
||||
> |
||||
<div |
||||
onClick={[Function]} |
||||
onMouseLeave={[Function]} |
||||
style={ |
||||
Object { |
||||
"flexGrow": 1, |
||||
"background": "inherit", |
||||
"border": "none", |
||||
"borderRadius": 10, |
||||
"color": "inherit", |
||||
"cursor": "pointer", |
||||
"overflow": "hidden", |
||||
"padding": 0, |
||||
} |
||||
} |
||||
> |
||||
<input |
||||
className="gf-form-input" |
||||
readOnly={true} |
||||
type="text" |
||||
value="Base" |
||||
/> |
||||
<div |
||||
style={ |
||||
Object { |
||||
"backgroundImage": "url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)", |
||||
"border": "none", |
||||
"float": "left", |
||||
"height": 15, |
||||
"margin": 0, |
||||
"position": "relative", |
||||
"width": 15, |
||||
"zIndex": 0, |
||||
} |
||||
} |
||||
> |
||||
<div |
||||
style={ |
||||
Object { |
||||
"backgroundColor": "#73BF69", |
||||
"bottom": 0, |
||||
"display": "block", |
||||
"left": 0, |
||||
"position": "absolute", |
||||
"right": 0, |
||||
"top": 0, |
||||
} |
||||
} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</Input> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</ForwardRef(ColorPickerTrigger)> |
||||
</PopoverController> |
||||
</ColorPicker> |
||||
</WithTheme(ColorPicker)> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="thresholds-row-input-inner-value" |
||||
> |
||||
<Input |
||||
className="" |
||||
readOnly={true} |
||||
type="text" |
||||
value="Base" |
||||
> |
||||
<div |
||||
style={ |
||||
Object { |
||||
"flexGrow": 1, |
||||
} |
||||
} |
||||
> |
||||
<input |
||||
className="gf-form-input" |
||||
readOnly={true} |
||||
type="text" |
||||
value="Base" |
||||
/> |
||||
</div> |
||||
</Input> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</Component> |
||||
</ThresholdsEditor> |
||||
</div> |
||||
</div> |
||||
`; |
||||
|
Loading…
Reference in new issue