Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fill gradients for scatter traces #6905

Merged
merged 10 commits into from
Mar 6, 2024
19 changes: 19 additions & 0 deletions src/components/color/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,25 @@ color.combine = function(front, back) {
return tinycolor(fcflat).toRgbString();
};

/*
* Linearly interpolate between two colors at a normalized interpolation position (0 to 1).
*
* Ignores alpha channel values.
* The resulting color is computed as: factor * first + (1 - factor) * second.
*/
color.interpolate = function(first, second, factor) {
var fc = tinycolor(first).toRgb();
var sc = tinycolor(second).toRgb();

var ic = {
r: factor * fc.r + (1 - factor) * sc.r,
g: factor * fc.g + (1 - factor) * sc.g,
b: factor * fc.b + (1 - factor) * sc.b,
};

return tinycolor(ic).toRgbString();
};

/*
* Create a color that contrasts with cstr.
*
Expand Down
125 changes: 111 additions & 14 deletions src/components/drawing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,9 @@ drawing.dashStyle = function(dash, lineWidth) {
return dash;
};

function setFillStyle(sel, trace, gd) {
function setFillStyle(sel, trace, gd, forLegend) {
var markerPattern = trace.fillpattern;
var fillgradient = trace.fillgradient;
var patternShape = markerPattern && drawing.getPatternAttr(markerPattern.shape, 0, '');
if(patternShape) {
var patternBGColor = drawing.getPatternAttr(markerPattern.bgcolor, 0, null);
Expand All @@ -192,6 +193,55 @@ function setFillStyle(sel, trace, gd) {
undefined, markerPattern.fillmode,
patternBGColor, patternFGColor, patternFGOpacity
);
} else if(fillgradient && fillgradient.type !== 'none') {
var direction = fillgradient.type;
var gradientID = 'scatterfill-' + trace.uid;
if(forLegend) {
gradientID = 'legendfill-' + trace.uid;
}

if(!forLegend && (fillgradient.start !== undefined || fillgradient.stop !== undefined)) {
var start, stop;
if(direction === 'horizontal') {
start = {
x: fillgradient.start,
y: 0,
};
stop = {
x: fillgradient.stop,
y: 0,
};
} else if(direction === 'vertical') {
start = {
x: 0,
y: fillgradient.start,
};
stop = {
x: 0,
y: fillgradient.stop,
};
}

start.x = trace._xA.c2p(
(start.x === undefined) ? trace._extremes.x.min[0].val : start.x, true
);
start.y = trace._yA.c2p(
(start.y === undefined) ? trace._extremes.y.min[0].val : start.y, true
);

stop.x = trace._xA.c2p(
(stop.x === undefined) ? trace._extremes.x.max[0].val : stop.x, true
);
stop.y = trace._yA.c2p(
(stop.y === undefined) ? trace._extremes.y.max[0].val : stop.y, true
);
sel.call(gradientWithBounds, gd, gradientID, 'linear', fillgradient.colorscale, 'fill', start, stop, true, false);
} else {
if(direction === 'horizontal') {
direction = direction + 'reversed';
}
sel.call(drawing.gradient, gd, gradientID, direction, fillgradient.colorscale, 'fill');
}
} else if(trace.fillcolor) {
sel.call(Color.fill, trace.fillcolor);
}
Expand All @@ -202,17 +252,17 @@ drawing.singleFillStyle = function(sel, gd) {
var node = d3.select(sel.node());
var data = node.data();
var trace = ((data[0] || [])[0] || {}).trace || {};
setFillStyle(sel, trace, gd);
setFillStyle(sel, trace, gd, false);
};

drawing.fillGroupStyle = function(s, gd) {
drawing.fillGroupStyle = function(s, gd, forLegend) {
s.style('stroke-width', 0)
.each(function(d) {
var shape = d3.select(this);
// N.B. 'd' won't be a calcdata item when
// fill !== 'none' on a segment-less and marker-less trace
if(d[0].trace) {
setFillStyle(shape, d[0].trace, gd);
setFillStyle(shape, d[0].trace, gd, forLegend);
}
});
};
Expand Down Expand Up @@ -294,16 +344,14 @@ function makePointPath(symbolNumber, r, t, s) {
return drawing.symbolFuncs[base](r, t, s) + (symbolNumber >= 200 ? DOTPATH : '');
}

var HORZGRADIENT = {x1: 1, x2: 0, y1: 0, y2: 0};
var VERTGRADIENT = {x1: 0, x2: 0, y1: 1, y2: 0};
var stopFormatter = numberFormat('~f');
var gradientInfo = {
radial: {node: 'radialGradient'},
radialreversed: {node: 'radialGradient', reversed: true},
horizontal: {node: 'linearGradient', attrs: HORZGRADIENT},
horizontalreversed: {node: 'linearGradient', attrs: HORZGRADIENT, reversed: true},
vertical: {node: 'linearGradient', attrs: VERTGRADIENT},
verticalreversed: {node: 'linearGradient', attrs: VERTGRADIENT, reversed: true}
radial: {type: 'radial'},
radialreversed: {type: 'radial', reversed: true},
horizontal: {type: 'linear', start: {x: 1, y: 0}, stop: {x: 0, y: 0}},
horizontalreversed: {type: 'linear', start: {x: 1, y: 0}, stop: {x: 0, y: 0}, reversed: true},
vertical: {type: 'linear', start: {x: 0, y: 1}, stop: {x: 0, y: 0}},
verticalreversed: {type: 'linear', start: {x: 0, y: 1}, stop: {x: 0, y: 0}, reversed: true}
};

/**
Expand All @@ -321,8 +369,57 @@ var gradientInfo = {
* @param {string} prop: the property to apply to, 'fill' or 'stroke'
*/
drawing.gradient = function(sel, gd, gradientID, type, colorscale, prop) {
var len = colorscale.length;
var info = gradientInfo[type];
return gradientWithBounds(
sel, gd, gradientID, info.type, colorscale, prop, info.start, info.stop, false, info.reversed
);
};

