Commit d1817829 by Torkel Ödegaard Committed by GitHub

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>
parent c052abed
......@@ -14,6 +14,7 @@ You can apply standard field options to most built-in Grafana panels. Some older
Most field options will not affect the visualization until you click outside of the field option box you are editing or press Enter.
For more information about applying these options, refer to:
- [Configure all fields]({{< relref "configure-all-fields.md" >}})
- [Configure specific fields]({{< relref "configure-specific-fields.md" >}})
......@@ -86,6 +87,26 @@ You can also paste a native emoji in the unit picker and pick it as a custom uni
Grafana can sometime be too aggressive in parsing strings and displaying them as numbers. To make Grafana show the original string create a field override and add a unit property with the `string` unit.
## Color scheme
> **Note:** Only available in Grafana 7.3+.
The field color option defines how Grafana colors series or fields. There are multiple modes here that work very differently, and their utility depends largely on what visualization you currently have selected.
Continuous color modes use the percentage of a value relative to min and max to interpolate a color.
- **Single color:** Set a specific color using the color picker. Mostly useful from an override rule.
- **From thresholds:** Color is derived from the matching threshold. Useful for gauges, stat and table visualizations.
### Color by series
Then there are color schemes that define color by series. Useful for graphs and pie charts for example.
### Color by value
In addition to deriving color from thresholds there are also continuous (gradient) color schemes. Useful
for visualizations that that color individual values. For example stat panels and the table.
## Thresholds
Thresholds allow you to change the color of a field based on the value.
......
......@@ -23,6 +23,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "4.0.0",
"@types/d3-interpolate": "^1.3.1",
"apache-arrow": "0.16.0",
"lodash": "4.17.19",
"rxjs": "6.6.2",
......
import { getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor';
import { DisplayProcessor, DisplayValue } from '../types/displayValue';
import { MappingType, ValueMapping } from '../types/valueMapping';
import { Field, FieldConfig, FieldType, GrafanaTheme, Threshold, ThresholdsMode } from '../types';
import { getScaleCalculator, sortThresholds } from './scale';
import { ArrayVector } from '../vector';
import { validateFieldConfig } from './fieldOverrides';
import { FieldConfig, FieldType, ThresholdsMode } from '../types';
import { systemDateFormats } from '../datetime';
function getDisplayProcessorFromConfig(config: FieldConfig) {
......@@ -16,18 +13,6 @@ function getDisplayProcessorFromConfig(config: FieldConfig) {
});
}
function getColorFromThreshold(value: number, steps: Threshold[], theme?: GrafanaTheme): string {
const field: Field = {
name: 'test',
config: { thresholds: { mode: ThresholdsMode.Absolute, steps: sortThresholds(steps) } },
type: FieldType.number,
values: new ArrayVector([]),
};
validateFieldConfig(field.config!);
const calc = getScaleCalculator(field, theme);
return calc(value).color!;
}
function assertSame(input: any, processors: DisplayProcessor[], match: DisplayValue) {
processors.forEach(processor => {
const value = processor(input);
......@@ -42,7 +27,7 @@ describe('Process simple display values', () => {
// Don't test float values here since the decimal formatting changes
const processors = [
// Without options, this shortcuts to a much easier implementation
getDisplayProcessor(),
getDisplayProcessor({ field: { config: {} } }),
// Add a simple option that is not used (uses a different base class)
getDisplayProcessorFromConfig({ min: 0, max: 100 }),
......@@ -104,31 +89,6 @@ describe('Process simple display values', () => {
});
});
describe('Get color from threshold', () => {
it('should get first threshold color when only one threshold', () => {
const thresholds = [{ index: 0, value: -Infinity, color: '#7EB26D' }];
expect(getColorFromThreshold(49, thresholds)).toEqual('#7EB26D');
});
it('should get the threshold color if value is same as a threshold', () => {
const thresholds = [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
];
expect(getColorFromThreshold(50, thresholds)).toEqual('#EAB839');
});
it('should get the nearest threshold color between thresholds', () => {
const thresholds = [
{ index: 2, value: 75, color: '#6ED0E0' },
{ index: 1, value: 50, color: '#EAB839' },
{ index: 0, value: -Infinity, color: '#7EB26D' },
];
expect(getColorFromThreshold(55, thresholds)).toEqual('#EAB839');
});
});
describe('Format value', () => {
it('should return if value isNaN', () => {
const valueMappings: ValueMapping[] = [];
......@@ -316,6 +276,7 @@ describe('Date display options', () => {
timeZone: 'utc',
field: {
type: FieldType.time,
config: {},
},
});
......
......@@ -3,7 +3,7 @@ import _ from 'lodash';
// Types
import { Field, FieldType } from '../types/dataFrame';
import { GrafanaTheme } from '../types/theme';
import { GrafanaTheme, GrafanaThemeType } from '../types/theme';
import { DecimalCount, DecimalInfo, DisplayProcessor, DisplayValue } from '../types/displayValue';
import { getValueFormat } from '../valueFormats/valueFormats';
import { getMappedValue } from '../utils/valueMappings';
......@@ -13,10 +13,14 @@ import { getScaleCalculator } from './scale';
interface DisplayProcessorOptions {
field: Partial<Field>;
// Context
/**
* Will pick browser timezone if not defined
*/
timeZone?: TimeZone;
theme?: GrafanaTheme; // Will pick 'dark' if not defined
/**
* Will pick 'dark' if not defined
*/
theme?: GrafanaTheme;
}
// Reasonable units for time
......@@ -35,6 +39,10 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP
const { field } = options;
const config = field.config ?? {};
// Theme should be required or we need access to default theme instance from here
const theme = options.theme ?? ({ type: GrafanaThemeType.Dark } as GrafanaTheme);
let unit = config.unit;
let hasDateUnit = unit && (timeFormats[unit] || unit.startsWith('time:'));
......@@ -44,7 +52,7 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP
}
const formatFunc = getValueFormat(unit || 'none');
const scaleFunc = getScaleCalculator(field as Field, options.theme);
const scaleFunc = getScaleCalculator(field as Field, theme);
return (value: any) => {
const { mappings } = config;
......@@ -106,7 +114,7 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP
}
}
return { text, numeric, prefix, suffix, ...scaleFunc(-Infinity) };
return { text, numeric, prefix, suffix, ...scaleFunc(0) };
};
}
......
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');
});
});
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);
};
}
......@@ -11,7 +11,6 @@ import { MutableDataFrame, toDataFrame } from '../dataframe';
import {
DataFrame,
Field,
FieldColorMode,
FieldConfig,
FieldConfigPropertyItem,
FieldConfigSource,
......@@ -19,6 +18,7 @@ import {
GrafanaTheme,
InterpolateFunction,
ThresholdsMode,
FieldColorModeId,
ScopedVars,
} from '../types';
import { locationUtil, Registry } from '../utils';
......@@ -600,7 +600,7 @@ describe('applyRawFieldOverrides', () => {
},
mappings: [],
color: {
mode: FieldColorMode.Thresholds,
mode: FieldColorModeId.Thresholds,
},
min: 0,
max: 1599124316808,
......@@ -633,6 +633,7 @@ describe('applyRawFieldOverrides', () => {
prefix: undefined,
suffix: undefined,
text: '1599045551050',
percent: expect.any(Number),
threshold: {
color: 'red',
value: 80,
......@@ -642,6 +643,7 @@ describe('applyRawFieldOverrides', () => {
expect(getDisplayValue(frames, frameIndex, 1)).toEqual({
color: '#73BF69',
numeric: 3.14159265359,
percent: expect.any(Number),
prefix: undefined,
suffix: undefined,
text: '3.142',
......@@ -654,6 +656,7 @@ describe('applyRawFieldOverrides', () => {
expect(getDisplayValue(frames, frameIndex, 2)).toEqual({
color: '#73BF69',
numeric: 0,
percent: expect.any(Number),
prefix: undefined,
suffix: undefined,
text: '0',
......@@ -664,24 +667,33 @@ describe('applyRawFieldOverrides', () => {
});
expect(getDisplayValue(frames, frameIndex, 3)).toEqual({
color: '#808080',
numeric: 0,
percent: expect.any(Number),
prefix: undefined,
suffix: undefined,
text: '0',
threshold: expect.anything(),
});
expect(getDisplayValue(frames, frameIndex, 4)).toEqual({
color: '#808080',
numeric: NaN,
percent: 0,
prefix: undefined,
suffix: undefined,
text: 'A - string',
threshold: expect.anything(),
});
expect(getDisplayValue(frames, frameIndex, 5)).toEqual({
color: '#808080',
numeric: 1599045551050,
percent: expect.any(Number),
prefix: undefined,
suffix: undefined,
text: '2020-09-02 11:19:11',
threshold: expect.anything(),
});
};
......
import {
ApplyFieldOverrideOptions,
ColorScheme,
DataFrame,
DataLink,
DataSourceInstanceSettings,
DynamicConfigValue,
Field,
FieldColorMode,
FieldColorModeId,
FieldConfig,
FieldConfigPropertyItem,
FieldOverrideContext,
......@@ -84,6 +83,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
const fieldConfigRegistry = options.fieldConfigRegistry ?? standardFieldConfigEditorRegistry;
let seriesIndex = 0;
let range: GlobalMinMax | undefined = undefined;
// Prepare the Matchers
......@@ -188,28 +188,35 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
}
}
// Some color modes needs series index to assign field color so we count
// up series index here but ignore time fields
if (field.type !== FieldType.time) {
seriesIndex++;
}
// Overwrite the configs
const f: Field = {
const newField: Field = {
...field,
config,
type,
state: {
...field.state,
displayName: null,
seriesIndex,
},
};
// and set the display processor using it
f.display = getDisplayProcessor({
field: f,
newField.display = getDisplayProcessor({
field: newField,
theme: options.theme,
timeZone: options.timeZone,
});
// Attach data links supplier
f.getLinks = getLinksSupplier(
newField.getLinks = getLinksSupplier(
newFrame,
f,
newField,
fieldScopedVars,
context.replaceVariables,
context.getDataSourceSettingsByUid,
......@@ -219,7 +226,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
}
);
return f;
return newField;
});
newFrame.fields = fields;
......@@ -311,22 +318,13 @@ export function validateFieldConfig(config: FieldConfig) {
if (!config.color) {
if (thresholds) {
config.color = {
mode: FieldColorMode.Thresholds,
mode: FieldColorModeId.Thresholds,
};
}
// No Color settings
} else if (!config.color.mode) {
// Without a mode, skip color altogether
delete config.color;
} else {
const { color } = config;
if (color.mode === FieldColorMode.Scheme) {
if (!color.schemeName) {
color.schemeName = ColorScheme.BrBG;
}
} else {
delete color.schemeName;
}
}
// Verify that max > min (swap if necessary)
......
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';
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,
},
},
]
`);
});
});
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!,
};
}
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]);
});
});
});
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);
}
......@@ -130,6 +130,12 @@ export interface FieldState {
* Appropriate values for templating
*/
scopedVars?: ScopedVars;
/**
* Series index is index for this field in a larger data set that can span multiple DataFrames
* Useful for assigning color to series by looking up a color in a palette using this index
*/
seriesIndex?: number;
}
export interface DataFrame extends QueryResultBase {
......
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';
......@@ -185,3 +185,62 @@ export const getNamedColorPalette = () => {
colorsPaletteInstance = buildNamedColorsPalette();
return colorsPaletteInstance;
};
export const classicColors = [
'#7EB26D', // 0: pale green
'#EAB839', // 1: mustard
'#6ED0E0', // 2: light blue
'#EF843C', // 3: orange
'#E24D42', // 4: red
'#1F78C1', // 5: ocean
'#BA43A9', // 6: purple
'#705DA0', // 7: violet
'#508642', // 8: dark green
'#CCA300', // 9: dark sand
'#447EBC',
'#C15C17',
'#890F02',
'#0A437C',
'#6D1F62',
'#584477',
'#B7DBAB',
'#F4D598',
'#70DBED',
'#F9BA8F',
'#F29191',
'#82B5D8',
'#E5A8E2',
'#AEA2E0',
'#629E51',
'#E5AC0E',
'#64B0C8',
'#E0752D',
'#BF1B00',
'#0A50A1',
'#962D82',
'#614D93',
'#9AC48A',
'#F2C96D',
'#65C5DB',
'#F9934E',
'#EA6460',
'#5195CE',
'#D683CE',
'#806EB7',
'#3F6833',
'#967302',
'#2F575E',
'#99440A',
'#58140C',
'#052B51',
'#511749',
'#3F2B5B',
'#E0F9D7',
'#FCEACA',
'#CFFAFF',
'#F9E2D2',
'#FCE2DE',
'#BADFF4',
'#F9D9F9',
'#DEDAF7',
];
// Library
import React, { PureComponent, CSSProperties, ReactNode } from 'react';
import tinycolor from 'tinycolor2';
import * as d3 from 'd3-scale-chromatic';
import {
TimeSeriesValue,
DisplayValue,
......@@ -11,7 +10,8 @@ import {
ThresholdsMode,
DisplayProcessor,
FieldConfig,
FieldColorMode,
FieldColorModeId,
getFieldColorMode,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
......@@ -512,39 +512,16 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
* Only exported to for unit test
*/
export function getBarGradient(props: Props, maxSize: number): string {
const { field, value, orientation } = props;
const { field, value, orientation, theme } = props;
const cssDirection = isVertical(orientation) ? '0deg' : '90deg';
const minValue = field.min!;
const maxValue = field.max!;
let gradient = '';
let lastpos = 0;
let mode = getFieldColorMode(field.color?.mode);
if (field.color && field.color.mode === FieldColorMode.Scheme) {
const schemeSet = (d3 as any)[`scheme${field.color.schemeName}`] as any[];
if (!schemeSet) {
// Error: unknown scheme
const color = '#F00';
gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
gradient += ` ${maxSize}px, ${color}`;
return gradient + ')';
}
// Get the scheme with as many steps as possible
const scheme = schemeSet[schemeSet.length - 1] as string[];
for (let i = 0; i < scheme.length; i++) {
const color = scheme[i];
const valuePercent = i / (scheme.length - 1);
const pos = valuePercent * maxSize;
const offset = Math.round(pos - (pos - lastpos) / 2);
if (gradient === '') {
gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`;
} else {
lastpos = pos;
gradient += ` ${offset}px, ${color}`;
}
}
} else {
if (mode.id === FieldColorModeId.Thresholds) {
const thresholds = field.thresholds!;
for (let i = 0; i < thresholds.steps.length; i++) {
......@@ -563,9 +540,27 @@ export function getBarGradient(props: Props, maxSize: number): string {
gradient += ` ${offset}px, ${color}`;
}
}
return gradient + ')';
}
if (mode.colors) {
const scheme = mode.colors.map(item => getColorFromHexRgbOrName(item, theme.type));
for (let i = 0; i < scheme.length; i++) {
const color = scheme[i];
if (gradient === '') {
gradient = `linear-gradient(${cssDirection}, ${color} 0px`;
} else {
const valuePercent = i / (scheme.length - 1);
const pos = valuePercent * maxSize;
gradient += `, ${color} ${pos}px`;
}
}
return gradient + ')';
}
return gradient + ')';
return 'gray';
}
/**
......
......@@ -42,6 +42,7 @@ exports[`BarGauge Render with basic options should render 1`] = `
Object {
"color": "#73BF69",
"numeric": 25,
"percent": 0.25,
"prefix": undefined,
"suffix": undefined,
"text": "25",
......
......@@ -32,7 +32,7 @@ export const getFieldStyles = stylesFactory((theme: GrafanaTheme) => {
field: css`
display: flex;
flex-direction: column;
margin-bottom: ${theme.spacing.formSpacingBase * 2}px;
margin-bottom: ${theme.spacing.formInputMargin};
`,
fieldHorizontal: css`
flex-direction: row;
......
import React from 'react';
import { Graph } from '@grafana/ui';
import Chart from '../Chart';
import { dateTime, ArrayVector, FieldType, GraphSeriesXY, FieldColorMode } from '@grafana/data';
import { dateTime, ArrayVector, FieldType, GraphSeriesXY, FieldColorModeId } from '@grafana/data';
import { select } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { TooltipContentProps } from '../Chart/Tooltip';
......@@ -49,7 +49,7 @@ const series: GraphSeriesXY[] = [
values: new ArrayVector([10, 20, 10]),
config: {
color: {
mode: FieldColorMode.Fixed,
mode: FieldColorModeId.Fixed,
fixedColor: 'red',
},
},
......@@ -83,7 +83,7 @@ const series: GraphSeriesXY[] = [
values: new ArrayVector([20, 30, 40]),
config: {
color: {
mode: FieldColorMode.Fixed,
mode: FieldColorModeId.Fixed,
fixedColor: 'blue',
},
},
......
......@@ -2,7 +2,7 @@ import React from 'react';
import { mount } from 'enzyme';
import Graph from './Graph';
import Chart from '../Chart';
import { GraphSeriesXY, FieldType, ArrayVector, dateTime, FieldColorMode } from '@grafana/data';
import { GraphSeriesXY, FieldType, ArrayVector, dateTime, FieldColorModeId } from '@grafana/data';
const series: GraphSeriesXY[] = [
{
......@@ -25,7 +25,7 @@ const series: GraphSeriesXY[] = [
type: FieldType.number,
name: 'a-series',
values: new ArrayVector([10, 20, 10]),
config: { color: { mode: FieldColorMode.Fixed, fixedColor: 'red' } },
config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'red' } },
},
timeStep: 3600000,
yAxis: {
......@@ -52,7 +52,7 @@ const series: GraphSeriesXY[] = [
type: FieldType.number,
name: 'b-series',
values: new ArrayVector([20, 30, 40]),
config: { color: { mode: FieldColorMode.Fixed, fixedColor: 'blue' } },
config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'blue' } },
},
timeStep: 3600000,
yAxis: {
......
......@@ -5,7 +5,7 @@ import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { GraphWithLegend, GraphWithLegendProps } from './GraphWithLegend';
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
import { GraphSeriesXY, FieldType, ArrayVector, dateTime, FieldColorMode } from '@grafana/data';
import { GraphSeriesXY, FieldType, ArrayVector, dateTime, FieldColorModeId } from '@grafana/data';
export default {
title: 'Visualizations/Graph',
......@@ -36,7 +36,7 @@ const series: GraphSeriesXY[] = [
values: new ArrayVector([10, 20, 10]),
config: {
color: {
mode: FieldColorMode.Fixed,
mode: FieldColorModeId.Fixed,
fixedColor: 'red',
},
},
......@@ -68,7 +68,7 @@ const series: GraphSeriesXY[] = [
values: new ArrayVector([20, 30, 40]),
config: {
color: {
mode: FieldColorMode.Fixed,
mode: FieldColorModeId.Fixed,
fixedColor: 'blue',
},
},
......
......@@ -3,7 +3,7 @@ import {
toDataFrame,
FieldType,
FieldCache,
FieldColorMode,
FieldColorModeId,
getColorFromHexRgbOrName,
GrafanaThemeType,
Field,
......@@ -34,7 +34,7 @@ const aSeries = toDataFrame({
name: 'value',
type: FieldType.number,
values: [10, 20, 10],
config: { color: { mode: FieldColorMode.Fixed, fixedColor: 'red' } },
config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'red' } },
},
],
});
......@@ -45,7 +45,7 @@ const bSeries = toDataFrame({
name: 'value',
type: FieldType.number,
values: [30, 60, 30],
config: { color: { mode: FieldColorMode.Fixed, fixedColor: 'blue' } },
config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'blue' } },
},
],
});
......@@ -57,7 +57,7 @@ const cSeries = toDataFrame({
name: 'value',
type: FieldType.number,
values: [30, 30],
config: { color: { mode: FieldColorMode.Fixed, fixedColor: 'yellow' } },
config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'yellow' } },
},
],
});
......
import React, { useCallback } from 'react';
import {
FieldConfigEditorProps,
ColorFieldConfigSettings,
GrafanaTheme,
getColorFromHexRgbOrName,
FieldColor,
} from '@grafana/data';
import React from 'react';
import { GrafanaTheme, getColorFromHexRgbOrName } from '@grafana/data';
import { ColorPicker } from '../ColorPicker/ColorPicker';
import { getTheme, stylesFactory } from '../../themes';
import { Icon } from '../Icon/Icon';
import { stylesFactory, useTheme } from '../../themes';
import { css } from 'emotion';
import { ColorPickerTrigger } from '../ColorPicker/ColorPickerTrigger';
export interface Props {
value?: string;
onChange: (value?: string) => void;
}
// Supporting FixedColor only currently
export const ColorValueEditor: React.FC<FieldConfigEditorProps<FieldColor, ColorFieldConfigSettings>> = ({
value,
onChange,
item,
}) => {
const { settings } = item;
const theme = getTheme();
export const ColorValueEditor: React.FC<Props> = ({ value, onChange }) => {
const theme = useTheme();
const styles = getStyles(theme);
const color = value?.fixedColor || item.defaultValue?.fixedColor;
const onValueChange = useCallback(
color => {
onChange({ ...value, fixedColor: color });
},
[value]
);
return (
<ColorPicker color={color || ''} onChange={onValueChange} enableNamedColors={!settings?.disableNamedColors}>
<ColorPicker color={value ?? ''} onChange={onChange} enableNamedColors={true}>
{({ ref, showColorPicker, hideColorPicker }) => {
return (
<div className={styles.spot} onBlur={hideColorPicker}>
......@@ -41,15 +25,15 @@ export const ColorValueEditor: React.FC<FieldConfigEditorProps<FieldColor, Color
ref={ref}
onClick={showColorPicker}
onMouseLeave={hideColorPicker}
color={color ? getColorFromHexRgbOrName(color, theme.type) : theme.colors.formInputBorder}
color={value ? getColorFromHexRgbOrName(value, theme.type) : theme.colors.formInputBorder}
/>
</div>
<div className={styles.colorText} onClick={showColorPicker}>
{color ?? settings?.textWhenUndefined ?? 'Pick Color'}
{/* <div className={styles.colorText} onClick={showColorPicker}>
{value ?? settings?.textWhenUndefined ?? 'Pick Color'}
</div>
{value && settings?.allowUndefined && (
<Icon className={styles.trashIcon} name="trash-alt" onClick={() => onChange(undefined)} />
)}
)} */}
</div>
);
}}
......@@ -63,6 +47,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
color: ${theme.colors.text};
background: ${theme.colors.formInputBg};
padding: 3px;
height: ${theme.spacing.formInputHeight}px;
border: 1px solid ${theme.colors.formInputBorder};
display: flex;
flex-direction: row;
......
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;
`,
};
};
......@@ -113,6 +113,7 @@ export function SelectBase<T>({
loadOptions,
loadingMessage = 'Loading options...',
maxMenuHeight = 300,
minMenuHeight,
maxVisibleValues,
menuPlacement = 'auto',
menuPosition,
......@@ -190,6 +191,7 @@ export function SelectBase<T>({
isOptionDisabled,
isSearchable,
maxMenuHeight,
minMenuHeight,
maxVisibleValues,
menuIsOpen: isOpen,
menuPlacement,
......
......@@ -54,6 +54,7 @@ export const SelectMenuOptions = React.forwardRef<HTMLDivElement, React.PropsWit
<div className={styles.optionBody}>
<span>{renderOptionLabel ? renderOptionLabel(data) : children}</span>
{data.description && <div className={styles.optionDescription}>{data.description}</div>}
{data.component && <data.component />}
</div>
{isSelected && (
<span>
......
......@@ -36,7 +36,10 @@ const getSelectOptionGroupStyles = stylesFactory((theme: GrafanaTheme) => {
padding: 7px 10px;
width: 100%;
border-bottom: 1px solid ${optionBorder};
text-transform: capitalize;
&:hover {
color: ${theme.colors.textStrong};
}
`,
label: css`
flex-grow: 1;
......@@ -80,11 +83,12 @@ class UnthemedSelectOptionGroup extends PureComponent<ExtendedGroupProps, State>
const { children, label, theme } = this.props;
const { expanded } = this.state;
const styles = getSelectOptionGroupStyles(theme);
return (
<div>
<div className={styles.header} onClick={this.onToggleChildren}>
<span className={styles.label}>{label}</span>
<Icon className={styles.icon} name={expanded ? 'angle-left' : 'angle-down'} />{' '}
<Icon className={styles.icon} name={expanded ? 'angle-up' : 'angle-down'} />{' '}
</div>
{expanded && children}
</div>
......
......@@ -32,6 +32,7 @@ export interface SelectCommonProps<T> {
isSearchable?: boolean;
showAllSelectedWhenOpen?: boolean;
maxMenuHeight?: number;
minMenuHeight?: number;
maxVisibleValues?: number;
menuPlacement?: 'auto' | 'bottom' | 'top';
menuPosition?: 'fixed' | 'absolute';
......
......@@ -15,7 +15,7 @@ import {
ThresholdsMode,
ThresholdsConfig,
validateFieldConfig,
FieldColorMode,
FieldColorModeId,
} from '@grafana/data';
export interface SingleStatBaseOptions {
......@@ -174,7 +174,7 @@ export function sharedSingleStatMigrationHandler(panel: PanelModel<SingleStatBas
const { defaults } = fieldOptions;
if (defaults.color && typeof defaults.color === 'string') {
defaults.color = {
mode: FieldColorMode.Fixed,
mode: FieldColorModeId.Fixed,
fixedColor: defaults.color,
};
}
......
......@@ -17,6 +17,7 @@ import {
ValueMappingFieldConfigSettings,
valueMappingsOverrideProcessor,
ThresholdsMode,
identityOverrideProcessor,
TimeZone,
FieldColor,
} from '@grafana/data';
......@@ -35,6 +36,7 @@ import { ThresholdsValueEditor } from '../components/OptionsUI/thresholds';
import { UnitValueEditor } from '../components/OptionsUI/units';
import { DataLinksValueEditor } from '../components/OptionsUI/links';
import { ColorValueEditor } from '../components/OptionsUI/color';
import { FieldColorEditor } from '../components/OptionsUI/fieldColor';
import { StatsPickerEditor } from '../components/OptionsUI/stats';
/**
......@@ -201,22 +203,19 @@ export const getStandardFieldConfigs = () => {
getItemsCount: value => (value ? value.length : 0),
};
// const color: FieldConfigPropertyItem<any, string, StringFieldConfigSettings> = {
// id: 'color',
// path: 'color',
// name: 'Color',
// description: 'Customise color',
// editor: standardEditorsRegistry.get('color').editor as any,
// override: standardEditorsRegistry.get('color').editor as any,
// process: identityOverrideProcessor,
// settings: {
// placeholder: '-',
// },
// shouldApply: field => field.type !== FieldType.time,
// category,
// };
return [unit, min, max, decimals, displayName, noValue, thresholds, mappings, links];
const color: FieldConfigPropertyItem<any, FieldColor | undefined, {}> = {
id: 'color',
path: 'color',
name: 'Color scheme',
description: 'Select palette, gradient or single color',
editor: standardEditorsRegistry.get('fieldColor').editor as any,
override: standardEditorsRegistry.get('fieldColor').editor as any,
process: identityOverrideProcessor,
shouldApply: () => true,
category,
};
return [unit, min, max, decimals, displayName, noValue, color, thresholds, mappings, links];
};
/**
......@@ -286,11 +285,18 @@ export const getStandardOptionEditors = () => {
editor: ValueMappingsValueEditor as any,
};
const color: StandardEditorsRegistryItem<FieldColor> = {
const color: StandardEditorsRegistryItem<string> = {
id: 'color',
name: 'Color',
description: 'Allows color selection',
editor: ColorValueEditor as any,
editor: props => <ColorValueEditor value={props.value} onChange={props.onChange} />,
};
const fieldColor: StandardEditorsRegistryItem<FieldColor> = {
id: 'fieldColor',
name: 'Field Color',
description: 'Field color selection',
editor: FieldColorEditor as any,
};
const links: StandardEditorsRegistryItem<DataLink[]> = {
......@@ -327,6 +333,7 @@ export const getStandardOptionEditors = () => {
statsPicker,
strings,
timeZone,
fieldColor,
color,
];
};
......@@ -489,6 +489,7 @@ describe('decorateWithLogsResult', () => {
labels: undefined,
index: 1,
display: expect.anything(),
state: expect.anything(),
},
timeStep: 0,
},
......
......@@ -14,7 +14,7 @@ import {
hasMsResolution,
systemDateFormats,
FieldColor,
FieldColorMode,
FieldColorModeId,
FieldConfigSource,
getFieldDisplayName,
} from '@grafana/data';
......@@ -82,7 +82,7 @@ export const getGraphSeriesModel = (
if (seriesOptions[field.name] && seriesOptions[field.name].color) {
// Case when panel has settings provided via SeriesOptions, i.e. graph panel
color = {
mode: FieldColorMode.Fixed,
mode: FieldColorModeId.Fixed,
fixedColor: seriesOptions[field.name].color,
};
} else if (field.config && field.config.color) {
......@@ -90,7 +90,7 @@ export const getGraphSeriesModel = (
color = field.config.color;
} else {
color = {
mode: FieldColorMode.Fixed,
mode: FieldColorModeId.Fixed,
fixedColor: colors[graphs.length % colors.length],
};
}
......
......@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
import {
Area,
Canvas,
colors,
ContextMenuPlugin,
GraphCustomFieldConfig,
LegendDisplayMode,
......@@ -14,6 +13,7 @@ import {
TooltipPlugin,
UPlotChart,
ZoomPlugin,
useTheme,
} from '@grafana/ui';
import {
......@@ -21,9 +21,9 @@ import {
FieldConfig,
FieldType,
formattedValueToString,
getColorFromHexRgbOrName,
getTimeField,
PanelProps,
getFieldColorModeForField,
systemDateFormats,
} from '@grafana/data';
......@@ -93,7 +93,9 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
options,
onChangeTimeRange,
}) => {
const theme = useTheme();
const [alignedData, setAlignedData] = useState<DataFrame | null>(null);
useEffect(() => {
if (!data || !data.series?.length) {
setAlignedData(null);
......@@ -137,9 +139,11 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
const field = alignedData.fields[i];
const config = field.config as FieldConfig<GraphCustomFieldConfig>;
const customConfig = config.custom;
if (i === timeIndex || field.type !== FieldType.number) {
continue;
}
const fmt = field.display ?? defaultFormatter;
const scale = config.unit || '__fixed';
......@@ -159,10 +163,11 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
);
}
const seriesColor =
customConfig?.line.color && customConfig?.line.color.fixedColor
? getColorFromHexRgbOrName(customConfig.line.color.fixedColor)
: colors[seriesIdx];
// need to update field state here because we use a transform to merge frames
field.state = { ...field.state, seriesIndex: seriesIdx };
const colorMode = getFieldColorModeForField(field);
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
if (customConfig?.line?.show) {
seriesGeometry.push(
......
import {
FieldColor,
FieldConfigProperty,
identityOverrideProcessor,
PanelPlugin,
standardEditorsRegistry,
} from '@grafana/data';
import { FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { GraphCustomFieldConfig } from '@grafana/ui';
import { GraphPanel } from './GraphPanel';
import { Options } from './types';
......@@ -14,6 +8,7 @@ export const plugin = new PanelPlugin<Options, GraphCustomFieldConfig>(GraphPane
standardOptions: [
// FieldConfigProperty.Min,
// FieldConfigProperty.Max,
FieldConfigProperty.Color,
FieldConfigProperty.Unit,
FieldConfigProperty.DisplayName,
FieldConfigProperty.Decimals,
......@@ -23,21 +18,6 @@ export const plugin = new PanelPlugin<Options, GraphCustomFieldConfig>(GraphPane
useCustomConfig: builder => {
builder
// TODO: Until we fix standard color property let's do it the custom editor way
.addCustomEditor<{}, FieldColor>({
path: 'line.color',
id: 'line.color',
name: 'Series color',
shouldApply: () => true,
settings: {
allowUndefined: true,
textWhenUndefined: 'Automatic',
},
defaultValue: undefined,
editor: standardEditorsRegistry.get('color').editor as any,
override: standardEditorsRegistry.get('color').editor as any,
process: identityOverrideProcessor,
})
.addBooleanSwitch({
path: 'line.show',
name: 'Show lines',
......
......@@ -6102,7 +6102,7 @@
resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-1.1.6.tgz#4c017521900813ea524c9ecb8d7985ec26a9ad9a"
integrity sha512-vvSaIDf/Ov0o3KwMT+1M8+WbnnlRiGjlGD5uvk83a1mPCTd/E5x12bUJ/oP55+wUY/4Kb5kc67rVpVGJ2KUHxg==
"@types/d3-interpolate@*":
"@types/d3-interpolate@*", "@types/d3-interpolate@^1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-1.3.1.tgz#1c280511f622de9b0b47d463fa55f9a4fd6f5fc8"
integrity sha512-z8Zmi08XVwe8e62vP6wcA+CNuRhpuUU5XPEfqpG0hRypDE5BWNthQHB1UNWWDB7ojCbGaN4qBdsWp5kWxhT1IQ==
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment