Skip to content

Commit 63eb009

Browse files
committed
Added static gradient fills for scatter traces.
Adds a new attribute `fillgradient` to `scatter` traces which allows the user to specify a gradient as a colorscale and an orientation in which it will be applied. `fillgradient` also has optional start and stop attributes which can be used to define absolute start and stop points in plot coordiantes. This allows to equip different traces with exactly matching fill gradients. Omitting start and stop will apply the gradient between the extrema of the fill polygon (along the fill direction). When a legend is displayed, multiple passes are made through the `Drawing.setFillStyle` function, for the legend previews and the actual plot, during which the axes are re-scaled. This leads to errors with gradients not computing start and stop coordinates correctly. To work around this, `setFillStyle` was modified to allow distinguishing between legend and plot passes.
1 parent b251947 commit 63eb009

10 files changed

+340
-18
lines changed

src/components/color/index.js

+19
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,25 @@ color.combine = function(front, back) {
5353
return tinycolor(fcflat).toRgbString();
5454
};
5555

56+
/*
57+
* Linearly interpolate between two colors at a normalized interpolation position (0 to 1).
58+
*
59+
* Ignores alpha channel values.
60+
* The resulting color is computed as: factor * first + (1 - factor) * second.
61+
*/
62+
color.interpolate = function(first, second, factor) {
63+
var fc = tinycolor(first).toRgb();
64+
var sc = tinycolor(second).toRgb();
65+
66+
var ic = {
67+
r: factor * fc.r + (1 - factor) * sc.r,
68+
g: factor * fc.g + (1 - factor) * sc.g,
69+
b: factor * fc.b + (1 - factor) * sc.b,
70+
};
71+
72+
return tinycolor(ic).toRgbString();
73+
};
74+
5675
/*
5776
* Create a color that contrasts with cstr.
5877
*

src/components/drawing/index.js

+110-14
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ drawing.dashStyle = function(dash, lineWidth) {
177177
return dash;
178178
};
179179

180-
function setFillStyle(sel, trace, gd) {
180+
function setFillStyle(sel, trace, gd, forLegend) {
181181
var markerPattern = trace.fillpattern;
182182
var patternShape = markerPattern && drawing.getPatternAttr(markerPattern.shape, 0, '');
183183
if(patternShape) {
@@ -192,6 +192,55 @@ function setFillStyle(sel, trace, gd) {
192192
undefined, markerPattern.fillmode,
193193
patternBGColor, patternFGColor, patternFGOpacity
194194
);
195+
} else if(trace.fillgradient && trace.fillgradient.orientation !== 'none') {
196+
var direction = trace.fillgradient.orientation;
197+
var gradientID = 'scatterfill-' + trace.uid;
198+
if(forLegend) {
199+
gradientID = 'legendfill-' + trace.uid;
200+
}
201+
202+
if(!forLegend && (trace.fillgradient.start !== undefined || trace.fillgradient.stop !== undefined)) {
203+
var start, stop;
204+
if(direction === 'horizontal') {
205+
start = {
206+
x: trace.fillgradient.start,
207+
y: 0,
208+
};
209+
stop = {
210+
x: trace.fillgradient.stop,
211+
y: 0,
212+
};
213+
} else if(direction === 'vertical') {
214+
start = {
215+
x: 0,
216+
y: trace.fillgradient.start,
217+
};
218+
stop = {
219+
x: 0,
220+
y: trace.fillgradient.stop,
221+
};
222+
}
223+
224+
start.x = trace._xA.c2p(
225+
(start.x === undefined) ? trace._extremes.x.min[0].val : start.x, true
226+
);
227+
start.y = trace._yA.c2p(
228+
(start.y === undefined) ? trace._extremes.y.min[0].val : start.y, true
229+
);
230+
231+
stop.x = trace._xA.c2p(
232+
(stop.x === undefined) ? trace._extremes.x.max[0].val : stop.x, true
233+
);
234+
stop.y = trace._yA.c2p(
235+
(stop.y === undefined) ? trace._extremes.y.max[0].val : stop.y, true
236+
);
237+
sel.call(gradientWithBounds, gd, gradientID, 'linear', trace.fillgradient.colorscale, 'fill', start, stop, true, false);
238+
} else {
239+
if(direction === 'horizontal') {
240+
direction = direction + 'reversed';
241+
}
242+
sel.call(drawing.gradient, gd, gradientID, direction, trace.fillgradient.colorscale, 'fill');
243+
}
195244
} else if(trace.fillcolor) {
196245
sel.call(Color.fill, trace.fillcolor);
197246
}
@@ -202,17 +251,17 @@ drawing.singleFillStyle = function(sel, gd) {
202251
var node = d3.select(sel.node());
203252
var data = node.data();
204253
var trace = ((data[0] || [])[0] || {}).trace || {};
205-
setFillStyle(sel, trace, gd);
254+
setFillStyle(sel, trace, gd, false);
206255
};
207256

208-
drawing.fillGroupStyle = function(s, gd) {
257+
drawing.fillGroupStyle = function(s, gd, forLegend) {
209258
s.style('stroke-width', 0)
210259
.each(function(d) {
211260
var shape = d3.select(this);
212261
// N.B. 'd' won't be a calcdata item when
213262
// fill !== 'none' on a segment-less and marker-less trace
214263
if(d[0].trace) {
215-
setFillStyle(shape, d[0].trace, gd);
264+
setFillStyle(shape, d[0].trace, gd, forLegend);
216265
}
217266
});
218267
};
@@ -294,16 +343,14 @@ function makePointPath(symbolNumber, r, t, s) {
294343
return drawing.symbolFuncs[base](r, t, s) + (symbolNumber >= 200 ? DOTPATH : '');
295344
}
296345

297-
var HORZGRADIENT = {x1: 1, x2: 0, y1: 0, y2: 0};
298-
var VERTGRADIENT = {x1: 0, x2: 0, y1: 1, y2: 0};
299346
var stopFormatter = numberFormat('~f');
300347
var gradientInfo = {
301-
radial: {node: 'radialGradient'},
302-
radialreversed: {node: 'radialGradient', reversed: true},
303-
horizontal: {node: 'linearGradient', attrs: HORZGRADIENT},
304-
horizontalreversed: {node: 'linearGradient', attrs: HORZGRADIENT, reversed: true},
305-
vertical: {node: 'linearGradient', attrs: VERTGRADIENT},
306-
verticalreversed: {node: 'linearGradient', attrs: VERTGRADIENT, reversed: true}
348+
radial: {type: 'radial'},
349+
radialreversed: {type: 'radial', reversed: true},
350+
horizontal: {type: 'linear', start: {x: 1, y: 0}, stop: {x: 0, y: 0}},
351+
horizontalreversed: {type: 'linear', start: {x: 1, y: 0}, stop: {x: 0, y: 0}, reversed: true},
352+
vertical: {type: 'linear', start: {x: 0, y: 1}, stop: {x: 0, y: 0}},
353+
verticalreversed: {type: 'linear', start: {x: 0, y: 1}, stop: {x: 0, y: 0}, reversed: true}
307354
};
308355

309356
/**
@@ -321,8 +368,57 @@ var gradientInfo = {
321368
* @param {string} prop: the property to apply to, 'fill' or 'stroke'
322369
*/
323370
drawing.gradient = function(sel, gd, gradientID, type, colorscale, prop) {
324-
var len = colorscale.length;
325371
var info = gradientInfo[type];
372+
return gradientWithBounds(
373+
sel, gd, gradientID, info.type, colorscale, prop, info.start, info.stop, false, info.reversed
374+
);
375+
};
376+
377+
/**
378+
* gradient_with_bounds: create and apply a gradient fill for defined start and stop positions
379+
*
380+
* @param {object} sel: d3 selection to apply this gradient to
381+
* You can use `selection.call(Drawing.gradient, ...)`
382+
* @param {DOM element} gd: the graph div `sel` is part of
383+
* @param {string} gradientID: a unique (within this plot) identifier
384+
* for this gradient, so that we don't create unnecessary definitions
385+
* @param {string} type: 'radial' or 'linear'. Radial goes center to edge,
386+
* horizontal goes as defined by start and stop
387+
* @param {array} colorscale: as in attribute values, [[fraction, color], ...]
388+
* @param {string} prop: the property to apply to, 'fill' or 'stroke'
389+
* @param {object} start: start point for linear gradients, { x: number, y: number }.
390+
* Ignored if type is 'radial'.
391+
* @param {object} stop: stop point for linear gradients, { x: number, y: number }.
392+
* Ignored if type is 'radial'.
393+
* @param {boolean} inUserSpace: If true, start and stop give absolute values in the plot.
394+
* If false, start and stop are fractions of the traces extent along each axis.
395+
* @param {boolean} reversed: If true, the gradient is reversed between normal start and stop,
396+
* i.e., the colorscale is applied in order from stop to start for linear, from edge
397+
* to center for radial gradients.
398+
*/
399+
function gradientWithBounds(sel, gd, gradientID, type, colorscale, prop, start, stop, inUserSpace, reversed) {
400+
var len = colorscale.length;
401+
402+
var info;
403+
if(type === 'linear') {
404+
info = {
405+
node: 'linearGradient',
406+
attrs: {
407+
x1: start.x,
408+
y1: start.y,
409+
x2: stop.x,
410+
y2: stop.y,
411+
gradientUnits: inUserSpace ? 'userSpaceOnUse' : 'objectBoundingBox',
412+
},
413+
reversed: reversed,
414+
};
415+
} else if(type === 'radial') {
416+
info = {
417+
node: 'radialGradient',
418+
reversed: reversed,
419+
};
420+
}
421+
326422
var colorStops = new Array(len);
327423
for(var i = 0; i < len; i++) {
328424
if(info.reversed) {
@@ -368,7 +464,7 @@ drawing.gradient = function(sel, gd, gradientID, type, colorscale, prop) {
368464
.style(prop + '-opacity', null);
369465

370466
sel.classed('gradient_filled', true);
371-
};
467+
}
372468

373469
/**
374470
* pattern: create and apply a pattern fill

src/components/legend/style.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ module.exports = function style(s, gd, legend) {
114114
var fillStyle = function(s) {
115115
if(s.size()) {
116116
if(showFill) {
117-
Drawing.fillGroupStyle(s, gd);
117+
Drawing.fillGroupStyle(s, gd, true);
118118
} else {
119119
var gradientID = 'legendfill-' + trace.uid;
120120
Drawing.gradient(s, gd, gradientID,
@@ -673,7 +673,6 @@ function getStyleGuide(d) {
673673
showGradientFill = true;
674674
}
675675
}
676-
677676
return {
678677
showMarker: showMarker,
679678
showLine: showLine,

src/traces/scatter/attributes.js

+59-1
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,67 @@ module.exports = {
398398
description: [
399399
'Sets the fill color.',
400400
'Defaults to a half-transparent variant of the line color,',
401-
'marker color, or marker line color, whichever is available.'
401+
'marker color, or marker line color, whichever is available.',
402+
'If fillgradient is specified, fillcolor is ignored except for',
403+
'setting the background color of the hover label, if any.'
402404
].join(' ')
403405
},
406+
fillgradient: extendFlat({
407+
orientation: {
408+
valType: 'enumerated',
409+
values: ['radial', 'horizontal', 'vertical', 'none'],
410+
dflt: 'none',
411+
editType: 'style',
412+
description: [
413+
'Sets the orientation of the color gradient.',
414+
'Defaults to *none*.'
415+
].join(' ')
416+
},
417+
start: {
418+
valType: 'number',
419+
editType: 'calc',
420+
description: [
421+
'Sets the gradient start value.',
422+
'It is given as the absolute position on the axis determined by',
423+
'the orientiation. E.g., if orientation is *horizontal*, the',
424+
'gradient will be horizontal and start from the x-position',
425+
'given by start. If omitted, the gradient starts at the lowest',
426+
'value of the trace along the respective axis.',
427+
'Ignored if orientation is *radial*.'
428+
].join(' ')
429+
},
430+
stop: {
431+
valType: 'number',
432+
editType: 'calc',
433+
description: [
434+
'Sets the gradient end value.',
435+
'It is given as the absolute position on the axis determined by',
436+
'the orientiation. E.g., if orientation is *horizontal*, the',
437+
'gradient will be horizontal and end at the x-position',
438+
'given by end. If omitted, the gradient ends at the highest',
439+
'value of the trace along the respective axis.',
440+
'Ignored if orientation is *radial*.'
441+
].join(' ')
442+
},
443+
colorscale: {
444+
valType: 'colorscale',
445+
editType: 'style',
446+
anim: true,
447+
description: [
448+
'Sets the fill gradient colors as a color scale.',
449+
'The color scale is interpreted as a gradient',
450+
'applied in the direction specified by *orientation*,',
451+
'from the lowest to the highest value of the scatter',
452+
'plot along that axis, or from the center to the most',
453+
'distant point from it, if orientation is *radial*.'
454+
].join(' ')
455+
},
456+
editType: 'style',
457+
description: [
458+
'Sets a fill gradient.',
459+
'If not specified, the fillcolor is used instead.'
460+
].join(' ')
461+
}),
404462
fillpattern: pattern,
405463
marker: extendFlat({
406464
symbol: {

src/traces/scatter/fillcolor_defaults.js

+27
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
var Color = require('../../components/color');
44
var isArrayOrTypedArray = require('../../lib').isArrayOrTypedArray;
5+
var assert = require('assert');
6+
7+
function averageColors(colorscale) {
8+
assert(colorscale.length >= 2);
9+
var color = Color.interpolate(colorscale[0][1], colorscale[1][1], 0.5);
10+
for(var i = 2; i < colorscale.length; i++) {
11+
var averageColorI = Color.interpolate(colorscale[i - 1][1], colorscale[i][1], 0.5);
12+
color = Color.interpolate(color, averageColorI, colorscale[i - 1][0] / colorscale[i][0]);
13+
}
14+
return color;
15+
}
516

617
module.exports = function fillColorDefaults(traceIn, traceOut, defaultColor, coerce) {
718
var inheritColorFromMarker = false;
@@ -18,9 +29,25 @@ module.exports = function fillColorDefaults(traceIn, traceOut, defaultColor, coe
1829
}
1930
}
2031

32+
var averageGradientColor;
33+
if(traceIn.fillgradient) {
34+
// if a fillgradient is specified, we use the average gradient color
35+
// to specifiy fillcolor after all other more specific candidates
36+
// are considered, but before the global default color.
37+
// fillcolor affects the background color of the hoverlabel in this case.
38+
var gradientOrientation = coerce('fillgradient.orientation');
39+
if(gradientOrientation !== 'none') {
40+
coerce('fillgradient.start');
41+
coerce('fillgradient.stop');
42+
coerce('fillgradient.colorscale');
43+
averageGradientColor = averageColors(traceOut.fillgradient.colorscale);
44+
}
45+
}
46+
2147
coerce('fillcolor', Color.addOpacity(
2248
(traceOut.line || {}).color ||
2349
inheritColorFromMarker ||
50+
averageGradientColor ||
2451
defaultColor, 0.5
2552
));
2653
};

src/traces/scatter/style.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function style(gd) {
2727
.call(Drawing.lineGroupStyle);
2828

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

3232
Registry.getComponentMethod('errorbars', 'style')(s);
3333
}
Loading
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"data": [
3+
{
4+
"x": [1, 1, 2, 2],
5+
"y": [1, 2, 2, 1],
6+
"type": "scatter",
7+
"mode": "none",
8+
"fill": "tonext",
9+
"hoveron": "points+fills",
10+
"fillgradient": {
11+
"orientation": "horizontal",
12+
"colorscale": [
13+
[0.0, "rgba(0, 255, 0, 1)"],
14+
[0.5, "rgba(0, 255, 0, 0)"],
15+
[1.0, "rgba(0, 255, 0, 1)"]
16+
]
17+
}
18+
},
19+
{
20+
"x": [0, 0, 3, 3],
21+
"y": [0, 4, 4, 1],
22+
"type": "scatter",
23+
"mode": "none",
24+
"fill": "tonext",
25+
"hoveron": "fills+points",
26+
"fillgradient": {
27+
"orientation": "radial",
28+
"colorscale": [
29+
[0.0, "rgba(255, 255, 0, 0.0)"],
30+
[0.8, "rgba(255, 0, 0, 0.3)"],
31+
[1.0, "rgba(255, 255, 0, 1.0)"]
32+
]
33+
}
34+
}
35+
],
36+
37+
"layout": {
38+
"autosize": true,
39+
"title": { "text": "Scatter traces with radial color gradient fills" },
40+
"showlegend": true
41+
}
42+
}

0 commit comments

Comments
 (0)