GraphNG: add gaps/nulls support to staircase & smooth interpolation modes (#29593)

This commit is contained in:
Leon Sorokin 2020-12-04 10:33:04 -06:00 committed by GitHub
parent f9c8d5ab49
commit 7236a44a4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 177 additions and 53 deletions

View File

@ -71,7 +71,7 @@
"react-transition-group": "4.4.1", "react-transition-group": "4.4.1",
"slate": "0.47.8", "slate": "0.47.8",
"tinycolor2": "1.4.1", "tinycolor2": "1.4.1",
"uplot": "1.4.6" "uplot": "1.4.7"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "16.0.0", "@rollup/plugin-commonjs": "16.0.0",

View File

@ -23,8 +23,9 @@ export enum GraphMode {
export enum LineInterpolation { export enum LineInterpolation {
Linear = 'linear', Linear = 'linear',
Staircase = 'staircase', // https://leeoniya.github.io/uPlot/demos/line-stepped.html Smooth = 'smooth',
Smooth = 'smooth', // https://leeoniya.github.io/uPlot/demos/line-smoothing.html StepBefore = 'stepBefore',
StepAfter = 'stepAfter',
} }
export interface LineConfig { export interface LineConfig {
@ -65,8 +66,9 @@ export const graphFieldOptions = {
lineInterpolation: [ lineInterpolation: [
{ label: 'Linear', value: LineInterpolation.Linear }, { label: 'Linear', value: LineInterpolation.Linear },
{ label: 'Staircase', value: LineInterpolation.Staircase },
{ label: 'Smooth', value: LineInterpolation.Smooth }, { label: 'Smooth', value: LineInterpolation.Smooth },
{ label: 'Step Before', value: LineInterpolation.StepBefore },
{ label: 'Step After', value: LineInterpolation.StepAfter },
] as Array<SelectableValue<LineInterpolation>>, ] as Array<SelectableValue<LineInterpolation>>,
points: [ points: [

View File

@ -1,7 +1,7 @@
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import uPlot, { Series } from 'uplot'; import uPlot, { Series } from 'uplot';
import { GraphMode, LineConfig, AreaConfig, PointsConfig, PointMode, LineInterpolation } from '../config'; import { GraphMode, LineConfig, AreaConfig, PointsConfig, PointMode, LineInterpolation } from '../config';
import { barsBuilder, smoothBuilder, staircaseBuilder } from '../paths'; import { barsBuilder, smoothBuilder, stepBeforeBuilder, stepAfterBuilder } from '../paths';
import { PlotConfigBuilder } from '../types'; import { PlotConfigBuilder } from '../types';
export interface SeriesProps extends LineConfig, AreaConfig, PointsConfig { export interface SeriesProps extends LineConfig, AreaConfig, PointsConfig {
@ -31,20 +31,29 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
} else { } else {
lineConfig.stroke = lineColor; lineConfig.stroke = lineColor;
lineConfig.width = lineWidth; lineConfig.width = lineWidth;
lineConfig.paths = (self: uPlot, seriesIdx: number, idx0: number, idx1: number) => { lineConfig.paths = (
self: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: Series.ExtendGap,
buildClip: Series.BuildClip
) => {
let pathsBuilder = self.paths; let pathsBuilder = self.paths;
if (mode === GraphMode.Bars) { if (mode === GraphMode.Bars) {
pathsBuilder = barsBuilder; pathsBuilder = barsBuilder;
} else if (mode === GraphMode.Line) { } else if (mode === GraphMode.Line) {
if (lineInterpolation === LineInterpolation.Staircase) { if (lineInterpolation === LineInterpolation.StepBefore) {
pathsBuilder = staircaseBuilder; pathsBuilder = stepBeforeBuilder;
} else if (lineInterpolation === LineInterpolation.StepAfter) {
pathsBuilder = stepAfterBuilder;
} else if (lineInterpolation === LineInterpolation.Smooth) { } else if (lineInterpolation === LineInterpolation.Smooth) {
pathsBuilder = smoothBuilder; pathsBuilder = smoothBuilder;
} }
} }
return pathsBuilder(self, seriesIdx, idx0, idx1); return pathsBuilder(self, seriesIdx, idx0, idx1, extendGap, buildClip);
}; };
} }

View File

@ -1,6 +1,13 @@
import uPlot, { Series } from 'uplot'; import uPlot, { Series } from 'uplot';
export const barsBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx0: number, idx1: number) => { export const barsBuilder: Series.PathBuilder = (
u: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: Series.ExtendGap,
buildClip: Series.BuildClip
) => {
const series = u.series[seriesIdx]; const series = u.series[seriesIdx];
const xdata = u.data[0]; const xdata = u.data[0];
const ydata = u.data[seriesIdx]; const ydata = u.data[seriesIdx];
@ -53,47 +60,115 @@ export const barsBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx
}; };
}; };
export const staircaseBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx0: number, idx1: number) => { /*
const series = u.series[seriesIdx]; const enum StepSide {
const xdata = u.data[0]; Before,
const ydata = u.data[seriesIdx]; After,
const scaleX = u.series[0].scale as string; }
const scaleY = series.scale as string; */
const stroke = new Path2D(); export const stepBeforeBuilder = stepBuilderFactory(false);
stroke.moveTo(Math.round(u.valToPos(xdata[0], scaleX, true)), Math.round(u.valToPos(ydata[0]!, scaleY, true))); export const stepAfterBuilder = stepBuilderFactory(true);
for (let i = idx0; i <= idx1 - 1; i++) { // babel does not support inlined const enums, so this uses a boolean flag for perf
let x0 = Math.round(u.valToPos(xdata[i], scaleX, true)); // possible workaround: https://github.com/dosentmatter/babel-plugin-const-enum
let y0 = Math.round(u.valToPos(ydata[i]!, scaleY, true)); function stepBuilderFactory(after: boolean): Series.PathBuilder {
let x1 = Math.round(u.valToPos(xdata[i + 1], scaleX, true)); return (
let y1 = Math.round(u.valToPos(ydata[i + 1]!, scaleY, true)); u: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: Series.ExtendGap,
buildClip: Series.BuildClip
) => {
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 halfStroke = series.width! / 2;
stroke.lineTo(x0, y0); const stroke = new Path2D();
stroke.lineTo(x1, y0);
if (i === idx1 - 1) { // find first non-null dataPt
stroke.lineTo(x1, y1); while (ydata[idx0] == null) {
idx0++;
} }
}
const fill = new Path2D(stroke); // find last-null dataPt
while (ydata[idx1] == null) {
idx1--;
}
//@ts-ignore let gaps: Series.Gaps = [];
let fillTo = series.fillTo(u, seriesIdx, series.min, series.max); let inGap = false;
let prevYPos = Math.round(u.valToPos(ydata[idx0]!, scaleY, true));
let firstXPos = Math.round(u.valToPos(xdata[idx0], scaleX, true));
let prevXPos = firstXPos;
let minY = Math.round(u.valToPos(fillTo, scaleY, true)); stroke.moveTo(firstXPos, prevYPos);
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); for (let i = idx0 + 1; i <= idx1; i++) {
fill.lineTo(minX, minY); let yVal1 = ydata[i];
return { let x1 = Math.round(u.valToPos(xdata[i], scaleX, true));
stroke,
fill, if (yVal1 == null) {
//@ts-ignore
if (series.isGap(u, seriesIdx, i)) {
extendGap(gaps, prevXPos, x1);
inGap = true;
}
continue;
}
let y1 = Math.round(u.valToPos(yVal1, scaleY, true));
if (inGap) {
extendGap(gaps, prevXPos, x1);
// don't clip vertical extenders
if (prevYPos !== y1) {
let lastGap = gaps[gaps.length - 1];
lastGap[0] += halfStroke;
lastGap[1] -= halfStroke;
}
inGap = false;
}
if (after) {
stroke.lineTo(x1, prevYPos);
} else {
stroke.lineTo(prevXPos, y1);
}
stroke.lineTo(x1, y1);
prevYPos = y1;
prevXPos = x1;
}
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));
fill.lineTo(prevXPos, minY);
fill.lineTo(firstXPos, minY);
let clip = !series.spanGaps ? buildClip(gaps) : null;
return {
stroke,
fill,
clip,
};
}; };
}; }
// adapted from https://gist.github.com/nicholaswmin/c2661eb11cad5671d816 (MIT) // adapted from https://gist.github.com/nicholaswmin/c2661eb11cad5671d816 (MIT)
/** /**
@ -217,26 +292,63 @@ function catmullRomFitting(xCoords: number[], yCoords: number[], alpha: number)
return path; return path;
} }
export const smoothBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, idx0: number, idx1: number) => { export const smoothBuilder: Series.PathBuilder = (
u: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: Series.ExtendGap,
buildClip: Series.BuildClip
) => {
const series = u.series[seriesIdx]; const series = u.series[seriesIdx];
const xdata = u.data[0]; const xdata = u.data[0];
const ydata = u.data[seriesIdx]; const ydata = u.data[seriesIdx];
const scaleX = u.series[0].scale as string; const scaleX = u.series[0].scale as string;
const scaleY = series.scale as string; const scaleY = series.scale as string;
const alpha = 0.5; // find first non-null dataPt
while (ydata[idx0] == null) {
idx0++;
}
// find last-null dataPt
while (ydata[idx1] == null) {
idx1--;
}
let gaps: Series.Gaps = [];
let inGap = false;
let firstXPos = Math.round(u.valToPos(xdata[idx0], scaleX, true));
let prevXPos = firstXPos;
let xCoords = []; let xCoords = [];
let yCoords = []; let yCoords = [];
for (let i = idx0; i <= idx1; i++) { for (let i = idx0; i <= idx1; i++) {
if (ydata[i] != null) { let yVal = ydata[i];
xCoords.push(u.valToPos(xdata[i], scaleX, true)); let xVal = xdata[i];
let xPos = u.valToPos(xVal, scaleX, true);
if (yVal == null) {
//@ts-ignore
if (series.isGap(u, seriesIdx, i)) {
extendGap(gaps, prevXPos + 1, xPos);
inGap = true;
}
continue;
} else {
if (inGap) {
extendGap(gaps, prevXPos + 1, xPos + 1);
inGap = false;
}
xCoords.push((prevXPos = xPos));
yCoords.push(u.valToPos(ydata[i]!, scaleY, true)); yCoords.push(u.valToPos(ydata[i]!, scaleY, true));
} }
} }
const stroke = catmullRomFitting(xCoords, yCoords, alpha); const stroke = catmullRomFitting(xCoords, yCoords, 0.5);
const fill = new Path2D(stroke); const fill = new Path2D(stroke);
@ -244,14 +356,15 @@ export const smoothBuilder: Series.PathBuilder = (u: uPlot, seriesIdx: number, i
let fillTo = series.fillTo(u, seriesIdx, series.min, series.max); let fillTo = series.fillTo(u, seriesIdx, series.min, series.max);
let minY = Math.round(u.valToPos(fillTo, scaleY, true)); 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(prevXPos, minY);
fill.lineTo(minX, minY); fill.lineTo(firstXPos, minY);
let clip = !series.spanGaps ? buildClip(gaps) : null;
return { return {
stroke, stroke,
fill, fill,
clip,
}; };
}; };

View File

@ -25667,10 +25667,10 @@ update-notifier@^2.5.0:
semver-diff "^2.0.0" semver-diff "^2.0.0"
xdg-basedir "^3.0.0" xdg-basedir "^3.0.0"
uplot@1.4.6: uplot@1.4.7:
version "1.4.6" version "1.4.7"
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.4.6.tgz#52b6812c06b8d3cb89d4a8d053f45415cb5d9a13" resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.4.7.tgz#feab0cf48b569184bfefacf29308deb021bad765"
integrity sha512-nw3LdjLFhAqKQF/Rv7QjZICVnjJemOQVj2L3b7889gHBrnC1LwltkzTcawDcbMe6pxo++0KVev9xSC0YCw+m5w== integrity sha512-zyVwJWuZ5/DOULPpJZb8XhVsSFgWXvitPg1acAwIjZEblUfKAwvfHXA6+Gz6JruCWCokNyC4f3ZGgMcqT0Bv0Q==
upper-case@^1.1.1: upper-case@^1.1.1:
version "1.1.3" version "1.1.3"