BarChart: rotate barchart x axis labels (#40299)

* GraphNG: add axes label rotation property

* Adds rotation option and adds padding to avoid clipping labels

* Slider: Enable slider marks display (#41275)

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Oscar Kilhed 2021-11-04 10:59:15 +01:00 committed by GitHub
parent 3b637f4b44
commit b82797d1b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 271 additions and 71 deletions

View File

@ -1,5 +1,13 @@
import { ComponentType } from 'react';
import { DataLink, Field, FieldOverrideContext, SelectableValue, ThresholdsConfig, ValueMapping } from '../../types';
import {
DataLink,
Field,
FieldOverrideContext,
SelectableValue,
SliderMarks,
ThresholdsConfig,
ValueMapping,
} from '../../types';
export const identityOverrideProcessor = <T>(value: T, _context: FieldOverrideContext, _settings: any) => {
return value;
@ -39,6 +47,8 @@ export interface SliderFieldConfigSettings {
min: number;
max: number;
step?: number;
included?: boolean;
marks?: SliderMarks;
ariaLabelForHandle?: string;
}

View File

@ -38,3 +38,4 @@ export * from './geometry';
export { isUnsignedPluginSignature } from './pluginSignature';
export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo } from './config';
export * from './alerts';
export * from './slider';

View File

@ -0,0 +1 @@
export type SliderMarks = Record<number, React.ReactNode | { style?: React.CSSProperties; label?: string }>;

View File

@ -12,6 +12,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "x",
"show": true,
"side": 2,
@ -37,6 +38,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "__fixed",
"show": true,
"side": 3,

View File

@ -16,6 +16,8 @@ export const SliderValueEditor: React.FC<FieldConfigEditorProps<number, SliderFi
min={settings?.min || 0}
max={settings?.max || 100}
step={settings?.step}
marks={settings?.marks}
included={settings?.included}
onChange={onChange}
ariaLabelForHandle={settings?.ariaLabelForHandle}
/>

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Slider } from '@grafana/ui';
import { SliderProps } from './types';
import { Orientation } from '../../types/orientation';
import { Story, Meta } from '@storybook/react';
export default {
@ -20,6 +21,16 @@ export default {
},
} as Meta;
const commonArgs = {
min: 0,
max: 100,
value: 10,
isStep: false,
orientation: 'horizontal' as Orientation,
reverse: false,
included: true,
};
interface StoryProps extends Partial<SliderProps> {
isStep: boolean;
}
@ -38,10 +49,23 @@ export const Basic: Story<StoryProps> = (args) => {
);
};
Basic.args = {
min: 0,
max: 100,
value: 10,
isStep: false,
orientation: 'horizontal',
reverse: false,
...commonArgs,
};
export const WithMarks: Story<StoryProps> = (args) => {
return (
<div style={{ width: '300px', height: '300px' }}>
<Slider
step={args.isStep ? 10 : undefined}
value={args.value}
min={args.min as number}
max={args.max as number}
{...args}
/>
</div>
);
};
WithMarks.args = {
...commonArgs,
marks: { 0: '0', 25: '25', 50: '50', 75: '75', 100: '100' },
};

View File

