瀏覽代碼

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
Torkel Ödegaard 6 年之前
父節點
當前提交
566b3d178a

+ 62 - 41
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.add('Simple with basic thresholds', () => {
-  const {
-    value,
-    minValue,
-    maxValue,
-    threshold1Color,
-    threshold2Color,
-    threshold1Value,
-    threshold2Value,
-    unit,
-    decimals,
-    horizontal,
-    lcd,
-  } = 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 },
-    ],
+BarGaugeStories.addDecorator(withCenteredStory);
+
+function addBarGaugeStory(name: string, overrides: Partial<Props>) {
+  BarGaugeStories.add(name, () => {
+    const {
+      value,
+      title,
+      minValue,
+      maxValue,
+      threshold1Color,
+      threshold2Color,
+      threshold1Value,
+      threshold2Value,
+    } = getKnobs();
+
+    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,
 });

+ 95 - 24
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>): 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(<BarGauge {...props} />);
   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');
     });
+  });
 
-    expect(instance.getValueColors().value).toEqual('#EAB839');
+  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('Render BarGauge with basic options', () => {
-  it('should render', () => {
-    const { wrapper } = setup();
-    expect(wrapper).toMatchSnapshot();
+  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)');
+    });
+
+    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 with basic options', () => {
+    it('should render', () => {
+      const { wrapper } = setup();
+      expect(wrapper).toMatchSnapshot();
+    });
   });
 });

+ 372 - 157
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<Props> {
     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 (
+      <div style={styles.wrapper}>
+        <div style={styles.title}>{title}</div>
+        {this.renderBarAndValue()}
+      </div>
+    );
+  }
+
+  renderBarAndValue() {
     switch (this.props.displayMode) {
       case 'lcd':
         return this.renderRetroBars();
@@ -45,143 +68,17 @@ export class BarGauge extends PureComponent<Props> {
     }
   }
 
-  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 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 { value } = this.props;
 
-    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 (
-      <div style={containerStyles}>
-        <div className="bar-gauge__value" style={valueStyles}>
+      <div style={styles.wrapper}>
+        <div className="bar-gauge__value" style={styles.value}>
           {value.text}
         </div>
-        <div style={barStyles} />
+        <div style={styles.bar} />
       </div>
     );
   }
@@ -197,7 +94,7 @@ export class BarGauge extends PureComponent<Props> {
       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<Props> {
       } 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<Props> {
   }
 
   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<Props> {
       };
 
       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(<div style={cellStyles} />);
+      cells.push(<div key={i.toString()} style={cellStyles} />);
     }
 
     return (
@@ -292,15 +196,326 @@ export class BarGauge extends PureComponent<Props> {
   }
 }
 
-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;
+// }

+ 9 - 8
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`] = `
 <div
   style={
     Object {
       "alignItems": "center",
       "display": "flex",
       "flexDirection": "row-reverse",
-      "height": "300px",
       "justifyContent": "flex-end",
-      "width": "300px",
     }
   }
 >
