From 566b3d178a9a60f8914d99b5ba54fa92e846d064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 5 Apr 2019 12:59:29 +0200 Subject: [PATCH] Bar Gauge: Show tile (series name) & refactorings & tests (#16397) * WIP: began work on adding title support to bar gauge * Feat: BarGauge progress * wip: trying improve text size handling in bar gauge * BarGauge: progress on title & value auto sizing * BarGauge: more auto size handling * bargauge: minor tweaks * Added tests * Refactoring: BarGauge refactoring moving css generation to seperate functions and adding some basic tests * Refactoring VizRepeater and more * Fix: updated and fixed tests --- .../components/BarGauge/BarGauge.story.tsx | 99 ++-- .../src/components/BarGauge/BarGauge.test.tsx | 119 +++- .../src/components/BarGauge/BarGauge.tsx | 527 ++++++++++++------ .../__snapshots__/BarGauge.test.tsx.snap | 17 +- .../src/components/SingleStatShared/shared.ts | 38 +- .../components/VizRepeater/VizRepeater.tsx | 57 +- .../plugins/panel/bargauge/BarGaugePanel.tsx | 26 +- public/app/plugins/panel/gauge/GaugePanel.tsx | 9 +- .../singlestat2/ProcessedValuesRepeater.tsx | 49 -- .../panel/singlestat2/SingleStatPanel.tsx | 10 +- 10 files changed, 625 insertions(+), 326 deletions(-) delete mode 100644 public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx index 455b29f3942..8e8f479691b 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx @@ -1,61 +1,82 @@ import { storiesOf } from '@storybook/react'; -import { number, text, boolean } from '@storybook/addon-knobs'; -import { BarGauge } from './BarGauge'; +import { number, text } from '@storybook/addon-knobs'; +import { BarGauge, Props } from './BarGauge'; import { VizOrientation } from '../../types'; -import { withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; const getKnobs = () => { return { value: number('value', 70), + title: text('title', 'Title'), minValue: number('minValue', 0), maxValue: number('maxValue', 100), threshold1Value: number('threshold1Value', 40), threshold1Color: text('threshold1Color', 'orange'), threshold2Value: number('threshold2Value', 60), threshold2Color: text('threshold2Color', 'red'), - unit: text('unit', 'ms'), - decimals: number('decimals', 1), - horizontal: boolean('horizontal', false), - lcd: boolean('lcd', false), }; }; const BarGaugeStories = storiesOf('UI/BarGauge/BarGauge', module); -BarGaugeStories.addDecorator(withHorizontallyCenteredStory); +BarGaugeStories.addDecorator(withCenteredStory); -BarGaugeStories.add('Simple with basic thresholds', () => { - const { - value, - minValue, - maxValue, - threshold1Color, - threshold2Color, - threshold1Value, - threshold2Value, - unit, - decimals, - horizontal, - lcd, - } = getKnobs(); +function addBarGaugeStory(name: string, overrides: Partial) { + BarGaugeStories.add(name, () => { + const { + value, + title, + minValue, + maxValue, + threshold1Color, + threshold2Color, + threshold1Value, + threshold2Value, + } = getKnobs(); - return renderComponentWithTheme(BarGauge, { - width: 300, - height: 300, - value: { text: value.toString(), numeric: value }, - minValue: minValue, - maxValue: maxValue, - unit: unit, - prefix: '', - postfix: '', - decimals: decimals, - orientation: horizontal ? VizOrientation.Horizontal : VizOrientation.Vertical, - displayMode: lcd ? 'lcd' : 'simple', - thresholds: [ - { index: 0, value: -Infinity, color: 'green' }, - { index: 1, value: threshold1Value, color: threshold1Color }, - { index: 1, value: threshold2Value, color: threshold2Color }, - ], + const props: Props = { + theme: {} as any, + width: 300, + height: 300, + value: { + text: value.toString(), + title: title, + numeric: value, + }, + minValue: minValue, + maxValue: maxValue, + orientation: VizOrientation.Vertical, + displayMode: 'basic', + thresholds: [ + { index: 0, value: -Infinity, color: 'green' }, + { index: 1, value: threshold1Value, color: threshold1Color }, + { index: 1, value: threshold2Value, color: threshold2Color }, + ], + }; + + Object.assign(props, overrides); + return renderComponentWithTheme(BarGauge, props); }); +} + +addBarGaugeStory('Gradient Vertical', { + displayMode: 'gradient', + orientation: VizOrientation.Vertical, + height: 500, + width: 100, +}); + +addBarGaugeStory('Gradient Horizontal', { + displayMode: 'gradient', + orientation: VizOrientation.Horizontal, + height: 100, + width: 500, +}); + +addBarGaugeStory('LCD Horizontal', { + displayMode: 'lcd', + orientation: VizOrientation.Vertical, + height: 500, + width: 100, }); diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx index 69cdbf5bff1..620222315fa 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx @@ -1,19 +1,27 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { BarGauge, Props } from './BarGauge'; -import { VizOrientation } from '../../types'; +import { BarGauge, Props, getValueColor, getBasicAndGradientStyles, getBarGradient, getTitleStyles } from './BarGauge'; +import { VizOrientation, DisplayValue } from '../../types'; import { getTheme } from '../../themes'; -jest.mock('jquery', () => ({ - plot: jest.fn(), -})); +// jest.mock('jquery', () => ({ +// plot: jest.fn(), +// })); -const setup = (propOverrides?: object) => { +const green = '#73BF69'; +const orange = '#FF9830'; +// const red = '#BB'; + +function getProps(propOverrides?: Partial): Props { const props: Props = { maxValue: 100, minValue: 0, displayMode: 'basic', - thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }], + thresholds: [ + { index: 0, value: -Infinity, color: 'green' }, + { index: 1, value: 70, color: 'orange' }, + { index: 2, value: 90, color: 'red' }, + ], height: 300, width: 300, value: { @@ -25,7 +33,11 @@ const setup = (propOverrides?: object) => { }; Object.assign(props, propOverrides); + return props; +} +const setup = (propOverrides?: object) => { + const props = getProps(propOverrides); const wrapper = shallow(); const instance = wrapper.instance() as BarGauge; @@ -35,29 +47,88 @@ const setup = (propOverrides?: object) => { }; }; -describe('Get font color', () => { - it('should get first threshold color when only one threshold', () => { - const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] }); +function getValue(value: number, title?: string): DisplayValue { + return { numeric: value, text: value.toString(), title: title }; +} - expect(instance.getValueColors().value).toEqual('#7EB26D'); +describe('BarGauge', () => { + describe('Get value color', () => { + it('should get the threshold color if value is same as a threshold', () => { + const props = getProps({ value: getValue(70) }); + expect(getValueColor(props)).toEqual(orange); + }); + it('should get the base threshold', () => { + const props = getProps({ value: getValue(-10) }); + expect(getValueColor(props)).toEqual(green); + }); }); - it('should get the threshold color if value is same as a threshold', () => { - const { instance } = setup({ - thresholds: [ - { index: 2, value: 75, color: '#6ED0E0' }, - { index: 1, value: 10, color: '#EAB839' }, - { index: 0, value: -Infinity, color: '#7EB26D' }, - ], + describe('Vertical bar without title', () => { + it('should not include title height in height', () => { + const props = getProps({ + height: 300, + value: getValue(100), + orientation: VizOrientation.Vertical, + }); + const styles = getBasicAndGradientStyles(props); + expect(styles.bar.height).toBe('270px'); + }); + }); + + describe('Vertical bar with title', () => { + it('should include title height in height', () => { + const props = getProps({ + height: 300, + value: getValue(100, 'ServerA'), + orientation: VizOrientation.Vertical, + }); + const styles = getBasicAndGradientStyles(props); + expect(styles.bar.height).toBe('249px'); + }); + }); + + describe('Horizontal bar with title', () => { + it('should place above if height > 40', () => { + const props = getProps({ + height: 41, + value: getValue(100, 'AA'), + orientation: VizOrientation.Horizontal, + }); + const styles = getTitleStyles(props); + expect(styles.wrapper.flexDirection).toBe('column'); + }); + }); + + describe('Horizontal bar with title', () => { + it('should place below if height < 40', () => { + const props = getProps({ + height: 30, + value: getValue(100, 'AA'), + orientation: VizOrientation.Horizontal, + }); + const styles = getTitleStyles(props); + expect(styles.wrapper.flexDirection).toBe('row'); + }); + }); + + describe('Gradient', () => { + it('should build gradient based on thresholds', () => { + const props = getProps({ orientation: VizOrientation.Vertical, value: getValue(100) }); + const gradient = getBarGradient(props, 300); + expect(gradient).toBe('linear-gradient(0deg, #73BF69, #73BF69 105px, #FF9830 240px, #F2495C)'); }); - expect(instance.getValueColors().value).toEqual('#EAB839'); + it('should stop gradient if value < threshold', () => { + const props = getProps({ orientation: VizOrientation.Vertical, value: getValue(70) }); + const gradient = getBarGradient(props, 300); + expect(gradient).toBe('linear-gradient(0deg, #73BF69, #73BF69 105px, #FF9830)'); + }); }); -}); -describe('Render BarGauge with basic options', () => { - it('should render', () => { - const { wrapper } = setup(); - expect(wrapper).toMatchSnapshot(); + describe('Render with basic options', () => { + it('should render', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); }); }); diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index a5a2190b8ca..6622545229e 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -8,7 +8,11 @@ import { getColorFromHexRgbOrName, getThresholdForValue } from '../../utils'; // Types import { DisplayValue, Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types'; -const BAR_SIZE_RATIO = 0.8; +const MIN_VALUE_HEIGHT = 18; +const MAX_VALUE_HEIGHT = 50; +const MIN_VALUE_WIDTH = 50; +const MAX_VALUE_WIDTH = 100; +const LINE_HEIGHT = 1.5; export interface Props extends Themeable { height: number; @@ -18,6 +22,7 @@ export interface Props extends Themeable { maxValue: number; minValue: number; orientation: VizOrientation; + itemSpacing?: number; displayMode: 'basic' | 'lcd' | 'gradient'; } @@ -32,9 +37,27 @@ export class BarGauge extends PureComponent { displayMode: 'lcd', orientation: VizOrientation.Horizontal, thresholds: [], + itemSpacing: 10, }; render() { + const { title } = this.props.value; + + if (!title) { + return this.renderBarAndValue(); + } + + const styles = getTitleStyles(this.props); + + return ( +
+
{title}
+ {this.renderBarAndValue()} +
+ ); + } + + renderBarAndValue() { switch (this.props.displayMode) { case 'lcd': return this.renderRetroBars(); @@ -45,143 +68,17 @@ export class BarGauge extends PureComponent { } } - getValueColors(): BarColors { - const { thresholds, theme, value } = this.props; - - const activeThreshold = getThresholdForValue(thresholds, value.numeric); - - if (activeThreshold !== null) { - const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type); - - return { - value: color, - border: color, - background: tinycolor(color) - .setAlpha(0.15) - .toRgbString(), - }; - } - - return { - value: getColorFromHexRgbOrName('gray', theme.type), - background: getColorFromHexRgbOrName('gray', theme.type), - border: getColorFromHexRgbOrName('gray', theme.type), - }; - } - - getValueStyles(value: string, color: string, width: number): CSSProperties { - const guess = width / (value.length * 1.1); - const fontSize = Math.min(Math.max(guess, 14), 40); - - return { - color: color, - fontSize: fontSize + 'px', - }; - } - - /* - * Return width or height depending on viz orientation - * */ - get size() { - const { height, width } = this.props; - return this.isVertical ? height : width; - } - - get isVertical() { - return this.props.orientation === VizOrientation.Vertical; - } - - getBarGradient(maxSize: number): string { - const { minValue, maxValue, thresholds, value } = this.props; - const cssDirection = this.isVertical ? '0deg' : '90deg'; - - let gradient = ''; - let lastpos = 0; - - for (let i = 0; i < thresholds.length; i++) { - const threshold = thresholds[i]; - const color = getColorFromHexRgbOrName(threshold.color); - const valuePercent = Math.min(threshold.value / (maxValue - minValue), 1); - const pos = valuePercent * maxSize; - const offset = Math.round(pos - (pos - lastpos) / 2); - - if (gradient === '') { - gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`; - } else if (value.numeric < threshold.value) { - break; - } else { - lastpos = pos; - gradient += ` ${offset}px, ${color}`; - } - } - - return gradient + ')'; - } - renderBasicAndGradientBars(): ReactNode { - const { height, width, displayMode, maxValue, minValue, value } = this.props; + const { value } = this.props; - const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1); - const maxSize = this.size * BAR_SIZE_RATIO; - const barSize = Math.max(valuePercent * maxSize, 0); - const colors = this.getValueColors(); - const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height); - const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText); - const isBasic = displayMode === 'basic'; - - const containerStyles: CSSProperties = { - width: `${width}px`, - height: `${height}px`, - display: 'flex', - }; - - const barStyles: CSSProperties = { - borderRadius: '3px', - }; - - if (this.isVertical) { - // Custom styles for vertical orientation - containerStyles.flexDirection = 'column'; - containerStyles.justifyContent = 'flex-end'; - barStyles.transition = 'height 1s'; - barStyles.height = `${barSize}px`; - barStyles.width = `${width}px`; - if (isBasic) { - // Basic styles - barStyles.background = `${colors.background}`; - barStyles.border = `1px solid ${colors.border}`; - barStyles.boxShadow = `0 0 4px ${colors.border}`; - } else { - // Gradient styles - barStyles.background = this.getBarGradient(maxSize); - } - } else { - // Custom styles for horizontal orientation - containerStyles.flexDirection = 'row-reverse'; - containerStyles.justifyContent = 'flex-end'; - containerStyles.alignItems = 'center'; - barStyles.transition = 'width 1s'; - barStyles.height = `${height}px`; - barStyles.width = `${barSize}px`; - barStyles.marginRight = '10px'; - - if (isBasic) { - // Basic styles - barStyles.background = `${colors.background}`; - barStyles.border = `1px solid ${colors.border}`; - barStyles.boxShadow = `0 0 4px ${colors.border}`; - } else { - // Gradient styles - barStyles.background = this.getBarGradient(maxSize); - } - } + const styles = getBasicAndGradientStyles(this.props); return ( -
-
+
+
{value.text}
-
+
); } @@ -197,7 +94,7 @@ export class BarGauge extends PureComponent { if (value === null || (positionValue !== null && positionValue > value.numeric)) { return { background: tinycolor(color) - .setAlpha(0.15) + .setAlpha(0.18) .toRgbString(), border: 'transparent', isLit: false, @@ -205,7 +102,7 @@ export class BarGauge extends PureComponent { } else { return { background: tinycolor(color) - .setAlpha(0.85) + .setAlpha(0.95) .toRgbString(), backgroundShade: tinycolor(color) .setAlpha(0.55) @@ -225,31 +122,39 @@ export class BarGauge extends PureComponent { } renderRetroBars(): ReactNode { - const { height, width, maxValue, minValue, value } = this.props; + const { maxValue, minValue, value, itemSpacing } = this.props; + const { + valueHeight, + valueWidth, + maxBarHeight, + maxBarWidth, + wrapperWidth, + wrapperHeight, + } = calculateBarAndValueDimensions(this.props); + const isVert = isVertical(this.props); const valueRange = maxValue - minValue; - const maxSize = this.size * BAR_SIZE_RATIO; - const cellSpacing = 5; + const maxSize = isVert ? maxBarHeight : maxBarWidth; + const cellSpacing = itemSpacing!; const cellCount = maxSize / 20; const cellSize = (maxSize - cellSpacing * cellCount) / cellCount; - const colors = this.getValueColors(); - const spaceForText = this.isVertical ? width : Math.min(this.size - maxSize, height); - const valueStyles = this.getValueStyles(value.text, colors.value, spaceForText); + const valueColor = getValueColor(this.props); + const valueStyles = getValueStyles(value.text, valueColor, valueWidth, valueHeight); const containerStyles: CSSProperties = { - width: `${width}px`, - height: `${height}px`, + width: `${wrapperWidth}px`, + height: `${wrapperHeight}px`, display: 'flex', }; - if (this.isVertical) { + if (isVert) { containerStyles.flexDirection = 'column-reverse'; containerStyles.alignItems = 'center'; - valueStyles.marginBottom = '20px'; + valueStyles.justifyContent = 'center'; } else { containerStyles.flexDirection = 'row'; containerStyles.alignItems = 'center'; - valueStyles.marginLeft = '20px'; + valueStyles.justifyContent = 'flex-end'; } const cells: JSX.Element[] = []; @@ -262,23 +167,22 @@ export class BarGauge extends PureComponent { }; if (cellColor.isLit) { - cellStyles.boxShadow = `0 0 4px ${cellColor.border}`; cellStyles.backgroundImage = `radial-gradient(${cellColor.background} 10%, ${cellColor.backgroundShade})`; } else { cellStyles.backgroundColor = cellColor.background; } - if (this.isVertical) { + if (isVert) { cellStyles.height = `${cellSize}px`; - cellStyles.width = `${width}px`; + cellStyles.width = `${maxBarWidth}px`; cellStyles.marginTop = `${cellSpacing}px`; } else { cellStyles.width = `${cellSize}px`; - cellStyles.height = `${height}px`; + cellStyles.height = `${maxBarHeight}px`; cellStyles.marginRight = `${cellSpacing}px`; } - cells.push(
); + cells.push(
); } return ( @@ -292,15 +196,326 @@ export class BarGauge extends PureComponent { } } -interface BarColors { - value: string; - background: string; - border: string; -} - interface CellColors { background: string; backgroundShade?: string; border: string; isLit?: boolean; } + +interface TitleDimensions { + fontSize: number; + placement: 'above' | 'left' | 'below'; + width: number; + height: number; +} + +function isVertical(props: Props) { + return props.orientation === VizOrientation.Vertical; +} + +function calculateTitleDimensions(props: Props): TitleDimensions { + const { title } = props.value; + const { height, width } = props; + + if (!title) { + return { fontSize: 0, width: 0, height: 0, placement: 'above' }; + } + + if (isVertical(props)) { + return { + fontSize: 14, + width: width, + height: 14 * LINE_HEIGHT, + placement: 'below', + }; + } + + // if height above 40 put text to above bar + if (height > 40) { + const maxTitleHeightRatio = 0.35; + const titleHeight = Math.max(Math.min(height * maxTitleHeightRatio, MAX_VALUE_HEIGHT), 17); + + return { + fontSize: titleHeight / LINE_HEIGHT, + width: 0, + height: titleHeight, + placement: 'above', + }; + } + + // title to left of bar scenario + const maxTitleHeightRatio = 0.6; + const maxTitleWidthRatio = 0.2; + const titleHeight = Math.max(height * maxTitleHeightRatio, MIN_VALUE_HEIGHT); + + return { + fontSize: titleHeight / LINE_HEIGHT, + height: 0, + width: Math.min(Math.max(width * maxTitleWidthRatio, 50), 200), + placement: 'left', + }; +} + +export function getTitleStyles(props: Props): { wrapper: CSSProperties; title: CSSProperties } { + const wrapperStyles: CSSProperties = { + display: 'flex', + overflow: 'hidden', + }; + + const titleDim = calculateTitleDimensions(props); + + const titleStyles: CSSProperties = { + fontSize: `${titleDim.fontSize}px`, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + width: '100%', + alignItems: 'center', + alignSelf: 'center', + }; + + if (isVertical(props)) { + wrapperStyles.flexDirection = 'column-reverse'; + titleStyles.textAlign = 'center'; + } else { + if (titleDim.placement === 'above') { + wrapperStyles.flexDirection = 'column'; + } else { + wrapperStyles.flexDirection = 'row'; + + titleStyles.width = `${titleDim.width}px`; + titleStyles.textAlign = 'right'; + titleStyles.marginRight = '10px'; + } + } + + return { + wrapper: wrapperStyles, + title: titleStyles, + }; +} + +interface BasicAndGradientStyles { + wrapper: CSSProperties; + bar: CSSProperties; + value: CSSProperties; +} + +interface BarAndValueDimensions { + valueWidth: number; + valueHeight: number; + maxBarWidth: number; + maxBarHeight: number; + wrapperHeight: number; + wrapperWidth: number; +} + +function calculateBarAndValueDimensions(props: Props): BarAndValueDimensions { + const { height, width } = props; + const titleDim = calculateTitleDimensions(props); + + let maxBarHeight = 0; + let maxBarWidth = 0; + let valueHeight = 0; + let valueWidth = 0; + let wrapperWidth = 0; + let wrapperHeight = 0; + + if (isVertical(props)) { + valueHeight = Math.min(Math.max(height * 0.1, MIN_VALUE_HEIGHT), MAX_VALUE_HEIGHT); + valueWidth = width; + maxBarHeight = height - (titleDim.height + valueHeight); + maxBarWidth = width; + wrapperWidth = width; + wrapperHeight = height - titleDim.height; + } else { + valueHeight = height - titleDim.height; + valueWidth = Math.max(Math.min(width * 0.2, MAX_VALUE_WIDTH), MIN_VALUE_WIDTH); + maxBarHeight = height - titleDim.height; + maxBarWidth = width - valueWidth - titleDim.width; + + if (titleDim.placement === 'above') { + wrapperWidth = width; + wrapperHeight = height - titleDim.height; + } else { + wrapperWidth = width - titleDim.width; + wrapperHeight = height; + } + } + + return { + valueWidth, + valueHeight, + maxBarWidth, + maxBarHeight, + wrapperHeight, + wrapperWidth, + }; +} + +/** + * Only exported to for unit test + */ +export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles { + const { displayMode, maxValue, minValue, value } = props; + const { valueWidth, valueHeight, maxBarHeight, maxBarWidth } = calculateBarAndValueDimensions(props); + + const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1); + const valueColor = getValueColor(props); + const valueStyles = getValueStyles(value.text, valueColor, valueWidth, valueHeight); + const isBasic = displayMode === 'basic'; + + const wrapperStyles: CSSProperties = { + display: 'flex', + }; + + const barStyles: CSSProperties = { + borderRadius: '3px', + }; + + if (isVertical(props)) { + const barHeight = Math.max(valuePercent * maxBarHeight, 1); + + // vertical styles + wrapperStyles.flexDirection = 'column'; + wrapperStyles.justifyContent = 'flex-end'; + + barStyles.transition = 'height 1s'; + barStyles.height = `${barHeight}px`; + barStyles.width = `${maxBarWidth}px`; + + // value styles centered + valueStyles.justifyContent = 'center'; + + if (isBasic) { + // Basic styles + barStyles.background = `${tinycolor(valueColor) + .setAlpha(0.25) + .toRgbString()}`; + barStyles.borderTop = `2px solid ${valueColor}`; + } else { + // Gradient styles + barStyles.background = getBarGradient(props, maxBarHeight); + } + } else { + const barWidth = Math.max(valuePercent * maxBarWidth, 1); + + // Custom styles for horizontal orientation + wrapperStyles.flexDirection = 'row-reverse'; + wrapperStyles.justifyContent = 'flex-end'; + wrapperStyles.alignItems = 'center'; + + barStyles.transition = 'width 1s'; + barStyles.height = `${maxBarHeight}px`; + barStyles.width = `${barWidth}px`; + barStyles.marginRight = '10px'; + + if (isBasic) { + // Basic styles + barStyles.background = `${tinycolor(valueColor) + .setAlpha(0.25) + .toRgbString()}`; + barStyles.borderRight = `2px solid ${valueColor}`; + } else { + // Gradient styles + barStyles.background = getBarGradient(props, maxBarWidth); + } + } + + return { + wrapper: wrapperStyles, + bar: barStyles, + value: valueStyles, + }; +} + +/** + * 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'; + + let gradient = ''; + let lastpos = 0; + + for (let i = 0; i < thresholds.length; i++) { + const threshold = thresholds[i]; + const color = getColorFromHexRgbOrName(threshold.color); + const valuePercent = Math.min(threshold.value / (maxValue - minValue), 1); + const pos = valuePercent * maxSize; + const offset = Math.round(pos - (pos - lastpos) / 2); + + if (gradient === '') { + gradient = `linear-gradient(${cssDirection}, ${color}, ${color}`; + } else if (value.numeric < threshold.value) { + break; + } else { + lastpos = pos; + gradient += ` ${offset}px, ${color}`; + } + } + + return gradient + ')'; +} + +/** + * Only exported to for unit test + */ +export function getValueColor(props: Props): string { + const { thresholds, theme, value } = props; + + const activeThreshold = getThresholdForValue(thresholds, value.numeric); + + if (activeThreshold !== null) { + return getColorFromHexRgbOrName(activeThreshold.color, theme.type); + } + + 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 / LINE_HEIGHT; + const guess = width / (value.length * 1.1); + const fontSize = Math.min(Math.max(guess, 14), heightFont); + + return { + color: color, + height: `${height}px`, + width: `${width}px`, + display: 'flex', + alignItems: 'center', + fontSize: fontSize.toFixed(2) + 'px', + }; +} + +// let canvasElement: HTMLCanvasElement | null = null; +// +// interface TextDimensions { +// width: number; +// height: number; +// } +// +// /** +// * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. +// * +// * @param {String} text The text to be rendered. +// * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana"). +// * +// * @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 +// */ +// function getTextWidth(text: string): number { +// // re-use canvas object for better performance +// canvasElement = canvasElement || document.createElement('canvas'); +// const context = canvasElement.getContext('2d'); +// if (context) { +// context.font = 'normal 16px Roboto'; +// const metrics = context.measureText(text); +// return metrics.width; +// } +// return 16; +// } 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 25a2e79b647..7a9fb4b468b 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 @@ -1,15 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Render BarGauge with basic options should render 1`] = ` +exports[`BarGauge Render with basic options should render 1`] = `
@@ -17,8 +15,12 @@ exports[`Render BarGauge with basic options should render 1`] = ` className="bar-gauge__value" style={ Object { - "color": "#7EB26D", - "fontSize": "27.27272727272727px", + "alignItems": "center", + "color": "#73BF69", + "display": "flex", + "fontSize": "27.27px", + "height": "300px", + "width": "60px", } } > @@ -27,10 +29,9 @@ exports[`Render BarGauge with basic options should render 1`] = `
{ - vizWidth: number; - vizHeight: number; - value: T; -} - interface Props { - children: (renderProps: RenderProps) => JSX.Element | JSX.Element[]; + renderValue: (value: T, width: number, height: number) => JSX.Element; height: number; width: number; - values: T[]; + source: any; // If this changes, new values will be requested + getValues: () => T[]; + renderCounter: number; // force update of values & render orientation: VizOrientation; + itemSpacing?: number; } -const SPACE_BETWEEN = 10; +interface DefaultProps { + itemSpacing: number; +} + +type PropsWithDefaults = Props & DefaultProps; + +interface State { + values: T[]; +} + +export class VizRepeater extends PureComponent, State> { + static defaultProps: DefaultProps = { + itemSpacing: 10, + }; + + constructor(props: Props) { + super(props); + + this.state = { + values: props.getValues(), + }; + } + + componentDidUpdate(prevProps: Props) { + const { renderCounter, source } = this.props; + if (renderCounter !== prevProps.renderCounter || source !== prevProps.source) { + this.setState({ values: this.props.getValues() }); + } + } -export class VizRepeater extends PureComponent> { getOrientation(): VizOrientation { const { orientation, width, height } = this.props; @@ -33,7 +57,8 @@ export class VizRepeater extends PureComponent> { } render() { - const { children, height, values, width } = this.props; + const { renderValue, height, width, itemSpacing } = this.props as PropsWithDefaults; + const { values } = this.state; const orientation = this.getOrientation(); const itemStyles: React.CSSProperties = { @@ -49,14 +74,14 @@ export class VizRepeater extends PureComponent> { if (orientation === VizOrientation.Horizontal) { repeaterStyle.flexDirection = 'column'; - itemStyles.margin = `${SPACE_BETWEEN / 2}px 0`; + itemStyles.margin = `${itemSpacing / 2}px 0`; vizWidth = width; - vizHeight = height / values.length - SPACE_BETWEEN; + vizHeight = height / values.length - itemSpacing; } else { repeaterStyle.flexDirection = 'row'; - itemStyles.margin = `0 ${SPACE_BETWEEN / 2}px`; + itemStyles.margin = `0 ${itemSpacing / 2}px`; vizHeight = height; - vizWidth = width / values.length - SPACE_BETWEEN; + vizWidth = width / values.length - itemSpacing; } itemStyles.width = `${vizWidth}px`; @@ -67,7 +92,7 @@ export class VizRepeater extends PureComponent> { {values.map((value, index) => { return (
- {children({ vizHeight, vizWidth, value })} + {renderValue(value, vizWidth, vizHeight)}
); })} diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx index ceaced93547..b76e391c870 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -2,12 +2,14 @@ import React, { PureComponent } from 'react'; // Services & Utils -import { DisplayValue, PanelProps, BarGauge, getSingleStatDisplayValues } from '@grafana/ui'; import { config } from 'app/core/config'; +// Components +import { BarGauge, VizRepeater, getSingleStatDisplayValues } from '@grafana/ui/src/components'; + // Types import { BarGaugeOptions } from './types'; -import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater'; +import { PanelProps, DisplayValue } from '@grafana/ui/src/types'; export class BarGaugePanel extends PureComponent> { renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => { @@ -21,12 +23,13 @@ export class BarGaugePanel extends PureComponent> { orientation={options.orientation} thresholds={options.thresholds} theme={config.theme} + itemSpacing={this.getItemSpacing()} displayMode={options.displayMode} /> ); }; - getProcessedValues = (): DisplayValue[] => { + getValues = (): DisplayValue[] => { return getSingleStatDisplayValues({ valueMappings: this.props.options.valueMappings, thresholds: this.props.options.thresholds, @@ -37,16 +40,25 @@ export class BarGaugePanel extends PureComponent> { }); }; + getItemSpacing(): number { + if (this.props.options.displayMode === 'lcd') { + return 2; + } + + return 10; + } + render() { const { height, width, options, data, renderCounter } = this.props; return ( - ); diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index 998fcdebcb8..344f0d21020 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -9,8 +9,7 @@ import { Gauge } from '@grafana/ui'; // Types import { GaugeOptions } from './types'; -import { DisplayValue, PanelProps, getSingleStatDisplayValues } from '@grafana/ui'; -import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater'; +import { DisplayValue, PanelProps, getSingleStatDisplayValues, VizRepeater } from '@grafana/ui'; export class GaugePanel extends PureComponent> { renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => { @@ -31,7 +30,7 @@ export class GaugePanel extends PureComponent> { ); }; - getProcessedValues = (): DisplayValue[] => { + getValues = (): DisplayValue[] => { return getSingleStatDisplayValues({ valueMappings: this.props.options.valueMappings, thresholds: this.props.options.thresholds, @@ -45,8 +44,8 @@ export class GaugePanel extends PureComponent> { render() { const { height, width, options, data, renderCounter } = this.props; return ( - { - width: number; - height: number; - orientation: VizOrientation; - source: any; // If this changes, the values will be processed - renderCounter: number; // change to force processing - - getProcessedValues: () => T[]; - renderValue: (value: T, width: number, height: number) => JSX.Element; -} - -interface State { - values: T[]; -} - -/** - * This is essentially a cache of processed values. This checks for changes - * to the source and then saves the processed values in the State - */ -export class ProcessedValuesRepeater extends PureComponent, State> { - constructor(props: Props) { - super(props); - this.state = { - values: props.getProcessedValues(), - }; - } - - componentDidUpdate(prevProps: Props) { - const { renderCounter, source } = this.props; - if (renderCounter !== prevProps.renderCounter || source !== prevProps.source) { - this.setState({ values: this.props.getProcessedValues() }); - } - } - - render() { - const { orientation, height, width, renderValue } = this.props; - const { values } = this.state; - - return ( - - {({ vizHeight, vizWidth, value }) => renderValue(value, vizWidth, vizHeight)} - - ); - } -} diff --git a/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx b/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx index de0d22da11d..40afb634e36 100644 --- a/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx +++ b/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx @@ -6,11 +6,11 @@ import { config } from 'app/core/config'; import { getFlotPairs } from '@grafana/ui/src/utils/flotPairs'; // Components -import { ProcessedValuesRepeater } from './ProcessedValuesRepeater'; +import { VizRepeater } from '@grafana/ui/src/components'; +import { BigValueSparkline, BigValue } from '@grafana/ui/src/components/BigValue/BigValue'; // Types import { SingleStatOptions } from './types'; -import { BigValueSparkline, BigValue } from '@grafana/ui/src/components/BigValue/BigValue'; import { DisplayValue, PanelProps, @@ -34,7 +34,7 @@ export class SingleStatPanel extends PureComponent return ; }; - getProcessedValues = (): SingleStatDisplay[] => { + getValues = (): SingleStatDisplay[] => { const { data, replaceVariables, options, timeRange } = this.props; const { valueOptions, valueMappings } = options; @@ -127,8 +127,8 @@ export class SingleStatPanel extends PureComponent render() { const { height, width, options, data, renderCounter } = this.props; return ( -