mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
GraphNG: Bars, Staircase, Smooth modes (#29359)
* initial impl of Bars, Staircase, Smooth modes * fix fillTo for staircase and smooth paths * fix pointSize * [bars] adjust gap factor & reduce bar width to include stroke width. remove rounding to favor gap & width uniformity over edge crispness. * remove line path fallback for too-dense data * "Auto" points mode implies "Never" for Bars * slightly reduce x axis tick density * fix bars height calc, make bars crisp again. * [bars] don't use hard-coded 'x' scale key * refactor catmullRomFitting() to accept x & y coord arrays. restrict to processing only visible data range. * move path building functions to own file * export typed functions Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
parent
18534a1c9c
commit
22ec38b043
@ -104,7 +104,10 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
for (let i = 0; i < alignedFrame.fields.length; i++) {
|
||||
const field = alignedFrame.fields[i];
|
||||
const config = field.config as FieldConfig<GraphFieldConfig>;
|
||||
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<GraphNGProps> = ({
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
|
@ -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<SelectableValue<GraphMode>>,
|
||||
|
||||
lineMode: [
|
||||
lineInterpolation: [
|
||||
{ label: 'Linear', value: LineInterpolation.Linear },
|
||||
{ label: 'Staircase', value: LineInterpolation.Staircase },
|
||||
{ label: 'Smooth', value: LineInterpolation.Smooth },
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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<SeriesProps, Series> {
|
||||
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<Series> = {};
|
||||
|
||||
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<Series> = {
|
||||
points: {
|
||||
@ -36,7 +57,11 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
257
packages/grafana-ui/src/components/uPlot/paths.ts
Normal file
257
packages/grafana-ui/src/components/uPlot/paths.ts
Normal file
@ -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,
|
||||
};
|
||||
};
|
@ -33,28 +33,27 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(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<Options, GraphFieldConfig>(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<Options, GraphFieldConfig>(GraphPanel)
|
||||
},
|
||||
})
|
||||
.addSliderInput({
|
||||
path: 'pointRadius',
|
||||
name: 'Point radius',
|
||||
defaultValue: 4,
|
||||
path: 'pointSize',
|
||||
name: 'Point size',
|
||||
defaultValue: 5,
|
||||
settings: {
|
||||
min: 1,
|
||||
max: 10,
|
||||
|
Loading…
Reference in New Issue
Block a user