Skip to content

Commit 6f35b5e

Browse files
authored
Merge pull request #2891 from plotly/contour-legend
Contour legend
2 parents b08b12a + 1d05081 commit 6f35b5e

35 files changed

+551
-207
lines changed

src/components/drawing/index.js

+71-30
Original file line numberDiff line numberDiff line change
@@ -266,43 +266,76 @@ function makePointPath(symbolNumber, r) {
266266

267267
var HORZGRADIENT = {x1: 1, x2: 0, y1: 0, y2: 0};
268268
var VERTGRADIENT = {x1: 0, x2: 0, y1: 1, y2: 0};
269+
var stopFormatter = d3.format('~.1f');
270+
var gradientInfo = {
271+
radial: {node: 'radialGradient'},
272+
radialreversed: {node: 'radialGradient', reversed: true},
273+
horizontal: {node: 'linearGradient', attrs: HORZGRADIENT},
274+
horizontalreversed: {node: 'linearGradient', attrs: HORZGRADIENT, reversed: true},
275+
vertical: {node: 'linearGradient', attrs: VERTGRADIENT},
276+
verticalreversed: {node: 'linearGradient', attrs: VERTGRADIENT, reversed: true}
277+
};
278+
279+
/**
280+
* gradient: create and apply a gradient fill
281+
*
282+
* @param {object} sel: d3 selection to apply this gradient to
283+
* You can use `selection.call(Drawing.gradient, ...)`
284+
* @param {DOM element} gd: the graph div `sel` is part of
285+
* @param {string} gradientID: a unique (within this plot) identifier
286+
* for this gradient, so that we don't create unnecessary definitions
287+
* @param {string} type: 'radial', 'horizontal', or 'vertical', optionally with
288+
* 'reversed' at the end. Normally radial goes center to edge,
289+
* horizontal goes right to left, and vertical goes bottom to top
290+
* @param {array} colorscale: as in attribute values, [[fraction, color], ...]
291+
* @param {string} prop: the property to apply to, 'fill' or 'stroke'
292+
*/
293+
drawing.gradient = function(sel, gd, gradientID, type, colorscale, prop) {
294+
var len = colorscale.length;
295+
var info = gradientInfo[type];
296+
var colorStops = new Array(len);
297+
for(var i = 0; i < len; i++) {
298+
if(info.reversed) {
299+
colorStops[len - 1 - i] = [stopFormatter((1 - colorscale[i][0]) * 100), colorscale[i][1]];
300+
}
301+
else {
302+
colorStops[i] = [stopFormatter(colorscale[i][0] * 100), colorscale[i][1]];
303+
}
304+
}
305+
306+
var fullID = 'g' + gd._fullLayout._uid + '-' + gradientID;
269307

270-
drawing.gradient = function(sel, gd, gradientID, type, color1, color2) {
271308
var gradient = gd._fullLayout._defs.select('.gradients')
272-
.selectAll('#' + gradientID)
273-
.data([type + color1 + color2], Lib.identity);
309+
.selectAll('#' + fullID)
310+
.data([type + colorStops.join(';')], Lib.identity);
274311

275312
gradient.exit().remove();
276313

277314
gradient.enter()
278-
.append(type === 'radial' ? 'radialGradient' : 'linearGradient')
315+
.append(info.node)
279316
.each(function() {
280317
var el = d3.select(this);
281-
if(type === 'horizontal') el.attr(HORZGRADIENT);
282-
else if(type === 'vertical') el.attr(VERTGRADIENT);
283-
284-
el.attr('id', gradientID);
285-
286-
var tc1 = tinycolor(color1);
287-
var tc2 = tinycolor(color2);
288-
289-
el.append('stop').attr({
290-
offset: '0%',
291-
'stop-color': Color.tinyRGB(tc2),
292-
'stop-opacity': tc2.getAlpha()
293-
});
294-
295-
el.append('stop').attr({
296-
offset: '100%',
297-
'stop-color': Color.tinyRGB(tc1),
298-
'stop-opacity': tc1.getAlpha()
318+
if(info.attrs) el.attr(info.attrs);
319+
320+
el.attr('id', fullID);
321+
322+
var stops = el.selectAll('stop')
323+
.data(colorStops);
324+
stops.exit().remove();
325+
stops.enter().append('stop');
326+
327+
stops.each(function(d) {
328+
var tc = tinycolor(d[1]);
329+
d3.select(this).attr({
330+
offset: d[0] + '%',
331+
'stop-color': Color.tinyRGB(tc),
332+
'stop-opacity': tc.getAlpha()
333+
});
299334
});
300335
});
301336

302-
sel.style({
303-
fill: 'url(#' + gradientID + ')',
304-
'fill-opacity': null
305-
});
337+
sel.style(prop, 'url(#' + fullID + ')')
338+
.style(prop + '-opacity', null);
306339
};
307340

308341
/*
@@ -420,21 +453,29 @@ drawing.singlePointStyle = function(d, sel, trace, fns, gd) {
420453
if(gradientType) perPointGradient = true;
421454
else gradientType = markerGradient && markerGradient.type;
422455

456+
// for legend - arrays will propagate through here, but we don't need
457+
// to treat it as per-point.
458+
if(Array.isArray(gradientType)) {
459+
gradientType = gradientType[0];
460+
if(!gradientInfo[gradientType]) gradientType = 0;
461+
}
462+
423463
if(gradientType && gradientType !== 'none') {
424464
var gradientColor = d.mgc;
425465
if(gradientColor) perPointGradient = true;
426466
else gradientColor = markerGradient.color;
427467

428-
var gradientID = 'g' + gd._fullLayout._uid + '-' + trace.uid;
468+
var gradientID = trace.uid;
429469
if(perPointGradient) gradientID += '-' + d.i;
430470

431-
sel.call(drawing.gradient, gd, gradientID, gradientType, fillColor, gradientColor);
471+
drawing.gradient(sel, gd, gradientID, gradientType,
472+
[[0, gradientColor], [1, fillColor]], 'fill');
432473
} else {
433-
sel.call(Color.fill, fillColor);
474+
Color.fill(sel, fillColor);
434475
}
435476

436477
if(lineWidth) {
437-
sel.call(Color.stroke, lineColor);
478+
Color.stroke(sel, lineColor);
438479
}
439480
}
440481
};

src/components/legend/defaults.js

+22-6
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,33 @@ var helpers = require('./helpers');
2121
module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {
2222
var containerIn = layoutIn.legend || {};
2323

24-
var visibleTraces = 0;
24+
var legendTraceCount = 0;
25+
var legendReallyHasATrace = false;
2526
var defaultOrder = 'normal';
2627

2728
var defaultX, defaultY, defaultXAnchor, defaultYAnchor;
2829

2930
for(var i = 0; i < fullData.length; i++) {
3031
var trace = fullData[i];
3132

32-
if(helpers.legendGetsTrace(trace)) {
33-
visibleTraces++;
34-
// always show the legend by default if there's a pie
35-
if(Registry.traceIs(trace, 'pie')) visibleTraces++;
33+
if(!trace.visible) continue;
34+
35+
// Note that we explicitly count any trace that is either shown or
36+
// *would* be shown by default, toward the two traces you need to
37+
// ensure the legend is shown by default, because this can still help
38+
// disambiguate.
39+
if(trace.showlegend || trace._dfltShowLegend) {
40+
legendTraceCount++;
41+
if(trace.showlegend) {
42+
legendReallyHasATrace = true;
43+
// Always show the legend by default if there's a pie,
44+
// or if there's only one trace but it's explicitly shown
45+
if(Registry.traceIs(trace, 'pie') ||
46+
trace._input.showlegend === true
47+
) {
48+
legendTraceCount++;
49+
}
50+
}
3651
}
3752

3853
if((Registry.traceIs(trace, 'bar') && layoutOut.barmode === 'stack') ||
@@ -48,7 +63,8 @@ module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {
4863
}
4964

5065
var showLegend = Lib.coerce(layoutIn, layoutOut,
51-
basePlotLayoutAttributes, 'showlegend', visibleTraces > 1);
66+
basePlotLayoutAttributes, 'showlegend',
67+
legendReallyHasATrace && legendTraceCount > 1);
5268

5369
if(showLegend === false) return;
5470

src/components/legend/get_legend_data.js

+5-5
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ module.exports = function getLegendData(calcdata, opts) {
4141

4242
// build an { legendgroup: [cd0, cd0], ... } object
4343
for(i = 0; i < calcdata.length; i++) {
44-
var cd = calcdata[i],
45-
cd0 = cd[0],
46-
trace = cd0.trace,
47-
lgroup = trace.legendgroup;
44+
var cd = calcdata[i];
45+
var cd0 = cd[0];
46+
var trace = cd0.trace;
47+
var lgroup = trace.legendgroup;
4848

49-
if(!helpers.legendGetsTrace(trace) || !trace.showlegend) continue;
49+
if(!trace.visible || !trace.showlegend) continue;
5050

5151
if(Registry.traceIs(trace, 'pie')) {
5252
if(!slicesShown[lgroup]) slicesShown[lgroup] = {};

src/components/legend/helpers.js

-10
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,6 @@
99

1010
'use strict';
1111

12-
exports.legendGetsTrace = function legendGetsTrace(trace) {
13-
// traceIs(trace, 'showLegend') is not sufficient anymore, due to contour(carpet)?
14-
// which are legend-eligible only if type: constraint. Otherwise, showlegend gets deleted.
15-
16-
// Note that we explicitly include showlegend: false, so a trace that *could* be
17-
// in the legend but is not shown still counts toward the two traces you need to
18-
// ensure the legend is shown by default, because this can still help disambiguate.
19-
return trace.visible && (trace.showlegend !== undefined);
20-
};
21-
2212
exports.isGrouped = function isGrouped(legendLayout) {
2313
return (legendLayout.traceorder || '').indexOf('grouped') !== -1;
2414
};

src/components/legend/style.js

+65-12
Original file line numberDiff line numberDiff line change
@@ -61,25 +61,78 @@ module.exports = function style(s, gd) {
6161
var showFill = trace.visible && trace.fill && trace.fill !== 'none';
6262
var showLine = subTypes.hasLines(trace);
6363
var contours = trace.contours;
64+
var showGradientLine = false;
65+
var showGradientFill = false;
6466

65-
if(contours && contours.type === 'constraint') {
66-
showLine = contours.showlines;
67-
showFill = contours._operation !== '=';
67+
if(contours) {
68+
var coloring = contours.coloring;
69+
70+
if(coloring === 'lines') {
71+
showGradientLine = true;
72+
}
73+
else {
74+
showLine = coloring === 'none' || coloring === 'heatmap' ||
75+
contours.showlines;
76+
}
77+
78+
if(contours.type === 'constraint') {
79+
showFill = contours._operation !== '=';
80+
}
81+
else if(coloring === 'fill' || coloring === 'heatmap') {
82+
showGradientFill = true;
83+
}
6884
}
6985

70-
var fill = d3.select(this).select('.legendfill').selectAll('path')
71-
.data(showFill ? [d] : []);
86+
// with fill and no markers or text, move the line and fill up a bit
87+
// so it's more centered
88+
var markersOrText = subTypes.hasMarkers(trace) || subTypes.hasText(trace);
89+
var anyFill = showFill || showGradientFill;
90+
var anyLine = showLine || showGradientLine;
91+
var pathStart = (markersOrText || !anyFill) ? 'M5,0' :
92+
// with a line leave it slightly below center, to leave room for the
93+
// line thickness and because the line is usually more prominent
94+
anyLine ? 'M5,-2' : 'M5,-3';
95+
96+
var this3 = d3.select(this);
97+
98+
var fill = this3.select('.legendfill').selectAll('path')
99+
.data(showFill || showGradientFill ? [d] : []);
72100
fill.enter().append('path').classed('js-fill', true);
73101
fill.exit().remove();
74-
fill.attr('d', 'M5,0h30v6h-30z')
75-
.call(Drawing.fillGroupStyle);
102+
fill.attr('d', pathStart + 'h30v6h-30z')
103+
.call(showFill ? Drawing.fillGroupStyle : fillGradient);
76104

77-
var line = d3.select(this).select('.legendlines').selectAll('path')
78-
.data(showLine ? [d] : []);
79-
line.enter().append('path').classed('js-line', true)
80-
.attr('d', 'M5,0h30');
105+
var line = this3.select('.legendlines').selectAll('path')
106+
.data(showLine || showGradientLine ? [d] : []);
107+
line.enter().append('path').classed('js-line', true);
81108
line.exit().remove();
82-
line.call(Drawing.lineGroupStyle);
109+
110+
// this is ugly... but you can't apply a gradient to a perfectly
111+
// horizontal or vertical line. Presumably because then
112+
// the system doesn't know how to scale vertical variation, even
113+
// though there *is* no vertical variation in this case.
114+
// so add an invisibly small angle to the line
115+
// This issue (and workaround) exist across (Mac) Chrome, FF, and Safari
116+
line.attr('d', pathStart + (showGradientLine ? 'l30,0.0001' : 'h30'))
117+
.call(showLine ? Drawing.lineGroupStyle : lineGradient);
118+
119+
function fillGradient(s) {
120+
if(s.size()) {
121+
var gradientID = 'legendfill-' + trace.uid;
122+
Drawing.gradient(s, gd, gradientID, 'horizontalreversed',
123+
trace.colorscale, 'fill');
124+
}
125+
}
126+
127+
function lineGradient(s) {
128+
if(s.size()) {
129+
var gradientID = 'legendline-' + trace.uid;
130+
Drawing.lineGroupStyle(s);
131+
Drawing.gradient(s, gd, gradientID, 'horizontalreversed',
132+
trace.colorscale, 'stroke');
133+
}
134+
}
135+
83136
}
84137

85138
function stylePoints(d) {

src/plots/layout_attributes.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,13 @@ module.exports = {
173173
valType: 'boolean',
174174
role: 'info',
175175
editType: 'legend',
176-
description: 'Determines whether or not a legend is drawn.'
176+
description: [
177+
'Determines whether or not a legend is drawn.',
178+
'Default is `true` if there is a trace to show and any of these:',
179+
'a) Two or more traces would by default be shown in the legend.',
180+
'b) One pie trace is shown in the legend.',
181+
'c) One trace is explicitly given with `showlegend: true`.'
182+
].join(' ')
177183
},
178184
colorway: {
179185
valType: 'colorlist',

src/plots/plots.js

+4
Original file line numberDiff line numberDiff line change
@@ -1144,9 +1144,13 @@ plots.supplyTraceDefaults = function(traceIn, traceOut, colorIndex, layout, trac
11441144
coerce('ids');
11451145

11461146
if(Registry.traceIs(traceOut, 'showLegend')) {
1147+
traceOut._dfltShowLegend = true;
11471148
coerce('showlegend');
11481149
coerce('legendgroup');
11491150
}
1151+
else {
1152+
traceOut._dfltShowLegend = false;
1153+
}
11501154

11511155
Registry.getComponentMethod(
11521156
'fx',

src/traces/contour/defaults.js

-3
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,6 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
3737
var isConstraint = (coerce('contours.type') === 'constraint');
3838
coerce('connectgaps', Lib.isArray1D(traceOut.z));
3939

40-
// trace-level showlegend has already been set, but is only allowed if this is a constraint
41-
if(!isConstraint) delete traceOut.showlegend;
42-
4340
if(isConstraint) {
4441
handleConstraintDefaults(traceIn, traceOut, coerce, layout, defaultColor);
4542
}

0 commit comments

Comments
 (0)