VizRepeater/BarGauge: Use common font dimensions across repeated visualisations (#19983)

* calculate metrics

* fix tests

* update test

* update names

* BarGauge: measure title width

* BarGauge: added tests

* BarGauge: Improved font size handling

* Removed unused var

* BarGauge: Further font size tweaks

* BarGauge: added comments

* BarGauge: final tweak

* Updated snapshot*

* Fixed issues
This commit is contained in:
Ryan McKinley 2019-11-23 17:00:08 -07:00 committed by Torkel Ödegaard
parent 662e514f1d
commit cbdca6cce8
8 changed files with 185 additions and 67 deletions

View File

@ -13,10 +13,6 @@ import {
import { VizOrientation } from '@grafana/data';
import { getTheme } from '../../themes';
// jest.mock('jquery', () => ({
// plot: jest.fn(),
// }));
const green = '#73BF69';
const orange = '#FF9830';
// const red = '#BB';
@ -136,6 +132,38 @@ describe('BarGauge', () => {
const styles = getTitleStyles(props);
expect(styles.wrapper.flexDirection).toBe('row');
});
it('should calculate title width based on title', () => {
const props = getProps({
height: 30,
value: getValue(100, 'AA'),
orientation: VizOrientation.Horizontal,
});
const styles = getTitleStyles(props);
expect(styles.title.width).toBe('17px');
const props2 = getProps({
height: 30,
value: getValue(120, 'Longer title with many words'),
orientation: VizOrientation.Horizontal,
});
const styles2 = getTitleStyles(props2);
expect(styles2.title.width).toBe('43px');
});
it('should use alignmentFactors if provided', () => {
const props = getProps({
height: 30,
value: getValue(100, 'AA'),
alignmentFactors: {
title: 'Super duper long title',
text: '1000',
},
orientation: VizOrientation.Horizontal,
});
const styles = getTitleStyles(props);
expect(styles.title.width).toBe('37px');
});
});
describe('Gradient', () => {

View File

@ -5,6 +5,7 @@ import { Threshold, TimeSeriesValue, getActiveThreshold, DisplayValue } from '@g
// Utils
import { getColorFromHexRgbOrName } from '@grafana/data';
import { measureText } from '../../utils/measureText';
// Types
import { VizOrientation } from '@grafana/data';
@ -16,6 +17,19 @@ const MIN_VALUE_WIDTH = 50;
const MAX_VALUE_WIDTH = 150;
const TITLE_LINE_HEIGHT = 1.5;
const VALUE_LINE_HEIGHT = 1;
const VALUE_LEFT_PADDING = 10;
/**
* These values calculate the internal font sizes and
* placement. For consistent behavior across repeating
* panels, we can optionally pass in the maximum values.
*
* If performace becomes a problem, we can cache the results
*/
export interface BarGaugeAlignmentFactors {
title: string;
text: string;
}
export interface Props extends Themeable {
height: number;
@ -29,6 +43,7 @@ export interface Props extends Themeable {
displayMode: 'basic' | 'lcd' | 'gradient';
onClick?: React.MouseEventHandler<HTMLElement>;
className?: string;
alignmentFactors?: BarGaugeAlignmentFactors;
}
export class BarGauge extends PureComponent<Props> {
@ -137,7 +152,7 @@ export class BarGauge extends PureComponent<Props> {
}
renderRetroBars(): ReactNode {
const { maxValue, minValue, value, itemSpacing } = this.props;
const { maxValue, minValue, value, itemSpacing, alignmentFactors, orientation } = this.props;
const {
valueHeight,
valueWidth,
@ -147,7 +162,7 @@ export class BarGauge extends PureComponent<Props> {
wrapperHeight,
} = calculateBarAndValueDimensions(this.props);
const isVert = isVertical(this.props);
const isVert = isVertical(orientation);
const valueRange = maxValue - minValue;
const maxSize = isVert ? maxBarHeight : maxBarWidth;
const cellSpacing = itemSpacing!;
@ -155,7 +170,9 @@ export class BarGauge extends PureComponent<Props> {
const cellCount = Math.floor(maxSize / cellWidth);
const cellSize = Math.floor((maxSize - cellSpacing * cellCount) / cellCount);
const valueColor = getValueColor(this.props);
const valueStyles = getValueStyles(value.text, valueColor, valueWidth, valueHeight);
const valueTextToBaseSizeOn = alignmentFactors ? alignmentFactors.text : value.text;
const valueStyles = getValueStyles(valueTextToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation);
const containerStyles: CSSProperties = {
width: `${wrapperWidth}px`,
@ -166,11 +183,9 @@ export class BarGauge extends PureComponent<Props> {
if (isVert) {
containerStyles.flexDirection = 'column-reverse';
containerStyles.alignItems = 'center';
valueStyles.justifyContent = 'center';
} else {
containerStyles.flexDirection = 'row';
containerStyles.alignItems = 'center';
valueStyles.justifyContent = 'flex-end';
}
const cells: JSX.Element[] = [];
@ -226,19 +241,19 @@ interface TitleDimensions {
height: number;
}
function isVertical(props: Props) {
return props.orientation === VizOrientation.Vertical;
function isVertical(orientation: VizOrientation) {
return orientation === VizOrientation.Vertical;
}
function calculateTitleDimensions(props: Props): TitleDimensions {
const { title } = props.value;
const { height, width } = props;
const { height, width, alignmentFactors, orientation } = props;
const title = alignmentFactors ? alignmentFactors.title : props.value.title;
if (!title) {
return { fontSize: 0, width: 0, height: 0, placement: 'above' };
}
if (isVertical(props)) {
if (isVertical(orientation)) {
return {
fontSize: 14,
width: width,
@ -262,13 +277,14 @@ function calculateTitleDimensions(props: Props): TitleDimensions {
// title to left of bar scenario
const maxTitleHeightRatio = 0.6;
const maxTitleWidthRatio = 0.2;
const titleHeight = Math.max(height * maxTitleHeightRatio, MIN_VALUE_HEIGHT);
const titleFontSize = titleHeight / TITLE_LINE_HEIGHT;
const textSize = measureText(title, titleFontSize);
return {
fontSize: titleHeight / TITLE_LINE_HEIGHT,
fontSize: titleFontSize,
height: 0,
width: Math.min(Math.max(width * maxTitleWidthRatio, 50), 200),
width: textSize.width + 15,
placement: 'left',
};
}
@ -291,7 +307,7 @@ export function getTitleStyles(props: Props): { wrapper: CSSProperties; title: C
alignSelf: 'center',
};
if (isVertical(props)) {
if (isVertical(props.orientation)) {
wrapperStyles.flexDirection = 'column-reverse';
titleStyles.textAlign = 'center';
} else {
@ -328,7 +344,7 @@ interface BarAndValueDimensions {
}
function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
const { height, width } = props;
const { height, width, orientation } = props;
const titleDim = calculateTitleDimensions(props);
let maxBarHeight = 0;
@ -338,7 +354,7 @@ function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions {
let wrapperWidth = 0;
let wrapperHeight = 0;
if (isVertical(props)) {
if (isVertical(orientation)) {
valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT);
valueWidth = width;
maxBarHeight = height - (titleDim.height + valueHeight);
@ -378,14 +394,16 @@ export function getValuePercent(value: number, minValue: number, maxValue: numbe
* Only exported to for unit test
*/
export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles {
const { displayMode, maxValue, minValue, value } = props;
const { displayMode, maxValue, minValue, value, alignmentFactors, orientation } = props;
const { valueWidth, valueHeight, maxBarHeight, maxBarWidth } = calculateBarAndValueDimensions(props);
const valuePercent = getValuePercent(value.numeric, minValue, maxValue);
const valueColor = getValueColor(props);
const valueStyles = getValueStyles(value.text, valueColor, valueWidth, valueHeight);
const isBasic = displayMode === 'basic';
const valueTextToBaseSizeOn = alignmentFactors ? alignmentFactors.text : value.text;
const valueStyles = getValueStyles(valueTextToBaseSizeOn, valueColor, valueWidth, valueHeight, orientation);
const isBasic = displayMode === 'basic';
const wrapperStyles: CSSProperties = {
display: 'flex',
};
@ -394,7 +412,7 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
borderRadius: '3px',
};
if (isVertical(props)) {
if (isVertical(orientation)) {
const barHeight = Math.max(valuePercent * maxBarHeight, 1);
// vertical styles
@ -405,9 +423,6 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
barStyles.height = `${barHeight}px`;
barStyles.width = `${maxBarWidth}px`;
// value styles centered
valueStyles.justifyContent = 'center';
if (isBasic) {
// Basic styles
barStyles.background = `${tinycolor(valueColor)
@ -430,8 +445,6 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
barStyles.height = `${maxBarHeight}px`;
barStyles.width = `${barWidth}px`;
valueStyles.paddingLeft = '10px';
if (isBasic) {
// Basic styles
barStyles.background = `${tinycolor(valueColor)
@ -455,8 +468,8 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
* Only exported to for unit test
*/
export function getBarGradient(props: Props, maxSize: number): string {
const { minValue, maxValue, thresholds, value } = props;
const cssDirection = isVertical(props) ? '0deg' : '90deg';
const { minValue, maxValue, thresholds, value, orientation } = props;
const cssDirection = isVertical(orientation) ? '0deg' : '90deg';
let gradient = '';
let lastpos = 0;
@ -496,29 +509,42 @@ export function getValueColor(props: Props): string {
return getColorFromHexRgbOrName('gray', theme.type);
}
/**
* Only exported to for unit test
*/
function getValueStyles(value: string, color: string, width: number, height: number): CSSProperties {
const heightFont = height / VALUE_LINE_HEIGHT;
const guess = width / (value.length * 1.1);
const fontSize = Math.min(Math.max(guess, 14), heightFont);
return {
function getValueStyles(
value: string,
color: string,
width: number,
height: number,
orientation: VizOrientation
): CSSProperties {
const valueStyles: CSSProperties = {
color: color,
height: `${height}px`,
width: `${width}px`,
display: 'flex',
alignItems: 'center',
lineHeight: VALUE_LINE_HEIGHT,
fontSize: fontSize.toFixed(4) + 'px',
};
}
// function getTextWidth(text: string): number {
// const canvas = getTextWidth.canvas || (getTextWidth.canvas = document.createElement("canvas"));
// var context = canvas.getContext("2d");
// context.font = "'Roboto', 'Helvetica Neue', Arial, sans-serif";
// var metrics = context.measureText(text);
// return metrics.width;
// }
// how many pixels in wide can the text be?
let textWidth = width;
if (isVertical(orientation)) {
valueStyles.justifyContent = `center`;
} else {
valueStyles.justifyContent = `flex-start`;
valueStyles.paddingLeft = `${VALUE_LEFT_PADDING}px`;
// Need to remove the left padding from the text width constraints
textWidth -= VALUE_LEFT_PADDING;
}
// calculate width in 14px
const textSize = measureText(value, 14);
// how much bigger than 14px can we make it while staying within our width constraints
const fontSizeBasedOnWidth = (textWidth / (textSize.width + 2)) * 14;
const fontSizeBasedOnHeight = height / VALUE_LINE_HEIGHT;
// final fontSize
valueStyles.fontSize = Math.min(fontSizeBasedOnHeight, fontSizeBasedOnWidth).toFixed(4) + 'px';
return valueStyles;
}

View File

@ -27,8 +27,9 @@ exports[`BarGauge Render with basic options should render 1`] = `
"alignItems": "center",
"color": "#73BF69",
"display": "flex",
"fontSize": "27.2727px",
"fontSize": "175.0000px",
"height": "300px",
"justifyContent": "flex-start",
"lineHeight": 1,
"paddingLeft": "10px",
"width": "60px",

View File

@ -1,12 +1,23 @@
import React, { PureComponent } from 'react';
import { VizOrientation } from '@grafana/data';
interface Props<T> {
renderValue: (value: T, width: number, height: number) => JSX.Element;
interface Props<V, D> {
/**
* Optionally precalculate dimensions to support consistent behavior between repeated
* values. Two typical patterns are:
* 1) Calculate raw values like font size etc and pass them to each vis
* 2) find the maximum input values and pass that to the vis
*/
getAlignmentFactors?: (values: V[], width: number, height: number) => D;
/**
* Render a single value
*/
renderValue: (value: V, width: number, height: number, dims: D) => JSX.Element;
height: number;
width: number;
source: any; // If this changes, new values will be requested
getValues: () => T[];
getValues: () => V[];
renderCounter: number; // force update of values & render
orientation: VizOrientation;
itemSpacing?: number;
@ -16,18 +27,18 @@ interface DefaultProps {
itemSpacing: number;
}
type PropsWithDefaults<T> = Props<T> & DefaultProps;
type PropsWithDefaults<V, D> = Props<V, D> & DefaultProps;
interface State<T> {
values: T[];
interface State<V> {
values: V[];
}
export class VizRepeater<T> extends PureComponent<Props<T>, State<T>> {
export class VizRepeater<V, D = {}> extends PureComponent<Props<V, D>, State<V>> {
static defaultProps: DefaultProps = {
itemSpacing: 10,
};
constructor(props: Props<T>) {
constructor(props: Props<V, D>) {
super(props);
this.state = {
@ -35,7 +46,7 @@ export class VizRepeater<T> extends PureComponent<Props<T>, State<T>> {
};
}
componentDidUpdate(prevProps: Props<T>) {
componentDidUpdate(prevProps: Props<V, D>) {
const { renderCounter, source } = this.props;
if (renderCounter !== prevProps.renderCounter || source !== prevProps.source) {
this.setState({ values: this.props.getValues() });
@ -57,7 +68,7 @@ export class VizRepeater<T> extends PureComponent<Props<T>, State<T>> {
}
render() {
const { renderValue, height, width, itemSpacing } = this.props as PropsWithDefaults<T>;
const { renderValue, height, width, itemSpacing, getAlignmentFactors } = this.props as PropsWithDefaults<V, D>;
const { values } = this.state;
const orientation = this.getOrientation();
@ -87,12 +98,13 @@ export class VizRepeater<T> extends PureComponent<Props<T>, State<T>> {
itemStyles.width = `${vizWidth}px`;
itemStyles.height = `${vizHeight}px`;
const dims = getAlignmentFactors ? getAlignmentFactors(values, vizWidth, vizHeight) : ({} as D);
return (
<div style={repeaterStyle}>
{values.map((value, index) => {
return (
<div key={index} style={itemStyles}>
{renderValue(value, vizWidth, vizHeight)}
{renderValue(value, vizWidth, vizHeight, dims)}
</div>
);
})}

View File

@ -53,8 +53,8 @@ export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph';
export { GraphLegend } from './Graph/GraphLegend';
export { GraphWithLegend } from './Graph/GraphWithLegend';
export { BarGauge, BarGaugeAlignmentFactors } from './BarGauge/BarGauge';
export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
export { BarGauge } from './BarGauge/BarGauge';
export { VizRepeater } from './VizRepeater/VizRepeater';
export {
LegendOptions,

View File

@ -3,6 +3,7 @@ export * from './validate';
export * from './slate';
export * from './dataLinks';
export * from './tags';
export * from './measureText';
export { default as ansicolor } from './ansicolor';
// Export with a namespace

View File

@ -0,0 +1,27 @@
let canvas: HTMLCanvasElement | null = null;
const cache: Record<string, TextMetrics> = {};
export function measureText(text: string, fontSize: number): TextMetrics {
const fontStyle = `${fontSize}px 'Roboto'`;
const cacheKey = text + fontStyle;
const fromCache = cache[cacheKey];
if (fromCache) {
return fromCache;
}
if (canvas === null) {
canvas = document.createElement('canvas');
}
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create context');
}
context.font = fontStyle;
const metrics = context.measureText(text);
cache[cacheKey] = metrics;
return metrics;
}

View File

@ -4,16 +4,37 @@ import React, { PureComponent } from 'react';
// Services & Utils
import { config } from 'app/core/config';
// Components
import { BarGauge, VizRepeater, DataLinksContextMenu } from '@grafana/ui';
// Types
import { BarGauge, BarGaugeAlignmentFactors, VizRepeater, DataLinksContextMenu } from '@grafana/ui';
import { BarGaugeOptions } from './types';
import { getFieldDisplayValues, FieldDisplay, PanelProps } from '@grafana/data';
import { getFieldLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
findMaximumInput = (values: FieldDisplay[], width: number, height: number): BarGaugeAlignmentFactors => {
const info: BarGaugeAlignmentFactors = {
title: '',
text: '',
};
for (let i = 0; i < values.length; i++) {
const v = values[i].display;
if (v.text && v.text.length > info.text.length) {
info.text = v.text;
}
if (v.title && v.title.length > info.title.length) {
info.title = v.title;
}
}
return info;
};
renderValue = (
value: FieldDisplay,
width: number,
height: number,
alignmentFactors: BarGaugeAlignmentFactors
): JSX.Element => {
const { options } = this.props;
const { field, display } = value;
@ -34,6 +55,7 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
maxValue={field.max}
onClick={openMenu}
className={targetClassName}
alignmentFactors={alignmentFactors}
/>
);
}}
@ -65,6 +87,7 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
return (
<VizRepeater
source={data}
getAlignmentFactors={this.findMaximumInput}
getValues={this.getValues}
renderValue={this.renderValue}
renderCounter={renderCounter}