/**
* gradient_with_bounds: create and apply a gradient fill for defined start and stop positions
*
* @param {object} sel: d3 selection to apply this gradient to
* You can use `selection.call(Drawing.gradient, ...)`
* @param {DOM element} gd: the graph div `sel` is part of
* @param {string} gradientID: a unique (within this plot) identifier
* for this gradient, so that we don't create unnecessary definitions
* @param {string} type: 'radial' or 'linear'. Radial goes center to edge,
* horizontal goes as defined by start and stop
* @param {array} colorscale: as in attribute values, [[fraction, color], ...]
* @param {string} prop: the property to apply to, 'fill' or 'stroke'
* @param {object} start: start point for linear gradients, { x: number, y: number }.
* Ignored if type is 'radial'.
* @param {object} stop: stop point for linear gradients, { x: number, y: number }.
* Ignored if type is 'radial'.
* @param {boolean} inUserSpace: If true, start and stop give absolute values in the plot.
* If false, start and stop are fractions of the traces extent along each axis.
* @param {boolean} reversed: If true, the gradient is reversed between normal start and stop,
* i.e., the colorscale is applied in order from stop to start for linear, from edge
* to center for radial gradients.
*/
function gradientWithBounds(sel, gd, gradientID, type, colorscale, prop, start, stop, inUserSpace, reversed) {
var len = colorscale.length;

var info;
if(type === 'linear') {
info = {
node: 'linearGradient',
attrs: {
x1: start.x,
y1: start.y,
x2: stop.x,
y2: stop.y,
gradientUnits: inUserSpace ? 'userSpaceOnUse' : 'objectBoundingBox',
},
reversed: reversed,
};
} else if(type === 'radial') {
info = {
node: 'radialGradient',
reversed: reversed,
};
}

var colorStops = new Array(len);
for(var i = 0; i < len; i++) {
if(info.reversed) {
Expand Down Expand Up @@ -368,7 +465,7 @@ drawing.gradient = function(sel, gd, gradientID, type, colorscale, prop) {
.style(prop + '-opacity', null);

sel.classed('gradient_filled', true);
};
}

/**
* pattern: create and apply a pattern fill
Expand Down
3 changes: 1 addition & 2 deletions src/components/legend/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ module.exports = function style(s, gd, legend) {
var fillStyle = function(s) {
if(s.size()) {
if(showFill) {
Drawing.fillGroupStyle(s, gd);
Drawing.fillGroupStyle(s, gd, true);
} else {
var gradientID = 'legendfill-' + trace.uid;
Drawing.gradient(s, gd, gradientID,
Expand Down Expand Up @@ -673,7 +673,6 @@ function getStyleGuide(d) {
showGradientFill = true;
}
}

return {
showMarker: showMarker,
showLine: showLine,
Expand Down
59 changes: 58 additions & 1 deletion src/traces/scatter/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,66 @@ module.exports = {
description: [
'Sets the fill color.',
'Defaults to a half-transparent variant of the line color,',
'marker color, or marker line color, whichever is available.'
'marker color, or marker line color, whichever is available.',
'If fillgradient is specified, fillcolor is ignored except for',
'setting the background color of the hover label, if any.'
].join(' ')
},
fillgradient: extendFlat({
type: {
valType: 'enumerated',
values: ['radial', 'horizontal', 'vertical', 'none'],
archmoj marked this conversation as resolved.
Show resolved Hide resolved
dflt: 'none',
editType: 'calc',
description: [
'Sets the type/orientation of the color gradient for the fill.',
'Defaults to *none*.'
].join(' ')
},
start: {
valType: 'number',
editType: 'calc',
description: [
'Sets the gradient start value.',
'It is given as the absolute position on the axis determined by',
'the orientiation. E.g., if orientation is *horizontal*, the',
archmoj marked this conversation as resolved.
Show resolved Hide resolved
'gradient will be horizontal and start from the x-position',
'given by start. If omitted, the gradient starts at the lowest',
'value of the trace along the respective axis.',
'Ignored if orientation is *radial*.'
].join(' ')
},
stop: {
valType: 'number',
editType: 'calc',
description: [
'Sets the gradient end value.',
'It is given as the absolute position on the axis determined by',
'the orientiation. E.g., if orientation is *horizontal*, the',
'gradient will be horizontal and end at the x-position',
'given by end. If omitted, the gradient ends at the highest',
'value of the trace along the respective axis.',
'Ignored if orientation is *radial*.'
].join(' ')
},
colorscale: {
valType: 'colorscale',
editType: 'style',
description: [
'Sets the fill gradient colors as a color scale.',
'The color scale is interpreted as a gradient',
'applied in the direction specified by *orientation*,',
'from the lowest to the highest value of the scatter',
'plot along that axis, or from the center to the most',
'distant point from it, if orientation is *radial*.'
].join(' ')
},
editType: 'calc',
description: [
'Sets a fill gradient.',
'If not specified, the fillcolor is used instead.'
].join(' ')
}),
fillpattern: pattern,
marker: extendFlat({
symbol: {
Expand Down
25 changes: 25 additions & 0 deletions src/traces/scatter/fillcolor_defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
var Color = require('../../components/color');
var isArrayOrTypedArray = require('../../lib').isArrayOrTypedArray;

function averageColors(colorscale) {
var color = Color.interpolate(colorscale[0][1], colorscale[1][1], 0.5);
for(var i = 2; i < colorscale.length; i++) {
var averageColorI = Color.interpolate(colorscale[i - 1][1], colorscale[i][1], 0.5);
color = Color.interpolate(color, averageColorI, colorscale[i - 1][0] / colorscale[i][0]);
}
return color;
}

module.exports = function fillColorDefaults(traceIn, traceOut, defaultColor, coerce) {
var inheritColorFromMarker = false;

Expand All @@ -18,9 +27,25 @@ module.exports = function fillColorDefaults(traceIn, traceOut, defaultColor, coe
}
}

var averageGradientColor;
if(traceIn.fillgradient) {
archmoj marked this conversation as resolved.
Show resolved Hide resolved
// if a fillgradient is specified, we use the average gradient color
// to specifiy fillcolor after all other more specific candidates
// are considered, but before the global default color.
// fillcolor affects the background color of the hoverlabel in this case.
var gradientOrientation = coerce('fillgradient.type');
if(gradientOrientation !== 'none') {
coerce('fillgradient.start');
coerce('fillgradient.stop');
coerce('fillgradient.colorscale');
averageGradientColor = averageColors(traceOut.fillgradient.colorscale);
}
}
archmoj marked this conversation as resolved.
Show resolved Hide resolved

coerce('fillcolor', Color.addOpacity(
(traceOut.line || {}).color ||
inheritColorFromMarker ||
averageGradientColor ||
defaultColor, 0.5
));
};
2 changes: 1 addition & 1 deletion src/traces/scatter/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function style(gd) {
.call(Drawing.lineGroupStyle);

s.selectAll('g.trace path.js-fill')
.call(Drawing.fillGroupStyle, gd);
.call(Drawing.fillGroupStyle, gd, false);

Registry.getComponentMethod('errorbars', 'style')(s);
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions test/image/mocks/zz-scatter_fill_gradient_tonext.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"data": [
{
"x": [1, 1, 2, 2],
"y": [1, 2, 2, 1],
"type": "scatter",
"mode": "none",
"fill": "tonext",
"hoveron": "points+fills",
"fillgradient": {
"type": "horizontal",
"colorscale": [
[0.0, "rgba(0, 255, 0, 1)"],
[0.5, "rgba(0, 255, 0, 0)"],
[1.0, "rgba(0, 255, 0, 1)"]
]
}
},
{
"x": [0, 0, 3, 3],
"y": [0, 4, 4, 1],
"type": "scatter",
"mode": "none",
"fill": "tonext",
"hoveron": "fills+points",
"fillgradient": {
"type": "radial",
"colorscale": [
[0.0, "rgba(255, 255, 0, 0.0)"],
[0.8, "rgba(255, 0, 0, 0.3)"],
[1.0, "rgba(255, 255, 0, 1.0)"]
]
}
}
],

"layout": {
"autosize": true,
"title": { "text": "Scatter traces with radial color gradient fills" },
"showlegend": true
}
}
Loading