From 26b168f7eb3a4fa1cb04e5a1220a81b022bc6596 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 29 Jan 2021 12:52:52 -0800 Subject: [PATCH] BarChart: add alpha bar chart panel (#30323) --- packages/grafana-data/src/types/options.ts | 4 +- .../components/BarChart/BarChart.story.tsx | 65 ++++ .../src/components/BarChart/BarChart.tsx | 322 ++++++++++++++++++ .../src/components/BarChart/bars.ts | 287 ++++++++++++++++ .../src/components/BarChart/distribute.ts | 49 +++ .../src/components/BarChart/quadtree.ts | 113 ++++++ .../src/components/BarChart/types.ts | 52 +++ .../src/components/GraphNG/GraphNG.tsx | 37 +- .../src/components/Sparkline/Sparkline.tsx | 19 +- .../src/components/VizLegend/VizLegend.tsx | 3 + .../components/VizLegend/VizLegendList.tsx | 3 + .../VizLegend/VizLegendListItem.tsx | 3 + .../VizLegend/VizLegendSeriesIcon.tsx | 3 + .../VizLegend/VizLegendStatsList.tsx | 3 + .../components/VizLegend/VizLegendTable.tsx | 3 + .../VizLegend/VizLegendTableItem.tsx | 3 + packages/grafana-ui/src/components/index.ts | 2 + .../grafana-ui/src/components/uPlot/config.ts | 30 +- .../uPlot/config/UPlotAxisBuilder.ts | 10 +- .../uPlot/config/UPlotConfigBuilder.test.ts | 50 ++- .../uPlot/config/UPlotConfigBuilder.ts | 30 +- .../uPlot/config/UPlotScaleBuilder.ts | 29 +- .../uPlot/config/UPlotSeriesBuilder.ts | 38 ++- .../grafana-ui/src/components/uPlot/types.ts | 3 +- .../PanelEditor/DefaultFieldConfigEditor.tsx | 2 +- .../PanelEditor/PanelOptionsEditor.tsx | 2 +- .../app/features/plugins/built_in_plugins.ts | 2 + .../plugins/panel/barchart/BarChartPanel.tsx | 103 ++++++ .../plugins/panel/barchart/img/barchart.svg | 1 + public/app/plugins/panel/barchart/module.tsx | 150 ++++++++ public/app/plugins/panel/barchart/plugin.json | 18 + public/app/plugins/panel/stat/types.ts | 9 +- public/app/plugins/panel/timeseries/config.ts | 168 +++++---- 33 files changed, 1495 insertions(+), 121 deletions(-) create mode 100644 packages/grafana-ui/src/components/BarChart/BarChart.story.tsx create mode 100644 packages/grafana-ui/src/components/BarChart/BarChart.tsx create mode 100644 packages/grafana-ui/src/components/BarChart/bars.ts create mode 100644 packages/grafana-ui/src/components/BarChart/distribute.ts create mode 100644 packages/grafana-ui/src/components/BarChart/quadtree.ts create mode 100644 packages/grafana-ui/src/components/BarChart/types.ts create mode 100755 public/app/plugins/panel/barchart/BarChartPanel.tsx create mode 100644 public/app/plugins/panel/barchart/img/barchart.svg create mode 100755 public/app/plugins/panel/barchart/module.tsx create mode 100755 public/app/plugins/panel/barchart/plugin.json diff --git a/packages/grafana-data/src/types/options.ts b/packages/grafana-data/src/types/options.ts index 13ec9a43ca0..a3ea2bbc96d 100644 --- a/packages/grafana-data/src/types/options.ts +++ b/packages/grafana-data/src/types/options.ts @@ -1,3 +1,5 @@ +import { DataFrame } from './dataFrame'; + /** * Base class for editor builders * @@ -49,5 +51,5 @@ export interface OptionEditorConfig { /** * Function that enables configuration of when option editor should be shown based on current panel option properties. */ - showIf?: (currentOptions: TOptions) => boolean | undefined; + showIf?: (currentOptions: TOptions, data?: DataFrame[]) => boolean | undefined; } diff --git a/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx b/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx new file mode 100644 index 00000000000..d200941b864 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/BarChart.story.tsx @@ -0,0 +1,65 @@ +import { toDataFrame, FieldType, VizOrientation } from '@grafana/data'; +import React from 'react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { BarChart } from './BarChart'; +import { LegendDisplayMode } from '../VizLegend/types'; +import { prepDataForStorybook } from '../../utils/storybook/data'; +import { useTheme } from '../../themes'; +import { select } from '@storybook/addon-knobs'; +import { BarChartOptions, BarStackingMode, BarValueVisibility } from './types'; + +export default { + title: 'Visualizations/BarChart', + component: BarChart, + decorators: [withCenteredStory], + parameters: { + docs: {}, + }, +}; + +const getKnobs = () => { + return { + legendPlacement: select( + 'Legend placement', + { + bottom: 'bottom', + right: 'right', + }, + 'bottom' + ), + orientation: select( + 'Bar orientation', + { + vertical: VizOrientation.Vertical, + horizontal: VizOrientation.Horizontal, + }, + VizOrientation.Vertical + ), + }; +}; + +export const Basic: React.FC = () => { + const { legendPlacement, orientation } = getKnobs(); + + const theme = useTheme(); + const frame = toDataFrame({ + fields: [ + { name: 'x', type: FieldType.string, values: ['group 1', 'group 2'] }, + { name: 'a', type: FieldType.number, values: [10, 20] }, + { name: 'b', type: FieldType.number, values: [30, 10] }, + ], + }); + + const data = prepDataForStorybook([frame], theme); + + const options: BarChartOptions = { + orientation: orientation, + legend: { displayMode: LegendDisplayMode.List, placement: legendPlacement, calcs: [] }, + stacking: BarStackingMode.None, + showValue: BarValueVisibility.Always, + barWidth: 0.97, + groupWidth: 0.7, + }; + + return ; +}; diff --git a/packages/grafana-ui/src/components/BarChart/BarChart.tsx b/packages/grafana-ui/src/components/BarChart/BarChart.tsx new file mode 100644 index 00000000000..21be6bafe34 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/BarChart.tsx @@ -0,0 +1,322 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import { + compareDataFrameStructures, + DataFrame, + DefaultTimeZone, + formattedValueToString, + getFieldDisplayName, + getFieldSeriesColor, + getFieldColorModeForField, + TimeRange, + VizOrientation, + fieldReducers, + reduceField, + DisplayValue, +} from '@grafana/data'; + +import { VizLayout } from '../VizLayout/VizLayout'; +import { Themeable } from '../../types'; +import { useRevision } from '../uPlot/hooks'; +import { UPlotChart } from '../uPlot/Plot'; +import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; +import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config'; +import { useTheme } from '../../themes'; +import { GraphNGLegendEvent, GraphNGLegendEventMode } from '../GraphNG/types'; +import { FIXED_UNIT } from '../GraphNG/GraphNG'; +import { LegendDisplayMode, VizLegendItem } from '../VizLegend/types'; +import { VizLegend } from '../VizLegend/VizLegend'; + +import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types'; +import { BarsOptions, getConfig } from './bars'; + +/** + * @alpha + */ +export interface Props extends Themeable, BarChartOptions { + height: number; + width: number; + data: DataFrame; + onLegendClick?: (event: GraphNGLegendEvent) => void; + onSeriesColorChange?: (label: string, color: string) => void; +} + +/** + * @alpha + */ +export const BarChart: React.FunctionComponent = ({ + width, + height, + data, + orientation, + groupWidth, + barWidth, + showValue, + legend, + onLegendClick, + onSeriesColorChange, + ...plotProps +}) => { + if (!data || data.fields.length < 2) { + return
Missing data
; + } + + // dominik? TODO? can this all be moved into `useRevision` + const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => { + if (a && b) { + return compareDataFrameStructures(a, b); + } + return false; + }, []); + + const configRev = useRevision(data, compareFrames); + + const theme = useTheme(); + + // Updates only when the structure changes + const configBuilder = useMemo(() => { + if (!orientation || orientation === VizOrientation.Auto) { + orientation = width < height ? VizOrientation.Horizontal : VizOrientation.Vertical; + } + + // bar orientation -> x scale orientation & direction + let xOri: ScaleOrientation, xDir: ScaleDirection, yOri: ScaleOrientation, yDir: ScaleDirection; + + if (orientation === VizOrientation.Vertical) { + xOri = ScaleOrientation.Horizontal; + xDir = ScaleDirection.Right; + yOri = ScaleOrientation.Vertical; + yDir = ScaleDirection.Up; + } else { + xOri = ScaleOrientation.Vertical; + xDir = ScaleDirection.Down; + yOri = ScaleOrientation.Horizontal; + yDir = ScaleDirection.Right; + } + + const formatValue = + showValue !== BarValueVisibility.Never + ? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value)) + : undefined; + + // Use bar width when only one field + if (data.fields.length === 2) { + groupWidth = barWidth; + barWidth = 1; + } + + const opts: BarsOptions = { + xOri, + xDir, + groupWidth, + barWidth, + formatValue, + onHover: (seriesIdx: number, valueIdx: number) => { + console.log('hover', { seriesIdx, valueIdx }); + }, + onLeave: (seriesIdx: number, valueIdx: number) => { + console.log('leave', { seriesIdx, valueIdx }); + }, + }; + const config = getConfig(opts); + + const builder = new UPlotConfigBuilder(); + + builder.addHook('init', config.init); + builder.addHook('drawClear', config.drawClear); + builder.addHook('setCursor', config.setCursor); + + builder.setCursor(config.cursor); + builder.setSelect(config.select); + + builder.addScale({ + scaleKey: 'x', + isTime: false, + distribution: ScaleDistribution.Ordinal, + orientation: xOri, + direction: xDir, + }); + + builder.addAxis({ + scaleKey: 'x', + isTime: false, + placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left, + splits: config.xSplits, + values: config.xValues, + grid: false, + ticks: false, + gap: 15, + theme, + }); + + let seriesIndex = 0; + + // iterate the y values + for (let i = 1; i < data.fields.length; i++) { + const field = data.fields[i]; + + field.state!.seriesIndex = seriesIndex++; + + const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom }; + + const scaleKey = field.config.unit || FIXED_UNIT; + const colorMode = getFieldColorModeForField(field); + const scaleColor = getFieldSeriesColor(field, theme); + const seriesColor = scaleColor.color; + + builder.addSeries({ + scaleKey, + lineWidth: customConfig.lineWidth, + lineColor: seriesColor, + fillOpacity: customConfig.fillOpacity, + theme, + colorMode, + pathBuilder: config.drawBars, + pointsBuilder: config.drawPoints, + show: !customConfig.hideFrom?.graph, + gradientMode: customConfig.gradientMode, + thresholds: field.config.thresholds, + + /* + lineColor: customConfig.lineColor ?? seriesColor, + lineWidth: customConfig.lineWidth, + lineStyle: customConfig.lineStyle, + */ + + // The following properties are not used in the uPlot config, but are utilized as transport for legend config + dataFrameFieldIndex: { + fieldIndex: i, + frameIndex: 0, + }, + fieldName: getFieldDisplayName(field, data), + hideInLegend: customConfig.hideFrom?.legend, + }); + + // The builder will manage unique scaleKeys and combine where appropriate + builder.addScale({ + scaleKey, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + orientation: yOri, + direction: yDir, + }); + + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { + let placement = customConfig.axisPlacement; + if (!placement || placement === AxisPlacement.Auto) { + placement = AxisPlacement.Left; + } + if (xOri === 1) { + if (placement === AxisPlacement.Left) { + placement = AxisPlacement.Bottom; + } + if (placement === AxisPlacement.Right) { + placement = AxisPlacement.Top; + } + } + + builder.addAxis({ + scaleKey, + label: customConfig.axisLabel, + size: customConfig.axisWidth, + placement, + formatValue: (v) => formattedValueToString(field.display!(v)), + theme, + }); + } + } + + return builder; + }, [data, configRev, orientation, width, height]); + + const onLabelClick = useCallback( + (legend: VizLegendItem, event: React.MouseEvent) => { + const { fieldIndex } = legend; + + if (!onLegendClick || !fieldIndex) { + return; + } + + onLegendClick({ + fieldIndex, + mode: GraphNGLegendEventMode.AppendToSelection, + }); + }, + [onLegendClick, data] + ); + + const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden); + + const legendItems = configBuilder + .getSeries() + .map((s) => { + const seriesConfig = s.props; + const fieldIndex = seriesConfig.dataFrameFieldIndex; + if (seriesConfig.hideInLegend || !fieldIndex) { + return undefined; + } + + const field = data.fields[fieldIndex.fieldIndex]; + if (!field) { + return undefined; + } + + return { + disabled: !seriesConfig.show ?? false, + fieldIndex, + color: seriesConfig.lineColor!, + label: seriesConfig.fieldName, + yAxis: 1, + getDisplayValues: () => { + if (!legend.calcs?.length) { + return []; + } + + const fieldCalcs = reduceField({ + field, + reducers: legend.calcs, + }); + + return legend.calcs.map((reducer) => { + return { + ...field.display!(fieldCalcs[reducer]), + title: fieldReducers.get(reducer).name, + }; + }); + }, + }; + }) + .filter((i) => i !== undefined) as VizLegendItem[]; + + let legendElement: React.ReactElement | undefined; + + if (hasLegend && legendItems.length > 0) { + legendElement = ( + + + + ); + } + + return ( + + {(vizWidth: number, vizHeight: number) => ( + + )} + + ); +}; diff --git a/packages/grafana-ui/src/components/BarChart/bars.ts b/packages/grafana-ui/src/components/BarChart/bars.ts new file mode 100644 index 00000000000..f40bdaa1c9b --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/bars.ts @@ -0,0 +1,287 @@ +import uPlot, { Axis, Series, Cursor, BBox } from 'uplot'; +import { Quadtree, Rect, pointWithin } from './quadtree'; +import { distribute, SPACE_BETWEEN } from './distribute'; + +const pxRatio = devicePixelRatio; + +const groupDistr = SPACE_BETWEEN; +const barDistr = SPACE_BETWEEN; + +const font = Math.round(10 * pxRatio) + 'px Arial'; + +type WalkTwoCb = null | ((idx: number, offPx: number, dimPx: number) => void); + +function walkTwo( + groupWidth: number, + barWidth: number, + yIdx: number, + xCount: number, + yCount: number, + xDim: number, + xDraw?: WalkTwoCb, + yDraw?: WalkTwoCb +) { + distribute(xCount, groupWidth, groupDistr, null, (ix, offPct, dimPct) => { + let groupOffPx = xDim * offPct; + let groupWidPx = xDim * dimPct; + + xDraw && xDraw(ix, groupOffPx, groupWidPx); + + yDraw && + distribute(yCount, barWidth, barDistr, yIdx, (iy, offPct, dimPct) => { + let barOffPx = groupWidPx * offPct; + let barWidPx = groupWidPx * dimPct; + + yDraw(ix, groupOffPx + barOffPx, barWidPx); + }); + }); +} + +/** + * @internal + */ +export interface BarsOptions { + xOri: 1 | 0; + xDir: 1 | -1; + groupWidth: number; + barWidth: number; + formatValue?: (seriesIdx: number, value: any) => string; + onHover?: (seriesIdx: number, valueIdx: any) => void; + onLeave?: (seriesIdx: number, valueIdx: any) => void; +} + +/** + * @internal + */ +export function getConfig(opts: BarsOptions) { + const { xOri: ori, xDir: dir, groupWidth, barWidth, formatValue, onHover, onLeave } = opts; + + let qt: Quadtree; + + const drawBars: Series.PathBuilder = (u, sidx, i0, i1) => { + return uPlot.orient( + u, + sidx, + (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => { + const fill = new Path2D(); + + let numGroups = dataX.length; + let barsPerGroup = u.series.length - 1; + + let y0Pos = valToPosY(0, scaleY, yDim, yOff); + + const _dir = dir * (ori === 0 ? 1 : -1); + + walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => { + let lft = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid)); + let barWid = Math.round(wid); + + if (dataY[ix] != null) { + let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff); + + let btm = Math.round(Math.max(yPos, y0Pos)); + let top = Math.round(Math.min(yPos, y0Pos)); + let barHgt = btm - top; + + rect(fill, lft, top, barWid, barHgt); + + let x = ori === 0 ? Math.round(lft - xOff) : Math.round(top - yOff); + let y = ori === 0 ? Math.round(top - yOff) : Math.round(lft - xOff); + let w = ori === 0 ? barWid : barHgt; + let h = ori === 0 ? barHgt : barWid; + + qt.add({ x, y, w, h, sidx: sidx, didx: ix }); + } + }); + + return { + stroke: fill, + fill, + }; + } + ); + }; + + const drawPoints: Series.Points.Show = + formatValue == null + ? false + : (u, sidx, i0, i1) => { + u.ctx.font = font; + u.ctx.fillStyle = 'white'; + + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect + ) => { + let numGroups = dataX.length; + let barsPerGroup = u.series.length - 1; + + const _dir = dir * (ori === 0 ? 1 : -1); + + walkTwo(groupWidth, barWidth, sidx - 1, numGroups, barsPerGroup, xDim, null, (ix, x0, wid) => { + let lft = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid)); + let barWid = Math.round(wid); + + // prettier-ignore + if (dataY[ix] != null) { + let yPos = valToPosY(dataY[ix]!, scaleY, yDim, yOff); + + /* eslint-disable no-multi-spaces */ + let x = ori === 0 ? Math.round(lft + barWid / 2) : Math.round(yPos); + let y = ori === 0 ? Math.round(yPos) : Math.round(lft + barWid / 2); + + u.ctx.textAlign = ori === 0 ? 'center' : dataY[ix]! >= 0 ? 'left' : 'right'; + u.ctx.textBaseline = ori === 1 ? 'middle' : dataY[ix]! >= 0 ? 'bottom' : 'top'; + /* eslint-enable */ + + u.ctx.fillText(formatValue(sidx, dataY[ix]), x, y); + } + }); + } + ); + + return false; + }; + + /* + const yRange: Scale.Range = (u, dataMin, dataMax) => { + // @ts-ignore + let [min, max] = uPlot.rangeNum(0, dataMax, 0.05, true); + return [0, max]; + }; + */ + + const xSplits: Axis.Splits = (u: uPlot, axisIdx: number) => { + const dim = ori === 0 ? u.bbox.width : u.bbox.height; + const _dir = dir * (ori === 0 ? 1 : -1); + + let splits: number[] = []; + + distribute(u.data[0].length, groupWidth, groupDistr, null, (di, lftPct, widPct) => { + let groupLftPx = (dim * lftPct) / pxRatio; + let groupWidPx = (dim * widPct) / pxRatio; + + let groupCenterPx = groupLftPx + groupWidPx / 2; + + splits.push(u.posToVal(groupCenterPx, 'x')); + }); + + return _dir === 1 ? splits : splits.reverse(); + }; + + // @ts-ignore + const xValues: Axis.Values = (u) => u.data[0]; + + let hovered: Rect | null = null; + + let barMark = document.createElement('div'); + barMark.classList.add('bar-mark'); + barMark.style.position = 'absolute'; + barMark.style.background = 'rgba(255,255,255,0.4)'; + + // hide crosshair cursor & hover points + const cursor: Cursor = { + x: false, + y: false, + points: { + show: false, + }, + }; + + // disable selection + // uPlot types do not export the Select interface prior to 1.6.4 + const select: Partial = { + show: false, + }; + + const init = (u: uPlot) => { + let over = u.root.querySelector('.u-over')! as HTMLElement; + over.style.overflow = 'hidden'; + over.appendChild(barMark); + }; + + const drawClear = (u: uPlot) => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // clear the path cache to force drawBars() to rebuild new quadtree + u.series.forEach((s) => { + // @ts-ignore + s._paths = null; + }); + }; + + // handle hover interaction with quadtree probing + const setCursor = (u: uPlot) => { + let found: Rect | null = null; + let cx = u.cursor.left! * pxRatio; + let cy = u.cursor.top! * pxRatio; + + qt.get(cx, cy, 1, 1, (o) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { + found = o; + } + }); + + if (found) { + // prettier-ignore + if (found !== hovered) { + /* eslint-disable no-multi-spaces */ + barMark.style.display = ''; + barMark.style.left = found!.x / pxRatio + 'px'; + barMark.style.top = found!.y / pxRatio + 'px'; + barMark.style.width = found!.w / pxRatio + 'px'; + barMark.style.height = found!.h / pxRatio + 'px'; + hovered = found; + /* eslint-enable */ + + if (onHover != null) { + onHover(hovered!.sidx, hovered!.didx); + } + } + } else if (hovered != null) { + if (onLeave != null) { + onLeave(hovered!.sidx, hovered!.didx); + } + + hovered = null; + barMark.style.display = 'none'; + } + }; + + return { + // cursor & select opts + cursor, + select, + + // scale & axis opts + // yRange, + xValues, + xSplits, + + // pathbuilders + drawBars, + drawPoints, + + // hooks + init, + drawClear, + setCursor, + }; +} diff --git a/packages/grafana-ui/src/components/BarChart/distribute.ts b/packages/grafana-ui/src/components/BarChart/distribute.ts new file mode 100644 index 00000000000..6a4b419b813 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/distribute.ts @@ -0,0 +1,49 @@ +function roundDec(val: number, dec: number) { + return Math.round(val * (dec = 10 ** dec)) / dec; +} + +export const SPACE_BETWEEN = 1; +export const SPACE_AROUND = 2; +export const SPACE_EVENLY = 3; + +const coord = (i: number, offs: number, iwid: number, gap: number) => roundDec(offs + i * (iwid + gap), 6); + +export type Each = (idx: number, offPct: number, dimPct: number) => void; + +/** + * @internal + */ +export function distribute(numItems: number, sizeFactor: number, justify: number, onlyIdx: number | null, each: Each) { + let space = 1 - sizeFactor; + + /* eslint-disable no-multi-spaces */ + // prettier-ignore + let gap = ( + justify === SPACE_BETWEEN ? space / (numItems - 1) : + justify === SPACE_AROUND ? space / (numItems ) : + justify === SPACE_EVENLY ? space / (numItems + 1) : 0 + ); + + if (isNaN(gap) || gap === Infinity) { + gap = 0; + } + + // prettier-ignore + let offs = ( + justify === SPACE_BETWEEN ? 0 : + justify === SPACE_AROUND ? gap / 2 : + justify === SPACE_EVENLY ? gap : 0 + ); + /* eslint-enable */ + + let iwid = sizeFactor / numItems; + let _iwid = roundDec(iwid, 6); + + if (onlyIdx == null) { + for (let i = 0; i < numItems; i++) { + each(i, coord(i, offs, iwid, gap), _iwid); + } + } else { + each(onlyIdx, coord(onlyIdx, offs, iwid, gap), _iwid); + } +} diff --git a/packages/grafana-ui/src/components/BarChart/quadtree.ts b/packages/grafana-ui/src/components/BarChart/quadtree.ts new file mode 100644 index 00000000000..dba30187da0 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/quadtree.ts @@ -0,0 +1,113 @@ +const MAX_OBJECTS = 10; +const MAX_LEVELS = 4; + +export type Quads = [Quadtree, Quadtree, Quadtree, Quadtree]; +export type Rect = { x: number; y: number; w: number; h: number; [_: string]: any }; + +/** + * @internal + */ +export function pointWithin(px: number, py: number, rlft: number, rtop: number, rrgt: number, rbtm: number) { + return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; +} + +/** + * @internal + */ +export class Quadtree { + o: Rect[]; + q: Quads | null; + constructor(public x: number, public y: number, public w: number, public h: number, public l: number = 0) { + this.o = []; + this.q = null; + } + + split() { + let t = this, + x = t.x, + y = t.y, + w = t.w / 2, + h = t.h / 2, + l = t.l + 1; + + t.q = [ + // top right + new Quadtree(x + w, y, w, h, l), + // top left + new Quadtree(x, y, w, h, l), + // bottom left + new Quadtree(x, y + h, w, h, l), + // bottom right + new Quadtree(x + w, y + h, w, h, l), + ]; + } + + // invokes callback with index of each overlapping quad + quads(x: number, y: number, w: number, h: number, cb: (q: Quadtree) => void) { + let t = this, + q = t.q!, + hzMid = t.x + t.w / 2, + vtMid = t.y + t.h / 2, + startIsNorth = y < vtMid, + startIsWest = x < hzMid, + endIsEast = x + w > hzMid, + endIsSouth = y + h > vtMid; + + // top-right quad + startIsNorth && endIsEast && cb(q[0]); + // top-left quad + startIsWest && startIsNorth && cb(q[1]); + // bottom-left quad + startIsWest && endIsSouth && cb(q[2]); + // bottom-right quad + endIsEast && endIsSouth && cb(q[3]); + } + + add(o: Rect) { + let t = this; + + if (t.q != null) { + t.quads(o.x, o.y, o.w, o.h, (q) => { + q.add(o); + }); + } else { + let os = t.o; + + os.push(o); + + if (os.length > MAX_OBJECTS && t.l < MAX_LEVELS) { + t.split(); + + for (let i = 0; i < os.length; i++) { + let oi = os[i]; + + t.quads(oi.x, oi.y, oi.w, oi.h, (q) => { + q.add(oi); + }); + } + + t.o.length = 0; + } + } + } + + get(x: number, y: number, w: number, h: number, cb: (o: Rect) => void) { + let t = this; + let os = t.o; + + for (let i = 0; i < os.length; i++) { + cb(os[i]); + } + + if (t.q != null) { + t.quads(x, y, w, h, (q) => { + q.get(x, y, w, h, cb); + }); + } + } + + clear() { + this.o.length = 0; + this.q = null; + } +} diff --git a/packages/grafana-ui/src/components/BarChart/types.ts b/packages/grafana-ui/src/components/BarChart/types.ts new file mode 100644 index 00000000000..208a9d22b67 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/types.ts @@ -0,0 +1,52 @@ +import { VizOrientation } from '@grafana/data'; +import { AxisConfig, GraphGradientMode, HideableFieldConfig } from '../uPlot/config'; +import { VizLegendOptions } from '../VizLegend/types'; + +/** + * @alpha + */ +export enum BarStackingMode { + None = 'none', + Standard = 'standard', + Percent = 'percent', +} + +/** + * @alpha + */ +export enum BarValueVisibility { + Auto = 'auto', + Never = 'never', + Always = 'always', +} + +/** + * @alpha + */ +export interface BarChartOptions { + orientation: VizOrientation; + legend: VizLegendOptions; + stacking: BarStackingMode; + showValue: BarValueVisibility; + barWidth: number; + groupWidth: number; +} + +/** + * @alpha + */ +export interface BarChartFieldConfig extends AxisConfig, HideableFieldConfig { + lineWidth?: number; // 0 + fillOpacity?: number; // 100 + gradientMode?: GraphGradientMode; +} + +/** + * @alpha + */ +export const defaultBarChartFieldConfig: BarChartFieldConfig = { + lineWidth: 1, + fillOpacity: 80, + gradientMode: GraphGradientMode.None, + axisSoftMin: 0, +}; diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 299a61f9414..37ba47e6bc9 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -18,7 +18,14 @@ import { import { useTheme } from '../../themes'; import { UPlotChart } from '../uPlot/Plot'; import { PlotProps } from '../uPlot/types'; -import { AxisPlacement, DrawStyle, GraphFieldConfig, PointVisibility } from '../uPlot/config'; +import { + AxisPlacement, + DrawStyle, + GraphFieldConfig, + PointVisibility, + ScaleDirection, + ScaleOrientation, +} from '../uPlot/config'; import { VizLayout } from '../VizLayout/VizLayout'; import { LegendDisplayMode, VizLegendItem, VizLegendOptions } from '../VizLegend/types'; import { VizLegend } from '../VizLegend/VizLegend'; @@ -123,6 +130,8 @@ export const GraphNG: React.FC = ({ if (xField.type === FieldType.time) { builder.addScale({ scaleKey: 'x', + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, isTime: true, range: () => { const r = currentTimeRange.current!; @@ -141,6 +150,8 @@ export const GraphNG: React.FC = ({ // Not time! builder.addScale({ scaleKey: 'x', + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, }); builder.addAxis({ @@ -170,18 +181,20 @@ export const GraphNG: React.FC = ({ const scaleColor = getFieldSeriesColor(field, theme); const seriesColor = scaleColor.color; - if (customConfig.axisPlacement !== AxisPlacement.Hidden) { - // The builder will manage unique scaleKeys and combine where appropriate - builder.addScale({ - scaleKey, - distribution: customConfig.scaleDistribution?.type, - log: customConfig.scaleDistribution?.log, - min: field.config.min, - max: field.config.max, - softMin: customConfig.axisSoftMin, - softMax: customConfig.axisSoftMax, - }); + // The builder will manage unique scaleKeys and combine where appropriate + builder.addScale({ + scaleKey, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + distribution: customConfig.scaleDistribution?.type, + log: customConfig.scaleDistribution?.log, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + }); + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { builder.addAxis({ scaleKey, label: customConfig.axisLabel, diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index e4388e09083..e323e51f0b4 100755 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -10,7 +10,14 @@ import { FieldConfig, getFieldDisplayName, } from '@grafana/data'; -import { AxisPlacement, DrawStyle, GraphFieldConfig, PointVisibility } from '../uPlot/config'; +import { + AxisPlacement, + DrawStyle, + GraphFieldConfig, + PointVisibility, + ScaleDirection, + ScaleOrientation, +} from '../uPlot/config'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { UPlotChart } from '../uPlot/Plot'; import { Themeable } from '../../types'; @@ -91,6 +98,8 @@ export class Sparkline extends PureComponent { const xField = data.fields[0]; builder.addScale({ scaleKey: 'x', + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, isTime: false, //xField.type === FieldType.time, range: () => { const { sparkline } = this.props; @@ -124,7 +133,13 @@ export class Sparkline extends PureComponent { } const scaleKey = config.unit || '__fixed'; - builder.addScale({ scaleKey, min: field.config.min, max: field.config.max }); + builder.addScale({ + scaleKey, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + min: field.config.min, + max: field.config.max, + }); builder.addAxis({ scaleKey, theme, diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx index 3b2180831c7..ac05a624b9a 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx @@ -3,6 +3,9 @@ import { LegendProps, LegendDisplayMode } from './types'; import { VizLegendTable } from './VizLegendTable'; import { VizLegendList } from './VizLegendList'; +/** + * @public + */ export const VizLegend: React.FunctionComponent = ({ items, displayMode, diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx index 50c7ed022bf..fef212ba39a 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx @@ -9,6 +9,9 @@ import { VizLegendListItem } from './VizLegendListItem'; export interface Props extends VizLegendBaseProps {} +/** + * @internal + */ export const VizLegendList: React.FunctionComponent = ({ items, itemRenderer, diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx index 51d536d9140..b820e04382a 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx @@ -14,6 +14,9 @@ export interface Props { onSeriesColorChange?: SeriesColorChangeHandler; } +/** + * @internal + */ export const VizLegendListItem: React.FunctionComponent = ({ item, onSeriesColorChange, onLabelClick }) => { const styles = useStyles(getStyles); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendSeriesIcon.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendSeriesIcon.tsx index 38ebd334880..fb0316341e3 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendSeriesIcon.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendSeriesIcon.tsx @@ -8,6 +8,9 @@ interface Props { onColorChange: (color: string) => void; } +/** + * @internal + */ export const VizLegendSeriesIcon: React.FunctionComponent = ({ disabled, color, onColorChange }) => { return disabled ? ( diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendStatsList.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendStatsList.tsx index 5ef1c653b31..78cfd42ca0b 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendStatsList.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendStatsList.tsx @@ -18,6 +18,9 @@ const VizLegendItemStat: React.FunctionComponent<{ stat: DisplayValue }> = ({ st VizLegendItemStat.displayName = 'VizLegendItemStat'; +/** + * @internal + */ export const VizLegendStatsList: React.FunctionComponent<{ stats: DisplayValue[] }> = ({ stats }) => { if (stats.length === 0) { return null; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index f4f4783420c..95c55d241c1 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -8,6 +8,9 @@ import sortBy from 'lodash/sortBy'; import { LegendTableItem } from './VizLegendTableItem'; import { GrafanaTheme } from '@grafana/data'; +/** + * @internal + */ export const VizLegendTable: FC = ({ items, sortBy: sortKey, diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 25357bf7c44..7b74c0968d5 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -15,6 +15,9 @@ export interface Props { onSeriesColorChange?: SeriesColorChangeHandler; } +/** + * @internal + */ export const LegendTableItem: React.FunctionComponent = ({ item, onSeriesColorChange, diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 49bb8f21ee4..0b86c16b896 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -207,5 +207,7 @@ export * from './uPlot/plugins'; export { useRefreshAfterGraphRendered } from './uPlot/hooks'; export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context'; export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG'; +export { BarChart } from './BarChart/BarChart'; +export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types'; export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types'; export * from './NodeGraph'; diff --git a/packages/grafana-ui/src/components/uPlot/config.ts b/packages/grafana-ui/src/components/uPlot/config.ts index 847b663a64c..c7806f870fc 100644 --- a/packages/grafana-ui/src/components/uPlot/config.ts +++ b/packages/grafana-ui/src/components/uPlot/config.ts @@ -46,6 +46,26 @@ export enum LineInterpolation { export enum ScaleDistribution { Linear = 'linear', Logarithmic = 'log', + Ordinal = 'ordinal', +} + +/** + * @alpha + */ +export enum ScaleOrientation { + Horizontal = 0, + Vertical = 1, +} + +/** + * @alpha + */ + +export enum ScaleDirection { + Up = 1, + Right = 1, + Down = -1, + Left = -1, } /** @@ -129,10 +149,16 @@ export interface HideSeriesConfig { /** * @alpha */ -export interface GraphFieldConfig extends LineConfig, FillConfig, PointsConfig, AxisConfig { +export interface HideableFieldConfig { + hideFrom?: HideSeriesConfig; +} + +/** + * @alpha + */ +export interface GraphFieldConfig extends LineConfig, FillConfig, PointsConfig, AxisConfig, HideableFieldConfig { drawStyle?: DrawStyle; gradientMode?: GraphGradientMode; - hideFrom?: HideSeriesConfig; } /** diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index 5828def590b..7e27027595e 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -11,9 +11,12 @@ export interface AxisProps { label?: string; show?: boolean; size?: number | null; + gap?: number; placement?: AxisPlacement; grid?: boolean; + ticks?: boolean; formatValue?: (v: any) => string; + splits?: Axis.Splits; values?: any; isTime?: boolean; timeZone?: TimeZone; @@ -37,7 +40,10 @@ export class UPlotAxisBuilder extends PlotConfigBuilder { show = true, placement = AxisPlacement.Auto, grid = true, + ticks = true, + gap = 5, formatValue, + splits, values, isTime, timeZone, @@ -54,16 +60,18 @@ export class UPlotAxisBuilder extends PlotConfigBuilder { font: `12px 'Roboto'`, labelFont: `12px 'Roboto'`, size: this.props.size ?? calculateAxisSize, + gap, grid: { show: grid, stroke: gridColor, width: 1 / devicePixelRatio, }, ticks: { - show: true, + show: ticks, stroke: gridColor, width: 1 / devicePixelRatio, }, + splits, values: values, space: calculateSpace, }; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index b7c414ce6b4..f177eda7e88 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -2,7 +2,15 @@ import { UPlotConfigBuilder } from './UPlotConfigBuilder'; import { GrafanaTheme } from '@grafana/data'; -import { GraphGradientMode, AxisPlacement, DrawStyle, PointVisibility, ScaleDistribution } from '../config'; +import { + GraphGradientMode, + AxisPlacement, + DrawStyle, + PointVisibility, + ScaleDistribution, + ScaleOrientation, + ScaleDirection, +} from '../config'; import darkTheme from '../../../themes/dark'; describe('UPlotConfigBuilder', () => { @@ -26,7 +34,9 @@ describe('UPlotConfigBuilder', () => { "width": [Function], }, }, + "hooks": Object {}, "scales": Object {}, + "select": undefined, "series": Array [ Object {}, ], @@ -41,11 +51,15 @@ describe('UPlotConfigBuilder', () => { builder.addScale({ scaleKey: 'scale-x', + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, isTime: true, }); builder.addScale({ scaleKey: 'scale-y', + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, isTime: false, }); @@ -66,20 +80,26 @@ describe('UPlotConfigBuilder', () => { "width": [Function], }, }, + "hooks": Object {}, "scales": Object { "scale-x": Object { "auto": false, + "dir": 1, + "ori": 0, "range": [Function], "time": true, }, "scale-y": Object { "auto": true, + "dir": 1, "distr": 1, "log": undefined, + "ori": 1, "range": [Function], "time": false, }, }, + "select": undefined, "series": Array [ Object {}, ], @@ -92,11 +112,15 @@ describe('UPlotConfigBuilder', () => { builder.addScale({ scaleKey: 'scale-x', + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, isTime: true, }); builder.addScale({ scaleKey: 'scale-x', + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, isTime: false, }); @@ -109,6 +133,8 @@ describe('UPlotConfigBuilder', () => { builder.addScale({ scaleKey: 'scale-y', + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, isTime: false, distribution: ScaleDistribution.Linear, }); @@ -129,15 +155,19 @@ describe('UPlotConfigBuilder', () => { "width": [Function], }, }, + "hooks": Object {}, "scales": Object { "scale-y": Object { "auto": true, + "dir": 1, "distr": 1, "log": undefined, + "ori": 1, "range": [Function], "time": false, }, }, + "select": undefined, "series": Array [ Object {}, ], @@ -150,6 +180,8 @@ describe('UPlotConfigBuilder', () => { builder.addScale({ scaleKey: 'scale-y', + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, isTime: false, distribution: ScaleDistribution.Linear, }); @@ -171,15 +203,19 @@ describe('UPlotConfigBuilder', () => { "width": [Function], }, }, + "hooks": Object {}, "scales": Object { "scale-y": Object { "auto": true, + "dir": 1, "distr": 1, "log": undefined, + "ori": 1, "range": [Function], "time": false, }, }, + "select": undefined, "series": Array [ Object {}, ], @@ -192,6 +228,8 @@ describe('UPlotConfigBuilder', () => { builder.addScale({ scaleKey: 'scale-y', + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, isTime: false, distribution: ScaleDistribution.Linear, log: 10, @@ -214,15 +252,19 @@ describe('UPlotConfigBuilder', () => { "width": [Function], }, }, + "hooks": Object {}, "scales": Object { "scale-y": Object { "auto": true, + "dir": 1, "distr": 1, "log": undefined, + "ori": 1, "range": [Function], "time": false, }, }, + "select": undefined, "series": Array [ Object {}, ], @@ -254,6 +296,7 @@ describe('UPlotConfigBuilder', () => { "axes": Array [ Object { "font": "12px 'Roboto'", + "gap": 5, "grid": Object { "show": false, "stroke": "#ffffff", @@ -267,6 +310,7 @@ describe('UPlotConfigBuilder', () => { "side": 2, "size": [Function], "space": [Function], + "splits": undefined, "stroke": "gray", "ticks": Object { "show": true, @@ -291,7 +335,9 @@ describe('UPlotConfigBuilder', () => { "width": [Function], }, }, + "hooks": Object {}, "scales": Object {}, + "select": undefined, "series": Array [ Object {}, ], @@ -410,7 +456,9 @@ describe('UPlotConfigBuilder', () => { "width": [Function], }, }, + "hooks": Object {}, "scales": Object {}, + "select": undefined, "series": Array [ Object {}, Object { diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 13a365b0d7b..2f0da9bfa0c 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -1,19 +1,32 @@ -import { PlotSeriesConfig } from '../types'; +import { PlotConfig } from '../types'; import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder'; import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder'; import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder'; import { AxisPlacement } from '../config'; -import { Cursor, Band } from 'uplot'; +import { Cursor, Band, Hooks, BBox } from 'uplot'; import { defaultsDeep } from 'lodash'; +type valueof = T[keyof T]; + export class UPlotConfigBuilder { private series: UPlotSeriesBuilder[] = []; private axes: Record = {}; private scales: UPlotScaleBuilder[] = []; private bands: Band[] = []; private cursor: Cursor | undefined; + // uPlot types don't export the Select interface prior to 1.6.4 + private select: Partial | undefined; private hasLeftAxis = false; private hasBottomAxis = false; + private hooks: Hooks.Arrays = {}; + + addHook(type: keyof Hooks.Defs, hook: valueof) { + if (!this.hooks[type]) { + this.hooks[type] = []; + } + + this.hooks[type]!.push(hook as any); + } addAxis(props: AxisProps) { props.placement = props.placement ?? AxisPlacement.Auto; @@ -54,6 +67,11 @@ export class UPlotConfigBuilder { this.cursor = cursor; } + // uPlot types don't export the Select interface prior to 1.6.4 + setSelect(select: Partial) { + this.select = select; + } + addSeries(props: SeriesProps) { this.series.push(new UPlotSeriesBuilder(props)); } @@ -77,13 +95,19 @@ export class UPlotConfigBuilder { } getConfig() { - const config: PlotSeriesConfig = { series: [{}] }; + const config: PlotConfig = { series: [{}] }; config.axes = this.ensureNonOverlappingAxes(Object.values(this.axes)).map((a) => a.getConfig()); config.series = [...config.series, ...this.series.map((s) => s.getConfig())]; config.scales = this.scales.reduce((acc, s) => { return { ...acc, ...s.getConfig() }; }, {}); + config.hooks = this.hooks; + + /* @ts-ignore */ + // uPlot types don't export the Select interface prior to 1.6.4 + config.select = this.select; + config.cursor = this.cursor || {}; // When bands exist, only keep fill when defined diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts index d9a4e33bc46..57a2dab9436 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts @@ -1,6 +1,6 @@ import uPlot, { Scale, Range } from 'uplot'; import { PlotConfigBuilder } from '../types'; -import { ScaleDistribution } from '../config'; +import { ScaleDistribution, ScaleOrientation, ScaleDirection } from '../config'; export interface ScaleProps { scaleKey: string; @@ -9,8 +9,10 @@ export interface ScaleProps { max?: number | null; softMin?: number | null; softMax?: number | null; - range?: () => number[]; // min/max + range?: Scale.Range; distribution?: ScaleDistribution; + orientation: ScaleOrientation; + direction: ScaleDirection; log?: number; } @@ -21,10 +23,25 @@ export class UPlotScaleBuilder extends PlotConfigBuilder { } getConfig() { - const { isTime, scaleKey, min: hardMin, max: hardMax, softMin, softMax, range } = this.props; + const { + isTime, + scaleKey, + min: hardMin, + max: hardMax, + softMin, + softMax, + range, + direction, + orientation, + } = this.props; const distribution = !isTime ? { - distr: this.props.distribution === ScaleDistribution.Logarithmic ? 3 : 1, + distr: + this.props.distribution === ScaleDistribution.Logarithmic + ? 3 + : this.props.distribution === ScaleDistribution.Ordinal + ? 2 + : 1, log: this.props.distribution === ScaleDistribution.Logarithmic ? this.props.log || 2 : undefined, } : {}; @@ -57,7 +74,7 @@ export class UPlotScaleBuilder extends PlotConfigBuilder { let hardMinOnly = softMin == null && hardMin != null; let hardMaxOnly = softMax == null && hardMax != null; - if (scale.distr === 1) { + if (scale.distr === 1 || scale.distr === 2) { // @ts-ignore here we may use hardMin / hardMax to make sure any extra padding is computed from a more accurate delta minMax = uPlot.rangeNum(hardMinOnly ? hardMin : dataMin, hardMaxOnly ? hardMax : dataMax, rangeConfig); } else if (scale.distr === 3) { @@ -81,6 +98,8 @@ export class UPlotScaleBuilder extends PlotConfigBuilder { time: isTime, auto: !isTime, range: range ?? rangeFn, + dir: direction, + ori: orientation, ...distribution, }, }; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts index 77812ea0585..ec22f9f73cb 100755 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts @@ -22,7 +22,9 @@ export interface SeriesProps extends LineConfig, FillConfig, PointsConfig { /** Used when gradientMode is set to Scheme */ colorMode?: FieldColorMode; fieldName: string; - drawStyle: DrawStyle; + drawStyle?: DrawStyle; + pathBuilder?: Series.PathBuilder; + pointsBuilder?: Series.Points.Show; show?: boolean; dataFrameFieldIndex?: DataFrameFieldIndex; hideInLegend?: boolean; @@ -33,6 +35,8 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { getConfig() { const { drawStyle, + pathBuilder, + pointsBuilder, lineInterpolation, lineWidth, lineStyle, @@ -46,9 +50,13 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { let lineConfig: Partial = {}; - if (drawStyle === DrawStyle.Points) { + if (pathBuilder != null) { + lineConfig.paths = pathBuilder; + lineConfig.stroke = this.getLineColor(); + lineConfig.width = lineWidth; + } else if (drawStyle === DrawStyle.Points) { lineConfig.paths = () => null; - } else { + } else if (drawStyle != null) { lineConfig.stroke = this.getLineColor(); lineConfig.width = lineWidth; if (lineStyle && lineStyle.fill !== 'solid') { @@ -71,18 +79,22 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { }, }; - // we cannot set points.show property above (even to undefined) as that will clear uPlot's default auto behavior - if (drawStyle === DrawStyle.Points) { - pointsConfig.points!.show = true; + if (pointsBuilder != null) { + pointsConfig.points!.show = pointsBuilder; } else { - if (showPoints === PointVisibility.Auto) { - if (drawStyle === DrawStyle.Bars) { - pointsConfig.points!.show = false; - } - } else if (showPoints === PointVisibility.Never) { - pointsConfig.points!.show = false; - } else if (showPoints === PointVisibility.Always) { + // we cannot set points.show property above (even to undefined) as that will clear uPlot's default auto behavior + if (drawStyle === DrawStyle.Points) { pointsConfig.points!.show = true; + } else { + if (showPoints === PointVisibility.Auto) { + if (drawStyle === DrawStyle.Bars) { + pointsConfig.points!.show = false; + } + } else if (showPoints === PointVisibility.Never) { + pointsConfig.points!.show = false; + } else if (showPoints === PointVisibility.Always) { + pointsConfig.points!.show = true; + } } } diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index 5c5e11a99d3..15103e60c3c 100755 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -3,7 +3,8 @@ import uPlot, { Options, Hooks } from 'uplot'; import { DataFrame, TimeRange, TimeZone } from '@grafana/data'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; -export type PlotSeriesConfig = Pick; +export type PlotConfig = Pick; + export type PlotPlugin = { id: string; /** can mutate provided opts as necessary */ diff --git a/public/app/features/dashboard/components/PanelEditor/DefaultFieldConfigEditor.tsx b/public/app/features/dashboard/components/PanelEditor/DefaultFieldConfigEditor.tsx index ea2686c327c..bce09875851 100644 --- a/public/app/features/dashboard/components/PanelEditor/DefaultFieldConfigEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/DefaultFieldConfigEditor.tsx @@ -18,7 +18,7 @@ export const DefaultFieldConfigEditor: React.FC = ({ data, onChange, conf const renderEditor = useCallback( (item: FieldConfigPropertyItem, categoryItemCount: number) => { - if (item.isCustom && item.showIf && !item.showIf(config.defaults.custom)) { + if (item.isCustom && item.showIf && !item.showIf(config.defaults.custom, data)) { return null; } diff --git a/public/app/features/dashboard/components/PanelEditor/PanelOptionsEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelOptionsEditor.tsx index 32c8b457161..7ed24dc01c8 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelOptionsEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelOptionsEditor.tsx @@ -60,7 +60,7 @@ export const PanelOptionsEditor: React.FC> = ({ {Object.keys(optionEditors).map((c, i) => { const optionsToShow = optionEditors[c] .map((e, j) => { - if (e.showIf && !e.showIf(options)) { + if (e.showIf && !e.showIf(options, data)) { return null; } diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 9277f2b99b6..3fc449e077f 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -55,6 +55,7 @@ import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module'; import * as gaugePanel from 'app/plugins/panel/gauge/module'; import * as pieChartPanel from 'app/plugins/panel/piechart/module'; import * as barGaugePanel from 'app/plugins/panel/bargauge/module'; +import * as barChartPanel from 'app/plugins/panel/barchart/module'; import * as logsPanel from 'app/plugins/panel/logs/module'; import * as newsPanel from 'app/plugins/panel/news/module'; import * as livePanel from 'app/plugins/panel/live/module'; @@ -101,6 +102,7 @@ const builtInPlugins: any = { 'app/plugins/panel/gauge/module': gaugePanel, 'app/plugins/panel/piechart/module': pieChartPanel, 'app/plugins/panel/bargauge/module': barGaugePanel, + 'app/plugins/panel/barchart/module': barChartPanel, 'app/plugins/panel/logs/module': logsPanel, 'app/plugins/panel/welcome/module': welcomeBanner, 'app/plugins/panel/nodeGraph/module': nodeGraph, diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx new file mode 100755 index 00000000000..418f63b0bc9 --- /dev/null +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useMemo } from 'react'; +import { DataFrame, Field, FieldType, PanelProps } from '@grafana/data'; +import { BarChart, BarChartOptions, GraphNGLegendEvent } from '@grafana/ui'; +import { changeSeriesColorConfigFactory } from '../timeseries/overrides/colorSeriesConfigFactory'; +import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory'; +import { config } from 'app/core/config'; + +interface Props extends PanelProps {} + +interface BarData { + error?: string; + frame?: DataFrame; // first string vs all numbers +} + +/** + * @alpha + */ +export const BarChartPanel: React.FunctionComponent = ({ + data, + options, + width, + height, + fieldConfig, + onFieldConfigChange, +}) => { + if (!data || !data.series?.length) { + return ( +
+

