Panels: Progress on new singlestat / BigValue (#19374)

* POC: friday hack

* exploring new singlestat styles

* minor changes

* Testing bizcharts

* style tweaks

* Updated

* minor progress

* updated

* Updated layout handling

* Updated editor

* added editor options

* adding mode

* progress on new display mode

* tweaks

* Added classic style

* Added final mode

* Minor tweak

* tweaks

* minor tweak

* Singlestat: Adjust colors for light theme

* fixed build issues with bizcharts

* fixed typescript issue

* updated snapshot

* Added demo dashboard
This commit is contained in:
Torkel Ödegaard
2019-10-04 12:01:42 +02:00
committed by GitHub
parent 45e0ebcc57
commit 81dd57524d
21 changed files with 1377 additions and 356 deletions

View File

@@ -29,6 +29,7 @@
"@grafana/slate-react": "0.22.9-grafana",
"@torkelo/react-select": "2.1.1",
"@types/react-color": "2.17.0",
"bizcharts": "^3.5.5",
"@types/slate": "0.47.1",
"@types/slate-react": "0.22.5",
"classnames": "2.2.6",

View File

@@ -57,6 +57,7 @@ const buildCjsPackage = ({ env }) => {
],
'node_modules/immutable/dist/immutable.js': ['Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack'],
'../../node_modules/esrever/esrever.js': ['reverse'],
'../../node_modules/bizcharts/es6/index.js': ['Chart', 'Geom', 'View', 'Tooltip', 'Legend'],
},
}),
resolve(),

View File