@ -1,5 +1,6 @@
import React, { useState, useCallback, ChangeEvent, FunctionComponent, FocusEvent } from 'react';
import SliderComponent from 'rc-slider';
import { cx } from '@emotion/css';
import { Global } from '@emotion/react';
import { useTheme2 } from '../../themes/ThemeContext';
@ -20,12 +21,14 @@ export const Slider: FunctionComponent<SliderProps> = ({
step,
value,
ariaLabelForHandle,
marks,
included,
}) => {
const isHorizontal = orientation === 'horizontal';
const theme = useTheme2();
const styles = getStyles(theme, isHorizontal);
const SliderWithTooltip = SliderComponent;
const [sliderValue, setSliderValue] = useState<number>(value || min);
const [sliderValue, setSliderValue] = useState<number>(value ?? min);
const onSliderChange = useCallback(
(v: number) => {
@ -93,6 +96,8 @@ export const Slider: FunctionComponent<SliderProps> = ({
vertical={!isHorizontal}
reverse={reverse}
ariaLabelForHandle={ariaLabelForHandle}
marks={marks}
included={included}
/>
{/* Uses text input so that the number spinners are not shown */}
<Input

View File

@ -23,6 +23,16 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2, isHorizontal: bool
flex-grow: 1;
margin-left: 7px; // half the size of the handle to align handle to the left on 0 value
}
.rc-slider-mark {
top: ${theme.spacing(1.75)};
}
.rc-slider-mark-text {
color: ${theme.colors.text.disabled};
font-size: ${theme.typography.bodySmall.fontSize};
}
.rc-slider-mark-text-active {
color: ${theme.colors.text.primary};
}
.rc-slider-vertical .rc-slider-handle {
margin-top: -10px;
}

View File

@ -1,30 +1,30 @@
import { SliderMarks } from '@grafana/data';
import { Orientation } from '../../types/orientation';
export interface SliderProps {
interface CommonSliderProps {
min: number;
max: number;
orientation?: Orientation;
/** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */
value?: number;
reverse?: boolean;
step?: number;
tooltipAlwaysVisible?: boolean;
formatTooltipResult?: (value: number) => number;
/** Marks on the slider. The key determines the position, and the value determines what will show. If you want to set the style of a specific mark point, the value should be an object which contains style and label properties. */
marks?: SliderMarks;
/** If the value is true, it means a continuous value interval, otherwise, it is a independent value. */
included?: boolean;
}
export interface SliderProps extends CommonSliderProps {
value?: number;
onChange?: (value: number) => void;
onAfterChange?: (value?: number) => void;
formatTooltipResult?: (value: number) => number;
ariaLabelForHandle?: string;
}
export interface RangeSliderProps {
min: number;
max: number;
orientation?: Orientation;
/** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */
export interface RangeSliderProps extends CommonSliderProps {
value?: number[];
reverse?: boolean;
step?: number;
tooltipAlwaysVisible?: boolean;
formatTooltipResult?: (value: number) => number | string;
onChange?: (value: number[]) => void;
onAfterChange?: (value: number[]) => void;
onAfterChange?: (value?: number[]) => void;
formatTooltipResult?: (value: number) => number | string;
}

View File

@ -252,6 +252,7 @@ export { LegacyForms, LegacyInputStatus };
export * from './uPlot/config';
export { ScaleDistribution } from '@grafana/schema';
export { UPlotConfigBuilder } from './uPlot/config/UPlotConfigBuilder';
export { UPLOT_AXIS_FONT_SIZE } from './uPlot/config/UPlotAxisBuilder';
export { UPlotChart } from './uPlot/Plot';
export { PlotLegend } from './uPlot/PlotLegend';
export * from './uPlot/geometries';

View File

@ -12,6 +12,7 @@ export interface AxisProps {
show?: boolean;
size?: number | null;
gap?: number;
valueRotation?: number;
placement?: AxisPlacement;
grid?: Axis.Grid;
ticks?: boolean;
@ -23,7 +24,7 @@ export interface AxisProps {
timeZone?: TimeZone;
}
const fontSize = 12;
export const UPLOT_AXIS_FONT_SIZE = 12;
const labelPad = 8;
export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
@ -36,6 +37,47 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
this.props.placement = props.placement;
}
}
/* Minimum grid & tick spacing in CSS pixels */
calculateSpace(self: uPlot, axisIdx: number, scaleMin: number, scaleMax: number, plotDim: number): number {
const axis = self.axes[axisIdx];
const scale = self.scales[axis.scale!];
// for axis left & right
if (axis.side !== 2 || !scale) {
return 30;
}
const defaultSpacing = 40;
if (scale.time) {
const maxTicks = plotDim / defaultSpacing;
const increment = (scaleMax - scaleMin) / maxTicks;
const sample = formatTime(self, [scaleMin], axisIdx, defaultSpacing, increment);
const width = measureText(sample[0], UPLOT_AXIS_FONT_SIZE).width + 18;
return width;
}
return defaultSpacing;
}
/** height of x axis or width of y axis in CSS pixels alloted for values, gap & ticks, but excluding axis label */
calculateAxisSize(self: uPlot, values: string[], axisIdx: number) {
const axis = self.axes[axisIdx];
let axisSize = axis.ticks!.size!;
if (axis.side === 2) {
axisSize += axis!.gap! + UPLOT_AXIS_FONT_SIZE;
} else if (values?.length) {
let maxTextWidth = values.reduce(
(acc, value) => Math.max(acc, measureText(value, UPLOT_AXIS_FONT_SIZE).width),
0
);
axisSize += axis!.gap! + axis!.labelGap! + maxTextWidth;
}
return Math.ceil(axisSize);
}
getConfig(): Axis {
let {
@ -52,9 +94,11 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
isTime,
timeZone,
theme,
valueRotation,
size,
} = this.props;
const font = `${fontSize}px ${theme.typography.fontFamily}`;
const font = `${UPLOT_AXIS_FONT_SIZE}px ${theme.typography.fontFamily}`;
const gridColor = theme.isDark ? 'rgba(240, 250, 255, 0.09)' : 'rgba(0, 10, 23, 0.09)';
@ -68,7 +112,12 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
stroke: theme.colors.text.primary,
side: getUPlotSideFromAxis(placement),
font,
size: this.props.size ?? calculateAxisSize,
size:
size ??
((self, values, axisIdx) => {
return this.calculateAxisSize(self, values, axisIdx);
}),
rotate: valueRotation,
gap,
labelGap: 0,
@ -86,12 +135,14 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
},
splits,
values: values,
space: calculateSpace,
space: (self, axisIdx, scaleMin, scaleMax, plotDim) => {
return this.calculateSpace(self, axisIdx, scaleMin, scaleMax, plotDim);
},
};
if (label != null && label.length > 0) {
config.label = label;
config.labelSize = fontSize + labelPad;
config.labelSize = UPLOT_AXIS_FONT_SIZE + labelPad;
config.labelFont = font;
config.labelGap = labelPad;
}
@ -111,45 +162,6 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
}
}
/* Minimum grid & tick spacing in CSS pixels */
function calculateSpace(self: uPlot, axisIdx: number, scaleMin: number, scaleMax: number, plotDim: number): number {
const axis = self.axes[axisIdx];
const scale = self.scales[axis.scale!];
// for axis left & right
if (axis.side !== 2 || !scale) {
return 30;
}
const defaultSpacing = 40;
if (scale.time) {
const maxTicks = plotDim / defaultSpacing;
const increment = (scaleMax - scaleMin) / maxTicks;
const sample = formatTime(self, [scaleMin], axisIdx, defaultSpacing, increment);
const width = measureText(sample[0], fontSize).width + 18;
return width;
}
return defaultSpacing;
}
/** height of x axis or width of y axis in CSS pixels alloted for values, gap & ticks, but excluding axis label */
function calculateAxisSize(self: uPlot, values: string[], axisIdx: number) {
const axis = self.axes[axisIdx];
let axisSize = axis.ticks!.size!;
if (axis.side === 2) {
axisSize += axis!.gap! + fontSize;
} else if (values?.length) {
let maxTextWidth = values.reduce((acc, value) => Math.max(acc, measureText(value, fontSize).width), 0);
axisSize += axis!.gap! + axis!.labelGap! + maxTextWidth;
}
return Math.ceil(axisSize);
}
const timeUnitSize = {
second: 1000,
minute: 60 * 1000,

View File

@ -360,6 +360,7 @@ describe('UPlotConfigBuilder', () => {
"labelFont": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
"labelGap": 8,
"labelSize": 20,
"rotate": undefined,
"scale": "scale-x",
"show": true,
"side": 2,

View File

@ -17,6 +17,8 @@ export interface BarChartProps
const propsToDiff: Array<string | PropDiffFn> = [
'orientation',
'barWidth',
'valueRotation',
'valueMaxLength',
'groupWidth',
'stacking',
'showValue',
@ -52,7 +54,19 @@ export const BarChart: React.FC<BarChartProps> = (props) => {
};
const prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const { timeZone, orientation, barWidth, showValue, groupWidth, stacking, legend, tooltip, text } = props;
const {
timeZone,
orientation,
barWidth,
showValue,
groupWidth,
stacking,
legend,
tooltip,
text,
valueRotation,
valueMaxLength,
} = props;
return preparePlotConfigBuilder({
frame: alignedFrame,
@ -64,6 +78,8 @@ export const BarChart: React.FC<BarChartProps> = (props) => {
barWidth,
showValue,
groupWidth,
valueRotation,
valueMaxLength,
stacking,
legend,
tooltip,

View File

@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { TooltipDisplayMode, StackingMode } from '@grafana/schema';
import { PanelProps, TimeRange, VizOrientation } from '@grafana/data';
import { TooltipPlugin, useTheme2 } from '@grafana/ui';
import { measureText, TooltipPlugin, UPLOT_AXIS_FONT_SIZE, useTheme2 } from '@grafana/ui';
import { BarChartOptions } from './types';
import { BarChart } from './BarChart';
import { prepareGraphableFrames } from './utils';
@ -23,6 +23,23 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({ data, options, w
return options.orientation;
}, [width, height, options.orientation]);
const valueMaxLength = useMemo(() => {
// If no max length is set, limit the number of characters to a length where it will use a maximum of half of the height of the viz.
if (!options.valueMaxLength) {
const rotationAngle = options.valueRotation;
const textSize = measureText('M', UPLOT_AXIS_FONT_SIZE).width; // M is usually the widest character so let's use that as an aproximation.
const maxHeightForValues = height / 2;
return (
maxHeightForValues /
(Math.sin(((rotationAngle >= 0 ? rotationAngle : rotationAngle * -1) * Math.PI) / 180) * textSize) -
3 //Subtract 3 for the "..." added to the end.
);
} else {
return options.valueMaxLength;
}
}, [height, options.valueRotation, options.valueMaxLength]);
// Force 'multi' tooltip setting or stacking mode
const tooltip = useMemo(() => {
if (options.stacking === StackingMode.Normal || options.stacking === StackingMode.Percent) {
@ -49,6 +66,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({ data, options, w
height={height}
{...options}
orientation={orientation}
valueMaxLength={valueMaxLength}
>
{(config, alignedFrame) => {
return <TooltipPlugin data={alignedFrame} config={config} mode={tooltip.mode} timeZone={timeZone} />;

View File

@ -12,6 +12,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": -0,
"scale": "x",
"show": true,
"side": 3,
@ -37,6 +38,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "m/s",
"show": true,
"side": 2,
@ -144,6 +146,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": -0,
"scale": "x",
"show": true,
"side": 3,
@ -169,6 +172,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "m/s",
"show": true,
"side": 2,
@ -276,6 +280,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": -0,
"scale": "x",
"show": true,
"side": 2,
@ -301,6 +306,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "m/s",
"show": true,
"side": 3,
@ -408,6 +414,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": -0,
"scale": "x",
"show": true,
"side": 3,
@ -433,6 +440,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "m/s",
"show": true,
"side": 2,
@ -540,6 +548,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": -0,
"scale": "x",
"show": true,
"side": 3,
@ -565,6 +574,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "m/s",
"show": true,
"side": 2,
@ -672,6 +682,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": -0,
"scale": "x",
"show": true,
"side": 3,
@ -697,6 +708,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "m/s",
"show": true,
"side": 2,
@ -804,6 +816,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": -0,
"scale": "x",
"show": true,
"side": 3,
@ -829,6 +842,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "m/s",
"show": true,
"side": 2,
@ -936,6 +950,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": -0,
"scale": "x",
"show": true,
"side": 3,
@ -961,6 +976,7 @@ Object {
"width": 1,
},
"labelGap": 0,
"rotate": undefined,
"scale": "m/s",
"show": true,
"side": 2,

View File

@ -9,7 +9,6 @@ import {
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 { BarChartSuggestionsSupplier } from './suggestions';
@ -77,6 +76,30 @@ export const plugin = new PanelPlugin<BarChartOptions, BarChartFieldConfig>(BarC
},
defaultValue: VizOrientation.Auto,
})
.addSliderInput({
path: 'valueRotation',
name: 'Rotate values',
defaultValue: 0,
settings: {
min: -90,
max: 90,
step: 15,
marks: { '-90': '-90°', '-45': '-45°', 0: '0°', 45: '45°', 90: '90°' },
included: false,
},
showIf: (opts) => {
return opts.orientation === VizOrientation.Auto || opts.orientation === VizOrientation.Vertical;
},
})
.addNumberInput({
path: 'valueMaxLength',
name: 'Value max length',
description: 'Axis value labels will be truncated to the length provided',
settings: {
placeholder: 'Auto',
min: 0,
},
})
.addRadio({
path: 'showValue',
name: 'Show values',

View File

@ -19,6 +19,8 @@ export interface BarChartOptions extends OptionsWithLegend, OptionsWithTooltip,
showValue: VisibilityMode;
barWidth: number;
groupWidth: number;
valueRotation: number;
valueMaxLength: number;
rawValue: (seriesIdx: number, valueIdx: number) => number;
}

View File

@ -87,6 +87,8 @@ describe('BarChart utils', () => {
placement: 'bottom',
calcs: [],
},
valueRotation: 0,
valueMaxLength: 20,
stacking: StackingMode.None,
tooltip: {
mode: TooltipDisplayMode.None,

View File

@ -13,6 +13,8 @@ import {
} from '@grafana/data';
import { BarChartFieldConfig, BarChartOptions, defaultBarChartFieldConfig } from './types';
import { BarsOptions, getConfig } from './bars';
import { FIXED_UNIT, measureText, UPlotConfigBuilder, UPlotConfigPrepFn, UPLOT_AXIS_FONT_SIZE } from '@grafana/ui';
import { Padding } from 'uplot';
import {
AxisPlacement,
ScaleDirection,
@ -21,7 +23,6 @@ import {
StackingMode,
VizLegendOptions,
} from '@grafana/schema';
import { FIXED_UNIT, UPlotConfigBuilder, UPlotConfigPrepFn } from '@grafana/ui';
import { collectStackingGroups, orderIdsByCalcs } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import { orderBy } from 'lodash';
@ -55,11 +56,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
text,
rawValue,
allFrames,
valueRotation,
valueMaxLength,
legend,
}) => {
const builder = new UPlotConfigBuilder();
const defaultValueFormatter = (seriesIdx: number, value: any) =>
formattedValueToString(frame.fields[seriesIdx].display!(value));
const defaultValueFormatter = (seriesIdx: number, value: any) => {
return shortenValue(formattedValueToString(frame.fields[seriesIdx].display!(value)), valueMaxLength);
};
// bar orientation -> x scale orientation & direction
const vizOrientation = getBarCharScaleOrientation(orientation);
@ -95,6 +99,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
builder.setTooltipInterpolator(config.interpolateTooltip);
if (vizOrientation.xOri === ScaleOrientation.Horizontal && valueRotation !== 0) {
builder.setPadding(getRotationPadding(frame, valueRotation, valueMaxLength));
}
builder.setPrepData(config.prepData);
builder.addScale({
@ -115,6 +123,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
grid: { show: false },
ticks: false,
gap: 15,
valueRotation: valueRotation * -1,
theme,
});
@ -218,6 +227,51 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
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 preparePlotFrame(data: DataFrame[]) {
const firstFrame = data[0];