@@ -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`] = `
   <div
     style={
       Object {
-        "background": "rgba(126, 178, 109, 0.15)",
-        "border": "1px solid #7EB26D",
+        "background": "rgba(115, 191, 105, 0.25)",
         "borderRadius": "3px",
-        "boxShadow": "0 0 4px #7EB26D",
+        "borderRight": "2px solid #73BF69",
         "height": "300px",
         "marginRight": "10px",
         "transition": "width 1s",

+ 22 - 18
packages/grafana-ui/src/components/SingleStatShared/shared.ts

@@ -31,7 +31,7 @@ export interface SingleStatValueOptions {
 }
 
 export interface GetSingleStatDisplayValueOptions {
-  data: SeriesData[];
+  data?: SeriesData[];
   theme: GrafanaTheme;
   valueMappings: ValueMapping[];
   thresholds: Threshold[];
@@ -55,24 +55,28 @@ export const getSingleStatDisplayValues = (options: GetSingleStatDisplayValueOpt
 
   const values: DisplayValue[] = [];
 
-  for (const series of data) {
-    if (stat === 'name') {
-      values.push(display(series.name));
-    }
+  if (data) {
+    for (const series of data) {
+      if (stat === 'name') {
+        values.push(display(series.name));
+      }
 
-    for (let i = 0; i < series.fields.length; i++) {
-      const column = series.fields[i];
-
-      // Show all fields that are not 'time'
-      if (column.type === FieldType.number) {
-        const stats = calculateStats({
-          series,
-          fieldIndex: i,
-          stats: [stat], // The stats to calculate
-          nullValueMode: NullValueMode.Null,
-        });
-        const displayValue = display(stats[stat]);
-        values.push(displayValue);
+      for (let i = 0; i < series.fields.length; i++) {
+        const column = series.fields[i];
+
+        // Show all fields that are not 'time'
+        if (column.type === FieldType.number) {
+          const stats = calculateStats({
+            series,
+            fieldIndex: i,
+            stats: [stat], // The stats to calculate
+            nullValueMode: NullValueMode.Null,
+          });
+
+          const displayValue = display(stats[stat]);
+          displayValue.title = series.name;
+          values.push(displayValue);
+        }
       }
     }
   }

+ 41 - 16
packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx

@@ -1,23 +1,47 @@
 import React, { PureComponent } from 'react';
 import { VizOrientation } from '../../types';
 
-interface RenderProps<T> {
-  vizWidth: number;
-  vizHeight: number;
-  value: T;
-}
-
 interface Props<T> {
-  children: (renderProps: RenderProps<T>) => 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;
+}
+
+interface DefaultProps {
+  itemSpacing: number;
+}
+
+type PropsWithDefaults<T> = Props<T> & DefaultProps;
+
+interface State<T> {
+  values: T[];
 }
 
-const SPACE_BETWEEN = 10;
+export class VizRepeater<T> extends PureComponent<Props<T>, State<T>> {
+  static defaultProps: DefaultProps = {
+    itemSpacing: 10,
+  };
+
+  constructor(props: Props<T>) {
+    super(props);
+
+    this.state = {
+      values: props.getValues(),
+    };
+  }
+
+  componentDidUpdate(prevProps: Props<T>) {
+    const { renderCounter, source } = this.props;
+    if (renderCounter !== prevProps.renderCounter || source !== prevProps.source) {
+      this.setState({ values: this.props.getValues() });
+    }
+  }
 
-export class VizRepeater<T> extends PureComponent<Props<T>> {
   getOrientation(): VizOrientation {
     const { orientation, width, height } = this.props;
 
@@ -33,7 +57,8 @@ export class VizRepeater<T> extends PureComponent<Props<T>> {
   }
 
   render() {
-    const { children, height, values, width } = this.props;
+    const { renderValue, height, width, itemSpacing } = this.props as PropsWithDefaults<T>;
+    const { values } = this.state;
     const orientation = this.getOrientation();
 
     const itemStyles: React.CSSProperties = {
@@ -49,14 +74,14 @@ export class VizRepeater<T> extends PureComponent<Props<T>> {
 
     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<T> extends PureComponent<Props<T>> {
         {values.map((value, index) => {
           return (
             <div key={index} style={itemStyles}>
-              {children({ vizHeight, vizWidth, value })}
+              {renderValue(value, vizWidth, vizHeight)}
             </div>
           );
         })}

+ 19 - 7
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<PanelProps<BarGaugeOptions>> {
   renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
@@ -21,12 +23,13 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
         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<PanelProps<BarGaugeOptions>> {
     });
   };
 
+  getItemSpacing(): number {
+    if (this.props.options.displayMode === 'lcd') {
+      return 2;
+    }
+
+    return 10;
+  }
+
   render() {
     const { height, width, options, data, renderCounter } = this.props;
     return (
-      <ProcessedValuesRepeater
-        getProcessedValues={this.getProcessedValues}
+      <VizRepeater
+        source={data}
+        getValues={this.getValues}
         renderValue={this.renderValue}
+        renderCounter={renderCounter}
         width={width}
         height={height}
-        source={data}
-        renderCounter={renderCounter}
+        itemSpacing={this.getItemSpacing()}
         orientation={options.orientation}
       />
     );

+ 4 - 5
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<PanelProps<GaugeOptions>> {
   renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
@@ -31,7 +30,7 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
     );
   };
 
-  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<PanelProps<GaugeOptions>> {
   render() {
     const { height, width, options, data, renderCounter } = this.props;
     return (
-      <ProcessedValuesRepeater
-        getProcessedValues={this.getProcessedValues}
+      <VizRepeater
+        getValues={this.getValues}
         renderValue={this.renderValue}
         width={width}
         height={height}

+ 0 - 49
public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx

@@ -1,49 +0,0 @@
-import React, { PureComponent } from 'react';
-import { VizOrientation } from '@grafana/ui';
-import { VizRepeater } from '@grafana/ui';
-
-export interface Props<T> {
-  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<T> {
-  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<T> extends PureComponent<Props<T>, State<T>> {
-  constructor(props: Props<T>) {
-    super(props);
-    this.state = {
-      values: props.getProcessedValues(),
-    };
-  }
-
-  componentDidUpdate(prevProps: Props<T>) {
-    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 (
-      <VizRepeater height={height} width={width} values={values} orientation={orientation}>
-        {({ vizHeight, vizWidth, value }) => renderValue(value, vizWidth, vizHeight)}
-      </VizRepeater>
-    );
-  }
-}

+ 5 - 5
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<PanelProps<SingleStatOptions>
     return <BigValue {...value} width={width} height={height} theme={config.theme} />;
   };
 
-  getProcessedValues = (): SingleStatDisplay[] => {
+  getValues = (): SingleStatDisplay[] => {
     const { data, replaceVariables, options, timeRange } = this.props;
     const { valueOptions, valueMappings } = options;
 
@@ -127,8 +127,8 @@ export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>
   render() {
     const { height, width, options, data, renderCounter } = this.props;
     return (
-      <ProcessedValuesRepeater
-        getProcessedValues={this.getProcessedValues}
+      <VizRepeater
+        getValues={this.getValues}
         renderValue={this.renderValue}
         width={width}
         height={height}