diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 411d7073b92..25a9a65b752 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -104,7 +104,10 @@ export const GraphNG: React.FC = ({ for (let i = 0; i < alignedFrame.fields.length; i++) { const field = alignedFrame.fields[i]; const config = field.config as FieldConfig; - const customConfig = config.custom || defaultConfig; + const customConfig: GraphFieldConfig = { + ...defaultConfig, + ...config.custom, + }; if (field === xField || field.type !== FieldType.number) { continue; @@ -134,14 +137,14 @@ export const GraphNG: React.FC = ({ builder.addSeries({ scaleKey: scale, - line: (customConfig.mode ?? GraphMode.Line) === GraphMode.Line, + mode: customConfig.mode!, lineColor: seriesColor, lineWidth: customConfig.lineWidth, + lineInterpolation: customConfig.lineInterpolation, points: pointsMode, - pointSize: customConfig.pointRadius, + pointSize: customConfig.pointSize, pointColor: seriesColor, - fill: customConfig.fillAlpha !== undefined, - fillOpacity: customConfig.fillAlpha, + fillOpacity: customConfig.fillOpacity, fillColor: seriesColor, }); diff --git a/packages/grafana-ui/src/components/uPlot/config.ts b/packages/grafana-ui/src/components/uPlot/config.ts index 00ee5e2850c..9da26df3227 100644 --- a/packages/grafana-ui/src/components/uPlot/config.ts +++ b/packages/grafana-ui/src/components/uPlot/config.ts @@ -11,13 +11,13 @@ export enum AxisPlacement { export enum PointMode { Auto = 'auto', // will show points when the density is low or line is hidden - Always = 'always', Never = 'never', + Always = 'always', } export enum GraphMode { Line = 'line', // default - Bar = 'bar', // will also have a gap percent + Bars = 'bars', // will also have a gap percent Points = 'points', // Only show points } @@ -27,31 +27,43 @@ export enum LineInterpolation { Smooth = 'smooth', // https://leeoniya.github.io/uPlot/demos/line-smoothing.html } -export interface GraphFieldConfig { - mode: GraphMode; +export interface LineConfig { + lineColor?: string; + lineWidth?: number; + lineInterpolation?: LineInterpolation; +} - lineMode?: LineInterpolation; - lineWidth?: number; // pixels - fillAlpha?: number; // 0-1 +export interface AreaConfig { + fillColor?: string; + fillOpacity?: number; +} +export interface PointsConfig { points?: PointMode; - pointRadius?: number; // pixels - symbol?: string; // eventually dot,star, etc + pointSize?: number; + pointColor?: string; + pointSymbol?: string; // eventually dot,star, etc +} - // Axis is actually unique based on the unit... not each field! +// Axis is actually unique based on the unit... not each field! +export interface AxisConfig { axisPlacement?: AxisPlacement; axisLabel?: string; axisWidth?: number; // pixels ideally auto? } +export interface GraphFieldConfig extends LineConfig, AreaConfig, PointsConfig, AxisConfig { + mode?: GraphMode; +} + export const graphFieldOptions = { mode: [ { label: 'Lines', value: GraphMode.Line }, - { label: 'Bars', value: GraphMode.Bar }, + { label: 'Bars', value: GraphMode.Bars }, { label: 'Points', value: GraphMode.Points }, ] as Array>, - lineMode: [ + lineInterpolation: [ { label: 'Linear', value: LineInterpolation.Linear }, { label: 'Staircase', value: LineInterpolation.Staircase }, { label: 'Smooth', value: LineInterpolation.Smooth }, diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index a5cfdd52125..8792d474d0d 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -83,7 +83,7 @@ function calculateSpace(self: uPlot, axisIdx: number, scaleMin: number, scaleMax // For x-axis (bottom) we need bigger spacing between labels if (axis.side === 2) { - return 50; + return 55; } return 30; 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 9d5e6b2c19c..18a3ae243e9 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 { AxisPlacement, PointMode } from '../config'; +import { AxisPlacement, GraphMode, PointMode } from '../config'; describe('UPlotConfigBuilder', () => { describe('scales config', () => { @@ -122,14 +122,13 @@ describe('UPlotConfigBuilder', () => { it('allows series configuration', () => { const builder = new UPlotConfigBuilder(); builder.addSeries({ + mode: GraphMode.Line, scaleKey: 'scale-x', - fill: true, fillColor: '#ff0000', fillOpacity: 0.5, points: PointMode.Auto, pointSize: 5, pointColor: '#00ff00', - line: true, lineColor: '#0000ff', lineWidth: 1, }); @@ -142,6 +141,7 @@ describe('UPlotConfigBuilder', () => { Object {}, Object { "fill": "rgba(255, 0, 0, 0.5)", + "paths": [Function], "points": Object { "fill": "#00ff00", "size": 5, diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts index 09efd4ac57a..24e48258454 100755 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts @@ -1,31 +1,52 @@ import tinycolor from 'tinycolor2'; -import { Series } from 'uplot'; -import { PointMode } from '../config'; +import uPlot, { Series } from 'uplot'; +import { GraphMode, LineConfig, AreaConfig, PointsConfig, PointMode, LineInterpolation } from '../config'; +import { barsBuilder, smoothBuilder, staircaseBuilder } from '../paths'; import { PlotConfigBuilder } from '../types'; -export interface SeriesProps { +export interface SeriesProps extends LineConfig, AreaConfig, PointsConfig { + mode: GraphMode; scaleKey: string; - line?: boolean; - lineColor?: string; - lineWidth?: number; - points?: PointMode; - pointSize?: number; - pointColor?: string; - fill?: boolean; - fillOpacity?: number; - fillColor?: string; } export class UPlotSeriesBuilder extends PlotConfigBuilder { getConfig() { - const { line, lineColor, lineWidth, points, pointColor, pointSize, fillColor, fillOpacity, scaleKey } = this.props; + const { + mode, + lineInterpolation, + lineColor, + lineWidth, + points, + pointColor, + pointSize, + fillColor, + fillOpacity, + scaleKey, + } = this.props; - const lineConfig = line - ? { - stroke: lineColor, - width: lineWidth, + let lineConfig: Partial = {}; + + if (mode === GraphMode.Points) { + lineConfig.paths = () => null; + } else { + lineConfig.stroke = lineColor; + lineConfig.width = lineWidth; + lineConfig.paths = (self: uPlot, seriesIdx: number, idx0: number, idx1: number) => { + let pathsBuilder = self.paths; + + if (mode === GraphMode.Bars) { + pathsBuilder = barsBuilder; + } else if (mode === GraphMode.Line) { + if (lineInterpolation === LineInterpolation.Staircase) { + pathsBuilder = staircaseBuilder; + } else if (lineInterpolation === LineInterpolation.Smooth) { + pathsBuilder = smoothBuilder; + } } - : {}; + + return pathsBuilder(self, seriesIdx, idx0, idx1); + }; + } const pointsConfig: Partial = { points: { @@ -36,7 +57,11 @@ 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 (points === PointMode.Never) { + if (points === PointMode.Auto) { + if (mode === GraphMode.Bars) { + pointsConfig.points!.show = false; + } + } else if (points === PointMode.Never) { pointsConfig.points!.show = false; } else if (points === PointMode.Always) { pointsConfig.points!.show = true; diff --git a/packages/grafana-ui/src/components/uPlot/paths.ts b/packages/grafana-ui/src/components/uPlot/paths.ts new file mode 100644 index 00000000000..f0818b81306 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/paths.ts @@ -0,0 +1,257 @@ +import uPlot, { Series } from 'uplot'; + +export const barsBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx0: number, idx1: number) => { + const series = u.series[seriesIdx]; + const xdata = u.data[0]; + const ydata = u.data[seriesIdx]; + const scaleX = u.series[0].scale as string; + const scaleY = series.scale as string; + + const gapFactor = 0.25; + + let gap = (u.width * gapFactor) / (idx1 - idx0); + let maxWidth = Infinity; + + //@ts-ignore + let fillTo = series.fillTo(u, seriesIdx, series.min, series.max); + + let y0Pos = u.valToPos(fillTo, scaleY, true); + let colWid = u.bbox.width / (idx1 - idx0); + + let strokeWidth = Math.round(series.width! * devicePixelRatio); + + let barWid = Math.round(Math.min(maxWidth, colWid - gap) - strokeWidth); + + let stroke = new Path2D(); + + for (let i = idx0; i <= idx1; i++) { + let yVal = ydata[i]; + + if (yVal == null) { + continue; + } + + let xVal = u.scales.x.distr === 2 ? i : xdata[i]; + + // TODO: all xPos can be pre-computed once for all series in aligned set + let xPos = u.valToPos(xVal, scaleX, true); + let yPos = u.valToPos(yVal, scaleY, true); + + let lft = Math.round(xPos - barWid / 2); + let btm = Math.round(Math.max(yPos, y0Pos)); + let top = Math.round(Math.min(yPos, y0Pos)); + let barHgt = btm - top; + + stroke.rect(lft, top, barWid, barHgt); + } + + let fill = series.fill != null ? new Path2D(stroke) : undefined; + + return { + stroke, + fill, + }; +}; + +export const staircaseBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx0: number, idx1: number) => { + const series = u.series[seriesIdx]; + const xdata = u.data[0]; + const ydata = u.data[seriesIdx]; + const scaleX = u.series[0].scale as string; + const scaleY = series.scale as string; + + const stroke = new Path2D(); + stroke.moveTo(Math.round(u.valToPos(xdata[0], scaleX, true)), Math.round(u.valToPos(ydata[0]!, scaleY, true))); + + for (let i = idx0; i <= idx1 - 1; i++) { + let x0 = Math.round(u.valToPos(xdata[i], scaleX, true)); + let y0 = Math.round(u.valToPos(ydata[i]!, scaleY, true)); + let x1 = Math.round(u.valToPos(xdata[i + 1], scaleX, true)); + let y1 = Math.round(u.valToPos(ydata[i + 1]!, scaleY, true)); + + stroke.lineTo(x0, y0); + stroke.lineTo(x1, y0); + + if (i === idx1 - 1) { + stroke.lineTo(x1, y1); + } + } + + const fill = new Path2D(stroke); + + //@ts-ignore + let fillTo = series.fillTo(u, seriesIdx, series.min, series.max); + + let minY = Math.round(u.valToPos(fillTo, scaleY, true)); + let minX = Math.round(u.valToPos(u.scales[scaleX].min!, scaleX, true)); + let maxX = Math.round(u.valToPos(u.scales[scaleX].max!, scaleX, true)); + + fill.lineTo(maxX, minY); + fill.lineTo(minX, minY); + + return { + stroke, + fill, + }; +}; + +// adapted from https://gist.github.com/nicholaswmin/c2661eb11cad5671d816 (MIT) +/** + * Interpolates a Catmull-Rom Spline through a series of x/y points + * Converts the CR Spline to Cubic Beziers for use with SVG items + * + * If 'alpha' is 0.5 then the 'Centripetal' variant is used + * If 'alpha' is 1 then the 'Chordal' variant is used + * + * + * @param {Array} data - Array of points, each point in object literal holding x/y values + * @return {String} d - SVG string with cubic bezier curves representing the Catmull-Rom Spline + */ +function catmullRomFitting(xCoords: number[], yCoords: number[], alpha: number) { + const path = new Path2D(); + + const dataLen = xCoords.length; + + let p0x, + p0y, + p1x, + p1y, + p2x, + p2y, + p3x, + p3y, + bp1x, + bp1y, + bp2x, + bp2y, + d1, + d2, + d3, + A, + B, + N, + M, + d3powA, + d2powA, + d3pow2A, + d2pow2A, + d1pow2A, + d1powA; + + path.moveTo(Math.round(xCoords[0]), Math.round(yCoords[0])); + + for (let i = 0; i < dataLen - 1; i++) { + let p0i = i === 0 ? 0 : i - 1; + + p0x = xCoords[p0i]; + p0y = yCoords[p0i]; + + p1x = xCoords[i]; + p1y = yCoords[i]; + + p2x = xCoords[i + 1]; + p2y = yCoords[i + 1]; + + if (i + 2 < dataLen) { + p3x = xCoords[i + 2]; + p3y = yCoords[i + 2]; + } else { + p3x = p2x; + p3y = p2y; + } + + d1 = Math.sqrt(Math.pow(p0x - p1x, 2) + Math.pow(p0y - p1y, 2)); + d2 = Math.sqrt(Math.pow(p1x - p2x, 2) + Math.pow(p1y - p2y, 2)); + d3 = Math.sqrt(Math.pow(p2x - p3x, 2) + Math.pow(p2y - p3y, 2)); + + // Catmull-Rom to Cubic Bezier conversion matrix + + // A = 2d1^2a + 3d1^a * d2^a + d3^2a + // B = 2d3^2a + 3d3^a * d2^a + d2^2a + + // [ 0 1 0 0 ] + // [ -d2^2a /N A/N d1^2a /N 0 ] + // [ 0 d3^2a /M B/M -d2^2a /M ] + // [ 0 0 1 0 ] + + d3powA = Math.pow(d3, alpha); + d3pow2A = Math.pow(d3, 2 * alpha); + d2powA = Math.pow(d2, alpha); + d2pow2A = Math.pow(d2, 2 * alpha); + d1powA = Math.pow(d1, alpha); + d1pow2A = Math.pow(d1, 2 * alpha); + + A = 2 * d1pow2A + 3 * d1powA * d2powA + d2pow2A; + B = 2 * d3pow2A + 3 * d3powA * d2powA + d2pow2A; + N = 3 * d1powA * (d1powA + d2powA); + + if (N > 0) { + N = 1 / N; + } + + M = 3 * d3powA * (d3powA + d2powA); + + if (M > 0) { + M = 1 / M; + } + + bp1x = (-d2pow2A * p0x + A * p1x + d1pow2A * p2x) * N; + bp1y = (-d2pow2A * p0y + A * p1y + d1pow2A * p2y) * N; + + bp2x = (d3pow2A * p1x + B * p2x - d2pow2A * p3x) * M; + bp2y = (d3pow2A * p1y + B * p2y - d2pow2A * p3y) * M; + + if (bp1x === 0 && bp1y === 0) { + bp1x = p1x; + bp1y = p1y; + } + + if (bp2x === 0 && bp2y === 0) { + bp2x = p2x; + bp2y = p2y; + } + + path.bezierCurveTo(bp1x, bp1y, bp2x, bp2y, p2x, p2y); + } + + return path; +} + +export const smoothBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx0: number, idx1: number) => { + const series = u.series[seriesIdx]; + const xdata = u.data[0]; + const ydata = u.data[seriesIdx]; + const scaleX = u.series[0].scale as string; + const scaleY = series.scale as string; + + const alpha = 0.5; + + let xCoords = []; + let yCoords = []; + + for (let i = idx0; i <= idx1; i++) { + if (ydata[i] != null) { + xCoords.push(u.valToPos(xdata[i], scaleX, true)); + yCoords.push(u.valToPos(ydata[i]!, scaleY, true)); + } + } + + const stroke = catmullRomFitting(xCoords, yCoords, alpha); + + const fill = new Path2D(stroke); + + //@ts-ignore + let fillTo = series.fillTo(u, seriesIdx, series.min, series.max); + + let minY = Math.round(u.valToPos(fillTo, scaleY, true)); + let minX = Math.round(u.valToPos(u.scales[scaleX].min!, scaleX, true)); + let maxX = Math.round(u.valToPos(u.scales[scaleX].max!, scaleX, true)); + + fill.lineTo(maxX, minY); + fill.lineTo(minX, minY); + + return { + stroke, + fill, + }; +}; diff --git a/public/app/plugins/panel/graph3/module.tsx b/public/app/plugins/panel/graph3/module.tsx index 32a4dc8bc9b..c2d0128e916 100644 --- a/public/app/plugins/panel/graph3/module.tsx +++ b/public/app/plugins/panel/graph3/module.tsx @@ -33,28 +33,27 @@ export const plugin = new PanelPlugin(GraphPanel) }, }) .addRadio({ - path: 'lineMode', + path: 'lineInterpolation', name: 'Line interpolation', - description: 'NOTE: not implemented yet', - defaultValue: graphFieldOptions.lineMode[0].value, + defaultValue: graphFieldOptions.lineInterpolation[0].value, settings: { - options: graphFieldOptions.lineMode, + options: graphFieldOptions.lineInterpolation, }, - showIf: c => !(c.mode === GraphMode.Bar || c.mode === GraphMode.Points), + showIf: c => c.mode === GraphMode.Line, }) .addSliderInput({ path: 'lineWidth', name: 'Line width', defaultValue: 1, settings: { - min: 1, + min: 0, max: 10, step: 1, }, - showIf: c => !(c.mode === GraphMode.Bar || c.mode === GraphMode.Points), + showIf: c => c.mode !== GraphMode.Points, }) .addSliderInput({ - path: 'fillAlpha', + path: 'fillOpacity', name: 'Fill area opacity', defaultValue: 0.1, settings: { @@ -62,7 +61,7 @@ export const plugin = new PanelPlugin(GraphPanel) max: 1, step: 0.1, }, - showIf: c => !(c.mode === GraphMode.Bar || c.mode === GraphMode.Points), + showIf: c => c.mode !== GraphMode.Points, }) .addRadio({ path: 'points', @@ -73,9 +72,9 @@ export const plugin = new PanelPlugin(GraphPanel) }, }) .addSliderInput({ - path: 'pointRadius', - name: 'Point radius', - defaultValue: 4, + path: 'pointSize', + name: 'Point size', + defaultValue: 5, settings: { min: 1, max: 10,