diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index 8b5cad6c727..1f8e7226414 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -6,7 +6,7 @@ import { getFieldTypeFromValue } from '../dataframe/processDataFrame'; import { toUtc, dateTimeParse } from '../datetime'; import { GrafanaTheme2 } from '../themes/types'; import { KeyValue, TimeZone } from '../types'; -import { EnumFieldConfig, Field, FieldType } from '../types/dataFrame'; +import { Field, FieldType } from '../types/dataFrame'; import { DecimalCount, DisplayProcessor, DisplayValue } from '../types/displayValue'; import { anyToNumber } from '../utils/anyToNumber'; import { getValueMappingResult } from '../utils/valueMappings'; @@ -44,6 +44,7 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP const field = options.field as Field; const config = field.config ?? {}; + const { palette } = options.theme.visualization; let unit = config.unit; let hasDateUnit = unit && (timeFormats[unit] || unit.startsWith('time:')); @@ -70,8 +71,6 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP } } else if (!unit && field.type === FieldType.string) { unit = 'string'; - } else if (field.type === FieldType.enum) { - return getEnumDisplayProcessor(options.theme, config.type?.enum); } const hasCurrencyUnit = unit?.startsWith('currency'); @@ -116,6 +115,28 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP icon = mappingResult.icon; } } + } else if (field.type === FieldType.enum) { + // Apply enum display handling if field is enum type and no mappings are specified + if (value == null) { + return { + text: '', + numeric: NaN, + }; + } + + const enumIndex = +value; + if (config && config.type && config.type.enum) { + const { text: enumText, color: enumColor } = config.type.enum; + + text = enumText ? enumText[enumIndex] : `${value}`; + // If no color specified in enum field config we will fallback to iterating through the theme palette + color = enumColor ? enumColor[enumIndex] : undefined; + + if (color == null) { + const namedColor = palette[enumIndex % palette.length]; + color = options.theme.visualization.getColorByName(namedColor); + } + } } if (!Number.isNaN(numeric)) { @@ -192,41 +213,6 @@ function toStringProcessor(value: unknown): DisplayValue { return { text: toString(value), numeric: anyToNumber(value) }; } -export function getEnumDisplayProcessor(theme: GrafanaTheme2, cfg?: EnumFieldConfig): DisplayProcessor { - const config = { - text: cfg?.text ?? [], - color: cfg?.color ?? [], - }; - // use the theme specific color values - config.color = config.color.map((v) => theme.visualization.getColorByName(v)); - - return (value: unknown) => { - if (value == null) { - return { - text: '', - numeric: NaN, - }; - } - const idx = +value; - let text = config.text[idx]; - if (text == null) { - text = `${value}`; // the original value - } - let color = config.color[idx]; - if (color == null) { - // constant color for index - const { palette } = theme.visualization; - color = palette[idx % palette.length]; - config.color[idx] = color; - } - return { - text, - numeric: idx, - color, - }; - }; -} - export function getRawDisplayProcessor(): DisplayProcessor { return (value: unknown) => ({ text: getFieldTypeFromValue(value) === 'other' ? `${JSON.stringify(value, getCircularReplacer())}` : `${value}`, diff --git a/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts index 23967cad334..64e4e5ace29 100644 --- a/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts @@ -21,6 +21,24 @@ const fieldTypeMatcher: FieldMatcherInfo = { }, }; +// General Field matcher (multiple types) +const fieldTypesMatcher: FieldMatcherInfo> = { + id: FieldMatcherID.byTypes, + name: 'Field Type', + description: 'match based on the field types', + defaultOptions: new Set(), + + get: (types) => { + return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => { + return types.has(field.type); + }; + }, + + getOptionsDisplayText: (types) => { + return `Field types: ${[...types].join(' | ')}`; + }, +}; + // Numeric Field matcher // This gets its own entry so it shows up in the dropdown const numericMatcher: FieldMatcherInfo = { @@ -56,5 +74,5 @@ const timeMatcher: FieldMatcherInfo = { * Registry Initialization */ export function getFieldTypeMatchers(): FieldMatcherInfo[] { - return [fieldTypeMatcher, numericMatcher, timeMatcher]; + return [fieldTypeMatcher, fieldTypesMatcher, numericMatcher, timeMatcher]; } diff --git a/packages/grafana-data/src/transformations/matchers/ids.ts b/packages/grafana-data/src/transformations/matchers/ids.ts index 0f44b80ec8b..5830e62f5fb 100644 --- a/packages/grafana-data/src/transformations/matchers/ids.ts +++ b/packages/grafana-data/src/transformations/matchers/ids.ts @@ -19,6 +19,7 @@ export enum FieldMatcherID { // With arguments byType = 'byType', + byTypes = 'byTypes', byName = 'byName', byNames = 'byNames', byRegexp = 'byRegexp', diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 131aa7670e3..230db2f0107 100644 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -10,6 +10,7 @@ import { Field, FieldMatcherID, fieldMatchers, + FieldType, LegacyGraphHoverEvent, TimeRange, TimeZone, @@ -120,7 +121,7 @@ export class GraphNG extends Component { frames, fields || { x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), - y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + y: fieldMatchers.get(FieldMatcherID.byTypes).get(new Set([FieldType.number, FieldType.enum])), }, props.timeRange ); diff --git a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap index 728f8039d72..32c048e6ca1 100644 --- a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -43,7 +43,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "incrs": undefined, "labelGap": 0, "rotate": undefined, - "scale": "__fixed/na-na/na-na/auto/linear/na", + "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "side": 3, "size": [Function], @@ -81,7 +81,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "key": "__global_", "scales": [ "x", - "__fixed/na-na/na-na/auto/linear/na", + "__fixed/na-na/na-na/auto/linear/na/number", ], }, }, @@ -101,7 +101,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` [Function], ], "scales": { - "__fixed/na-na/na-na/auto/linear/na": { + "__fixed/na-na/na-na/auto/linear/na/number": { "asinh": undefined, "auto": true, "dir": 1, @@ -140,7 +140,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "stroke": "#ff0000", }, "pxAlign": undefined, - "scale": "__fixed/na-na/na-na/auto/linear/na", + "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, "stroke": "#ff0000", @@ -163,7 +163,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "stroke": "#ff0000", }, "pxAlign": undefined, - "scale": "__fixed/na-na/na-na/auto/linear/na", + "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, "stroke": "#ff0000", @@ -186,7 +186,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "stroke": "#ff0000", }, "pxAlign": undefined, - "scale": "__fixed/na-na/na-na/auto/linear/na", + "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, "stroke": "#ff0000", @@ -209,7 +209,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "stroke": "#ff0000", }, "pxAlign": undefined, - "scale": "__fixed/na-na/na-na/auto/linear/na", + "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, "stroke": "#ff0000", @@ -232,7 +232,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` "stroke": "#ff0000", }, "pxAlign": undefined, - "scale": "__fixed/na-na/na-na/auto/linear/na", + "scale": "__fixed/na-na/na-na/auto/linear/na/number", "show": true, "spanGaps": false, "stroke": "#ff0000", diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts index cfdc8e36456..66f511195ae 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -146,7 +146,7 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers return null; } -export function buildScaleKey(config: FieldConfig) { +export function buildScaleKey(config: FieldConfig, fieldType: FieldType) { const defaultPart = 'na'; const scaleRange = `${config.min !== undefined ? config.min : defaultPart}-${ @@ -169,7 +169,7 @@ export function buildScaleKey(config: FieldConfig) { const scaleLabel = Boolean(config.custom?.axisLabel) ? config.custom!.axisLabel : defaultPart; - return `${scaleUnit}/${scaleRange}/${scaleSoftRange}/${scalePlacement}/${scaleDistribution}/${scaleLabel}`; + return `${scaleUnit}/${scaleRange}/${scaleSoftRange}/${scalePlacement}/${scaleDistribution}/${scaleLabel}/${fieldType}`; } function getScaleDistributionPart(config: ScaleDistributionConfig) { diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/components/TimeSeries/utils.ts index aa2792831ea..01ad4ab374b 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/components/TimeSeries/utils.ts @@ -214,7 +214,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ const customConfig: GraphFieldConfig = config.custom!; - if (field === xField || field.type !== FieldType.number) { + if (field === xField || (field.type !== FieldType.number && field.type !== FieldType.enum)) { continue; } @@ -231,7 +231,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ theme, }); } - const scaleKey = buildScaleKey(config); + const scaleKey = buildScaleKey(config, field.type); const colorMode = getFieldColorModeForField(field); const scaleColor = getFieldSeriesColor(field, theme); const seriesColor = scaleColor.color; @@ -258,6 +258,16 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ dataMax = dataMax > 0 ? 1 : 0; return [dataMin, dataMax]; } + : field.type === FieldType.enum + ? (u: uPlot, dataMin: number, dataMax: number) => { + // this is the exhaustive enum (stable) + let len = field.config.type!.enum!.text!.length; + + return [-1, len]; + + // these are only values that are present + // return [dataMin - 1, dataMax + 1] + } : undefined, decimals: field.config.decimals, }, @@ -302,8 +312,16 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ let incrs: uPlot.Axis.Incrs | undefined; + // TODO: these will be dynamic with frame updates, so need to accept getYTickLabels() + let values: uPlot.Axis.Values | undefined; + let splits: uPlot.Axis.Splits | undefined; + if (IEC_UNITS.has(config.unit!)) { incrs = BIN_INCRS; + } else if (field.type === FieldType.enum) { + let text = field.config.type!.enum!.text!; + splits = text.map((v: string, i: number) => i); + values = text; } builder.addAxis( @@ -318,6 +336,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ grid: { show: customConfig.axisGridShow }, decimals: field.config.decimals, distr: customConfig.scaleDistribution?.type, + splits, + values, incrs, ...axisColorOpts, }, diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 84eb359126a..dd861855813 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -82,7 +82,7 @@ export function getStackingBands(group: StackingGroup) { export function getStackingGroups(frame: DataFrame) { let groups: Map = new Map(); - frame.fields.forEach(({ config, values }, i) => { + frame.fields.forEach(({ config, values, type }, i) => { // skip x or time field if (i === 0) { return; @@ -125,7 +125,10 @@ export function getStackingGroups(frame: DataFrame) { ? (custom.lineInterpolation as LineInterpolation) : null; - let stackKey = `${stackDir}|${stackingMode}|${stackingGroup}|${buildScaleKey(config)}|${drawStyle}|${drawStyle2}`; + let stackKey = `${stackDir}|${stackingMode}|${stackingGroup}|${buildScaleKey( + config, + type + )}|${drawStyle}|${drawStyle2}`; let group = groups.get(stackKey); diff --git a/public/app/core/components/TimelineChart/TimelineChart.tsx b/public/app/core/components/TimelineChart/TimelineChart.tsx index 926a1f64532..ac5e2e62769 100644 --- a/public/app/core/components/TimelineChart/TimelineChart.tsx +++ b/public/app/core/components/TimelineChart/TimelineChart.tsx @@ -88,7 +88,11 @@ export class TimelineChart extends React.Component { {...this.props} fields={{ x: (f) => f.type === FieldType.time, - y: (f) => f.type === FieldType.number || f.type === FieldType.boolean || f.type === FieldType.string, + y: (f) => + f.type === FieldType.number || + f.type === FieldType.boolean || + f.type === FieldType.string || + f.type === FieldType.enum, }} prepConfig={this.prepConfig} propsToDiff={propsToDiff} diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 18c4a6f236b..102200fe8fb 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -466,6 +466,7 @@ export function prepareTimelineFields( hasTimeseries = true; fields.push(field); break; + case FieldType.enum: case FieldType.number: if (mergeValues && field.config.color?.mode === FieldColorModeId.Thresholds) { const f = mergeThresholdValues(field, theme); diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.ts b/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.ts index 0ae5ba48f08..0faab494fa9 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.ts +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/dataTransform.ts @@ -3,8 +3,8 @@ import { DataFrame, DisplayProcessor, Field, + FieldType, getDisplayProcessor, - getEnumDisplayProcessor, GrafanaTheme2, } from '@grafana/data'; @@ -107,7 +107,9 @@ export class FlameGraphDataContainer { // both a backward compatibility but also to allow using a simple dataFrame without enum config. This would allow // users to use this panel with correct query from data sources that do not return profiles natively. if (enumConfig) { - this.labelDisplayProcessor = getEnumDisplayProcessor(theme, enumConfig); + // TODO: Fix this from backend to set field type to enum correctly + this.labelField.type = FieldType.enum; + this.labelDisplayProcessor = getDisplayProcessor({ field: this.labelField, theme }); this.uniqueLabels = enumConfig.text || []; } else { this.labelDisplayProcessor = (value) => ({ diff --git a/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx index ae05da876d2..3db8c039e8d 100644 --- a/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx @@ -1,7 +1,7 @@ import React, { useState, useLayoutEffect, useMemo, useRef } from 'react'; import uPlot from 'uplot'; -import { FieldConfigSource, ThresholdsConfig, getValueFormat } from '@grafana/data'; +import { FieldConfigSource, ThresholdsConfig, getValueFormat, FieldType } from '@grafana/data'; import { UPlotConfigBuilder, buildScaleKey } from '@grafana/ui'; import { ThresholdDragHandle } from './ThresholdDragHandle'; @@ -40,7 +40,7 @@ export const ThresholdControlsPlugin = ({ config, fieldConfig, onThresholdsChang if (!thresholds) { return null; } - const scale = buildScaleKey(fieldConfig.defaults); + const scale = buildScaleKey(fieldConfig.defaults, FieldType.number); const decimals = fieldConfig.defaults.decimals; const handles = []; diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts index 4d2750b076c..83baf64b0da 100644 --- a/public/app/plugins/panel/timeseries/utils.ts +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -1,4 +1,5 @@ import { + ArrayVector, DataFrame, Field, FieldType, @@ -14,6 +15,58 @@ import { convertFieldType } from '@grafana/data/src/transformations/transformers import { GraphFieldConfig, LineInterpolation } from '@grafana/schema'; import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold'; import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue'; +import { buildScaleKey } from '@grafana/ui/src/components/GraphNG/utils'; + +type ScaleKey = string; + +// this will re-enumerate all enum fields on the same scale to create one ordinal progression +// e.g. ['a','b'][0,1,0] + ['c','d'][1,0,1] -> ['a','b'][0,1,0] + ['c','d'][3,2,3] +function reEnumFields(frames: DataFrame[]) { + let allTextsByKey: Map = new Map(); + + let frames2: DataFrame[] = frames.map((frame) => { + return { + ...frame, + fields: frame.fields.map((field) => { + if (field.type === FieldType.enum) { + let scaleKey = buildScaleKey(field.config, field.type); + let allTexts = allTextsByKey.get(scaleKey); + + if (!allTexts) { + allTexts = []; + allTextsByKey.set(scaleKey, allTexts); + } + + let idxs: number[] = field.values.toArray().slice(); + let txts = field.config.type!.enum!.text!; + + // by-reference incrementing + if (allTexts.length > 0) { + for (let i = 0; i < idxs.length; i++) { + idxs[i] += allTexts.length; + } + } + + allTexts.push(...txts); + + // shared among all enum fields on same scale + field.config.type!.enum!.text! = allTexts; + + return { + ...field, + values: new ArrayVector(idxs), + }; + + // TODO: update displayProcessor? + } + + return field; + }), + }; + }); + + return frames2; +} /** * Returns null if there are no graphable fields @@ -52,6 +105,17 @@ export function prepareGraphableFields( } } + let enumFieldsCount = 0; + + loopy: for (let frame of series) { + for (let field of frame.fields) { + if (field.type === FieldType.enum && ++enumFieldsCount > 1) { + series = reEnumFields(series); + break loopy; + } + } + } + let copy: Field; const frames: DataFrame[] = []; @@ -94,6 +158,8 @@ export function prepareGraphableFields( fields.push(copy); break; // ok + case FieldType.enum: + hasValueField = true; case FieldType.string: copy = { ...field, @@ -150,18 +216,37 @@ export function prepareGraphableFields( if (frames.length) { setClassicPaletteIdxs(frames, theme, 0); + matchEnumColorToSeriesColor(frames, theme); return frames; } return null; } +const matchEnumColorToSeriesColor = (frames: DataFrame[], theme: GrafanaTheme2) => { + const { palette } = theme.visualization; + for (const frame of frames) { + for (const field of frame.fields) { + if (field.type === FieldType.enum) { + const namedColor = palette[field.state?.seriesIndex! % palette.length]; + const hexColor = theme.visualization.getColorByName(namedColor); + const enumConfig = field.config.type!.enum!; + + enumConfig.color = Array(enumConfig.text!.length).fill(hexColor); + field.display = getDisplayProcessor({ field, theme }); + } + } + } +}; + const setClassicPaletteIdxs = (frames: DataFrame[], theme: GrafanaTheme2, skipFieldIdx?: number) => { let seriesIndex = 0; frames.forEach((frame) => { frame.fields.forEach((field, fieldIdx) => { - // TODO: also add FieldType.enum type here after https://github.com/grafana/grafana/pull/60491 - if (fieldIdx !== skipFieldIdx && (field.type === FieldType.number || field.type === FieldType.boolean)) { + if ( + fieldIdx !== skipFieldIdx && + (field.type === FieldType.number || field.type === FieldType.boolean || field.type === FieldType.enum) + ) { field.state = { ...field.state, seriesIndex: seriesIndex++, // TODO: skip this for fields with custom renderers (e.g. Candlestick)?