Skip to content

Commit 4676fd8

Browse files
committed
Add an optional title to pie charts.
The title can be shown either inside the hole (if there is one), or outside. If the title is placed outside, the algorithm tries to position it so that it does not intersect labels or pulled slices.
1 parent 06d1fc5 commit 4676fd8

File tree

10 files changed

+298
-8
lines changed

10 files changed

+298
-8
lines changed

src/traces/pie/attributes.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,33 @@ module.exports = {
180180
description: 'Sets the font used for `textinfo` lying outside the pie.'
181181
}),
182182

183+
title: {
184+
valType: 'string',
185+
dflt: '',
186+
role: 'info',
187+
editType: 'calc',
188+
description: [
189+
'Sets the title of the pie chart.',
190+
'If it is empty, no title is displayed.'
191+
].join(' ')
192+
},
193+
titleposition: {
194+
valType: 'enumerated',
195+
values: ['inhole', 'outside'],
196+
dflt: 'outside',
197+
role: 'info',
198+
editType: 'calc',
199+
description: [
200+
'Specifies the location of the `title`.',
201+
'If `inhole` and the chart is a donut, the text is scaled',
202+
'and displayed inside the hole.',
203+
'If `outside`, the text is shown above the pie chart.'
204+
].join(' ')
205+
},
206+
titlefont: extendFlat({}, textFontAttrs, {
207+
description: 'Sets the font used for `title`.'
208+
}),
209+
183210
// position and shape
184211
domain: domainAttrs({name: 'pie', trace: true, editType: 'calc'}),
185212

src/traces/pie/defaults.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
6565
}
6666
}
6767

68+
var title = coerce('title');
69+
if(title) {
70+
var titlePosition = coerce('titleposition');
71+
72+
if(titlePosition === 'inhole' || titlePosition === 'outside') {
73+
coerceFont(coerce, 'titlefont', layout.font);
74+
}
75+
}
76+
6877
handleDomainDefaults(traceOut, layout, coerce);
6978

7079
coerce('hole');

src/traces/pie/plot.js

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,53 @@ module.exports = function plot(gd, cdpie) {
308308
});
309309
});
310310