@@ -1,14 +1,13 @@
import { storiesOf } from '@storybook/react';
import { number, text } from '@storybook/addon-knobs';
import { BigValue } from './BigValue';
import { text } from '@storybook/addon-knobs';
import { BigValue, SingleStatDisplayMode } from './BigValue';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
const getKnobs = () => {
return {
value: text('value', 'Hello'),
valueFontSize: number('valueFontSize', 120),
prefix: text('prefix', ''),
value: text('value', '$5022'),
title: text('title', 'Total Earnings'),
};
};
@@ -16,22 +15,37 @@ const BigValueStories = storiesOf('UI/BigValue', module);
BigValueStories.addDecorator(withCenteredStory);
BigValueStories.add('Singlestat viz', () => {
const { value, prefix, valueFontSize } = getKnobs();
interface StoryOptions {
mode: SingleStatDisplayMode;
width?: number;
height?: number;
noSparkline?: boolean;
}
return renderComponentWithTheme(BigValue, {
width: 300,
height: 250,
value: {
text: value,
numeric: NaN,
fontSize: valueFontSize + '%',
},
prefix: prefix
? {
text: prefix,
numeric: NaN,
}
: null,
function addStoryForMode(options: StoryOptions) {
BigValueStories.add(`Mode: ${SingleStatDisplayMode[options.mode]}`, () => {
const { value, title } = getKnobs();
return renderComponentWithTheme(BigValue, {
width: options.width || 400,
height: options.height || 300,
displayMode: options.mode,
value: {
text: value,
numeric: 5022,
color: 'red',
title,
},
sparkline: {
minX: 0,
maxX: 5,
data: [[0, 10], [1, 20], [2, 15], [3, 25], [4, 5], [5, 10]],
},
});
});
});
}
addStoryForMode({ mode: SingleStatDisplayMode.Classic });
addStoryForMode({ mode: SingleStatDisplayMode.Classic2 });
addStoryForMode({ mode: SingleStatDisplayMode.Vibrant });
addStoryForMode({ mode: SingleStatDisplayMode.Vibrant2 });

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
import { BigValue, Props } from './BigValue';
import { BigValue, Props, SingleStatDisplayMode } from './BigValue';
import { getTheme } from '../../themes/index';
jest.mock('jquery', () => ({
@@ -11,6 +11,7 @@ const setup = (propOverrides?: object) => {
const props: Props = {
height: 300,
width: 300,
displayMode: SingleStatDisplayMode.Classic,
value: {
text: '25',
numeric: 25,
@@ -29,7 +30,7 @@ const setup = (propOverrides?: object) => {
};
};
describe('Render BarGauge with basic options', () => {
describe('Render SingleStat with basic options', () => {
it('should render', () => {
const { wrapper } = setup();
expect(wrapper).toBeDefined();

View File

@@ -1,168 +1,413 @@
// Library
import React, { PureComponent, ReactNode, CSSProperties } from 'react';
import $ from 'jquery';
import { css, cx } from 'emotion';
import React, { PureComponent, CSSProperties } from 'react';
import tinycolor from 'tinycolor2';
import { Chart, Geom } from 'bizcharts';
import { DisplayValue } from '@grafana/data';
// Utils
import { getColorFromHexRgbOrName } from '../../utils';
// Types
import { Themeable } from '../../types';
import { stylesFactory } from '../../themes/stylesFactory';
import { Themeable, GrafanaTheme } from '../../types';
export interface BigValueSparkline {
data: any[][]; // [[number,number]]
data: any[][];
minX: number;
maxX: number;
full: boolean; // full height
fillColor: string;
lineColor: string;
}
export enum SingleStatDisplayMode {
Classic,
Classic2,
Vibrant,
Vibrant2,
}
export interface Props extends Themeable {
height: number;
width: number;
value: DisplayValue;
prefix?: DisplayValue;
suffix?: DisplayValue;
sparkline?: BigValueSparkline;
backgroundColor?: string;
onClick?: React.MouseEventHandler<HTMLElement>;
className?: string;
displayMode: SingleStatDisplayMode;
}
const getStyles = stylesFactory(() => {
return {
wrapper: css`
position: 'relative';
display: 'table';
`,
title: css`
line-height: 1;
text-align: 'center';
z-index: 1;
display: 'block';
width: '100%';
position: 'absolute';
`,
value: css`
line-height: 1;
text-align: 'center';
z-index: 1;
display: 'table-cell';
vertical-align: 'middle';
position: 'relative';
font-size: '3em';
font-weight: 500;
`,
};
});
/*
* This visualization is still in POC state, needed more tests & better structure
*/
export class BigValue extends PureComponent<Props> {
canvasElement: any;
componentDidMount() {
this.draw();
}
componentDidUpdate() {
this.draw();
}
draw() {
const { sparkline, theme } = this.props;
if (sparkline && this.canvasElement) {
const { data, minX, maxX, fillColor, lineColor } = sparkline;
const options = {
legend: { show: false },
series: {
lines: {
show: true,
fill: 1,
zero: false,
lineWidth: 1,
fillColor: getColorFromHexRgbOrName(fillColor, theme.type),
},
},
yaxes: { show: false },
xaxis: {
show: false,
min: minX,
max: maxX,
},
grid: { hoverable: false, show: false },
};
const plotSeries = {
data,
color: getColorFromHexRgbOrName(lineColor, theme.type),
};
try {
$.plot(this.canvasElement, [plotSeries], options);
} catch (err) {
console.log('sparkline rendering error', err, options);
}
}
}
renderText = (value?: DisplayValue, padding?: string): ReactNode => {
if (!value || !value.text) {
return null;
}
const css: CSSProperties = {};
if (padding) {
css.padding = padding;
}
if (value.color) {
css.color = value.color;
}
if (value.fontSize) {
css.fontSize = value.fontSize;
}
return <span style={css}>{value.text}</span>;
};
renderSparkline(sparkline: BigValueSparkline) {
const { height, width } = this.props;
const plotCss: CSSProperties = {};
plotCss.position = 'absolute';
plotCss.bottom = '0px';
plotCss.left = '0px';
plotCss.width = width + 'px';
if (sparkline.full) {
const dynamicHeightMargin = height <= 100 ? 5 : Math.round(height / 100) * 15 + 5;
plotCss.height = height - dynamicHeightMargin + 'px';
} else {
plotCss.height = Math.floor(height * 0.25) + 'px';
}
return <div style={plotCss} ref={element => (this.canvasElement = element)} />;
}
render() {
const { height, width, value, prefix, suffix, sparkline, backgroundColor, onClick, className } = this.props;
const styles = getStyles();
const { value, onClick, className, sparkline } = this.props;
const layout = calculateLayout(this.props);
const panelStyles = getPanelStyles(layout);
const valueAndTitleContainerStyles = getValueAndTitleContainerStyles(layout);
const valueStyles = getValueStyles(layout);
const titleStyles = getTitleStyles(layout);
return (
<div className={cx(styles.wrapper, className)} style={{ width, height, backgroundColor }} onClick={onClick}>
{value.title && <div className={styles.title}>{value.title}</div>}
<span className={styles.value}>
{this.renderText(prefix, '0px 2px 0px 0px')}
{this.renderText(value)}
{this.renderText(suffix)}
</span>
{sparkline && this.renderSparkline(sparkline)}
<div className={className} style={panelStyles} onClick={onClick}>
<div style={valueAndTitleContainerStyles}>
{value.title && <div style={titleStyles}>{value.title}</div>}
<div style={valueStyles}>{value.text}</div>
</div>
{renderGraph(layout, sparkline)}
</div>
);
}
}
const MIN_VALUE_FONT_SIZE = 20;
const MAX_VALUE_FONT_SIZE = 50;
const MIN_TITLE_FONT_SIZE = 14;
const TITLE_VALUE_RATIO = 0.45;
const VALUE_HEIGHT_RATIO = 0.25;
const VALUE_HEIGHT_RATIO_WIDE = 0.3;
const LINE_HEIGHT = 1.2;
const PANEL_PADDING = 16;
const CHART_TOP_MARGIN = 8;
interface LayoutResult {
titleFontSize: number;
valueFontSize: number;
chartHeight: number;
chartWidth: number;
type: LayoutType;
width: number;
height: number;
displayMode: SingleStatDisplayMode;
theme: GrafanaTheme;
valueColor: string;
}
enum LayoutType {
Stacked,
StackedNoChart,
Wide,
WideNoChart,
}
export function calculateLayout(props: Props): LayoutResult {
const { width, height, sparkline, displayMode, theme, value } = props;
const useWideLayout = width / height > 2.8;
const valueColor = getColorFromHexRgbOrName(value.color || 'green', theme.type);
// handle wide layouts
if (useWideLayout) {
const valueFontSize = Math.min(
Math.max(height * VALUE_HEIGHT_RATIO_WIDE, MIN_VALUE_FONT_SIZE),
MAX_VALUE_FONT_SIZE
);
const titleFontSize = Math.max(valueFontSize * TITLE_VALUE_RATIO, MIN_TITLE_FONT_SIZE);
const chartHeight = height - PANEL_PADDING * 2;
const chartWidth = width / 2;
let type = !!sparkline ? LayoutType.Wide : LayoutType.WideNoChart;
if (height < 80 || !sparkline) {
type = LayoutType.WideNoChart;
}
return {
valueFontSize,
titleFontSize,
chartHeight,
chartWidth,
type,
width,
height,
displayMode,
theme,
valueColor,
};
}
// handle stacked layouts
const valueFontSize = Math.min(Math.max(height * VALUE_HEIGHT_RATIO, MIN_VALUE_FONT_SIZE), MAX_VALUE_FONT_SIZE);
const titleFontSize = Math.max(valueFontSize * TITLE_VALUE_RATIO, MIN_TITLE_FONT_SIZE);
const valueHeight = valueFontSize * LINE_HEIGHT;
const titleHeight = titleFontSize * LINE_HEIGHT;
let chartHeight = height - valueHeight - titleHeight - PANEL_PADDING * 2 - CHART_TOP_MARGIN;
let chartWidth = width - PANEL_PADDING * 2;
let type = LayoutType.Stacked;
if (height < 100 || !sparkline) {
type = LayoutType.StackedNoChart;
}
switch (displayMode) {
case SingleStatDisplayMode.Vibrant2:
case SingleStatDisplayMode.Classic:
case SingleStatDisplayMode.Classic2:
chartWidth = width;
chartHeight += PANEL_PADDING;
break;
}
return {
valueFontSize,
titleFontSize,
chartHeight,
chartWidth,
type,
width,
height,
displayMode,
theme,
valueColor,
};
}
export function getTitleStyles(layout: LayoutResult) {
const styles: CSSProperties = {
fontSize: `${layout.titleFontSize}px`,
textShadow: '#333 1px 1px 5px',
color: '#EEE',
};
if (layout.theme.isLight) {
styles.color = 'white';
}
return styles;
}
export function getValueStyles(layout: LayoutResult) {
const styles: CSSProperties = {
fontSize: `${layout.valueFontSize}px`,
color: '#EEE',
textShadow: '#333 1px 1px 5px',
lineHeight: LINE_HEIGHT,
};
switch (layout.displayMode) {
case SingleStatDisplayMode.Classic:
case SingleStatDisplayMode.Classic2:
styles.color = layout.valueColor;
}
return styles;
}
export function getValueAndTitleContainerStyles(layout: LayoutResult): CSSProperties {
switch (layout.type) {
case LayoutType.Wide:
return {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
};
case LayoutType.WideNoChart:
return {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexGrow: 1,
};
case LayoutType.StackedNoChart:
return {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
};
case LayoutType.Stacked:
default:
return {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
};
}
}
export function getPanelStyles(layout: LayoutResult) {
const panelStyles: CSSProperties = {
width: `${layout.width}px`,
height: `${layout.height}px`,
padding: `${PANEL_PADDING}px`,
borderRadius: '3px',
position: 'relative',
display: 'flex',
};
const themeFactor = layout.theme.isDark ? 1 : -0.7;
switch (layout.displayMode) {
case SingleStatDisplayMode.Vibrant:
case SingleStatDisplayMode.Vibrant2:
const bgColor2 = tinycolor(layout.valueColor)
.darken(15 * themeFactor)
.spin(8)
.toRgbString();
const bgColor3 = tinycolor(layout.valueColor)
.darken(5 * themeFactor)
.spin(-8)
.toRgbString();
panelStyles.background = `linear-gradient(120deg, ${bgColor2}, ${bgColor3})`;
break;
case SingleStatDisplayMode.Classic:
case SingleStatDisplayMode.Classic2:
panelStyles.background = `${layout.theme.colors.dark4}`;
break;
}
switch (layout.type) {
case LayoutType.Stacked:
panelStyles.flexDirection = 'column';
break;
case LayoutType.StackedNoChart:
panelStyles.alignItems = 'center';
break;
case LayoutType.Wide:
panelStyles.flexDirection = 'row';
panelStyles.alignItems = 'center';
panelStyles.justifyContent = 'space-between';
break;
case LayoutType.WideNoChart:
panelStyles.alignItems = 'center';
break;
}
return panelStyles;
}
function renderGraph(layout: LayoutResult, sparkline?: BigValueSparkline) {
if (!sparkline) {
return null;
}
const data = sparkline.data.map(values => {
return { time: values[0], value: values[1], name: 'A' };
});
const scales = {
time: {
type: 'time',
},
};
const chartStyles: CSSProperties = {
marginTop: `${CHART_TOP_MARGIN}`,
};
// default to line graph
let geomRender = renderLineGeom;
switch (layout.type) {
case LayoutType.Wide:
chartStyles.width = `${layout.chartWidth}px`;
chartStyles.height = `${layout.chartHeight}px`;
break;
case LayoutType.Stacked:
chartStyles.position = 'relative';
chartStyles.top = '8px';
break;
case LayoutType.WideNoChart:
case LayoutType.StackedNoChart:
return null;
}
if (layout.chartWidth === layout.width) {
chartStyles.position = 'absolute';
chartStyles.bottom = 0;
chartStyles.right = 0;
chartStyles.left = 0;
chartStyles.right = 0;
chartStyles.top = 'unset';
}
switch (layout.displayMode) {
case SingleStatDisplayMode.Vibrant2:
geomRender = renderVibrant2Geom;
break;
case SingleStatDisplayMode.Classic:
geomRender = renderClassicAreaGeom;
break;
case SingleStatDisplayMode.Classic2:
geomRender = renderAreaGeom;
break;
}
return (
<Chart
height={layout.chartHeight}
width={layout.chartWidth}
data={data}
animate={false}
padding={[4, 0, 0, 0]}
scale={scales}
style={chartStyles}
>
{geomRender(layout)}
</Chart>
);
}
function renderLineGeom(layout: LayoutResult) {
const lineStyle: any = {
stroke: '#CCC',
lineWidth: 2,
shadowBlur: 15,
shadowColor: '#444',
shadowOffsetY: 7,
};
return <Geom type="line" position="time*value" size={2} color="white" style={lineStyle} shape="smooth" />;
}
function renderVibrant2Geom(layout: LayoutResult) {
const lineStyle: any = {
stroke: '#CCC',
lineWidth: 2,
shadowBlur: 15,
shadowColor: '#444',
shadowOffsetY: -5,
};
return (
<>
<Geom type="area" position="time*value" size={0} color="rgba(255,255,255,0.4)" style={lineStyle} shape="smooth" />
<Geom type="line" position="time*value" size={1} color="white" style={lineStyle} shape="smooth" />
</>
);
}
function renderClassicAreaGeom(layout: LayoutResult) {
const lineStyle: any = {
opacity: 1,
fillOpacity: 1,
};
const fillColor = tinycolor(layout.valueColor)
.setAlpha(0.2)
.toRgbString();
lineStyle.stroke = layout.valueColor;
return (
<>
<Geom type="area" position="time*value" size={0} color={fillColor} style={lineStyle} shape="smooth" />
<Geom type="line" position="time*value" size={1} color={layout.valueColor} style={lineStyle} shape="smooth" />
</>
);
}
function renderAreaGeom(layout: LayoutResult) {
const lineStyle: any = {
opacity: 1,
fillOpacity: 1,
};
const color1 = tinycolor(layout.valueColor)
.darken(0)
.spin(20)
.toRgbString();
const color2 = tinycolor(layout.valueColor)
.lighten(0)
.spin(-20)
.toRgbString();
const fillColor = `l (0) 0:${color1} 1:${color2}`;
return <Geom type="area" position="time*value" size={0} color={fillColor} style={lineStyle} shape="smooth" />;
}

View File

@@ -164,6 +164,8 @@ exports[`Render should render with base threshold 1`] = `
"md": "32px",
"sm": "24px",
},
"isDark": true,
"isLight": false,
"name": "Grafana Dark",
"panelHeaderHeight": 28,
"panelPadding": 8,
@@ -331,6 +333,8 @@ exports[`Render should render with base threshold 1`] = `
"md": "32px",
"sm": "24px",
},
"isDark": true,
"isLight": false,
"name": "Grafana Dark",
"panelHeaderHeight": 28,
"panelPadding": 8,

View File

@@ -46,7 +46,7 @@ export { Table } from './Table/Table';
export { TableInputCSV } from './Table/TableInputCSV';
// Visualizations
export { BigValue } from './BigValue/BigValue';
export { BigValue, SingleStatDisplayMode } from './BigValue/BigValue';
export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph';
export { GraphLegend } from './Graph/GraphLegend';

View File

@@ -42,6 +42,8 @@ const basicColors = {
const darkTheme: GrafanaTheme = {
...defaultTheme,
type: GrafanaThemeType.Dark,
isDark: true,
isLight: false,
name: 'Grafana Dark',
colors: {
...basicColors,

View File

@@ -42,6 +42,8 @@ const basicColors = {
const lightTheme: GrafanaTheme = {
...defaultTheme,
type: GrafanaThemeType.Light,
isDark: false,
isLight: true,
name: 'Grafana Light',
colors: {
...basicColors,

View File

@@ -92,6 +92,8 @@ export interface GrafanaThemeCommons {
export interface GrafanaTheme extends GrafanaThemeCommons {
type: GrafanaThemeType;
isDark: boolean;
isLight: boolean;
background: {
dropdown: string;
scrollbar: string;