diff --git a/docs/sources/panels/field-options/standard-field-options.md b/docs/sources/panels/field-options/standard-field-options.md index 7b657e38815..920040ae6f1 100644 --- a/docs/sources/panels/field-options/standard-field-options.md +++ b/docs/sources/panels/field-options/standard-field-options.md @@ -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. diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index c85d589bff8..92f03cb493e 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -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", diff --git a/packages/grafana-data/src/field/displayProcessor.test.ts b/packages/grafana-data/src/field/displayProcessor.test.ts index 637d37356bb..9d7c4b5f998 100644 --- a/packages/grafana-data/src/field/displayProcessor.test.ts +++ b/packages/grafana-data/src/field/displayProcessor.test.ts @@ -1,10 +1,7 @@ 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: {}, }, }); diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index 4c19c0800df..180a25e6952 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -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; - - // 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) }; }; } diff --git a/packages/grafana-data/src/field/fieldColor.test.ts b/packages/grafana-data/src/field/fieldColor.test.ts new file mode 100644 index 00000000000..42846325f84 --- /dev/null +++ b/packages/grafana-data/src/field/fieldColor.test.ts @@ -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'); + }); +}); diff --git a/packages/grafana-data/src/field/fieldColor.ts b/packages/grafana-data/src/field/fieldColor.ts new file mode 100644 index 00000000000..c11a988c34e --- /dev/null +++ b/packages/grafana-data/src/field/fieldColor.ts @@ -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(() => { + 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); + }; +} diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index f76854572da..86dca6f647f 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -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(), }); }; diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index e4df99a3be0..fad547fc982 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -1,12 +1,11 @@ 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) diff --git a/packages/grafana-data/src/field/index.ts b/packages/grafana-data/src/field/index.ts index e471b07e139..33cbbda7af8 100644 --- a/packages/grafana-data/src/field/index.ts +++ b/packages/grafana-data/src/field/index.ts @@ -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'; diff --git a/packages/grafana-data/src/field/scale.test.ts b/packages/grafana-data/src/field/scale.test.ts index 3245b20f79e..a11c2497a9f 100644 --- a/packages/grafana-data/src/field/scale.test.ts +++ b/packages/grafana-data/src/field/scale.test.ts @@ -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 }; +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' }, + ]; - // 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 = { + 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, - }, - }, - ] - `); }); }); diff --git a/packages/grafana-data/src/field/scale.ts b/packages/grafana-data/src/field/scale.ts index 858c38c41d1..c11207427fd 100644 --- a/packages/grafana-data/src/field/scale.ts +++ b/packages/grafana-data/src/field/scale.ts @@ -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 { - 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 {}; + const percent = (value - info.min!) / info.delta; + const threshold = getActiveThresholdForValue(field, value, percent); + + return { + percent, + threshold, + color: getColor(value, percent, threshold), + }; }; } -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; +interface FieldMinMaxInfo { + min?: number | null; + max?: number | null; + delta: number; } -/** - * Sorts the thresholds - */ -export function sortThresholds(thresholds: Threshold[]) { - return thresholds.sort((t1, t2) => { - return t1.value - t2.value; - }); +function getMinMaxAndDelta(field: Field): FieldMinMaxInfo { + if (field.type !== FieldType.number) { + return { min: 0, max: 100, delta: 100 }; + } + + // Calculate min/max if required + let min = field.config.min; + let max = field.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; + } + } + + return { + min, + max, + delta: max! - min!, + }; } diff --git a/packages/grafana-data/src/field/thresholds.test.ts b/packages/grafana-data/src/field/thresholds.test.ts new file mode 100644 index 00000000000..837c29cea89 --- /dev/null +++ b/packages/grafana-data/src/field/thresholds.test.ts @@ -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]); + }); + }); +}); diff --git a/packages/grafana-data/src/field/thresholds.ts b/packages/grafana-data/src/field/thresholds.ts new file mode 100644 index 00000000000..8328483aa20 --- /dev/null +++ b/packages/grafana-data/src/field/thresholds.ts @@ -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); +} diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index cefbe58045f..3fdd660a80b 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -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 { diff --git a/packages/grafana-data/src/types/fieldColor.ts b/packages/grafana-data/src/types/fieldColor.ts index 34314a0d72e..f419196281d 100644 --- a/packages/grafana-data/src/types/fieldColor.ts +++ b/packages/grafana-data/src/types/fieldColor.ts @@ -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'; diff --git a/packages/grafana-data/src/utils/namedColorsPalette.ts b/packages/grafana-data/src/utils/namedColorsPalette.ts index e2d2c833fb5..6ee5ff8604e 100644 --- a/packages/grafana-data/src/utils/namedColorsPalette.ts +++ b/packages/grafana-data/src/utils/namedColorsPalette.ts @@ -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', +]; diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 66e5cf97cda..e28cea73641 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -1,7 +1,6 @@ // 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 + ')'; } - 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 'gray'; } /** diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap index c0757d98335..cb2c15faf80 100644 --- a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -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", diff --git a/packages/grafana-ui/src/components/Forms/Field.tsx b/packages/grafana-ui/src/components/Forms/Field.tsx index 82d0255a73b..0496c5c0e5d 100644 --- a/packages/grafana-ui/src/components/Forms/Field.tsx +++ b/packages/grafana-ui/src/components/Forms/Field.tsx @@ -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; diff --git a/packages/grafana-ui/src/components/Graph/Graph.story.tsx b/packages/grafana-ui/src/components/Graph/Graph.story.tsx index f9b08ba3d64..b5543699970 100644 --- a/packages/grafana-ui/src/components/Graph/Graph.story.tsx +++ b/packages/grafana-ui/src/components/Graph/Graph.story.tsx @@ -1,7 +1,7 @@ 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', }, }, diff --git a/packages/grafana-ui/src/components/Graph/Graph.test.tsx b/packages/grafana-ui/src/components/Graph/Graph.test.tsx index 21cc2ce3c57..af05b7eae58 100644 --- a/packages/grafana-ui/src/components/Graph/Graph.test.tsx +++ b/packages/grafana-ui/src/components/Graph/Graph.test.tsx @@ -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: { diff --git a/packages/grafana-ui/src/components/Graph/GraphWithLegend.story.tsx b/packages/grafana-ui/src/components/Graph/GraphWithLegend.story.tsx index d0755e6532f..a0aeabc98cf 100644 --- a/packages/grafana-ui/src/components/Graph/GraphWithLegend.story.tsx +++ b/packages/grafana-ui/src/components/Graph/GraphWithLegend.story.tsx @@ -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', }, }, diff --git a/packages/grafana-ui/src/components/Graph/utils.test.ts b/packages/grafana-ui/src/components/Graph/utils.test.ts index 32ec64d5a72..8acb71102c9 100644 --- a/packages/grafana-ui/src/components/Graph/utils.test.ts +++ b/packages/grafana-ui/src/components/Graph/utils.test.ts @@ -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' } }, }, ], }); diff --git a/packages/grafana-ui/src/components/OptionsUI/color.tsx b/packages/grafana-ui/src/components/OptionsUI/color.tsx index c2b795f4311..60b0a696ecc 100644 --- a/packages/grafana-ui/src/components/OptionsUI/color.tsx +++ b/packages/grafana-ui/src/components/OptionsUI/color.tsx @@ -1,38 +1,22 @@ -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> = ({ - value, - onChange, - item, -}) => { - const { settings } = item; - const theme = getTheme(); +export const ColorValueEditor: React.FC = ({ 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 ( - + {({ ref, showColorPicker, hideColorPicker }) => { return (
@@ -41,15 +25,15 @@ export const ColorValueEditor: React.FC
-
- {color ?? settings?.textWhenUndefined ?? 'Pick Color'} + {/*
+ {value ?? settings?.textWhenUndefined ?? 'Pick Color'}
{value && settings?.allowUndefined && ( onChange(undefined)} /> - )} + )} */}
); }} @@ -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; diff --git a/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx b/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx new file mode 100644 index 00000000000..d0c953759a8 --- /dev/null +++ b/packages/grafana-ui/src/components/OptionsUI/fieldColor.tsx @@ -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> = ({ + 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: () => , + }; + }); + + const onModeChange = (newMode: SelectableValue) => { + 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 ( +
+ ; +}; + +interface ModeProps { + mode: FieldColorMode; + theme: GrafanaTheme; +} + +const FieldColorModeViz: FC = ({ 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
; +}; + +const getStyles = (theme: GrafanaTheme) => { + return { + group: css` + display: flex; + `, + select: css` + margin-right: 8px; + flex-grow: 1; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index a386c21c3b8..453b8a49576 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -113,6 +113,7 @@ export function SelectBase({ loadOptions, loadingMessage = 'Loading options...', maxMenuHeight = 300, + minMenuHeight, maxVisibleValues, menuPlacement = 'auto', menuPosition, @@ -190,6 +191,7 @@ export function SelectBase({ isOptionDisabled, isSearchable, maxMenuHeight, + minMenuHeight, maxVisibleValues, menuIsOpen: isOpen, menuPlacement, diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index 92468032d5f..be5d70c05ec 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -54,6 +54,7 @@ export const SelectMenuOptions = React.forwardRef {renderOptionLabel ? renderOptionLabel(data) : children} {data.description &&
{data.description}
} + {data.component && }
{isSelected && ( diff --git a/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx b/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx index 8e08b9cc896..931f0241163 100644 --- a/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx +++ b/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx @@ -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 const { children, label, theme } = this.props; const { expanded } = this.state; const styles = getSelectOptionGroupStyles(theme); + return (
{label} - {' '} + {' '}
{expanded && children}
diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index 01cd4541e56..a99cbdb6979 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -32,6 +32,7 @@ export interface SelectCommonProps { isSearchable?: boolean; showAllSelectedWhenOpen?: boolean; maxMenuHeight?: number; + minMenuHeight?: number; maxVisibleValues?: number; menuPlacement?: 'auto' | 'bottom' | 'top'; menuPosition?: 'fixed' | 'absolute'; diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts index 9f1382bd58f..2b6cf375882 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts @@ -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 { getItemsCount: value => (value ? value.length : 0), }; - // const color: FieldConfigPropertyItem = { - // 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, - // }; + const color: FieldConfigPropertyItem = { + 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, thresholds, mappings, links]; + 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 = { + const color: StandardEditorsRegistryItem = { id: 'color', name: 'Color', description: 'Allows color selection', - editor: ColorValueEditor as any, + editor: props => , + }; + + const fieldColor: StandardEditorsRegistryItem = { + id: 'fieldColor', + name: 'Field Color', + description: 'Field color selection', + editor: FieldColorEditor as any, }; const links: StandardEditorsRegistryItem = { @@ -327,6 +333,7 @@ export const getStandardOptionEditors = () => { statsPicker, strings, timeZone, + fieldColor, color, ]; }; diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index d913531c953..0c43cdcb2d6 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -489,6 +489,7 @@ describe('decorateWithLogsResult', () => { labels: undefined, index: 1, display: expect.anything(), + state: expect.anything(), }, timeStep: 0, }, diff --git a/public/app/plugins/panel/graph2/getGraphSeriesModel.ts b/public/app/plugins/panel/graph2/getGraphSeriesModel.ts index 9908ea16fd1..3f68162274c 100644 --- a/public/app/plugins/panel/graph2/getGraphSeriesModel.ts +++ b/public/app/plugins/panel/graph2/getGraphSeriesModel.ts @@ -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], }; } diff --git a/public/app/plugins/panel/graph3/GraphPanel.tsx b/public/app/plugins/panel/graph3/GraphPanel.tsx index e1002059b05..08ab0b5ac05 100644 --- a/public/app/plugins/panel/graph3/GraphPanel.tsx +++ b/public/app/plugins/panel/graph3/GraphPanel.tsx @@ -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 = ({ options, onChangeTimeRange, }) => { + const theme = useTheme(); const [alignedData, setAlignedData] = useState(null); + useEffect(() => { if (!data || !data.series?.length) { setAlignedData(null); @@ -137,9 +139,11 @@ export const GraphPanel: React.FC = ({ const field = alignedData.fields[i]; const config = field.config as FieldConfig; 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 = ({ ); } - 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( diff --git a/public/app/plugins/panel/graph3/module.tsx b/public/app/plugins/panel/graph3/module.tsx index 78e5912c6ad..7b7b0273232 100644 --- a/public/app/plugins/panel/graph3/module.tsx +++ b/public/app/plugins/panel/graph3/module.tsx @@ -1,10 +1,4 @@ -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(GraphPane standardOptions: [ // FieldConfigProperty.Min, // FieldConfigProperty.Max, + FieldConfigProperty.Color, FieldConfigProperty.Unit, FieldConfigProperty.DisplayName, FieldConfigProperty.Decimals, @@ -23,21 +18,6 @@ export const plugin = new PanelPlugin(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', diff --git a/yarn.lock b/yarn.lock index 2f4cc0aa00a..ada92470293 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==