From e75edc810d025e4eee0f328e133affa4c1136b65 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Thu, 23 Dec 2021 12:04:41 -0600 Subject: [PATCH] BarChart: color by field, x time field, bar radius, label skipping (#43257) Co-authored-by: Ryan McKinley --- .../uPlot/config/UPlotAxisBuilder.ts | 8 +- .../app/plugins/panel/barchart/BarChart.tsx | 103 --------- .../plugins/panel/barchart/BarChartPanel.tsx | 197 ++++++++++++++++-- public/app/plugins/panel/barchart/bars.ts | 94 ++++++++- .../app/plugins/panel/barchart/models.gen.ts | 49 +++++ public/app/plugins/panel/barchart/module.tsx | 64 +++++- .../app/plugins/panel/barchart/suggestions.ts | 4 +- public/app/plugins/panel/barchart/types.ts | 55 ++--- .../app/plugins/panel/barchart/utils.test.ts | 56 ++--- public/app/plugins/panel/barchart/utils.ts | 187 ++++++++++------- .../panel/geomap/components/DataHoverView.tsx | 4 +- .../app/plugins/panel/state-timeline/utils.ts | 5 +- 12 files changed, 543 insertions(+), 283 deletions(-) delete mode 100644 public/app/plugins/panel/barchart/BarChart.tsx create mode 100644 public/app/plugins/panel/barchart/models.gen.ts diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index bac588eb8c7..aed1c014597 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -185,7 +185,13 @@ const timeUnitSize = { }; /** Format time axis ticks */ -function formatTime(self: uPlot, splits: number[], axisIdx: number, foundSpace: number, foundIncr: number): string[] { +export function formatTime( + self: uPlot, + splits: number[], + axisIdx: number, + foundSpace: number, + foundIncr: number +): string[] { const timeZone = (self.axes[axisIdx] as any).timeZone; const scale = self.scales.x; const range = (scale?.max ?? 0) - (scale?.min ?? 0); diff --git a/public/app/plugins/panel/barchart/BarChart.tsx b/public/app/plugins/panel/barchart/BarChart.tsx deleted file mode 100644 index 42b66df847a..00000000000 --- a/public/app/plugins/panel/barchart/BarChart.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React, { useRef } from 'react'; -import { DataFrame, FieldType, TimeRange } from '@grafana/data'; -import { GraphNG, GraphNGProps, PlotLegend, UPlotConfigBuilder, usePanelContext, useTheme2 } from '@grafana/ui'; -import { LegendDisplayMode } from '@grafana/schema'; -import { BarChartOptions } from './types'; -import { isLegendOrdered, preparePlotConfigBuilder, preparePlotFrame } from './utils'; -import { PropDiffFn } from '../../../../../packages/grafana-ui/src/components/GraphNG/GraphNG'; - -/** - * @alpha - */ -export interface BarChartProps - extends BarChartOptions, - Omit {} - -const propsToDiff: Array = [ - 'orientation', - 'barWidth', - 'xTickLabelRotation', - 'xTickLabelMaxLength', - 'groupWidth', - 'stacking', - 'showValue', - 'legend', - (prev: BarChartProps, next: BarChartProps) => next.text?.valueSize === prev.text?.valueSize, -]; - -export const BarChart: React.FC = (props) => { - const theme = useTheme2(); - const { eventBus } = usePanelContext(); - - const frame0Ref = useRef(); - frame0Ref.current = props.frames[0]; - - const renderLegend = (config: UPlotConfigBuilder) => { - if (!config || props.legend.displayMode === LegendDisplayMode.Hidden) { - return null; - } - - return ; - }; - - const rawValue = (seriesIdx: number, valueIdx: number) => { - // When sorted by legend state.seriesIndex is not changed and is not equal to the sorted index of the field - if (isLegendOrdered(props.legend)) { - return frame0Ref.current!.fields[seriesIdx].values.get(valueIdx); - } - - let field = frame0Ref.current!.fields.find( - (f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIdx - 1 - ); - return field!.values.get(valueIdx); - }; - - const prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { - const { - timeZone, - orientation, - barWidth, - showValue, - groupWidth, - stacking, - legend, - tooltip, - text, - xTickLabelRotation, - xTickLabelMaxLength, - } = props; - - return preparePlotConfigBuilder({ - frame: alignedFrame, - getTimeRange, - theme, - timeZone, - eventBus, - orientation, - barWidth, - showValue, - groupWidth, - xTickLabelRotation, - xTickLabelMaxLength, - stacking, - legend, - tooltip, - text, - rawValue, - allFrames: props.frames, - }); - }; - - return ( - - ); -}; -BarChart.displayName = 'BarChart'; diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index 4e63d277b7e..b0920763bf6 100755 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -1,26 +1,84 @@ -import React, { useMemo } from 'react'; -import { TooltipDisplayMode, StackingMode } from '@grafana/schema'; -import { PanelProps, TimeRange, VizOrientation } from '@grafana/data'; -import { measureText, TooltipPlugin, UPLOT_AXIS_FONT_SIZE, useTheme2 } from '@grafana/ui'; -import { BarChartOptions } from './types'; -import { BarChart } from './BarChart'; -import { prepareGraphableFrames } from './utils'; +import React, { useMemo, useRef } from 'react'; +import { TooltipDisplayMode, StackingMode, LegendDisplayMode } from '@grafana/schema'; +import { + compareDataFrameStructures, + DataFrame, + getFieldDisplayName, + PanelProps, + TimeRange, + VizOrientation, +} from '@grafana/data'; +import { + GraphNG, + GraphNGProps, + measureText, + PlotLegend, + TooltipPlugin, + UPlotConfigBuilder, + UPLOT_AXIS_FONT_SIZE, + usePanelContext, + useTheme2, + VizLayout, + VizLegend, +} from '@grafana/ui'; +import { PanelOptions } from './models.gen'; +import { prepareBarChartDisplayValues, preparePlotConfigBuilder } from './utils'; import { PanelDataErrorView } from '@grafana/runtime'; - -interface Props extends PanelProps {} +import { DataHoverView } from '../geomap/components/DataHoverView'; +import { getFieldLegendItem } from '../state-timeline/utils'; +import { PropDiffFn } from '@grafana/ui/src/components/GraphNG/GraphNG'; /** * @alpha */ +export interface BarChartProps + extends PanelOptions, + Omit {} + +const propsToDiff: Array = [ + 'orientation', + 'barWidth', + 'barRadius', + 'xTickLabelRotation', + 'xTickLabelMaxLength', + 'xTickLabelSpacing', + 'groupWidth', + 'stacking', + 'showValue', + 'xField', + 'colorField', + 'legend', + (prev: BarChartProps, next: BarChartProps) => next.text?.valueSize === prev.text?.valueSize, +]; + +interface Props extends PanelProps {} + export const BarChartPanel: React.FunctionComponent = ({ data, options, width, height, timeZone, id }) => { const theme = useTheme2(); + const { eventBus } = usePanelContext(); + + const frame0Ref = useRef(); + const info = useMemo(() => prepareBarChartDisplayValues(data?.series, theme, options), [data, theme, options]); + const structureRef = useRef(10000); + useMemo(() => { + structureRef.current++; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options]); // change every time the options object changes (while editing) + + const structureRev = useMemo(() => { + const f0 = info.viz; + const f1 = frame0Ref.current; + if (!(f0 && f1 && compareDataFrameStructures(f0, f1, true))) { + structureRef.current++; + } + frame0Ref.current = f0; + return (data.structureRev ?? 0) + structureRef.current; + }, [info, data.structureRev]); - const frames = useMemo(() => prepareGraphableFrames(data?.series, theme, options), [data, theme, options]); const orientation = useMemo(() => { if (!options.orientation || options.orientation === VizOrientation.Auto) { return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical; } - return options.orientation; }, [width, height, options.orientation]); @@ -49,25 +107,122 @@ export const BarChartPanel: React.FunctionComponent = ({ data, options, w return options.tooltip; }, [options.tooltip, options.stacking]); - if (!frames) { - return ; + if (!info.viz?.fields.length) { + return ; } + const renderTooltip = (alignedFrame: DataFrame, seriesIdx: number | null, datapointIdx: number | null) => { + const field = seriesIdx == null ? null : alignedFrame.fields[seriesIdx]; + if (field) { + const disp = getFieldDisplayName(field, alignedFrame); + seriesIdx = info.aligned.fields.findIndex((f) => disp === getFieldDisplayName(f, info.aligned)); + } + + return ; + }; + + const renderLegend = (config: UPlotConfigBuilder) => { + const { legend } = options; + if (!config || legend.displayMode === LegendDisplayMode.Hidden) { + return null; + } + + if (info.colorByField) { + const items = getFieldLegendItem([info.colorByField], theme); + if (items?.length) { + return ( + + + + ); + } + } + + return ; + }; + + const rawValue = (seriesIdx: number, valueIdx: number) => { + return frame0Ref.current!.fields[seriesIdx].values.get(valueIdx); + }; + + // Color by value + let getColor: ((seriesIdx: number, valueIdx: number) => string) | undefined = undefined; + + let fillOpacity = 1; + + if (info.colorByField) { + const colorByField = info.colorByField; + const disp = colorByField.display!; + fillOpacity = (colorByField.config.custom.fillOpacity ?? 100) / 100; + // gradientMode? ignore? + getColor = (seriesIdx: number, valueIdx: number) => disp(colorByField.values.get(valueIdx)).color!; + } + + const prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { + const { + barWidth, + barRadius = 0, + showValue, + groupWidth, + stacking, + legend, + tooltip, + text, + xTickLabelRotation, + xTickLabelSpacing, + } = options; + + return preparePlotConfigBuilder({ + frame: alignedFrame, + getTimeRange, + theme, + timeZone, + eventBus, + orientation, + barWidth, + barRadius, + showValue, + groupWidth, + xTickLabelRotation, + xTickLabelMaxLength, + xTickLabelSpacing, + stacking, + legend, + tooltip, + text, + rawValue, + getColor, + fillOpacity, + allFrames: [info.viz], + }); + }; + return ( - f[0]} // already processed in by the panel above! + renderLegend={renderLegend} + legend={options.legend} timeZone={timeZone} timeRange={({ from: 1, to: 1 } as unknown) as TimeRange} // HACK - structureRev={data.structureRev} + structureRev={structureRev} width={width} height={height} - {...options} - orientation={orientation} - xTickLabelMaxLength={xTickLabelMaxLength} > {(config, alignedFrame) => { - return ; + return ( + + ); }} - + ); }; diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index 83e1532ee70..eb9437b53e9 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -12,6 +12,8 @@ import { VizLegendOptions, } from '@grafana/schema'; import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils'; +import { alpha } from '@grafana/data/src/themes/colorManipulator'; +import { formatTime } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; const groupDistr = SPACE_BETWEEN; const barDistr = SPACE_BETWEEN; @@ -40,27 +42,32 @@ export interface BarsOptions { xDir: ScaleDirection; groupWidth: number; barWidth: number; + barRadius: number; showValue: VisibilityMode; stacking: StackingMode; rawValue: (seriesIdx: number, valueIdx: number) => number | null; + getColor?: (seriesIdx: number, valueIdx: number, value: any) => string | null; + fillOpacity?: number; formatValue: (seriesIdx: number, value: any) => string; text?: VizTextDisplayOptions; onHover?: (seriesIdx: number, valueIdx: number) => void; onLeave?: (seriesIdx: number, valueIdx: number) => void; legend?: VizLegendOptions; + xSpacing?: number; + xTimeAuto?: boolean; } /** * @internal */ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { - const { xOri, xDir: dir, rawValue, formatValue, showValue } = opts; + const { xOri, xDir: dir, rawValue, getColor, formatValue, fillOpacity = 1, showValue, xSpacing = 0 } = opts; const isXHorizontal = xOri === ScaleOrientation.Horizontal; const hasAutoValueSize = !Boolean(opts.text?.valueSize); const isStacked = opts.stacking !== StackingMode.None; const pctStacked = opts.stacking === StackingMode.Percent; - let { groupWidth, barWidth } = opts; + let { groupWidth, barWidth, barRadius = 0 } = opts; if (isStacked) { [groupWidth, barWidth] = [barWidth, groupWidth]; @@ -75,16 +82,56 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { barMark.style.background = 'rgba(255,255,255,0.4)'; const xSplits: Axis.Splits = (u: uPlot) => { + const dim = isXHorizontal ? u.bbox.width : u.bbox.height; const _dir = dir * (isXHorizontal ? 1 : -1); + let dataLen = u.data[0].length; + let lastIdx = dataLen - 1; + + let skipMod = 0; + + if (xSpacing !== 0) { + let cssDim = dim / devicePixelRatio; + let maxTicks = Math.abs(Math.floor(cssDim / xSpacing)); + + skipMod = dataLen < maxTicks ? 0 : Math.ceil(dataLen / maxTicks); + } + + let splits: number[] = []; + // for distr: 2 scales, the splits array should contain indices into data[0] rather than values - let splits = u.data[0].map((v, i) => i); + u.data[0].forEach((v, i) => { + let shouldSkip = skipMod !== 0 && (xSpacing > 0 ? i : lastIdx - i) % skipMod > 0; + + if (!shouldSkip) { + splits.push(i); + } + }); return _dir === 1 ? splits : splits.reverse(); }; // the splits passed into here are data[0] values looked up by the indices returned from splits() - const xValues: Axis.Values = (u, splits) => { + const xValues: Axis.Values = (u, splits, axisIdx, foundSpace, foundIncr) => { + if (opts.xTimeAuto) { + // bit of a hack: + // temporarily set x scale range to temporal (as expected by formatTime()) rather than ordinal + let xScale = u.scales.x; + let oMin = xScale.min; + let oMax = xScale.max; + + xScale.min = u.data[0][0]; + xScale.max = u.data[0][u.data[0].length - 1]; + + let vals = formatTime(u, splits, axisIdx, foundSpace, foundIncr); + + // revert + xScale.min = oMin; + xScale.max = oMax; + + return vals; + } + return splits.map((v) => formatValue(0, v)); }; @@ -145,13 +192,30 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { }; let barsPctLayout: Array = []; + let barsColors: Array; stroke: Array }> = []; let barRects: Rect[] = []; // minimum available space for labels between bar end and plotting area bound (in canvas pixels) let vSpace = Infinity; let hSpace = Infinity; + let useMappedColors = getColor != null; + + let mappedColorDisp = useMappedColors + ? { + fill: { + unit: 3, + values: (u: uPlot, seriesIdx: number) => barsColors[seriesIdx]!.fill, + }, + stroke: { + unit: 3, + values: (u: uPlot, seriesIdx: number) => barsColors[seriesIdx]!.stroke, + }, + } + : {}; + let barsBuilder = uPlot.paths.bars!({ + radius: barRadius, disp: { x0: { unit: 2, @@ -161,6 +225,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { unit: 2, values: (u, seriesIdx) => barsPctLayout[seriesIdx]!.size, }, + ...mappedColorDisp, }, // collect rendered bar geometry each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => { @@ -210,6 +275,27 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { } else { barsPctLayout = [null as any].concat(distrTwo(u.data[0].length, u.data.length - 1)); } + + if (useMappedColors) { + barsColors = [null]; + + // map per-bar colors + for (let i = 1; i < u.data.length; i++) { + let colors = u.data[i].map((value, valueIdx) => { + if (value != null) { + return getColor!(i, valueIdx, value); + } + + return null; + }); + + barsColors.push({ + fill: fillOpacity < 1 ? colors.map((c) => (c != null ? alpha(c, fillOpacity) : null)) : colors, + stroke: colors, + }); + } + } + barRects.length = 0; vSpace = hSpace = Infinity; }; diff --git a/public/app/plugins/panel/barchart/models.gen.ts b/public/app/plugins/panel/barchart/models.gen.ts new file mode 100644 index 00000000000..afa42ccb3de --- /dev/null +++ b/public/app/plugins/panel/barchart/models.gen.ts @@ -0,0 +1,49 @@ +import { + OptionsWithLegend, + OptionsWithTextFormatting, + OptionsWithTooltip, + AxisConfig, + VisibilityMode, + GraphGradientMode, + HideableFieldConfig, + StackingMode, +} from '@grafana/schema'; +import { VizOrientation } from '@grafana/data'; + +export interface PanelOptions extends OptionsWithLegend, OptionsWithTooltip, OptionsWithTextFormatting { + xField?: string; + colorByField?: string; + orientation: VizOrientation; + stacking: StackingMode; + showValue: VisibilityMode; + barWidth: number; + barRadius?: number; + groupWidth: number; + xTickLabelRotation: number; + xTickLabelMaxLength: number; + xTickLabelSpacing?: number; // negative values indicate backwards skipping behavior +} + +export const defaultPanelOptions: Partial = { + stacking: StackingMode.None, + orientation: VizOrientation.Auto, + xTickLabelRotation: 0, + xTickLabelMaxLength: 0, + showValue: VisibilityMode.Auto, + groupWidth: 0.7, + barWidth: 0.97, + barRadius: 0, +}; + +export interface BarChartFieldConfig extends AxisConfig, HideableFieldConfig { + lineWidth?: number; // 0 + fillOpacity?: number; // 100 + gradientMode?: GraphGradientMode; +} + +export const defaultBarChartFieldConfig: BarChartFieldConfig = { + lineWidth: 1, + fillOpacity: 80, + gradientMode: GraphGradientMode.None, + axisSoftMin: 0, +}; diff --git a/public/app/plugins/panel/barchart/module.tsx b/public/app/plugins/panel/barchart/module.tsx index 17ff1176deb..bf576186124 100755 --- a/public/app/plugins/panel/barchart/module.tsx +++ b/public/app/plugins/panel/barchart/module.tsx @@ -3,16 +3,19 @@ import { FieldColorModeId, FieldConfigProperty, FieldType, + getFieldDisplayName, PanelPlugin, VizOrientation, } from '@grafana/data'; import { BarChartPanel } from './BarChartPanel'; import { StackingMode, VisibilityMode } from '@grafana/schema'; import { graphFieldOptions, commonOptionsBuilder } from '@grafana/ui'; -import { BarChartFieldConfig, BarChartOptions, defaultBarChartFieldConfig } from 'app/plugins/panel/barchart/types'; +import { BarChartFieldConfig, PanelOptions, defaultBarChartFieldConfig, defaultPanelOptions } from './models.gen'; import { BarChartSuggestionsSupplier } from './suggestions'; +import { prepareBarChartDisplayValues } from './utils'; +import { config } from '@grafana/runtime'; -export const plugin = new PanelPlugin(BarChartPanel) +export const plugin = new PanelPlugin(BarChartPanel) .useFieldConfig({ standardOptions: { [FieldConfigProperty.Color]: { @@ -62,8 +65,22 @@ export const plugin = new PanelPlugin(BarC commonOptionsBuilder.addHideFrom(builder); }, }) - .setPanelOptions((builder) => { + .setPanelOptions((builder, context) => { + const disp = prepareBarChartDisplayValues(context.data, config.theme2, context.options ?? ({} as any)); + let xaxisPlaceholder = 'First string or time field'; + if (disp.viz?.fields?.length) { + const first = disp.viz.fields[0]; + xaxisPlaceholder += ` (${getFieldDisplayName(first, disp.viz)})`; + } + builder + .addFieldNamePicker({ + path: 'xField', + name: 'X Axis', + settings: { + placeholderText: xaxisPlaceholder, + }, + }) .addRadio({ path: 'orientation', name: 'Orientation', @@ -74,12 +91,12 @@ export const plugin = new PanelPlugin(BarC { value: VizOrientation.Vertical, label: 'Vertical' }, ], }, - defaultValue: VizOrientation.Auto, + defaultValue: defaultPanelOptions.orientation, }) .addSliderInput({ path: 'xTickLabelRotation', name: 'Rotate bar labels', - defaultValue: 0, + defaultValue: defaultPanelOptions.xTickLabelRotation, settings: { min: -90, max: 90, @@ -100,6 +117,19 @@ export const plugin = new PanelPlugin(BarC min: 0, }, }) + // .addSliderInput({ + // path: 'xTickLabelSpacing', + // name: 'Bar label minimum spacing', + // description: 'Bar labels will be skipped to maintain this distance', + // defaultValue: 0, + // settings: { + // min: -300, + // max: 300, + // step: 10, + // marks: { '-300': 'Backward', 0: 'None', 300: 'Forward' }, + // included: false, + // }, + // }) .addRadio({ path: 'showValue', name: 'Show values', @@ -110,7 +140,7 @@ export const plugin = new PanelPlugin(BarC { value: VisibilityMode.Never, label: 'Never' }, ], }, - defaultValue: VisibilityMode.Auto, + defaultValue: defaultPanelOptions.showValue, }) .addRadio({ path: 'stacking', @@ -118,12 +148,12 @@ export const plugin = new PanelPlugin(BarC settings: { options: graphFieldOptions.stacking, }, - defaultValue: StackingMode.None, + defaultValue: defaultPanelOptions.stacking, }) .addSliderInput({ path: 'groupWidth', name: 'Group width', - defaultValue: 0.7, + defaultValue: defaultPanelOptions.groupWidth, settings: { min: 0, max: 1, @@ -139,14 +169,30 @@ export const plugin = new PanelPlugin(BarC .addSliderInput({ path: 'barWidth', name: 'Bar width', - defaultValue: 0.97, + defaultValue: defaultPanelOptions.barWidth, settings: { min: 0, max: 1, step: 0.01, }, + }) + .addSliderInput({ + path: 'barRadius', + name: 'Bar radius', + defaultValue: defaultPanelOptions.barRadius, + settings: { + min: 0, + max: 0.5, + step: 0.05, + }, }); + builder.addFieldNamePicker({ + path: 'colorByField', + name: 'Color by field', + description: 'Use the color value for a sibling field to color each bar value.', + }); + commonOptionsBuilder.addTooltipOptions(builder); commonOptionsBuilder.addLegendOptions(builder); commonOptionsBuilder.addTextSizeOptions(builder, false); diff --git a/public/app/plugins/panel/barchart/suggestions.ts b/public/app/plugins/panel/barchart/suggestions.ts index 2264328b186..850435df26d 100644 --- a/public/app/plugins/panel/barchart/suggestions.ts +++ b/public/app/plugins/panel/barchart/suggestions.ts @@ -1,11 +1,11 @@ import { VisualizationSuggestionsBuilder, VizOrientation } from '@grafana/data'; import { LegendDisplayMode, StackingMode, VisibilityMode } from '@grafana/schema'; import { SuggestionName } from 'app/types/suggestions'; -import { BarChartFieldConfig, BarChartOptions } from './types'; +import { BarChartFieldConfig, PanelOptions } from './models.gen'; export class BarChartSuggestionsSupplier { getListWithDefaults(builder: VisualizationSuggestionsBuilder) { - return builder.getListAppender({ + return builder.getListAppender({ name: SuggestionName.BarChart, pluginId: 'barchart', options: { diff --git a/public/app/plugins/panel/barchart/types.ts b/public/app/plugins/panel/barchart/types.ts index f1fabd89aba..ba8cb9f69b9 100644 --- a/public/app/plugins/panel/barchart/types.ts +++ b/public/app/plugins/panel/barchart/types.ts @@ -1,44 +1,15 @@ -import { - OptionsWithLegend, - OptionsWithTextFormatting, - OptionsWithTooltip, - AxisConfig, - VisibilityMode, - GraphGradientMode, - HideableFieldConfig, - StackingMode, -} from '@grafana/schema'; -import { VizOrientation } from '@grafana/data'; +import { DataFrame, Field } from '@grafana/data'; -/** - * @alpha - */ -export interface BarChartOptions extends OptionsWithLegend, OptionsWithTooltip, OptionsWithTextFormatting { - orientation: VizOrientation; - stacking: StackingMode; - showValue: VisibilityMode; - barWidth: number; - groupWidth: number; - xTickLabelRotation: number; - xTickLabelMaxLength: number; - rawValue: (seriesIdx: number, valueIdx: number) => number; +export interface BarChartDisplayValues { + /** When the data can not display, this will be returned */ + warn?: string; + + /** All fields joined */ + aligned: DataFrame; + + /** The fields we can display, first field is X axis */ + viz: DataFrame; + + /** Potentialy color by a field value */ + colorByField?: Field; } - -/** - * @alpha - */ -export interface BarChartFieldConfig extends AxisConfig, HideableFieldConfig { - lineWidth?: number; // 0 - fillOpacity?: number; // 100 - gradientMode?: GraphGradientMode; -} - -/** - * @alpha - */ -export const defaultBarChartFieldConfig: BarChartFieldConfig = { - lineWidth: 1, - fillOpacity: 80, - gradientMode: GraphGradientMode.None, - axisSoftMin: 0, -}; diff --git a/public/app/plugins/panel/barchart/utils.test.ts b/public/app/plugins/panel/barchart/utils.test.ts index 7e83c878d6e..b9fa83e0d8f 100644 --- a/public/app/plugins/panel/barchart/utils.test.ts +++ b/public/app/plugins/panel/barchart/utils.test.ts @@ -1,4 +1,4 @@ -import { prepareGraphableFrames, preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { BarChartOptionsEX, prepareBarChartDisplayValues, preparePlotConfigBuilder } from './utils'; import { LegendDisplayMode, TooltipDisplayMode, @@ -16,7 +16,7 @@ import { MutableDataFrame, VizOrientation, } from '@grafana/data'; -import { BarChartFieldConfig, BarChartOptions } from './types'; +import { BarChartFieldConfig } from './models.gen'; function mockDataFrame() { const df1 = new MutableDataFrame({ @@ -65,7 +65,7 @@ function mockDataFrame() { state: {}, }); - return preparePlotFrame([df1, df2]); + return prepareBarChartDisplayValues([df1], createTheme(), {} as any).aligned; } jest.mock('@grafana/data', () => ({ @@ -77,7 +77,7 @@ describe('BarChart utils', () => { describe('preparePlotConfigBuilder', () => { const frame = mockDataFrame(); - const config: BarChartOptions = { + const config: BarChartOptionsEX = { orientation: VizOrientation.Auto, groupWidth: 20, barWidth: 2, @@ -146,19 +146,20 @@ describe('BarChart utils', () => { describe('prepareGraphableFrames', () => { it('will warn when there is no data in the response', () => { - const result = prepareGraphableFrames([], createTheme(), { stacking: StackingMode.None } as any); - expect(result).toBeNull(); + const result = prepareBarChartDisplayValues([], createTheme(), { stacking: StackingMode.None } as any); + expect(result.warn).toEqual('No data in response'); }); - it('will warn when there is no string field in the response', () => { + it('will warn when there is no string or time field', () => { const df = new MutableDataFrame({ fields: [ - { name: 'a', type: FieldType.time, values: [1, 2, 3, 4, 5] }, + { name: 'a', type: FieldType.other, values: [1, 2, 3, 4, 5] }, { name: 'value', values: [1, 2, 3, 4, 5] }, ], }); - const result = prepareGraphableFrames([df], createTheme(), { stacking: StackingMode.None } as any); - expect(result).toBeNull(); + const result = prepareBarChartDisplayValues([df], createTheme(), { stacking: StackingMode.None } as any); + expect(result.warn).toEqual('Bar charts requires a string or time field'); + expect(result.viz).toBeUndefined(); }); it('will warn when there are no numeric fields in the response', () => { @@ -168,8 +169,9 @@ describe('BarChart utils', () => { { name: 'value', type: FieldType.boolean, values: [true, true, true, true, true] }, ], }); - const result = prepareGraphableFrames([df], createTheme(), { stacking: StackingMode.None } as any); - expect(result).toBeNull(); + const result = prepareBarChartDisplayValues([df], createTheme(), { stacking: StackingMode.None } as any); + expect(result.warn).toEqual('No numeric fields found'); + expect(result.viz).toBeUndefined(); }); it('will convert NaN and Infinty to nulls', () => { @@ -179,9 +181,9 @@ describe('BarChart utils', () => { { name: 'value', values: [-10, NaN, 10, -Infinity, +Infinity] }, ], }); - const frames = prepareGraphableFrames([df], createTheme(), { stacking: StackingMode.None } as any)!; + const result = prepareBarChartDisplayValues([df], createTheme(), { stacking: StackingMode.None } as any); - const field = frames[0].fields[1]; + const field = result.viz.fields[1]; expect(field!.values.toArray()).toMatchInlineSnapshot(` Array [ -10, @@ -203,23 +205,21 @@ describe('BarChart utils', () => { ], }); - const framesAsc = prepareGraphableFrames([frame], createTheme(), { + const resultAsc = prepareBarChartDisplayValues([frame], createTheme(), { legend: { sortBy: 'Min', sortDesc: false }, - } as any)!; + } as any); + expect(resultAsc.viz.fields[0].type).toBe(FieldType.string); + expect(resultAsc.viz.fields[1].name).toBe('a'); + expect(resultAsc.viz.fields[2].name).toBe('c'); + expect(resultAsc.viz.fields[3].name).toBe('b'); - expect(framesAsc[0].fields[0].type).toBe(FieldType.string); - expect(framesAsc[0].fields[1].name).toBe('a'); - expect(framesAsc[0].fields[2].name).toBe('c'); - expect(framesAsc[0].fields[3].name).toBe('b'); - - const framesDesc = prepareGraphableFrames([frame], createTheme(), { + const resultDesc = prepareBarChartDisplayValues([frame], createTheme(), { legend: { sortBy: 'Min', sortDesc: true }, - } as any)!; - - expect(framesDesc[0].fields[0].type).toBe(FieldType.string); - expect(framesDesc[0].fields[1].name).toBe('b'); - expect(framesDesc[0].fields[2].name).toBe('c'); - expect(framesDesc[0].fields[3].name).toBe('a'); + } as any); + expect(resultDesc.viz.fields[0].type).toBe(FieldType.string); + expect(resultDesc.viz.fields[1].name).toBe('b'); + expect(resultDesc.viz.fields[2].name).toBe('c'); + expect(resultDesc.viz.fields[3].name).toBe('a'); }); }); }); diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts index 7f0757ab2e8..4c21c6fa17e 100644 --- a/public/app/plugins/panel/barchart/utils.ts +++ b/public/app/plugins/panel/barchart/utils.ts @@ -8,10 +8,12 @@ import { getFieldColorModeForField, getFieldSeriesColor, GrafanaTheme2, - MutableDataFrame, + outerJoinDataFrames, + reduceField, VizOrientation, } from '@grafana/data'; -import { BarChartFieldConfig, BarChartOptions, defaultBarChartFieldConfig } from './types'; +import { BarChartFieldConfig, PanelOptions, defaultBarChartFieldConfig } from './models.gen'; +import { BarChartDisplayValues } from './types'; import { BarsOptions, getConfig } from './bars'; import { FIXED_UNIT, measureText, UPlotConfigBuilder, UPlotConfigPrepFn, UPLOT_AXIS_FONT_SIZE } from '@grafana/ui'; import { Padding } from 'uplot'; @@ -25,8 +27,8 @@ import { } from '@grafana/schema'; import { collectStackingGroups, orderIdsByCalcs } from '../../../../../packages/grafana-ui/src/components/uPlot/utils'; import { orderBy } from 'lodash'; +import { findField } from 'app/features/dimensions'; -/** @alpha */ function getBarCharScaleOrientation(orientation: VizOrientation) { if (orientation === VizOrientation.Vertical) { return { @@ -45,19 +47,29 @@ function getBarCharScaleOrientation(orientation: VizOrientation) { }; } -export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ +export interface BarChartOptionsEX extends PanelOptions { + rawValue: (seriesIdx: number, valueIdx: number) => number | null; + getColor?: (seriesIdx: number, valueIdx: number, value: any) => string | null; + fillOpacity?: number; +} + +export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ frame, theme, orientation, showValue, groupWidth, barWidth, + barRadius = 0, stacking, text, rawValue, + getColor, + fillOpacity, allFrames, xTickLabelRotation, xTickLabelMaxLength, + xTickLabelSpacing = 0, legend, }) => { const builder = new UPlotConfigBuilder(); @@ -81,12 +93,17 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ xDir: vizOrientation.xDir, groupWidth, barWidth, + barRadius, stacking, rawValue, + getColor, + fillOpacity, formatValue, text, showValue, legend, + xSpacing: xTickLabelSpacing, + xTimeAuto: frame.fields[0]?.type === FieldType.time && !frame.fields[0].config.unit?.startsWith('time:'), }; const config = getConfig(opts, theme); @@ -275,60 +292,66 @@ function getRotationPadding(frame: DataFrame, rotateLabel: number, valueMaxLengt } /** @internal */ -export function preparePlotFrame(data: DataFrame[]) { - const firstFrame = data[0]; - const firstString = firstFrame.fields.find((f) => f.type === FieldType.string); - - if (!firstString) { - throw new Error('No string field in DF'); +export function prepareBarChartDisplayValues( + series: DataFrame[], + theme: GrafanaTheme2, + options: PanelOptions +): BarChartDisplayValues { + if (!series?.length) { + return { warn: 'No data in response' } as BarChartDisplayValues; } - const resultFrame = new MutableDataFrame(); - resultFrame.addField(firstString); + // Bar chart requires a single frame + const frame = series.length === 1 ? series[0] : outerJoinDataFrames({ frames: series, enforceSort: false }); + if (!frame) { + return { warn: 'Unable to join data' } as BarChartDisplayValues; + } - for (const f of firstFrame.fields) { - if (f.type === FieldType.number) { - resultFrame.addField(f); + // Color by a field different than the input + let colorByField: Field | undefined = undefined; + if (options.colorByField) { + colorByField = findField(frame, options.colorByField); + if (!colorByField) { + return { warn: 'Color field not found' } as BarChartDisplayValues; } } - return resultFrame; -} - -/** @internal */ -export function prepareGraphableFrames( - series: DataFrame[], - theme: GrafanaTheme2, - options: BarChartOptions -): DataFrame[] | null { - if (!series?.length) { - return null; + let xField: Field | undefined = undefined; + if (options.xField) { + xField = findField(frame, options.xField); + if (!xField) { + return { warn: 'Configured x field not found' } as BarChartDisplayValues; + } } - const frames: DataFrame[] = []; - const firstFrame = series[0]; + let stringField: Field | undefined = undefined; + let timeField: Field | undefined = undefined; + let fields: Field[] = []; + for (const field of frame.fields) { + if (field === xField) { + continue; + } - if (!firstFrame.fields.some((f) => f.type === FieldType.string)) { - return null; - } + switch (field.type) { + case FieldType.string: + if (!stringField) { + stringField = field; + } + break; - if (!firstFrame.fields.some((f) => f.type === FieldType.number)) { - return null; - } + case FieldType.time: + if (!timeField) { + timeField = field; + } + break; - const legendOrdered = isLegendOrdered(options.legend); - let seriesIndex = 0; - - for (let frame of series) { - const fields: Field[] = []; - for (const field of frame.fields) { - if (field.type === FieldType.number) { - field.state = field.state ?? {}; - - field.state.seriesIndex = seriesIndex++; - - let copy = { + case FieldType.number: { + const copy = { ...field, + state: { + ...field.state, + seriesIndex: fields.length, // off by one? + }, config: { ...field.config, custom: { @@ -355,34 +378,58 @@ export function prepareGraphableFrames( } fields.push(copy); - } else { - fields.push({ ...field }); } } - - let orderedFields: Field[] | undefined; - - if (legendOrdered) { - orderedFields = orderBy( - fields, - ({ state }) => { - return state?.calcs?.[options.legend.sortBy!.toLowerCase()]; - }, - options.legend.sortDesc ? 'desc' : 'asc' - ); - // The string field needs to be the first one - if (orderedFields[orderedFields.length - 1].type === FieldType.string) { - orderedFields.unshift(orderedFields.pop()!); - } - } - - frames.push({ - ...frame, - fields: orderedFields || fields, - }); } - return frames; + let firstField = xField; + if (!firstField) { + firstField = stringField || timeField; + } + + if (!firstField) { + return { + warn: 'Bar charts requires a string or time field', + } as BarChartDisplayValues; + } + + if (!fields.length) { + return { + warn: 'No numeric fields found', + } as BarChartDisplayValues; + } + + // Show the first number value + if (colorByField && fields.length > 1) { + const firstNumber = fields.find((f) => f !== colorByField); + if (firstNumber) { + fields = [firstNumber]; + } + } + + if (isLegendOrdered(options.legend)) { + const sortKey = options.legend.sortBy!.toLowerCase(); + const reducers = options.legend.calcs ?? [sortKey]; + fields = orderBy( + fields, + (field) => { + return reduceField({ field, reducers })[sortKey]; + }, + options.legend.sortDesc ? 'desc' : 'asc' + ); + } + + // String field is first + fields.unshift(firstField); + + return { + aligned: frame, + colorByField, + viz: { + length: firstField.values.length, + fields: fields, // ideally: fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.viz)), + }, + }; } export const isLegendOrdered = (options: VizLegendOptions) => Boolean(options?.sortBy && options.sortDesc !== null); diff --git a/public/app/plugins/panel/geomap/components/DataHoverView.tsx b/public/app/plugins/panel/geomap/components/DataHoverView.tsx index 6f585ed024a..4740175ddf3 100644 --- a/public/app/plugins/panel/geomap/components/DataHoverView.tsx +++ b/public/app/plugins/panel/geomap/components/DataHoverView.tsx @@ -15,8 +15,8 @@ import { FeatureLike } from 'ol/Feature'; export interface Props { data?: DataFrame; // source data feature?: FeatureLike; - rowIndex?: number; // the hover row - columnIndex?: number; // the hover column + rowIndex?: number | null; // the hover row + columnIndex?: number | null; // the hover column } export class DataHoverView extends PureComponent { diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/plugins/panel/state-timeline/utils.ts index fdd99a988e8..ab5c6ba524b 100644 --- a/public/app/plugins/panel/state-timeline/utils.ts +++ b/public/app/plugins/panel/state-timeline/utils.ts @@ -485,7 +485,10 @@ export function prepareTimelineLegendItems( return undefined; } - const fields = allNonTimeFields(frames); + return getFieldLegendItem(allNonTimeFields(frames), theme); +} + +export function getFieldLegendItem(fields: Field[], theme: GrafanaTheme2): VizLegendItem[] | undefined { if (!fields.length) { return undefined; }