diff --git a/public/app/plugins/panel/xychart/scatter.ts b/public/app/plugins/panel/xychart/scatter.ts index 5f741b92323..0e23d513e7b 100644 --- a/public/app/plugins/panel/xychart/scatter.ts +++ b/public/app/plugins/panel/xychart/scatter.ts @@ -2,6 +2,7 @@ import tinycolor from 'tinycolor2'; import uPlot from 'uplot'; import { + EnumFieldConfig, FALLBACK_COLOR, Field, FieldType, @@ -429,7 +430,11 @@ export const prepConfig = (xySeries: XYSeries[], theme: GrafanaTheme2) => { const dispColors = xySeries.map((s): FieldColorValuesWithCache => { const cfg: FieldColorValuesWithCache = { - index: [], + index: { + color: [], + text: [], + icon: [], + }, getAll: () => [], getOne: () => -1, // cache for renderer, refreshed in prepData() @@ -440,8 +445,8 @@ export const prepConfig = (xySeries: XYSeries[], theme: GrafanaTheme2) => { const f = s.color.field; if (f != null) { - Object.assign(cfg, fieldValueColors(f, theme)); - cfg.hasAlpha = cfg.index.some((v) => !(v as string).endsWith('ff')); + Object.assign(cfg, getEnumConfig(f, theme)); + cfg.hasAlpha = cfg.index.color!.some((v) => !(v as string).endsWith('ff')); } return cfg; @@ -551,7 +556,7 @@ function getHex8Color(color: string, theme: GrafanaTheme2) { } interface FieldColorValues { - index: unknown[]; + index: EnumFieldConfig; getOne: GetOneValue; getAll: GetAllValues; } @@ -563,8 +568,13 @@ type GetAllValues = (values: unknown[], min?: number, max?: number) => number[]; type GetOneValue = (value: unknown, min?: number, max?: number) => number; /** compiler for values to palette color idxs (from thresholds, mappings, by-value gradients) */ -function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues { - let index: unknown[] = []; +export function getEnumConfig(f: Field, theme: GrafanaTheme2): FieldColorValues { + const index: EnumFieldConfig = { + color: [], + text: [], + icon: [], + }; + let getAll: GetAllValues = () => []; let getOne: GetOneValue = () => -1; @@ -574,21 +584,42 @@ function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues { if (f.config.mappings?.length ?? 0 > 0) { let mappings = f.config.mappings!; + // this is color+text+icon that deduplicates the index above + // e.g. if multiple values + ranges map "OK"+"green", this ensures they map to same state by key + let keys: string[] = []; + + function indexOf(color = '', text = '', icon = '') { + let key = `${color}|${text}|${icon}`; + + let idx = keys.indexOf(key); + + if (idx === -1) { + idx = keys.length; + keys.push(key); + + index.color!.push(getHex8Color(color, theme)); + index.text!.push(text); + index.icon!.push(icon); + } + + return idx; + } + for (let i = 0; i < mappings.length; i++) { let m = mappings[i]; if (m.type === MappingType.ValueToText) { for (let k in m.options) { - let { color } = m.options[k]; + let { color, text, icon } = m.options[k]; if (color != null) { - let rhs = f.type === FieldType.string ? JSON.stringify(k) : Number(k); - conds += `v === ${rhs} ? ${index.length} : `; - index.push(getHex8Color(color, theme)); + let idx = indexOf(color, text, icon); + let rhs = k.toLowerCase() === 'null' ? 'null' : f.type === FieldType.string ? JSON.stringify(k) : Number(k); + conds += `v === ${rhs} ? ${idx} : `; } } } else if (m.options.result.color != null) { - let { color } = m.options.result; + let { color, text, icon } = m.options.result; if (m.type === MappingType.RangeToText) { let range = []; @@ -602,8 +633,8 @@ function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues { } if (range.length > 0) { - conds += `${range.join(' && ')} ? ${index.length} : `; - index.push(getHex8Color(color, theme)); + let idx = indexOf(color, text, icon); + conds += `${range.join(' && ')} ? ${idx} : `; } } else if (m.type === MappingType.SpecialValue) { let spl = m.options.match; @@ -626,8 +657,8 @@ function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues { }`; } - conds += ` ? ${index.length} : `; - index.push(getHex8Color(color, theme)); + let idx = indexOf(color, text, icon); + conds += ` ? ${idx} : `; } else if (m.type === MappingType.RegexToText) { // TODO } @@ -646,18 +677,20 @@ function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues { conds += '0'; - index = steps.map((s) => getHex8Color(s.color, theme)); + index.color = steps.map((s) => getHex8Color(s.color, theme)); + index.text = Array(steps.length).fill(''); + index.icon = index.text!.slice(); } else { // TODO: percent thresholds? } } else if (f.config.color?.mode?.startsWith('continuous')) { let calc = getFieldColorModeForField(f).getCalculator(f, theme); - index = Array(32); + index.color = Array(32); - for (let i = 0; i < index.length; i++) { - let pct = i / (index.length - 1); - index[i] = getHex8Color(calc(pct, pct), theme); + for (let i = 0; i < index.color.length; i++) { + let pct = i / (index.color.length - 1); + index.color[i] = getHex8Color(calc(pct, pct), theme); } getAll = (vals, min, max) => valuesToFills(vals as number[], index as string[], min!, max!); diff --git a/public/app/plugins/panel/xychart/utils.test.ts b/public/app/plugins/panel/xychart/utils.test.ts new file mode 100644 index 00000000000..cba4897126d --- /dev/null +++ b/public/app/plugins/panel/xychart/utils.test.ts @@ -0,0 +1,155 @@ +import { createTheme, FieldType, Field, ThresholdsMode, MappingType } from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema/dist/esm/index.gen'; + +import { getEnumConfig } from './scatter'; + +describe('value mapping function', () => { + it('thresholds', () => { + const field: Field = { + name: 'A', + type: FieldType.number, + values: [0, 10, 20, 30, 40, 50], + config: { + mappings: undefined, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { + value: -Infinity, + color: 'green', + }, + { + value: 30, + color: 'red', + }, + ], + }, + color: { + mode: FieldColorModeId.Thresholds, + }, + }, + }; + + const { index, getAll } = getEnumConfig(field, createTheme()); + expect(index).toEqual({ color: ['#73bf69ff', '#f2495cff'], icon: ['', ''], text: ['', ''] }); + expect(getAll(field.values)).toEqual([0, 0, 0, 1, 1, 1]); + }); + + it('mappings (with dedupe)', () => { + const field: Field = { + name: 'A', + type: FieldType.number, + values: [5, 6, 7, 8, 9, 10, 11, 32, 40, null], + config: { + mappings: [ + { + options: { + '21': { + color: '#fade2a', + index: 10, + text: 'Manual Stop', + }, + '22': { + color: '#f2495c', + index: 9, + text: 'Instant Shutdown', + }, + '23': { + color: '#ff9830', + index: 8, + text: 'Delayed Shutdown', + }, + '30': { + color: '#5794f2', + index: 7, + text: 'Propel', + }, + '31': { + color: '#ffa6b0', + index: 6, + text: 'Limits Mode', + }, + '32': { + color: '#73bf69', + index: 5, + text: 'Production', + }, + '33': { + color: '#ffcb7d', + index: 4, + text: 'Motivator Mode', + }, + '40': { + color: '#73bf69', + index: 3, + text: 'Production', + }, + null: { + color: '#808080', + index: 2, + text: 'N/A', + }, + }, + type: MappingType.ValueToText, + }, + { + options: { + from: 41, + result: { + color: '#a352cc', + index: 0, + text: 'Maintenance Mode', + }, + to: 45, + }, + type: MappingType.RangeToText, + }, + { + options: { + from: 5, + result: { + color: '#73bf69', + index: 1, + text: 'Production', + }, + to: 11, + }, + type: MappingType.RangeToText, + }, + ], + color: { + mode: FieldColorModeId.Fixed, + }, + }, + }; + + // this should merge states with equal text+color+icon + const { index, getAll } = getEnumConfig(field, createTheme()); + expect(index).toEqual({ + color: [ + '#fade2aff', + '#f2495cff', + '#ff9830ff', + '#5794f2ff', + '#ffa6b0ff', + '#73bf69ff', + '#ffcb7dff', + '#808080ff', + '#a352ccff', + ], + icon: ['', '', '', '', '', '', '', '', ''], + text: [ + 'Manual Stop', + 'Instant Shutdown', + 'Delayed Shutdown', + 'Propel', + 'Limits Mode', + 'Production', + 'Motivator Mode', + 'N/A', + 'Maintenance Mode', + ], + }); + expect(getAll(field.values)).toEqual([5, 5, 5, 5, 5, 5, 5, 5, 5, 7]); + }); +});