The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/plugins/panel/barchart/utils.ts

555 lines
16 KiB

import uPlot, { Padding } from 'uplot';
import {
DataFrame,
Field,
FieldConfigSource,
FieldType,
GrafanaTheme2,
cacheFieldDisplayNames,
formattedValueToString,
getDisplayProcessor,
getFieldColorModeForField,
getFieldSeriesColor,
outerJoinDataFrames,
} from '@grafana/data';
import { decoupleHideFromState } from '@grafana/data/src/field/fieldState';
import {
AxisColorMode,
AxisPlacement,
FieldColorModeId,
GraphGradientMode,
GraphThresholdsStyleMode,
GraphTransform,
ScaleDistribution,
TimeZone,
TooltipDisplayMode,
VizOrientation,
} from '@grafana/schema';
import {
FIXED_UNIT,
ScaleDirection,
ScaleOrientation,
StackingMode,
UPlotConfigBuilder,
measureText,
} from '@grafana/ui';
import { AxisProps, UPLOT_AXIS_FONT_SIZE } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
import { getStackingGroups } from '@grafana/ui/src/components/uPlot/utils';
import { setClassicPaletteIdxs } from '../timeseries/utils';
import { BarsOptions, getConfig } from './bars';
import { FieldConfig, Options, defaultFieldConfig } from './panelcfg.gen';
// import { isLegendOrdered } from './utils';
interface BarSeries {
series: DataFrame[];
_rest: Field[];
color?: Field | null;
warn?: string | null;
}
export function prepSeries(
frames: DataFrame[],
fieldConfig: FieldConfigSource<any>,
stacking: StackingMode,
theme: GrafanaTheme2,
xFieldName?: string,
colorFieldName?: string
): BarSeries {
if (frames.length === 0 || frames.every((fr) => fr.length === 0)) {
return { series: [], _rest: [], warn: 'No data in response' };
}
cacheFieldDisplayNames(frames);
decoupleHideFromState(frames, fieldConfig);
let frame: DataFrame | undefined = { ...frames[0] };
// auto-sort and/or join on first time field (if any)
// TODO: should this always join on the xField (if supplied?)
const timeFieldIdx = frame.fields.findIndex((f) => f.type === FieldType.time);
if (timeFieldIdx >= 0 && frames.length > 1) {
frame = outerJoinDataFrames({ frames, keepDisplayNames: true }) ?? frame;
}
const xField =
// TODO: use matcher
frame.fields.find((field) => field.state?.displayName === xFieldName || field.name === xFieldName) ??
frame.fields.find((field) => field.type === FieldType.string) ??
frame.fields[timeFieldIdx];
if (xField != null) {
const fields: Field[] = [xField];
const _rest: Field[] = [];
const colorField =
colorFieldName == null
? undefined
: frame.fields.find(
// TODO: use matcher
(field) => field.state?.displayName === colorFieldName || field.name === colorFieldName
);
frame.fields.forEach((field) => {
if (field !== xField) {
if (field.type === FieldType.number && !field.config.custom?.hideFrom?.viz) {
const field2 = {
...field,
values: field.values.map((v) => (Number.isFinite(v) ? v : null)),
// TODO: stacking should be moved from panel opts to fieldConfig (like TimeSeries) so we dont have to do this
config: {
...field.config,
custom: {
...field.config.custom,
stacking: {
group: '_',
mode: stacking,
},
},
},
};
fields.push(field2);
} else {
_rest.push(field);
}
}
});
let warn: string | null = null;
if (fields.length === 1) {
warn = 'No numeric fields found';
}
frame.fields = fields;
const series = [frame];
setClassicPaletteIdxs(series, theme, 0);
return {
series,
_rest,
color: colorField,
warn,
};
}
return {
series: [],
_rest: [],
color: null,
warn: 'Bar charts requires a string or time field',
};
}
export interface PrepConfigOpts {
series: DataFrame[]; // series with hideFrom.viz: false
totalSeries: number; // total series count (including hidden)
color?: Field | null;
orientation: VizOrientation;
options: Options;
timeZone: TimeZone;
theme: GrafanaTheme2;
}
export const prepConfig = ({ series, totalSeries, color, orientation, options, timeZone, theme }: PrepConfigOpts) => {
let {
showValue,
groupWidth,
barWidth,
barRadius = 0,
stacking,
text,
tooltip,
xTickLabelRotation,
xTickLabelMaxLength,
xTickLabelSpacing = 0,
legend,
fullHighlight,
} = options;
// this and color is kept up to date by returned prepData()
let frame = series[0];
const builder = new UPlotConfigBuilder();
const formatters = frame.fields.map((f, i) => {
if (stacking === StackingMode.Percent) {
return getDisplayProcessor({
field: {
...f,
config: {
...f.config,
unit: 'percentunit',
},
},
theme,
});
}
return f.display!;
});
const formatValue = (seriesIdx: number, value: unknown) => {
return formattedValueToString(formatters[seriesIdx](value));
};
const formatShortValue = (seriesIdx: number, value: unknown) => {
return shortenValue(formatValue(seriesIdx, value), xTickLabelMaxLength);
};
// bar orientation -> x scale orientation & direction
const vizOrientation = getScaleOrientation(orientation);
// Use bar width when only one field
if (frame.fields.length === 2 && stacking === StackingMode.None) {
if (totalSeries === 1) {
groupWidth = barWidth;
}
barWidth = 1;
}
const rawValue = (seriesIdx: number, valueIdx: number) => {
return frame.fields[seriesIdx].values[valueIdx];
};
// Color by value
let getColor: ((seriesIdx: number, valueIdx: number) => string) | undefined = undefined;
let fillOpacity = 1;
if (color != null) {
const disp = color.display!;
fillOpacity = (color.config.custom.fillOpacity ?? 100) / 100;
// gradientMode? ignore?
getColor = (seriesIdx: number, valueIdx: number) => disp(color!.values[valueIdx]).color!;
} else {
const hasPerBarColor = frame.fields.some((f) => {
const fromThresholds =
f.config.custom?.gradientMode === GraphGradientMode.Scheme &&
f.config.color?.mode === FieldColorModeId.Thresholds;
return (
fromThresholds ||
f.config.mappings?.some((m) => {
// ValueToText mappings have a different format, where all of them are grouped into an object keyed by value
if (m.type === 'value') {
// === MappingType.ValueToText
return Object.values(m.options).some((result) => result.color != null);
}
return m.options.result.color != null;
})
);
});
if (hasPerBarColor) {
// use opacity from first numeric field
let opacityField = frame.fields.find((f) => f.type === FieldType.number)!;
fillOpacity = (opacityField.config.custom.fillOpacity ?? 100) / 100;
getColor = (seriesIdx: number, valueIdx: number) => {
let field = frame.fields[seriesIdx];
return field.display!(field.values[valueIdx]).color!;
};
}
}
const opts: BarsOptions = {
xOri: vizOrientation.xOri,
xDir: vizOrientation.xDir,
groupWidth,
barWidth,
barRadius,
stacking,
rawValue,
getColor,
fillOpacity,
formatValue,
formatShortValue,
timeZone,
text,
showValue,
legend,
xSpacing: xTickLabelSpacing,
xTimeAuto: frame.fields[0]?.type === FieldType.time && !frame.fields[0].config.unit?.startsWith('time:'),
negY: frame.fields.map((f) => f.config.custom?.transform === GraphTransform.NegativeY),
fullHighlight,
hoverMulti: tooltip.mode === TooltipDisplayMode.Multi,
};
const config = getConfig(opts, theme);
builder.setCursor(config.cursor);
builder.addHook('init', config.init);
builder.addHook('drawClear', config.drawClear);
builder.addHook('draw', config.draw);
if (xTickLabelRotation !== 0) {
// these are the amount of space we already have available between plot edge and first label
// TODO: removing these hardcoded value requires reading back uplot instance props
let lftSpace = 50;
let btmSpace = vizOrientation.xOri === ScaleOrientation.Horizontal ? 14 : 5;
builder.setPadding(getRotationPadding(frame, xTickLabelRotation, xTickLabelMaxLength, lftSpace, btmSpace));
}
builder.setPrepData(config.prepData);
builder.addScale({
scaleKey: 'x',
isTime: false,
range: config.xRange,
distribution: ScaleDistribution.Ordinal,
orientation: vizOrientation.xOri,
direction: vizOrientation.xDir,
});
const xFieldAxisPlacement =
frame.fields[0]?.config.custom?.axisPlacement !== AxisPlacement.Hidden
? vizOrientation.xOri === ScaleOrientation.Horizontal
? AxisPlacement.Bottom
: AxisPlacement.Left
: AxisPlacement.Hidden;
const xFieldAxisShow = frame.fields[0]?.config.custom?.axisPlacement !== AxisPlacement.Hidden;
builder.addAxis({
scaleKey: 'x',
isTime: false,
placement: xFieldAxisPlacement,
label: frame.fields[0]?.config.custom?.axisLabel,
splits: config.xSplits,
filter: vizOrientation.xOri === 0 ? config.hFilter : undefined,
values: config.xValues,
timeZone,
grid: { show: false },
ticks: { show: false },
gap: 15,
tickLabelRotation: vizOrientation.xOri === 0 ? xTickLabelRotation * -1 : 0,
theme,
show: xFieldAxisShow,
});
// let seriesIndex = 0;
// const legendOrdered = isLegendOrdered(legend);
// iterate the y values
for (let i = 1; i < frame.fields.length; i++) {
const field = frame.fields[i];
// seriesIndex++;
const customConfig: FieldConfig = { ...defaultFieldConfig, ...field.config.custom };
const scaleKey = field.config.unit || FIXED_UNIT;
const colorMode = getFieldColorModeForField(field);
const scaleColor = getFieldSeriesColor(field, theme);
const seriesColor = scaleColor.color;
// make barcharts start at 0 unless explicitly overridden
let softMin = customConfig.axisSoftMin;
let softMax = customConfig.axisSoftMax;
if (softMin == null && field.config.min == null) {
softMin = 0;
}
if (softMax == null && field.config.max == null) {
softMax = 0;
}
// Render thresholds in graph
if (customConfig.thresholdsStyle && field.config.thresholds) {
const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphThresholdsStyleMode.Off;
if (thresholdDisplay !== GraphThresholdsStyleMode.Off) {
builder.addThresholds({
config: customConfig.thresholdsStyle,
thresholds: field.config.thresholds,
scaleKey,
theme,
hardMin: field.config.min,
hardMax: field.config.max,
softMin: customConfig.axisSoftMin,
softMax: customConfig.axisSoftMax,
});
}
}
builder.addSeries({
scaleKey,
pxAlign: true,
lineWidth: customConfig.lineWidth,
lineColor: seriesColor,
fillOpacity: customConfig.fillOpacity,
theme,
colorMode,
pathBuilder: config.barsBuilder,
show: !customConfig.hideFrom?.viz,
gradientMode: customConfig.gradientMode,
thresholds: field.config.thresholds,
hardMin: field.config.min,
hardMax: field.config.max,
softMin: customConfig.axisSoftMin,
softMax: customConfig.axisSoftMax,
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
// PlotLegend currently gets unfiltered DataFrame[], so index must be into that field array, not the prepped frame's which we're iterating here
// dataFrameFieldIndex: {
// fieldIndex: legendOrdered
// ? i
// : allFrames[0].fields.findIndex(
// (f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIndex - 1
// ),
// frameIndex: 0,
// },
});
// The builder will manage unique scaleKeys and combine where appropriate
builder.addScale({
scaleKey,
min: field.config.min,
max: field.config.max,
softMin,
softMax,
centeredZero: customConfig.axisCenteredZero,
orientation: vizOrientation.yOri,
direction: vizOrientation.yDir,
distribution: customConfig.scaleDistribution?.type,
log: customConfig.scaleDistribution?.log,
});
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
let placement = customConfig.axisPlacement;
if (!placement || placement === AxisPlacement.Auto) {
placement = AxisPlacement.Left;
}
if (vizOrientation.xOri === 1) {
if (placement === AxisPlacement.Left) {
placement = AxisPlacement.Bottom;
}
if (placement === AxisPlacement.Right) {
placement = AxisPlacement.Top;
}
}
let axisOpts: AxisProps = {
scaleKey,
label: customConfig.axisLabel,
size: customConfig.axisWidth,
placement,
formatValue: (v, decimals) => formattedValueToString(field.display!(v, decimals)),
filter: vizOrientation.yOri === 0 ? config.hFilter : undefined,
tickLabelRotation: vizOrientation.xOri === 1 ? xTickLabelRotation * -1 : 0,
theme,
grid: { show: customConfig.axisGridShow },
};
if (customConfig.axisBorderShow) {
axisOpts.border = {
show: true,
};
}
if (customConfig.axisColorMode === AxisColorMode.Series) {
axisOpts.color = seriesColor;
}
builder.addAxis(axisOpts);
}
}
let stackingGroups = getStackingGroups(frame);
builder.setStackingGroups(stackingGroups);
return {
builder,
prepData: (_series: DataFrame[], _color?: Field | null) => {
series = _series;
frame = series[0];
color = _color;
return builder.prepData!(series);
},
};
};
function shortenValue(value: string, length: number) {
if (value.length > length) {
return value.substring(0, length).concat('...');
} else {
return value;
}
}
function getRotationPadding(
frame: DataFrame,
rotateLabel: number,
valueMaxLength: number,
lftSpace = 0,
btmSpace = 0
): Padding {
const values = frame.fields[0].values;
const fontSize = UPLOT_AXIS_FONT_SIZE;
const displayProcessor = frame.fields[0].display;
const getProcessedValue = (i: number) => {
return displayProcessor ? displayProcessor(values[i]) : values[i];
};
let maxLength = 0;
for (let i = 0; i < values.length; i++) {
let size = measureText(shortenValue(formattedValueToString(getProcessedValue(i)), valueMaxLength), fontSize);
maxLength = size.width > maxLength ? size.width : maxLength;
}
// Add padding to the right if the labels are rotated in a way that makes the last label extend outside the graph.
const paddingRight =
rotateLabel > 0
? Math.cos((rotateLabel * Math.PI) / 180) *
measureText(
shortenValue(formattedValueToString(getProcessedValue(values.length - 1)), valueMaxLength),
fontSize
).width
: 0;
// Add padding to the left if the labels are rotated in a way that makes the first label extend outside the graph.
const paddingLeft =
rotateLabel < 0
? Math.cos((rotateLabel * -1 * Math.PI) / 180) *
measureText(shortenValue(formattedValueToString(getProcessedValue(0)), valueMaxLength), fontSize).width
: 0;
// Add padding to the bottom to avoid clipping the rotated labels.
const paddingBottom =
Math.sin(((rotateLabel >= 0 ? rotateLabel : rotateLabel * -1) * Math.PI) / 180) * maxLength - btmSpace;
return [
Math.round(UPLOT_AXIS_FONT_SIZE * uPlot.pxRatio),
paddingRight,
paddingBottom,
Math.max(0, paddingLeft - lftSpace),
];
}
function getScaleOrientation(orientation: VizOrientation) {
if (orientation === VizOrientation.Vertical) {
return {
xOri: ScaleOrientation.Horizontal,
xDir: ScaleDirection.Right,
yOri: ScaleOrientation.Vertical,
yDir: ScaleDirection.Up,
};
}
return {
xOri: ScaleOrientation.Vertical,
xDir: ScaleDirection.Down,
yOri: ScaleOrientation.Horizontal,
yDir: ScaleDirection.Right,
};
}