No data found in response

+
+ ); + } + + const onLegendClick = useCallback( + (event: GraphNGLegendEvent) => { + onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, data.series)); + }, + [fieldConfig, onFieldConfigChange, data.series] + ); + + const onSeriesColorChange = useCallback( + (label: string, color: string) => { + onFieldConfigChange(changeSeriesColorConfigFactory(label, color, fieldConfig)); + }, + [fieldConfig, onFieldConfigChange] + ); + + const barData = useMemo(() => { + const firstFrame = data.series[0]; + const firstString = firstFrame.fields.find((f) => f.type === FieldType.string); + if (!firstString) { + return { + error: 'Bar charts requires a string field', + }; + } + const fields: Field[] = [firstString]; + for (const f of firstFrame.fields) { + if (f.type === FieldType.number) { + fields.push(f); + } + } + if (fields.length < 2) { + return { + error: 'No numeric fields found', + }; + } + + return { + frame: { + ...firstFrame, + fields, // filtered to to the values we have + }, + }; + }, [width, height, options, data]); + + if (barData.error) { + return ( +
+

{barData.error}

+
+ ); + } + + if (!barData.frame) { + return ( +
+

No data found in response

+
+ ); + } + + return ( + + ); +}; diff --git a/public/app/plugins/panel/barchart/img/barchart.svg b/public/app/plugins/panel/barchart/img/barchart.svg new file mode 100644 index 00000000000..91c44d9a366 --- /dev/null +++ b/public/app/plugins/panel/barchart/img/barchart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/barchart/module.tsx b/public/app/plugins/panel/barchart/module.tsx new file mode 100755 index 00000000000..72269956cad --- /dev/null +++ b/public/app/plugins/panel/barchart/module.tsx @@ -0,0 +1,150 @@ +import { + DataFrame, + FieldColorModeId, + FieldConfigProperty, + FieldType, + PanelPlugin, + VizOrientation, +} from '@grafana/data'; +import { BarChartPanel } from './BarChartPanel'; +import { + BarChartFieldConfig, + BarChartOptions, + BarStackingMode, + BarValueVisibility, + graphFieldOptions, +} from '@grafana/ui'; +import { addAxisConfig, addHideFrom, addLegendOptions } from '../timeseries/config'; +import { defaultBarChartFieldConfig } from '@grafana/ui/src/components/BarChart/types'; + +export const plugin = new PanelPlugin(BarChartPanel) + .useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: false, + }, + defaultValue: { + mode: FieldColorModeId.PaletteClassic, + }, + }, + }, + useCustomConfig: (builder) => { + const cfg = defaultBarChartFieldConfig; + + builder + .addSliderInput({ + path: 'lineWidth', + name: 'Line width', + defaultValue: cfg.lineWidth, + settings: { + min: 0, + max: 10, + step: 1, + }, + }) + .addSliderInput({ + path: 'fillOpacity', + name: 'Fill opacity', + defaultValue: cfg.fillOpacity, + settings: { + min: 0, + max: 100, + step: 1, + }, + }) + .addRadio({ + path: 'gradientMode', + name: 'Gradient mode', + defaultValue: graphFieldOptions.fillGradient[0].value, + settings: { + options: graphFieldOptions.fillGradient, + }, + }); + + addAxisConfig(builder, cfg, true); + addHideFrom(builder); + }, + }) + .setPanelOptions((builder) => { + builder + .addRadio({ + path: 'orientation', + name: 'Orientation', + settings: { + options: [ + { value: VizOrientation.Auto, label: 'Auto' }, + { value: VizOrientation.Horizontal, label: 'Horizontal' }, + { value: VizOrientation.Vertical, label: 'Vertical' }, + ], + }, + defaultValue: VizOrientation.Auto, + }) + .addRadio({ + path: 'stacking', + name: 'Stacking', + settings: { + options: [ + { value: BarStackingMode.None, label: 'None' }, + { value: BarStackingMode.Standard, label: 'Standard' }, + { value: BarStackingMode.Percent, label: 'Percent' }, + ], + }, + defaultValue: BarStackingMode.None, + showIf: () => false, // <<< Hide from the UI for now + }) + .addRadio({ + path: 'showValue', + name: 'Show values', + settings: { + options: [ + { value: BarValueVisibility.Auto, label: 'Auto' }, + { value: BarValueVisibility.Always, label: 'Always' }, + { value: BarValueVisibility.Never, label: 'Never' }, + ], + }, + defaultValue: BarValueVisibility.Auto, + }) + .addSliderInput({ + path: 'groupWidth', + name: 'Group width', + defaultValue: 0.7, + settings: { + min: 0, + max: 1, + step: 0.01, + }, + showIf: (c, data) => { + if (c.stacking && c.stacking !== BarStackingMode.None) { + return false; + } + return countNumberFields(data) !== 1; + }, + }) + .addSliderInput({ + path: 'barWidth', + name: 'Bar width', + defaultValue: 0.97, + settings: { + min: 0, + max: 1, + step: 0.01, + }, + }); + + addLegendOptions(builder); + }); + +function countNumberFields(data?: DataFrame[]): number { + let count = 0; + if (data) { + for (const frame of data) { + for (const field of frame.fields) { + if (field.type === FieldType.number) { + count++; + } + } + } + } + return count; +} diff --git a/public/app/plugins/panel/barchart/plugin.json b/public/app/plugins/panel/barchart/plugin.json new file mode 100755 index 00000000000..d876ba3c060 --- /dev/null +++ b/public/app/plugins/panel/barchart/plugin.json @@ -0,0 +1,18 @@ +{ + "type": "panel", + "name": "Bar chart", + "id": "barchart", + + "state": "alpha", + + "info": { + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/barchart.svg", + "large": "img/barchart.svg" + } + } +} diff --git a/public/app/plugins/panel/stat/types.ts b/public/app/plugins/panel/stat/types.ts index 2ba51bbdfaa..f795e6d1c28 100644 --- a/public/app/plugins/panel/stat/types.ts +++ b/public/app/plugins/panel/stat/types.ts @@ -11,6 +11,7 @@ import { FieldOverrideContext, getFieldDisplayName, escapeStringForRegex, + VizOrientation, } from '@grafana/data'; import { PanelOptionsEditorBuilder } from '@grafana/data'; @@ -101,12 +102,12 @@ export function addStandardDataReduceOptions( description: 'Stacking direction in case of multiple series or fields', settings: { options: [ - { value: 'auto', label: 'Auto' }, - { value: 'horizontal', label: 'Horizontal' }, - { value: 'vertical', label: 'Vertical' }, + { value: VizOrientation.Auto, label: 'Auto' }, + { value: VizOrientation.Horizontal, label: 'Horizontal' }, + { value: VizOrientation.Vertical, label: 'Vertical' }, ], }, - defaultValue: 'auto', + defaultValue: VizOrientation.Auto, }); } diff --git a/public/app/plugins/panel/timeseries/config.ts b/public/app/plugins/panel/timeseries/config.ts index f07bcf88d08..67a1a367080 100644 --- a/public/app/plugins/panel/timeseries/config.ts +++ b/public/app/plugins/panel/timeseries/config.ts @@ -1,5 +1,6 @@ import { FieldColorModeId, + FieldConfigEditorBuilder, FieldConfigProperty, FieldType, identityOverrideProcessor, @@ -21,6 +22,7 @@ import { ScaleDistributionConfig, GraphGradientMode, LegendDisplayMode, + AxisConfig, } from '@grafana/ui'; import { SeriesConfigEditor } from './HideSeriesConfigEditor'; import { ScaleDistributionEditor } from './ScaleDistributionEditor'; @@ -151,85 +153,103 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption step: 1, }, showIf: (c) => c.showPoints !== PointVisibility.Never || c.drawStyle === DrawStyle.Points, - }) - .addRadio({ - path: 'axisPlacement', - name: 'Placement', - category: ['Axis'], - defaultValue: graphFieldOptions.axisPlacement[0].value, - settings: { - options: graphFieldOptions.axisPlacement, - }, - }) - .addTextInput({ - path: 'axisLabel', - name: 'Label', - category: ['Axis'], - defaultValue: '', - settings: { - placeholder: 'Optional text', - }, - showIf: (c) => c.axisPlacement !== AxisPlacement.Hidden, - // no matter what the field type is - shouldApply: () => true, - }) - .addNumberInput({ - path: 'axisWidth', - name: 'Width', - category: ['Axis'], - settings: { - placeholder: 'Auto', - }, - showIf: (c) => c.axisPlacement !== AxisPlacement.Hidden, - }) - .addNumberInput({ - path: 'axisSoftMin', - name: 'Soft min', - category: ['Axis'], - settings: { - placeholder: 'See: Standard options > Min', - }, - }) - .addNumberInput({ - path: 'axisSoftMax', - name: 'Soft max', - category: ['Axis'], - settings: { - placeholder: 'See: Standard options > Max', - }, - }) - .addCustomEditor({ - id: 'scaleDistribution', - path: 'scaleDistribution', - name: 'Scale', - category: ['Axis'], - editor: ScaleDistributionEditor, - override: ScaleDistributionEditor, - defaultValue: { type: ScaleDistribution.Linear }, - shouldApply: (f) => f.type === FieldType.number, - process: identityOverrideProcessor, - }) - .addCustomEditor({ - id: 'hideFrom', - name: 'Hide in area', - category: ['Series'], - path: 'hideFrom', - defaultValue: { - tooltip: false, - graph: false, - legend: false, - }, - editor: SeriesConfigEditor, - override: SeriesConfigEditor, - shouldApply: () => true, - hideFromDefaults: true, - hideFromOverrides: true, - process: (value) => value, }); + + addAxisConfig(builder, cfg); + addHideFrom(builder); }, }; } +export function addHideFrom(builder: FieldConfigEditorBuilder) { + builder.addCustomEditor({ + id: 'hideFrom', + name: 'Hide in area', + category: ['Series'], + path: 'hideFrom', + defaultValue: { + tooltip: false, + graph: false, + legend: false, + }, + editor: SeriesConfigEditor, + override: SeriesConfigEditor, + shouldApply: () => true, + hideFromDefaults: true, + hideFromOverrides: true, + process: (value) => value, + }); +} + +export function addAxisConfig( + builder: FieldConfigEditorBuilder, + defaultConfig: AxisConfig, + hideScale?: boolean +) { + builder + .addRadio({ + path: 'axisPlacement', + name: 'Placement', + category: ['Axis'], + defaultValue: graphFieldOptions.axisPlacement[0].value, + settings: { + options: graphFieldOptions.axisPlacement, + }, + }) + .addTextInput({ + path: 'axisLabel', + name: 'Label', + category: ['Axis'], + defaultValue: '', + settings: { + placeholder: 'Optional text', + }, + showIf: (c) => c.axisPlacement !== AxisPlacement.Hidden, + // no matter what the field type is + shouldApply: () => true, + }) + .addNumberInput({ + path: 'axisWidth', + name: 'Width', + category: ['Axis'], + settings: { + placeholder: 'Auto', + }, + showIf: (c) => c.axisPlacement !== AxisPlacement.Hidden, + }) + .addNumberInput({ + path: 'axisSoftMin', + name: 'Soft min', + defaultValue: defaultConfig.axisSoftMin, + category: ['Axis'], + settings: { + placeholder: 'See: Standard options > Min', + }, + }) + .addNumberInput({ + path: 'axisSoftMax', + name: 'Soft max', + defaultValue: defaultConfig.axisSoftMax, + category: ['Axis'], + settings: { + placeholder: 'See: Standard options > Max', + }, + }); + if (!hideScale) { + builder.addCustomEditor({ + id: 'scaleDistribution', + path: 'scaleDistribution', + name: 'Scale', + category: ['Axis'], + editor: ScaleDistributionEditor, + override: ScaleDistributionEditor, + defaultValue: { type: ScaleDistribution.Linear }, + shouldApply: (f) => f.type === FieldType.number, + process: identityOverrideProcessor, + }); + } +} + export function addLegendOptions(builder: PanelOptionsEditorBuilder) { builder .addRadio({