BarChart: color by field, x time field, bar radius, label skipping (#43257)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Leon Sorokin 2021-12-23 12:04:41 -06:00 committed by GitHub
parent 4233a62aeb
commit e75edc810d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 543 additions and 283 deletions

View File

@ -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);

View File

@ -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<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend' | 'theme'> {}
const propsToDiff: Array<string | PropDiffFn> = [
'orientation',
'barWidth',
'xTickLabelRotation',
'xTickLabelMaxLength',
'groupWidth',
'stacking',
'showValue',
'legend',
(prev: BarChartProps, next: BarChartProps) => next.text?.valueSize === prev.text?.valueSize,
];
export const BarChart: React.FC<BarChartProps> = (props) => {
const theme = useTheme2();
const { eventBus } = usePanelContext();
const frame0Ref = useRef<DataFrame>();
frame0Ref.current = props.frames[0];
const renderLegend = (config: UPlotConfigBuilder) => {
if (!config || props.legend.displayMode === LegendDisplayMode.Hidden) {
return null;
}
return <PlotLegend data={props.frames} config={config} maxHeight="35%" maxWidth="60%" {...props.legend} />;
};
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 (
<GraphNG
{...props}
theme={theme}
frames={props.frames}
prepConfig={prepConfig}
propsToDiff={propsToDiff}
preparePlotFrame={preparePlotFrame}
renderLegend={renderLegend}
/>
);
};
BarChart.displayName = 'BarChart';

View File

@ -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<BarChartOptions> {}
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<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend' | 'theme'> {}
const propsToDiff: Array<string | PropDiffFn> = [
'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<PanelOptions> {}
export const BarChartPanel: React.FunctionComponent<Props> = ({ data, options, width, height, timeZone, id }) => {
const theme = useTheme2();
const { eventBus } = usePanelContext();
const frame0Ref = useRef<DataFrame>();
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<Props> = ({ data, options, w
return options.tooltip;
}, [options.tooltip, options.stacking]);
if (!frames) {
return <PanelDataErrorView panelId={id} data={data} needsStringField={true} needsNumberField={true} />;
if (!info.viz?.fields.length) {
return <PanelDataErrorView panelId={id} data={data} message={info.warn} needsNumberField={true} />;
}
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 <DataHoverView data={info.aligned} rowIndex={datapointIdx} columnIndex={seriesIdx} />;
};
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 (
<VizLayout.Legend placement={legend.placement}>
<VizLegend placement={legend.placement} items={items} displayMode={legend.displayMode} />
</VizLayout.Legend>
);
}
}
return <PlotLegend data={[info.viz]} config={config} maxHeight="35%" maxWidth="60%" {...options.legend} />;
};
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 (
<BarChart
frames={frames}
<GraphNG
theme={theme}
frames={[info.viz]}
prepConfig={prepConfig}
propsToDiff={propsToDiff}
preparePlotFrame={(f) => 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 <TooltipPlugin data={alignedFrame} config={config} mode={tooltip.mode} timeZone={timeZone} />;
return (
<TooltipPlugin
data={alignedFrame}
config={config}
mode={tooltip.mode}
timeZone={timeZone}
renderTooltip={renderTooltip}
/>
);
}}
</BarChart>
</GraphNG>
);
};

View File

@ -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<null | { offs: number[]; size: number[] }> = [];
let barsColors: Array<null | { fill: Array<string | null>; stroke: Array<string | null> }> = [];
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;
};

View File

@ -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<PanelOptions> = {
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,
};

View File

@ -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<BarChartOptions, BarChartFieldConfig>(BarChartPanel)
export const plugin = new PanelPlugin<PanelOptions, BarChartFieldConfig>(BarChartPanel)
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Color]: {
@ -62,8 +65,22 @@ export const plugin = new PanelPlugin<BarChartOptions, BarChartFieldConfig>(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<BarChartOptions, BarChartFieldConfig>(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<BarChartOptions, BarChartFieldConfig>(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<BarChartOptions, BarChartFieldConfig>(BarC
{ value: VisibilityMode.Never, label: 'Never' },
],
},
defaultValue: VisibilityMode.Auto,
defaultValue: defaultPanelOptions.showValue,
})
.addRadio({
path: 'stacking',
@ -118,12 +148,12 @@ export const plugin = new PanelPlugin<BarChartOptions, BarChartFieldConfig>(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<BarChartOptions, BarChartFieldConfig>(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);

View File

@ -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<BarChartOptions, BarChartFieldConfig>({
return builder.getListAppender<PanelOptions, BarChartFieldConfig>({
name: SuggestionName.BarChart,
pluginId: 'barchart',
options: {

View File

@ -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,
};

View File

@ -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');
});
});
});

View File

@ -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<BarChartOptions> = ({
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<BarChartOptionsEX> = ({
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<BarChartOptions> = ({
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);

View File

@ -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<Props> {

View File

@ -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;
}