mirror of
https://github.com/grafana/grafana.git
synced 2025-02-14 09:33:34 -06:00
3c6e0e8ef8
* Add and configure eslint-plugin-import * Fix the lint:ts npm command * Autofix + prettier all the files * Manually fix remaining files * Move jquery code in jest-setup to external file to safely reorder imports * Resolve issue caused by circular dependencies within Prometheus * Update .betterer.results * Fix missing // @ts-ignore * ignore iconBundle.ts * Fix missing // @ts-ignore
446 lines
12 KiB
TypeScript
446 lines
12 KiB
TypeScript
import { orderBy } from 'lodash';
|
|
import { Padding } from 'uplot';
|
|
|
|
import {
|
|
ArrayVector,
|
|
DataFrame,
|
|
Field,
|
|
FieldType,
|
|
formattedValueToString,
|
|
getDisplayProcessor,
|
|
getFieldColorModeForField,
|
|
getFieldSeriesColor,
|
|
GrafanaTheme2,
|
|
outerJoinDataFrames,
|
|
reduceField,
|
|
VizOrientation,
|
|
} from '@grafana/data';
|
|
import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames';
|
|
import {
|
|
AxisPlacement,
|
|
ScaleDirection,
|
|
ScaleDistribution,
|
|
ScaleOrientation,
|
|
StackingMode,
|
|
VizLegendOptions,
|
|
} from '@grafana/schema';
|
|
import { FIXED_UNIT, measureText, UPlotConfigBuilder, UPlotConfigPrepFn, UPLOT_AXIS_FONT_SIZE } from '@grafana/ui';
|
|
import { getStackingGroups } from '@grafana/ui/src/components/uPlot/utils';
|
|
import { findField } from 'app/features/dimensions';
|
|
|
|
import { BarsOptions, getConfig } from './bars';
|
|
import { BarChartFieldConfig, PanelOptions, defaultBarChartFieldConfig } from './models.gen';
|
|
import { BarChartDisplayValues } from './types';
|
|
|
|
function getBarCharScaleOrientation(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,
|
|
};
|
|
}
|
|
|
|
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();
|
|
const defaultValueFormatter = (seriesIdx: number, value: any) => {
|
|
return shortenValue(formattedValueToString(frame.fields[seriesIdx].display!(value)), xTickLabelMaxLength);
|
|
};
|
|
|
|
// bar orientation -> x scale orientation & direction
|
|
const vizOrientation = getBarCharScaleOrientation(orientation);
|
|
|
|
const formatValue = defaultValueFormatter;
|
|
|
|
// Use bar width when only one field
|
|
if (frame.fields.length === 2) {
|
|
groupWidth = barWidth;
|
|
barWidth = 1;
|
|
}
|
|
|
|
const opts: BarsOptions = {
|
|
xOri: vizOrientation.xOri,
|
|
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);
|
|
|
|
builder.setCursor(config.cursor);
|
|
|
|
builder.addHook('init', config.init);
|
|
builder.addHook('drawClear', config.drawClear);
|
|
builder.addHook('draw', config.draw);
|
|
|
|
builder.setTooltipInterpolator(config.interpolateTooltip);
|
|
|
|
if (vizOrientation.xOri === ScaleOrientation.Horizontal && xTickLabelRotation !== 0) {
|
|
builder.setPadding(getRotationPadding(frame, xTickLabelRotation, xTickLabelMaxLength));
|
|
}
|
|
|
|
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,
|
|
values: config.xValues,
|
|
grid: { show: false },
|
|
ticks: { show: false },
|
|
gap: 15,
|
|
tickLabelRotation: xTickLabelRotation * -1,
|
|
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: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom };
|
|
|
|
const scaleKey = field.config.unit || FIXED_UNIT;
|
|
const colorMode = getFieldColorModeForField(field);
|
|
const scaleColor = getFieldSeriesColor(field, theme);
|
|
const seriesColor = scaleColor.color;
|
|
|
|
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: customConfig.axisSoftMin,
|
|
softMax: customConfig.axisSoftMax,
|
|
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;
|
|
}
|
|
}
|
|
|
|
builder.addAxis({
|
|
scaleKey,
|
|
label: customConfig.axisLabel,
|
|
size: customConfig.axisWidth,
|
|
placement,
|
|
formatValue: (v) => formattedValueToString(field.display!(v)),
|
|
theme,
|
|
grid: { show: customConfig.axisGridShow },
|
|
});
|
|
}
|
|
}
|
|
|
|
let stackingGroups = getStackingGroups(frame);
|
|
|
|
builder.setStackingGroups(stackingGroups);
|
|
|
|
return builder;
|
|
};
|
|
|
|
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): Padding {
|
|
const values = frame.fields[0].values;
|
|
const fontSize = UPLOT_AXIS_FONT_SIZE;
|
|
const displayProcessor = frame.fields[0].display ?? ((v) => v);
|
|
let maxLength = 0;
|
|
for (let i = 0; i < values.length; i++) {
|
|
let size = measureText(
|
|
shortenValue(formattedValueToString(displayProcessor(values.get(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(displayProcessor(values.get(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(displayProcessor(values.get(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;
|
|
|
|
return [0, paddingRight, paddingBottom, paddingLeft];
|
|
}
|
|
|
|
/** @internal */
|
|
export function prepareBarChartDisplayValues(
|
|
series: DataFrame[],
|
|
theme: GrafanaTheme2,
|
|
options: PanelOptions
|
|
): BarChartDisplayValues {
|
|
if (!series?.length) {
|
|
return { warn: 'No data in response' } as BarChartDisplayValues;
|
|
}
|
|
|
|
// Bar chart requires a single frame
|
|
const frame =
|
|
series.length === 1
|
|
? maybeSortFrame(
|
|
series[0],
|
|
series[0].fields.findIndex((f) => f.type === FieldType.time)
|
|
)
|
|
: outerJoinDataFrames({ frames: series });
|
|
if (!frame) {
|
|
return { warn: 'Unable to join data' } as BarChartDisplayValues;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
let xField: Field | undefined = undefined;
|
|
if (options.xField) {
|
|
xField = findField(frame, options.xField);
|
|
if (!xField) {
|
|
return { warn: 'Configured x field not found' } as BarChartDisplayValues;
|
|
}
|
|
}
|
|
|
|
let stringField: Field | undefined = undefined;
|
|
let timeField: Field | undefined = undefined;
|
|
let fields: Field[] = [];
|
|
for (const field of frame.fields) {
|
|
if (field === xField) {
|
|
continue;
|
|
}
|
|
|
|
switch (field.type) {
|
|
case FieldType.string:
|
|
if (!stringField) {
|
|
stringField = field;
|
|
}
|
|
break;
|
|
|
|
case FieldType.time:
|
|
if (!timeField) {
|
|
timeField = field;
|
|
}
|
|
break;
|
|
|
|
case FieldType.number: {
|
|
const copy = {
|
|
...field,
|
|
state: {
|
|
...field.state,
|
|
seriesIndex: fields.length, // off by one?
|
|
},
|
|
config: {
|
|
...field.config,
|
|
custom: {
|
|
...field.config.custom,
|
|
stacking: {
|
|
group: '_',
|
|
mode: options.stacking,
|
|
},
|
|
},
|
|
},
|
|
values: new ArrayVector(
|
|
field.values.toArray().map((v) => {
|
|
if (!(Number.isFinite(v) || v == null)) {
|
|
return null;
|
|
}
|
|
return v;
|
|
})
|
|
),
|
|
};
|
|
|
|
if (options.stacking === StackingMode.Percent) {
|
|
copy.config.unit = 'percentunit';
|
|
copy.display = getDisplayProcessor({ field: copy, theme });
|
|
}
|
|
|
|
fields.push(copy);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|