From cbdca6cce8f5fd5960a14b5f5b69e66a249ba752 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Sat, 23 Nov 2019 17:00:08 -0700 Subject: [PATCH] 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 --- .../src/components/BarGauge/BarGauge.test.tsx | 36 +++++- .../src/components/BarGauge/BarGauge.tsx | 116 +++++++++++------- .../__snapshots__/BarGauge.test.tsx.snap | 3 +- .../components/VizRepeater/VizRepeater.tsx | 34 +++-- packages/grafana-ui/src/components/index.ts | 2 +- packages/grafana-ui/src/utils/index.ts | 1 + packages/grafana-ui/src/utils/measureText.ts | 27 ++++ .../plugins/panel/bargauge/BarGaugePanel.tsx | 33 ++++- 8 files changed, 185 insertions(+), 67 deletions(-) create mode 100644 packages/grafana-ui/src/utils/measureText.ts diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx index 447e93886cc..d797742d01f 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx @@ -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', () => { diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 25a72b69955..9cd9666be73 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -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; className?: string; + alignmentFactors?: BarGaugeAlignmentFactors; } export class BarGauge extends PureComponent { @@ -137,7 +152,7 @@ export class BarGauge extends PureComponent { } 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 { 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 { 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 { 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; +} diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap index 320b53aa161..bbb3313d411 100644 --- a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -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", diff --git a/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx index c20b0482600..597440fc6cb 100644 --- a/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx +++ b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx @@ -1,12 +1,23 @@ import React, { PureComponent } from 'react'; import { VizOrientation } from '@grafana/data'; -interface Props { - renderValue: (value: T, width: number, height: number) => JSX.Element; +interface Props { + /** + * 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 = Props & DefaultProps; +type PropsWithDefaults = Props & DefaultProps; -interface State { - values: T[]; +interface State { + values: V[]; } -export class VizRepeater extends PureComponent, State> { +export class VizRepeater extends PureComponent, State> { static defaultProps: DefaultProps = { itemSpacing: 10, }; - constructor(props: Props) { + constructor(props: Props) { super(props); this.state = { @@ -35,7 +46,7 @@ export class VizRepeater extends PureComponent, State> { }; } - componentDidUpdate(prevProps: Props) { + componentDidUpdate(prevProps: Props) { 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 extends PureComponent, State> { } render() { - const { renderValue, height, width, itemSpacing } = this.props as PropsWithDefaults; + const { renderValue, height, width, itemSpacing, getAlignmentFactors } = this.props as PropsWithDefaults; const { values } = this.state; const orientation = this.getOrientation(); @@ -87,12 +98,13 @@ export class VizRepeater extends PureComponent, State> { itemStyles.width = `${vizWidth}px`; itemStyles.height = `${vizHeight}px`; + const dims = getAlignmentFactors ? getAlignmentFactors(values, vizWidth, vizHeight) : ({} as D); return (
{values.map((value, index) => { return (
- {renderValue(value, vizWidth, vizHeight)} + {renderValue(value, vizWidth, vizHeight, dims)}
); })} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 15683d21de1..bdbe57303c0 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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, diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index f32d8cfebad..a9e1c7328db 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -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 diff --git a/packages/grafana-ui/src/utils/measureText.ts b/packages/grafana-ui/src/utils/measureText.ts new file mode 100644 index 00000000000..ec0ec223dd2 --- /dev/null +++ b/packages/grafana-ui/src/utils/measureText.ts @@ -0,0 +1,27 @@ +let canvas: HTMLCanvasElement | null = null; +const cache: Record = {}; + +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; +} diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx index 09c88e0ea49..32a61b2b71a 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -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> { - 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> { maxValue={field.max} onClick={openMenu} className={targetClassName} + alignmentFactors={alignmentFactors} /> ); }} @@ -65,6 +87,7 @@ export class BarGaugePanel extends PureComponent> { return (