diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index fdab55da862..d7cbad6819b 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -6,7 +6,7 @@ import { LiveChannelSupport } from './live'; export enum PluginState { alpha = 'alpha', // Only included if `enable_alpha` config option is true beta = 'beta', // Will show a warning banner - stable = 'stable', // Will not show anything + stable = 'stable', // Will not show anything deprecated = 'deprecated', // Will continue to work -- but not show up in the options to add } diff --git a/packages/grafana-ui/src/components/uPlot/config.ts b/packages/grafana-ui/src/components/uPlot/config.ts index bb50c36fd7a..732b72dd775 100644 --- a/packages/grafana-ui/src/components/uPlot/config.ts +++ b/packages/grafana-ui/src/components/uPlot/config.ts @@ -70,16 +70,16 @@ export interface LineConfig { /** * @alpha */ -export interface AreaConfig { +export interface FillConfig { fillColor?: string; fillOpacity?: number; - fillGradient?: AreaGradientMode; + fillGradient?: FillGradientMode; } /** * @alpha */ -export enum AreaGradientMode { +export enum FillGradientMode { None = 'none', Opacity = 'opacity', Hue = 'hue', @@ -126,7 +126,7 @@ export interface HideSeriesConfig { /** * @alpha */ -export interface GraphFieldConfig extends LineConfig, AreaConfig, PointsConfig, AxisConfig { +export interface GraphFieldConfig extends LineConfig, FillConfig, PointsConfig, AxisConfig { drawStyle?: DrawStyle; hideFrom?: HideSeriesConfig; } @@ -162,8 +162,8 @@ export const graphFieldOptions = { ] as Array>, fillGradient: [ - { label: 'None', value: AreaGradientMode.None }, - { label: 'Opacity', value: AreaGradientMode.Opacity }, - { label: 'Hue', value: AreaGradientMode.Hue }, - ] as Array>, + { label: 'None', value: FillGradientMode.None }, + { label: 'Opacity', value: FillGradientMode.Opacity }, + { label: 'Hue', value: FillGradientMode.Hue }, + ] as Array>, }; 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 ef7e899d039..f50cdec562a 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -3,7 +3,7 @@ import { UPlotConfigBuilder } from './UPlotConfigBuilder'; import { GrafanaTheme } from '@grafana/data'; import { expect } from '../../../../../../public/test/lib/common'; -import { AreaGradientMode, AxisPlacement, DrawStyle, PointVisibility, ScaleDistribution } from '../config'; +import { FillGradientMode, AxisPlacement, DrawStyle, PointVisibility, ScaleDistribution } from '../config'; describe('UPlotConfigBuilder', () => { describe('default config', () => { @@ -352,7 +352,7 @@ describe('UPlotConfigBuilder', () => { scaleKey: 'scale-x', lineColor: '#FFAABB', fillOpacity: 50, - fillGradient: AreaGradientMode.Opacity, + fillGradient: FillGradientMode.Opacity, }); expect(builder.getConfig().series[1].fill).toBeInstanceOf(Function); @@ -364,7 +364,7 @@ describe('UPlotConfigBuilder', () => { drawStyle: DrawStyle.Line, scaleKey: 'scale-x', fillOpacity: 50, - fillGradient: AreaGradientMode.Opacity, + fillGradient: FillGradientMode.Opacity, showPoints: PointVisibility.Auto, pointSize: 5, pointColor: '#00ff00', diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts index 56a126fab25..bffd0785dd1 100755 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts @@ -4,15 +4,15 @@ import { getCanvasContext } from '../../../utils/measureText'; import { DrawStyle, LineConfig, - AreaConfig, + FillConfig, PointsConfig, PointVisibility, LineInterpolation, - AreaGradientMode, + FillGradientMode, } from '../config'; import { PlotConfigBuilder } from '../types'; -export interface SeriesProps extends LineConfig, AreaConfig, PointsConfig { +export interface SeriesProps extends LineConfig, FillConfig, PointsConfig { drawStyle: DrawStyle; scaleKey: string; show?: boolean; @@ -89,10 +89,10 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { return fillColor; } - const mode = fillGradient ?? AreaGradientMode.None; + const mode = fillGradient ?? FillGradientMode.None; let fillOpacityNumber = fillOpacity ?? 0; - if (mode !== AreaGradientMode.None) { + if (mode !== FillGradientMode.None) { return getCanvasGradient({ color: (fillColor ?? lineColor)!, opacity: fillOpacityNumber / 100, @@ -160,7 +160,7 @@ function mapDrawStyleToPathBuilder( interface AreaGradientOptions { color: string; - mode: AreaGradientMode; + mode: FillGradientMode; opacity: number; } @@ -172,7 +172,7 @@ function getCanvasGradient(opts: AreaGradientOptions): (self: uPlot, seriesIdx: const gradient = ctx.createLinearGradient(0, plot.bbox.top, 0, plot.bbox.top + plot.bbox.height); switch (mode) { - case AreaGradientMode.Hue: + case FillGradientMode.Hue: const color1 = tinycolor(color) .spin(-25) .darken(30) @@ -186,7 +186,7 @@ function getCanvasGradient(opts: AreaGradientOptions): (self: uPlot, seriesIdx: gradient.addColorStop(0, color2); gradient.addColorStop(1, color1); - case AreaGradientMode.Opacity: + case FillGradientMode.Opacity: default: gradient.addColorStop( 0, diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 6d163335204..f731c6be991 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -41,6 +41,7 @@ const tempoPlugin = async () => import * as textPanel from 'app/plugins/panel/text/module'; import * as timeseriesPanel from 'app/plugins/panel/timeseries/module'; import * as graphPanel from 'app/plugins/panel/graph/module'; +import * as xyChartPanel from 'app/plugins/panel/xychart/module'; import * as dashListPanel from 'app/plugins/panel/dashlist/module'; import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module'; @@ -83,6 +84,7 @@ const builtInPlugins: any = { 'app/plugins/panel/text/module': textPanel, 'app/plugins/panel/timeseries/module': timeseriesPanel, 'app/plugins/panel/graph/module': graphPanel, + 'app/plugins/panel/xychart/module': xyChartPanel, 'app/plugins/panel/dashlist/module': dashListPanel, 'app/plugins/panel/pluginlist/module': pluginsListPanel, 'app/plugins/panel/alertlist/module': alertListPanel, diff --git a/public/app/plugins/panel/timeseries/config.ts b/public/app/plugins/panel/timeseries/config.ts new file mode 100644 index 00000000000..0a23b91f2eb --- /dev/null +++ b/public/app/plugins/panel/timeseries/config.ts @@ -0,0 +1,190 @@ +import { FieldColorModeId, FieldConfigProperty, FieldType, identityOverrideProcessor } from '@grafana/data'; +import { + AxisPlacement, + DrawStyle, + GraphFieldConfig, + graphFieldOptions, + LineInterpolation, + LineStyle, + PointVisibility, + ScaleDistribution, + ScaleDistributionConfig, +} from '@grafana/ui'; +import { SeriesConfigEditor } from './HideSeriesConfigEditor'; +import { ScaleDistributionEditor } from './ScaleDistributionEditor'; +import { LineStyleEditor } from './LineStyleEditor'; +import { SetFieldConfigOptionsArgs } from '@grafana/data/src/panel/PanelPlugin'; +import { FillGradientMode } from '@grafana/ui/src/components/uPlot/config'; + +export const defaultGraphConfig: GraphFieldConfig = { + drawStyle: DrawStyle.Line, + lineInterpolation: LineInterpolation.Linear, + lineWidth: 1, + fillOpacity: 0, + fillGradient: FillGradientMode.None, +}; + +export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs { + return { + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: false, + }, + defaultValue: { + mode: FieldColorModeId.PaletteClassic, + }, + }, + }, + useCustomConfig: builder => { + builder + .addRadio({ + path: 'drawStyle', + name: 'Style', + defaultValue: cfg.drawStyle, + settings: { + options: graphFieldOptions.drawStyle, + }, + }) + .addRadio({ + path: 'lineInterpolation', + name: 'Line interpolation', + defaultValue: cfg.lineInterpolation, + settings: { + options: graphFieldOptions.lineInterpolation, + }, + showIf: c => c.drawStyle === DrawStyle.Line, + }) + .addSliderInput({ + path: 'lineWidth', + name: 'Line width', + defaultValue: cfg.lineWidth, + settings: { + min: 0, + max: 10, + step: 1, + }, + showIf: c => c.drawStyle !== DrawStyle.Points, + }) + .addSliderInput({ + path: 'fillOpacity', + name: 'Fill opacity', + defaultValue: cfg.fillOpacity, + settings: { + min: 0, + max: 100, + step: 1, + }, + showIf: c => c.drawStyle !== DrawStyle.Points, + }) + .addRadio({ + path: 'fillGradient', + name: 'Fill gradient', + defaultValue: graphFieldOptions.fillGradient[0].value, + settings: { + options: graphFieldOptions.fillGradient, + }, + showIf: c => !!(c.drawStyle !== DrawStyle.Points && c.fillOpacity && c.fillOpacity > 0), + }) + .addCustomEditor({ + id: 'lineStyle', + path: 'lineStyle', + name: 'Line style', + showIf: c => c.drawStyle === DrawStyle.Line, + editor: LineStyleEditor, + override: LineStyleEditor, + process: identityOverrideProcessor, + shouldApply: f => f.type === FieldType.number, + }) + .addRadio({ + path: 'spanNulls', + name: 'Null values', + defaultValue: false, + settings: { + options: [ + { label: 'Gaps', value: false }, + { label: 'Connected', value: true }, + ], + }, + showIf: c => c.drawStyle === DrawStyle.Line, + }) + .addRadio({ + path: 'showPoints', + name: 'Show points', + defaultValue: graphFieldOptions.showPoints[0].value, + settings: { + options: graphFieldOptions.showPoints, + }, + }) + .addSliderInput({ + path: 'pointSize', + name: 'Point size', + defaultValue: 5, + settings: { + min: 1, + max: 40, + 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, + }) + .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, + }); + }, + }; +} diff --git a/public/app/plugins/panel/timeseries/migrations.ts b/public/app/plugins/panel/timeseries/migrations.ts index df787b2ba32..b9d6af16683 100644 --- a/public/app/plugins/panel/timeseries/migrations.ts +++ b/public/app/plugins/panel/timeseries/migrations.ts @@ -12,7 +12,7 @@ import { } from '@grafana/data'; import { GraphFieldConfig, LegendDisplayMode } from '@grafana/ui'; import { - AreaGradientMode, + FillGradientMode, AxisPlacement, DrawStyle, LineInterpolation, @@ -234,7 +234,7 @@ export function flotToGraphOptions(angular: any): { fieldConfig: FieldConfigSour } if (isNumber(angular.fillGradient) && angular.fillGradient > 0) { - graph.fillGradient = AreaGradientMode.Opacity; + graph.fillGradient = FillGradientMode.Opacity; graph.fillOpacity = angular.fillGradient * 10; // fill is 0-10 } diff --git a/public/app/plugins/panel/timeseries/module.tsx b/public/app/plugins/panel/timeseries/module.tsx index 00079289d1a..217fb9c04cf 100644 --- a/public/app/plugins/panel/timeseries/module.tsx +++ b/public/app/plugins/panel/timeseries/module.tsx @@ -1,192 +1,13 @@ -import { - FieldColorModeId, - FieldConfigProperty, - FieldType, - identityOverrideProcessor, - PanelPlugin, -} from '@grafana/data'; -import { - AxisPlacement, - DrawStyle, - GraphFieldConfig, - graphFieldOptions, - LegendDisplayMode, - LineStyle, - PointVisibility, - ScaleDistribution, - ScaleDistributionConfig, -} from '@grafana/ui'; -import { SeriesConfigEditor } from './HideSeriesConfigEditor'; +import { PanelPlugin } from '@grafana/data'; +import { GraphFieldConfig, LegendDisplayMode } from '@grafana/ui'; import { TimeSeriesPanel } from './TimeSeriesPanel'; import { graphPanelChangedHandler } from './migrations'; import { Options } from './types'; -import { ScaleDistributionEditor } from './ScaleDistributionEditor'; -import { LineStyleEditor } from './LineStyleEditor'; +import { getGraphFieldConfig, defaultGraphConfig } from './config'; export const plugin = new PanelPlugin(TimeSeriesPanel) .setPanelChangeHandler(graphPanelChangedHandler) - .useFieldConfig({ - standardOptions: { - [FieldConfigProperty.Color]: { - settings: { - byValueSupport: false, - }, - defaultValue: { - mode: FieldColorModeId.PaletteClassic, - }, - }, - }, - useCustomConfig: builder => { - builder - .addRadio({ - path: 'drawStyle', - name: 'Style', - defaultValue: graphFieldOptions.drawStyle[0].value, - settings: { - options: graphFieldOptions.drawStyle, - }, - }) - .addRadio({ - path: 'lineInterpolation', - name: 'Line interpolation', - defaultValue: graphFieldOptions.lineInterpolation[0].value, - settings: { - options: graphFieldOptions.lineInterpolation, - }, - showIf: c => c.drawStyle === DrawStyle.Line, - }) - .addSliderInput({ - path: 'lineWidth', - name: 'Line width', - defaultValue: 1, - settings: { - min: 0, - max: 10, - step: 1, - }, - showIf: c => c.drawStyle !== DrawStyle.Points, - }) - .addSliderInput({ - path: 'fillOpacity', - name: 'Fill opacity', - defaultValue: 0, - settings: { - min: 0, - max: 100, - step: 1, - }, - showIf: c => c.drawStyle !== DrawStyle.Points, - }) - .addRadio({ - path: 'fillGradient', - name: 'Fill gradient', - defaultValue: graphFieldOptions.fillGradient[0].value, - settings: { - options: graphFieldOptions.fillGradient, - }, - showIf: c => !!(c.drawStyle !== DrawStyle.Points && c.fillOpacity && c.fillOpacity > 0), - }) - .addCustomEditor({ - id: 'lineStyle', - path: 'lineStyle', - name: 'Line style', - showIf: c => c.drawStyle === DrawStyle.Line, - editor: LineStyleEditor, - override: LineStyleEditor, - process: identityOverrideProcessor, - shouldApply: f => f.type === FieldType.number, - }) - .addRadio({ - path: 'spanNulls', - name: 'Null values', - defaultValue: false, - settings: { - options: [ - { label: 'Gaps', value: false }, - { label: 'Connected', value: true }, - ], - }, - showIf: c => c.drawStyle === DrawStyle.Line, - }) - .addRadio({ - path: 'showPoints', - name: 'Show points', - defaultValue: graphFieldOptions.showPoints[0].value, - settings: { - options: graphFieldOptions.showPoints, - }, - }) - .addSliderInput({ - path: 'pointSize', - name: 'Point size', - defaultValue: 5, - settings: { - min: 1, - max: 40, - 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, - }) - .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, - }); - }, - }) + .useFieldConfig(getGraphFieldConfig(defaultGraphConfig)) .setPanelOptions(builder => { builder .addRadio({ diff --git a/public/app/plugins/panel/xychart/README.md b/public/app/plugins/panel/xychart/README.md new file mode 100644 index 00000000000..74f4e190d5c --- /dev/null +++ b/public/app/plugins/panel/xychart/README.md @@ -0,0 +1,4 @@ +# XY Chart - Native Plugin + +Support arbitrary X vs Y in graph + diff --git a/public/app/plugins/panel/xychart/XYChartPanel.tsx b/public/app/plugins/panel/xychart/XYChartPanel.tsx new file mode 100644 index 00000000000..70fcbe4dc07 --- /dev/null +++ b/public/app/plugins/panel/xychart/XYChartPanel.tsx @@ -0,0 +1,59 @@ +import React, { useCallback, useMemo } from 'react'; +import { Button, TooltipPlugin, GraphNG, GraphNGLegendEvent } from '@grafana/ui'; +import { PanelProps } from '@grafana/data'; +import { Options } from './types'; +import { hideSeriesConfigFactory } from '../timeseries/hideSeriesConfigFactory'; +import { getXYDimensions } from './dims'; + +interface XYChartPanelProps extends PanelProps {} + +export const XYChartPanel: React.FC = ({ + data, + timeRange, + timeZone, + width, + height, + options, + fieldConfig, + onFieldConfigChange, +}) => { + const dims = useMemo(() => getXYDimensions(options.dims, data.series), [options.dims, data.series]); + if (dims.error) { + return ( +
+
ERROR: {dims.error}
+ {dims.hasData && ( +
+ + {dims.hasTime && } +
+ )} +
+ ); + } + + const frames = useMemo(() => [dims.frame], [dims]); + + const onLegendClick = useCallback( + (event: GraphNGLegendEvent) => { + onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, frames)); + }, + [fieldConfig, onFieldConfigChange, frames] + ); + + return ( + + + <>{/* needs to be an array */} + + ); +}; diff --git a/public/app/plugins/panel/xychart/XYDimsEditor.tsx b/public/app/plugins/panel/xychart/XYDimsEditor.tsx new file mode 100644 index 00000000000..8dca3bbbe7c --- /dev/null +++ b/public/app/plugins/panel/xychart/XYDimsEditor.tsx @@ -0,0 +1,177 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { css } from 'emotion'; +import { IconButton, Label, Select, stylesFactory, Switch, useTheme } from '@grafana/ui'; +import { + SelectableValue, + getFrameDisplayName, + GrafanaTheme, + StandardEditorProps, + getFieldDisplayName, +} from '@grafana/data'; + +import { XYDimensionConfig, Options } from './types'; +import { getXYDimensions, isGraphable } from './dims'; + +interface XYInfo { + numberFields: Array>; + xAxis: SelectableValue; + yFields: Array>; +} + +export const XYDimsEditor: FC> = ({ + value, + onChange, + context, +}) => { + if (!context.data) { + return
No data...
; + } + + const frameNames = useMemo(() => { + if (context.data && context.data.length > 0) { + return context.data.map((f, idx) => ({ + value: idx, + label: getFrameDisplayName(f, idx), + })); + } + return [{ value: 0, label: 'First result' }]; + }, [context.data, value?.frame]); + + const dims = useMemo(() => getXYDimensions(value, context.data), [context.data, value]); + + const info = useMemo(() => { + const first = { + label: '?', + value: undefined, // empty + }; + const v: XYInfo = { + numberFields: [first], + yFields: [], + xAxis: value?.x + ? { + label: `${value.x} (Not found)`, + value: value.x, // empty + } + : first, + }; + const frame = context.data ? context.data[value?.frame ?? 0] : undefined; + if (frame) { + const xName = getFieldDisplayName(dims.x, dims.frame, context.data); + for (let field of frame.fields) { + if (isGraphable(field)) { + const name = getFieldDisplayName(field, frame, context.data); + const sel = { + label: name, + value: name, + }; + v.numberFields.push(sel); + if (first.label === '?') { + first.label = `${name} (First)`; + } + if (value?.x && name === value.x) { + v.xAxis = sel; + } + if (xName !== name) { + v.yFields.push({ + label: name, + value: value?.exclude?.includes(name), + }); + } + } + } + } + + return v; + }, [dims, context.data, value]); + + const toggleSort = useCallback(() => { + onChange({ + ...value, + sort: !value?.sort, + }); + }, [value, onChange]); + + const theme = useTheme(); + const styles = getStyles(theme); + + return ( +
+ { + onChange({ + ...value, + x: v.value, + }); + }} + /> +
+ +
  Sort field
+
+
+ +
+ {info.yFields.map(v => ( +
+ { + const exclude: string[] = value?.exclude ? [...value.exclude] : []; + let idx = exclude.indexOf(v.label!); + if (idx < 0) { + exclude.push(v.label!); + } else { + exclude.splice(idx, 1); + } + onChange({ + ...value, + exclude, + }); + }} + /> + {v.label} +
+ ))} +
+

+
+ ); +}; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + sorter: css` + margin-top: 10px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + cursor: pointer; + `, + + row: css` + padding: ${theme.spacing.xs} ${theme.spacing.sm}; + border-radius: ${theme.border.radius.sm}; + background: ${theme.colors.bg2}; + min-height: ${theme.spacing.formInputHeight}px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + margin-bottom: 3px; + border: 1px solid ${theme.colors.formInputBorder}; + `, +})); diff --git a/public/app/plugins/panel/xychart/dims.ts b/public/app/plugins/panel/xychart/dims.ts new file mode 100644 index 00000000000..46a04228894 --- /dev/null +++ b/public/app/plugins/panel/xychart/dims.ts @@ -0,0 +1,103 @@ +import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName, sortDataFrame } from '@grafana/data'; +import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/GraphNG'; +import { XYDimensionConfig } from './types'; + +export enum DimensionError { + NoData, + BadFrameSelection, + XNotFound, +} + +export interface XYDimensions { + frame: DataFrame; // matches order from configs, excluds non-graphable values + x: Field; + fields: XYFieldMatchers; + error?: DimensionError; + hasData?: boolean; + hasTime?: boolean; +} + +export function isGraphable(field: Field) { + return field.type === FieldType.number; +} + +export function getXYDimensions(cfg: XYDimensionConfig, data?: DataFrame[]): XYDimensions { + if (!data || !data.length) { + return { error: DimensionError.NoData } as XYDimensions; + } + if (!cfg) { + cfg = { + frame: 0, + }; + } + + let frame = data[cfg.frame ?? 0]; + if (!frame) { + return { error: DimensionError.BadFrameSelection } as XYDimensions; + } + + let xIndex = -1; + for (let i = 0; i < frame.fields.length; i++) { + const f = frame.fields[i]; + if (cfg.x && cfg.x === getFieldDisplayName(f, frame, data)) { + xIndex = i; + break; + } + if (isGraphable(f) && !cfg.x) { + xIndex = i; + break; + } + } + + // Optionally sort + if (cfg.sort) { + frame = sortDataFrame(frame, xIndex); + } + + let hasTime = false; + const x = frame.fields[xIndex]; + const fields: Field[] = [x]; + for (const f of frame.fields) { + if (f.type === FieldType.time) { + hasTime = true; + } + if (f === x || !isGraphable(f)) { + continue; + } + if (cfg.exclude) { + const name = getFieldDisplayName(f, frame, data); + if (cfg.exclude.includes(name)) { + continue; + } + } + fields.push(f); + } + + return { + x, + fields: { + x: getSimpleFieldMatcher(x), + y: getSimpleFieldNotMatcher(x), // Not x + }, + frame: { + ...frame, + fields, + }, + hasData: frame.fields.length > 0, + hasTime, + }; +} + +function getSimpleFieldMatcher(f: Field): FieldMatcher { + if (!f) { + return () => false; + } + return field => f === field; +} + +function getSimpleFieldNotMatcher(f: Field): FieldMatcher { + if (!f) { + return () => false; + } + return field => f !== field; +} diff --git a/public/app/plugins/panel/xychart/img/icn-xychart.svg b/public/app/plugins/panel/xychart/img/icn-xychart.svg new file mode 100644 index 00000000000..eefedfb679a --- /dev/null +++ b/public/app/plugins/panel/xychart/img/icn-xychart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/xychart/module.tsx b/public/app/plugins/panel/xychart/module.tsx new file mode 100644 index 00000000000..df2d403e799 --- /dev/null +++ b/public/app/plugins/panel/xychart/module.tsx @@ -0,0 +1,62 @@ +import { PanelPlugin } from '@grafana/data'; +import { DrawStyle, GraphFieldConfig, LegendDisplayMode } from '@grafana/ui'; +import { XYChartPanel } from './XYChartPanel'; +import { Options } from './types'; +import { XYDimsEditor } from './XYDimsEditor'; +import { getGraphFieldConfig, defaultGraphConfig } from '../timeseries/config'; + +export const plugin = new PanelPlugin(XYChartPanel) + .useFieldConfig( + getGraphFieldConfig({ + ...defaultGraphConfig, + drawStyle: DrawStyle.Points, + }) + ) + .setPanelOptions(builder => { + builder + .addCustomEditor({ + id: 'xyPlotConfig', + path: 'dims', + name: 'Data', + editor: XYDimsEditor, + }) + .addRadio({ + path: 'tooltipOptions.mode', + name: 'Tooltip mode', + description: '', + defaultValue: 'single', + settings: { + options: [ + { value: 'single', label: 'Single' }, + { value: 'multi', label: 'All' }, + { value: 'none', label: 'Hidden' }, + ], + }, + }) + .addRadio({ + path: 'legend.displayMode', + name: 'Legend mode', + description: '', + defaultValue: LegendDisplayMode.List, + settings: { + options: [ + { value: LegendDisplayMode.List, label: 'List' }, + { value: LegendDisplayMode.Table, label: 'Table' }, + { value: LegendDisplayMode.Hidden, label: 'Hidden' }, + ], + }, + }) + .addRadio({ + path: 'legend.placement', + name: 'Legend placement', + description: '', + defaultValue: 'bottom', + settings: { + options: [ + { value: 'bottom', label: 'Bottom' }, + { value: 'right', label: 'Right' }, + ], + }, + showIf: c => c.legend.displayMode !== LegendDisplayMode.Hidden, + }); + }); diff --git a/public/app/plugins/panel/xychart/plugin.json b/public/app/plugins/panel/xychart/plugin.json new file mode 100644 index 00000000000..945aa611f9d --- /dev/null +++ b/public/app/plugins/panel/xychart/plugin.json @@ -0,0 +1,17 @@ +{ + "type": "panel", + "name": "XY Chart", + "id": "xychart", + "state": "alpha", + + "info": { + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-xychart.svg", + "large": "img/icn-xychart.svg" + } + } +} diff --git a/public/app/plugins/panel/xychart/types.ts b/public/app/plugins/panel/xychart/types.ts new file mode 100644 index 00000000000..fcd649f3d9d --- /dev/null +++ b/public/app/plugins/panel/xychart/types.ts @@ -0,0 +1,14 @@ +import { LegendOptions, GraphTooltipOptions } from '@grafana/ui'; + +export interface XYDimensionConfig { + frame: number; + sort?: boolean; + x?: string; // name | first + exclude?: string[]; // all other numbers except +} + +export interface Options { + dims: XYDimensionConfig; + legend: LegendOptions; + tooltipOptions: GraphTooltipOptions; +}