311+
// add the title
312+
var hasTitle = trace.title &&
313+
((trace.titleposition === 'inhole' && trace.hole > 0) ||
314+
(trace.titleposition === 'outside'));
315+
var titleTextGroup = d3.select(this).selectAll('g.titletext')
316+
.data(hasTitle ? [0] : []);
317+
318+
titleTextGroup.enter().append('g')
319+
.classed('titletext', true);
320+
titleTextGroup.exit().remove();
321+
322+
titleTextGroup.each(function() {
323+
var titleText = Lib.ensureSingle(d3.select(this), 'text', '', function(s) {
324+
// prohibit tex interpretation as above
325+
s.attr('data-notex', 1);
326+
});
327+
328+
titleText.text(trace.title)
329+
.attr({
330+
'class': 'titletext',
331+
transform: '',
332+
'text-anchor': 'middle'
333+
})
334+
.call(Drawing.font, trace.titlefont)
335+
.call(svgTextUtils.convertToTspans, gd);
336+
337+
338+
var titleBB = Drawing.bBox(titleText.node());
339+
// translation and scaling for the title text box.
340+
// The translation is for the center point.
341+
var transform;
342+
343+
if(trace.titleposition === 'outside') {
344+
transform = positionTitleOutside(titleBB, cd0, fullLayout._size);
345+
} else {
346+
transform = positionTitleInside(titleBB, cd0);
347+
}
348+
349+
titleText.attr('transform',
350+
'translate(' + transform.x + ',' + transform.y + ')' +
351+
(transform.scale < 1 ? ('scale(' + transform.scale + ')') : '') +
352+
'translate(' +
353+
(-(titleBB.left + titleBB.right) / 2) + ',' +
354+
(-(titleBB.top + titleBB.bottom) / 2) +
355+
')');
356+
});
357+
311358
// now make sure no labels overlap (at least within one pie)
312359
if(hasOutsideText) scootLabels(quadrants, trace);
313360
slices.each(function(pt) {
@@ -454,6 +501,72 @@ function transformOutsideText(textBB, pt) {
454501
};
455502
}
456503

504+
function positionTitleInside(titleBB, cd0) {
505+
var textDiameter = Math.sqrt(titleBB.width * titleBB.width + titleBB.height * titleBB.height);
506+
return {
507+
x: cd0.cx,
508+
y: cd0.cy,
509+
scale: cd0.trace.hole * cd0.r * 2 / textDiameter
510+
};
511+
}
512+
513+
function positionTitleOutside(titleBB, cd0, plotSize) {
514+
var scaleX, scaleY, chartWidth, titleSpace, titleShift, maxPull;
515+
var trace = cd0.trace;
516+
517+
maxPull = getMaxPull(trace);
518+
chartWidth = plotSize.w * (trace.domain.x[1] - trace.domain.x[0]);
519+
scaleX = chartWidth / titleBB.width;
520+
if(isSinglePie(trace)) {
521+
titleShift = trace.titlefont.size / 2;
522+
// we need to leave enough free space for an outside label
523+
if(trace.outsidetextfont) titleShift += 3 * trace.outsidetextfont.size / 2;
524+
else titleShift += trace.titlefont.size / 4;
525+
return {
526+
x: cd0.cx,
527+
y: cd0.cy - (1 + maxPull) * cd0.r - titleShift,
528+
scale: scaleX
529+
};
530+
}
531+
titleSpace = getTitleSpace(trace, plotSize);
532+
// we previously left a free space of height titleSpace.
533+
// The text must fit in this space.
534+
scaleY = titleSpace / titleBB.height;
535+
return {
536+
x: cd0.cx,
537+
y: cd0.cy - (1 + maxPull) * cd0.r - (titleSpace / 2),
538+
scale: Math.min(scaleX, scaleY)
539+
};
540+
}
541+
542+
function isSinglePie(trace) {
543+
// check if there is a single pie per y-column
544+
if(trace.domain.y[0] === 0 && trace.domain.y[1] === 1) return true;
545+
return false;
546+
}
547+
548+
function getTitleSpace(trace, plotSize) {
549+
var chartHeight = plotSize.h * (trace.domain.y[1] - trace.domain.y[0]);
550+
// leave 3/2 * titlefont.size free space. We need at least titlefont.size
551+
// space, and the 1/2 * titlefont.size is a small buffer to avoid the text
552+
// touching the pie.
553+
var titleSpace = (trace.title && trace.titleposition === 'outside') ?
554+
(3 * trace.titlefont.size / 2) : 0;
555+
if(chartHeight > titleSpace) return titleSpace;
556+
else return chartHeight / 2;
557+
}
558+
559+
function getMaxPull(trace) {
560+
var maxPull = trace.pull, j;
561+
if(Array.isArray(maxPull)) {
562+
maxPull = 0;
563+
for(j = 0; j < trace.pull.length; j++) {
564+
if(trace.pull[j] > maxPull) maxPull = trace.pull[j];
565+
}
566+
}
567+
return maxPull;
568+
}
569+
457570
function scootLabels(quadrants, trace) {
458571
var xHalf, yHalf, equatorFirst, farthestX, farthestY,
459572
xDiffSign, yDiffSign, thisQuad, oppositeQuad,
@@ -570,21 +683,18 @@ function scalePies(cdpie, plotSize) {
570683
for(i = 0; i < cdpie.length; i++) {
571684
cd0 = cdpie[i][0];
572685
trace = cd0.trace;
686+
573687
pieBoxWidth = plotSize.w * (trace.domain.x[1] - trace.domain.x[0]);
574688
pieBoxHeight = plotSize.h * (trace.domain.y[1] - trace.domain.y[0]);
689+
// leave some space for the title, if it will be displayed outside
690+
if(!isSinglePie(trace)) pieBoxHeight -= getTitleSpace(trace, plotSize);
575691

576-
maxPull = trace.pull;
577-
if(Array.isArray(maxPull)) {
578-
maxPull = 0;
579-
for(j = 0; j < trace.pull.length; j++) {
580-
if(trace.pull[j] > maxPull) maxPull = trace.pull[j];
581-
}
582-
}
692+
maxPull = getMaxPull(trace);
583693

584694
cd0.r = Math.min(pieBoxWidth, pieBoxHeight) / (2 + 2 * maxPull);
585695

586696
cd0.cx = plotSize.l + plotSize.w * (trace.domain.x[1] + trace.domain.x[0]) / 2;
587-
cd0.cy = plotSize.t + plotSize.h * (2 - trace.domain.y[1] - trace.domain.y[0]) / 2;
697+
cd0.cy = plotSize.t + plotSize.h * (1 - trace.domain.y[0]) - pieBoxHeight / 2;
588698

589699
if(trace.scalegroup && scaleGroups.indexOf(trace.scalegroup) === -1) {
590700
scaleGroups.push(trace.scalegroup);
37.8 KB
Loading
28.2 KB
Loading
28.9 KB
Loading
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"data": [
3+
{
4+
"values": [955, 405, 360, 310, 295],
5+
"labels": ["Mandarin", "Spanish", "English", "Hindi", "Arabic"],
6+
"textinfo": "label+percent",
7+
"hole": 0.3,
8+
"title": "Num. speakers",
9+
"titleposition": "inhole",
10+
"type": "pie"
11+
}
12+
],
13+
"layout": {
14+
"title": "Top 5 languages by number of native speakers (2010, est.)",
15+
"height": 600,
16+
"width": 600
17+
}
18+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"data": [
3+
{
4+
"values": [38600, 83100, 11100, 15400, 1740, 77],
5+
"labels": ["Platinum", "Palladium", "Rhodium", "Ruthenium", "Iridium", "Osmium"],
6+
"type": "pie",
7+
"name": "Year 2013",
8+
"title": "Year 2013",
9+
"domain": {
10+
"x": [0, 0.5],
11+
"y": [0.51, 1]
12+
},
13+
"hoverinfo": "label+percent+name",
14+
"textinfo": "none"
15+
},{
16+
"values": [45800, 92900, 11100, 11000, 1960, 322],
17+
"labels": ["Platinum", "Palladium", "Rhodium", "Ruthenium", "Iridium", "Osmium"],
18+
"type": "pie",
19+
"name": "Year 2014",
20+
"title": "Year 2014",
21+
"domain": {
22+
"x": [0.51, 1],
23+
"y": [0.51, 1]
24+
},
25+
"hoverinfo": "label+percent+name",
26+
"textinfo": "none"
27+
},{
28+
"values": [42700, 85300, 10600, 8230, 1010, 8],
29+
"labels": ["Platinum", "Palladium", "Rhodium", "Ruthenium", "Iridium", "Osmium"],
30+
"type": "pie",
31+
"name": "Year 2015",
32+
"title": "Year 2015",
33+
"domain": {
34+
"x": [0, 0.5],
35+
"y": [0, 0.5]
36+
},
37+
"hoverinfo": "label+percent+name",
38+
"textinfo": "none"
39+
},{
40+
"values": [42300, 80400, 10700, 8410, 1300, 27],
41+
"labels": ["Platinum", "Palladium", "Rhodium", "Ruthenium", "Iridium", "Osmium"],
42+
"type": "pie",
43+
"name": "Year 2016",
44+
"title": "Year 2016",
45+
"domain": {
46+
"x": [0.51, 1],
47+
"y": [0, 0.5]
48+
},
49+
"hoverinfo": "label+percent+name",
50+
"textinfo": "none"
51+
}
52+
],
53+
"layout": {
54+
"title": "U.S. Imports for Platinum-group Metals",
55+
"height": 400,
56+
"width": 500
57+
}
58+
}

test/image/mocks/pie_title_pull.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"data": [
3+
{
4+
"values": [
5+
50, 49, 48, 47, 46, 45, 44, 43, 42, 41
6+
],
7+
"pull": [
8+
0, 0.5, 0, 0.5, 0, 0.5, 0, 0.5, 0, 0.5
9+
],
10+
"sort": false,
11+
"type": "pie",
12+
"textposition": "inside",
13+
"title": "Withering Flower"
14+
}
15+
],
16+
"layout": {
17+
"height": 600,
18+
"width": 400,
19+
"showlegend": false
20+
}
21+
}

test/jasmine/tests/pie_test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,53 @@ describe('Pie traces:', function() {
182182
.catch(failTest)
183183
.then(done);
184184
});
185+
186+
it('shows title in hole', function(done) {
187+
Plotly.newPlot(gd, [{
188+
values: [2, 2, 2, 2],
189+
title: 'Pie',
190+
titleposition: 'inhole',
191+
hole: 0.5,
192+
type: 'pie',
193+
}], {height: 300, width: 300})
194+
.then(function() {
195+
var title = d3.selectAll('.titletext text');
196+
expect(title.size()).toBe(1);
197+
})
198+
.catch(failTest)
199+
.then(done);
200+
});
201+
202+
it('does not show title inside if there is no hole', function(done) {
203+
Plotly.newPlot(gd, [{
204+
values: [2, 2, 2, 2],
205+
title: 'Pie',
206+
titleposition: 'inhole',
207+
hole: 0,
208+
type: 'pie',
209+
}], {height: 300, width: 300})
210+
.then(function() {
211+
var title = d3.selectAll('.titletext text');
212+
expect(title.size()).toBe(0);
213+
})
214+
.catch(failTest)
215+
.then(done);
216+
});
217+
218+
it('shows title outside', function(done) {
219+
Plotly.newPlot(gd, [{
220+
values: [1, 1, 1, 1, 2],
221+
title: 'Pie',
222+
titleposition: 'outside',
223+
type: 'pie',
224+
}], {height: 300, width: 300})
225+
.then(function() {
226+
var title = d3.selectAll('.titletext text');
227+
expect(title.size()).toBe(1);
228+
})
229+
.catch(failTest)
230+
.then(done);
231+
});
185232
});
186233

187234
describe('pie hovering', function() {

0 commit comments

Comments
 (0)