GraphNG: refactor by-value color schemes (#37670)

* GraphNG: account for top canvas padding in gradient gen for color scheme/thresholds-by-value

* Updated test dashboard

* Added fix for issue when scaleMin was same as threshold

* fixed firefox issue

* revert docs changes

* update gdev dash for easier comparisons & regression spotting

* refactor

* optimize gradient re-gen/re-use and color more tinycolor.setAlpha() -> alpha(). update uPlot to dev build.

* fix percentage steps

* implement % threshold region rendering

* crisp threshold line rendering

* simplify

* WIP: hoverpoint dynamic color interpolation

* fix hover point color interp

* re-use gradient gen to draw threshold areas

* re-implement by-value color scales

* tweak comment

* mimic tinycolor behavior in colorManipulator.alpha() for empty colors

* explicitly disable hover points for BarChart and Histogram

* reduce test failures and required changes to tests

* fix barchart tests

* uPlot 1.6.15

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Leon Sorokin
2021-08-13 09:38:04 -05:00
committed by GitHub
parent dd0a906fb7
commit 6a77cd43ae
12 changed files with 1122 additions and 708 deletions

View File

@ -255,6 +255,10 @@ export function emphasize(color: string, coefficient = 0.15) {
* @beta
*/
export function alpha(color: string, value: number) {
if (color === '') {
return '#000000';
}
value = clamp(value);
// hex 6, hex 8 (w/alpha)

View File

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

View File

@ -436,7 +436,7 @@ describe('UPlotConfigBuilder', () => {
theme: darkTheme,
});
expect(builder.getConfig().series[1].fill).toBe('rgba(255, 170, 187, 0.5)');
expect(builder.getConfig().series[1].fill).toBe('#FFAABB80');
});
it('when fillColor is set ignore fillOpacity', () => {

View File

@ -25,10 +25,6 @@ const cursorDefaults: Cursor = {
size: (u, seriesIdx) => u.series[seriesIdx].points.size * 2,
/*@ts-ignore*/
width: (u, seriesIdx, size) => size / 4,
/*@ts-ignore*/
stroke: (u, seriesIdx) => u.series[seriesIdx].points.stroke(u, seriesIdx) + '80',
/*@ts-ignore*/
fill: (u, seriesIdx) => u.series[seriesIdx].points.stroke(u, seriesIdx),
},
focus: {
prox: 30,
@ -50,6 +46,7 @@ export class UPlotConfigBuilder {
private hooks: Hooks.Arrays = {};
private tz: string | undefined = undefined;
private sync = false;
private frame: DataFrame | undefined = undefined;
// to prevent more than one threshold per scale
private thresholds: Record<string, UPlotThresholdOptions> = {};
/**
@ -158,7 +155,10 @@ export class UPlotConfigBuilder {
}
setPrepData(prepData: PrepData) {
this.prepData = prepData;
this.prepData = (frame) => {
this.frame = frame;
return prepData(frame);
};
}
setSync() {
@ -187,7 +187,25 @@ export class UPlotConfigBuilder {
config.select = this.select;
config.cursor = merge({}, cursorDefaults, this.cursor);
const pointColorFn = (alphaHex = '') => (u: uPlot, seriesIdx: number) => {
/*@ts-ignore*/
let s = u.series[seriesIdx].points._stroke;
// interpolate for gradients/thresholds
if (typeof s !== 'string') {
let field = this.frame!.fields[seriesIdx];
s = field.display!(field.values.get(u.cursor.idxs![seriesIdx]!)).color!;
}
return s + alphaHex;
};
config.cursor = merge({}, cursorDefaults, this.cursor, {
points: {
stroke: pointColorFn('80'),
fill: pointColorFn(),
},
});
config.tzDate = this.tzDate;

View File

@ -1,5 +1,11 @@
import { DataFrameFieldIndex, FALLBACK_COLOR, FieldColorMode, GrafanaTheme2, ThresholdsConfig } from '@grafana/data';
import tinycolor from 'tinycolor2';
import {
colorManipulator,
DataFrameFieldIndex,
FALLBACK_COLOR,
FieldColorMode,
GrafanaTheme2,
ThresholdsConfig,
} from '@grafana/data';
import uPlot, { Series } from 'uplot';
import {
BarAlignment,
@ -55,16 +61,18 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
} = this.props;
let lineConfig: Partial<Series> = {};
const lineColor = this.getLineColor();
let lineColor = this.getLineColor();
// DrawStyle.Points mode also needs this for fill/stroke sharing & re-use in series.points. see getColor() below.
lineConfig.stroke = lineColor;
if (pathBuilder != null) {
lineConfig.paths = pathBuilder;
lineConfig.stroke = lineColor;
lineConfig.width = lineWidth;
} else if (drawStyle === DrawStyle.Points) {
lineConfig.paths = () => null;
} else if (drawStyle != null) {
lineConfig.stroke = lineColor;
lineConfig.width = lineWidth;
if (lineStyle && lineStyle.fill !== 'solid') {
if (lineStyle.fill === 'dot') {
@ -84,10 +92,14 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
};
}
const useColor: uPlot.Series.Stroke =
// @ts-ignore
typeof lineColor === 'string' ? lineColor : (u, seriesIdx) => u.series[seriesIdx]._stroke;
const pointsConfig: Partial<Series> = {
points: {
stroke: lineColor,
fill: lineColor,
stroke: useColor,
fill: useColor,
size: pointSize,
filter: pointsFilter,
},
@ -153,7 +165,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
return getScaleGradientFn(opacityPercent, theme, colorMode, thresholds);
default:
if (opacityPercent > 0) {
return tinycolor(lineColor).setAlpha(opacityPercent).toString();
return colorManipulator.alpha(lineColor ?? '', opacityPercent);
}
}

View File

@ -1,6 +1,7 @@
import { getColorForTheme, GrafanaTheme2, ThresholdsConfig } from '@grafana/data';
import { GrafanaTheme2, ThresholdsConfig, ThresholdsMode } from '@grafana/data';
import tinycolor from 'tinycolor2';
import { GraphThresholdsStyleConfig, GraphTresholdsStyleMode } from '../config';
import { getDataRange, GradientDirection, scaleGradient } from './gradientFills';
export interface UPlotThresholdOptions {
scaleKey: string;
@ -13,7 +14,6 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
return (u: uPlot) => {
const ctx = u.ctx;
const { scaleKey, thresholds, theme, config } = options;
const { steps } = thresholds;
const { min: xMin, max: xMax } = u.scales.x;
const { min: yMin, max: yMax } = u.scales[scaleKey];
@ -21,6 +21,18 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
return;
}
let { steps, mode } = thresholds;
if (mode === ThresholdsMode.Percentage) {
let [min, max] = getDataRange(u, scaleKey);
let range = max - min;
steps = steps.map((step) => ({
...step,
value: min + range * (step.value / 100),
}));
}
function addLines() {
// Thresholds below a transparent threshold is treated like "less than", and line drawn previous threshold
let transparentIndex = 0;
@ -40,9 +52,9 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
// if we are below a transparent index treat this a less then threshold, use previous thresholds color
if (transparentIndex >= idx && idx > 0) {
color = tinycolor(getColorForTheme(steps[idx - 1].color, theme.v1));
color = tinycolor(theme.visualization.getColorByName(steps[idx - 1].color));
} else {
color = tinycolor(getColorForTheme(step.color, theme.v1));
color = tinycolor(theme.visualization.getColorByName(step.color));
}
// Unless alpha specififed set to default value
@ -50,10 +62,10 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
color.setAlpha(0.7);
}
let x0 = u.valToPos(xMin!, 'x', true);
let y0 = u.valToPos(step.value, scaleKey, true);
let x1 = u.valToPos(xMax!, 'x', true);
let y1 = u.valToPos(step.value, scaleKey, true);
let x0 = Math.round(u.valToPos(xMin!, 'x', true));
let y0 = Math.round(u.valToPos(step.value, scaleKey, true));
let x1 = Math.round(u.valToPos(xMax!, 'x', true));
let y1 = Math.round(u.valToPos(step.value, scaleKey, true));
ctx.beginPath();
ctx.lineWidth = 2;
@ -66,49 +78,26 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
}
function addAreas() {
for (let idx = 0; idx < steps.length; idx++) {
const step = steps[idx];
let grd = scaleGradient(
u,
u.series[1].scale!,
GradientDirection.Up,
steps.map((step) => {
let color = tinycolor(theme.visualization.getColorByName(step.color));
// skip thresholds that cannot be seen
if (step.value > yMax!) {
continue;
}
if (color.getAlpha() === 1) {
color.setAlpha(0.15);
}
// if this is the last step make the next step the same color but +Infinity
const nextStep =
idx + 1 < steps.length
? steps[idx + 1]
: {
...step,
value: Infinity,
};
return [step.value, color.toString()];
}),
true
);
let color = tinycolor(getColorForTheme(step.color, theme.v1));
// Ignore fully transparent colors
const alpha = color.getAlpha();
if (alpha === 0) {
continue;
}
/// if no alpha set automatic alpha
if (alpha === 1) {
color = color.setAlpha(0.15);
}
let value = step.value === -Infinity ? yMin : step.value;
let nextValue = nextStep.value === Infinity || nextStep.value > yMax! ? yMax : nextStep.value;
let x0 = u.valToPos(xMin ?? 0, 'x', true);
let y0 = u.valToPos(value ?? 0, scaleKey, true);
let x1 = u.valToPos(xMax ?? 1, 'x', true);
let y1 = u.valToPos(nextValue ?? 1, scaleKey, true);
ctx.save();
ctx.fillStyle = color.toString();
ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
ctx.restore();
}
ctx.save();
ctx.fillStyle = grd;
ctx.fillRect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
ctx.restore();
}
switch (config.mode) {

View File

@ -1,4 +1,11 @@
import { FieldColorMode, FieldColorModeId, GrafanaTheme2, ThresholdsConfig, ThresholdsMode } from '@grafana/data';
import {
colorManipulator,
FieldColorMode,
FieldColorModeId,
GrafanaTheme2,
ThresholdsConfig,
ThresholdsMode,
} from '@grafana/data';
import tinycolor from 'tinycolor2';
import uPlot from 'uplot';
import { getCanvasContext } from '../../../utils/measureText';
@ -11,8 +18,8 @@ export function getOpacityGradientFn(
const ctx = getCanvasContext();
const gradient = ctx.createLinearGradient(0, plot.bbox.top, 0, plot.bbox.top + plot.bbox.height);
gradient.addColorStop(0, tinycolor(color).setAlpha(opacity).toRgbString());
gradient.addColorStop(1, tinycolor(color).setAlpha(0).toRgbString());
gradient.addColorStop(0, colorManipulator.alpha(color, opacity));
gradient.addColorStop(1, colorManipulator.alpha(color, 0));
return gradient;
};
@ -39,10 +46,132 @@ export function getHueGradientFn(
return gradient;
};
}
/**
* Experimental & quick and dirty test
* Not being used
*/
export enum GradientDirection {
'Right' = 0,
'Up' = 1,
}
type ValueStop = [value: number, color: string];
type ScaleValueStops = ValueStop[];
export function scaleGradient(
u: uPlot,
scaleKey: string,
dir: GradientDirection,
scaleStops: ScaleValueStops,
discrete = false
) {
let scale = u.scales[scaleKey];
// we want the stop below or at the scaleMax
// and the stop below or at the scaleMin, else the stop above scaleMin
let minStopIdx: number | null = null;
let maxStopIdx: number | null = null;
for (let i = 0; i < scaleStops.length; i++) {
let stopVal = scaleStops[i][0];
if (stopVal <= scale.min! || minStopIdx == null) {
minStopIdx = i;
}
maxStopIdx = i;
if (stopVal >= scale.max!) {
break;
}
}
if (minStopIdx === maxStopIdx) {
return scaleStops[minStopIdx!][1];
}
let minStopVal = scaleStops[minStopIdx!][0];
let maxStopVal = scaleStops[maxStopIdx!][0];
if (minStopVal === -Infinity) {
minStopVal = scale.min!;
}
if (maxStopVal === Infinity) {
maxStopVal = scale.max!;
}
let minStopPos = Math.round(u.valToPos(minStopVal, scaleKey, true));
let maxStopPos = Math.round(u.valToPos(maxStopVal, scaleKey, true));
let range = minStopPos - maxStopPos;
let x0, y0, x1, y1;
if (dir === GradientDirection.Up) {
x0 = x1 = 0;
y0 = minStopPos;
y1 = maxStopPos;
} else {
y0 = y1 = 0;
x0 = minStopPos;
x1 = maxStopPos;
}
let ctx = getCanvasContext();
let grd = ctx.createLinearGradient(x0, y0, x1, y1);
let prevColor: string;
for (let i = minStopIdx!; i <= maxStopIdx!; i++) {
let s = scaleStops[i];
let stopPos =
i === minStopIdx ? minStopPos : i === maxStopIdx ? maxStopPos : Math.round(u.valToPos(s[0], scaleKey, true));
let pct = (minStopPos - stopPos) / range;
if (discrete && i > minStopIdx!) {
grd.addColorStop(pct, prevColor!);
}
grd.addColorStop(pct, (prevColor = s[1]));
}
return grd;
}
export function getDataRange(plot: uPlot, scaleKey: string) {
let sc = plot.scales[scaleKey];
let min = Infinity;
let max = -Infinity;
plot.series.forEach((ser, seriesIdx) => {
if (ser.show && ser.scale === scaleKey) {
// uPlot skips finding data min/max when a scale has a pre-defined range
if (ser.min == null) {
let data = plot.data[seriesIdx];
for (let i = 0; i < data.length; i++) {
if (data[i] != null) {
min = Math.min(min, data[i]!);
max = Math.max(max, data[i]!);
}
}
} else {
min = Math.min(min, ser.min!);
max = Math.max(max, ser.max!);
}
}
});
if (max === min) {
min = sc.min!;
max = sc.max!;
}
return [min, max];
}
export function getScaleGradientFn(
opacity: number,
theme: GrafanaTheme2,
@ -58,68 +187,41 @@ export function getScaleGradientFn(
}
return (plot: uPlot, seriesIdx: number) => {
// A uplot bug (I think) where this is called before there is bbox
// Color used for cursor highlight, not sure what to do here as this is called before we have bbox
// and only once so same color is used for all points
if (plot.bbox.top == null) {
return theme.colors.text.primary;
}
let scaleKey = plot.series[seriesIdx].scale!;
const ctx = getCanvasContext();
const gradient = ctx.createLinearGradient(0, plot.bbox.top, 0, plot.bbox.top + plot.bbox.height);
const canvasHeight = plot.bbox.height;
const series = plot.series[seriesIdx];
const scale = plot.scales[series.scale!];
const scaleMin = scale.min ?? 0;
const scaleMax = scale.max ?? 100;
const scaleRange = scaleMax - scaleMin;
const addColorStop = (value: number, color: string) => {
const pos = plot.valToPos(value, series.scale!, true);
// when above range we get negative values here
if (pos < 0) {
return;
}
const percent = Math.max(pos / canvasHeight, 0);
const realColor = tinycolor(theme.visualization.getColorByName(color)).setAlpha(opacity).toString();
const colorStopPos = Math.min(percent, 1);
gradient.addColorStop(colorStopPos, realColor);
};
let gradient: CanvasGradient | string = '';
if (colorMode.id === FieldColorModeId.Thresholds) {
for (let idx = 0; idx < thresholds.steps.length; idx++) {
const step = thresholds.steps[idx];
if (thresholds.mode === ThresholdsMode.Absolute) {
const value = step.value === -Infinity ? scaleMin : step.value;
addColorStop(value, step.color);
if (thresholds.steps.length > idx + 1) {
// to make the gradient discrete
addColorStop(thresholds.steps[idx + 1].value - 0.00000001, step.color);
}
} else {
const percent = step.value === -Infinity ? 0 : step.value;
const realValue = (percent / 100) * scaleRange;
addColorStop(realValue, step.color);
// to make the gradient discrete
if (thresholds.steps.length > idx + 1) {
// to make the gradient discrete
const nextValue = (thresholds.steps[idx + 1].value / 100) * scaleRange - 0.0000001;
addColorStop(nextValue, step.color);
}
}
if (thresholds.mode === ThresholdsMode.Absolute) {
const valueStops = thresholds.steps.map(
(step) =>
[step.value, colorManipulator.alpha(theme.visualization.getColorByName(step.color), opacity)] as ValueStop
);
gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, true);
} else {
const [min, max] = getDataRange(plot, scaleKey);
const range = max - min;
const valueStops = thresholds.steps.map(
(step) =>
[
min + range * (step.value / 100),
colorManipulator.alpha(theme.visualization.getColorByName(step.color), opacity),
] as ValueStop
);
gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, true);
}
} else if (colorMode.getColors) {
const colors = colorMode.getColors(theme);
const stepValue = (scaleMax - scaleMin) / colors.length;
for (let idx = 0; idx < colors.length; idx++) {
addColorStop(scaleMin + stepValue * idx, colors[idx]);
}
const [min, max] = getDataRange(plot, scaleKey);
const range = max - min;
const valueStops = colors.map(
(color, i) =>
[
min + range * (i / (colors.length - 1)),
colorManipulator.alpha(theme.visualization.getColorByName(color), opacity),
] as ValueStop
);
gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, false);
}
return gradient;

View File

@ -63,6 +63,7 @@ Object {
},
"points": Object {
"fill": [Function],
"show": false,
"size": [Function],
"stroke": [Function],
"width": [Function],
@ -191,6 +192,7 @@ Object {
},
"points": Object {
"fill": [Function],
"show": false,
"size": [Function],
"stroke": [Function],
"width": [Function],
@ -319,6 +321,7 @@ Object {
},
"points": Object {
"fill": [Function],
"show": false,
"size": [Function],
"stroke": [Function],
"width": [Function],
@ -447,6 +450,7 @@ Object {
},
"points": Object {
"fill": [Function],
"show": false,
"size": [Function],
"stroke": [Function],
"width": [Function],
@ -575,6 +579,7 @@ Object {
},
"points": Object {
"fill": [Function],
"show": false,
"size": [Function],
"stroke": [Function],
"width": [Function],
@ -703,6 +708,7 @@ Object {
},
"points": Object {
"fill": [Function],
"show": false,
"size": [Function],
"stroke": [Function],
"width": [Function],
@ -831,6 +837,7 @@ Object {
},
"points": Object {
"fill": [Function],
"show": false,
"size": [Function],
"stroke": [Function],
"width": [Function],
@ -959,6 +966,7 @@ Object {
},
"points": Object {
"fill": [Function],
"show": false,
"size": [Function],
"stroke": [Function],
"width": [Function],

View File

@ -324,6 +324,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
cursor: {
x: false,
y: false,
points: { show: false },
},
// scale & axis opts
xValues,

View File

@ -146,6 +146,7 @@ const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => {
});
builder.setCursor({
points: { show: false },
drag: {
x: true,
y: false,

View File

@ -23909,10 +23909,10 @@ update-notifier@^2.5.0:
semver-diff "^2.0.0"
xdg-basedir "^3.0.0"
uplot@1.6.14:
version "1.6.14"
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.6.14.tgz#49edfaea3090a9c71d8ae389780b90635aeda3e0"
integrity sha512-I/fO/pujHe6uurtCEVy6L0Vy6/p7AclbrUGu3Mw+oW0PTGPo0khnAWLyyDqSRyMyOwIin8y5HbBEiN3g4qOLuw==
uplot@1.6.15:
version "1.6.15"
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.6.15.tgz#785ca9f66d9c28ec2fdfb7e623d627ea0dcb0dd5"
integrity sha512-6Fgq9tMaEM9Yu9oLkKd0w7VLJtV8LHG6dBrg1TmYi0LmSLkrj2Hqr11IrHk68cMaExnWAqay6YToQCrMZt1fcQ==
upper-case@^1.1.1:
version "1.1.3"