diff --git a/lib/index.js b/lib/index.js
index 9c91dd6a464..6ef0ea0266a 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -18,11 +18,13 @@ Plotly.register([
require('./histogram'),
require('./histogram2d'),
require('./histogram2dcontour'),
- require('./pie'),
require('./contour'),
require('./scatterternary'),
require('./violin'),
+ require('./pie'),
+ require('./sunburst'),
+
require('./scatter3d'),
require('./surface'),
require('./isosurface'),
diff --git a/lib/sunburst.js b/lib/sunburst.js
new file mode 100644
index 00000000000..5f154ee9d9a
--- /dev/null
+++ b/lib/sunburst.js
@@ -0,0 +1,11 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+module.exports = require('../src/traces/sunburst');
diff --git a/package-lock.json b/package-lock.json
index 895198d1bdb..28ff683375f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2298,6 +2298,11 @@
"d3-timer": "1"
}
},
+ "d3-hierarchy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.8.tgz",
+ "integrity": "sha512-L+GHMSZNwTpiq4rt9GEsNcpLa4M96lXMR8M/nMG9p5hBE0jy6C+3hWtyZMenPQdwla249iJy7Nx0uKt3n+u9+w=="
+ },
"d3-interpolate": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.3.2.tgz",
diff --git a/package.json b/package.json
index 83842e38f45..72db3a0888f 100644
--- a/package.json
+++ b/package.json
@@ -66,6 +66,7 @@
"country-regex": "^1.1.0",
"d3": "^3.5.12",
"d3-force": "^1.0.6",
+ "d3-hierarchy": "^1.1.8",
"d3-interpolate": "1",
"d3-sankey-circular": "0.33.0",
"delaunay-triangulate": "^1.1.6",
diff --git a/src/components/fx/helpers.js b/src/components/fx/helpers.js
index b9006b43db9..2c169421b76 100644
--- a/src/components/fx/helpers.js
+++ b/src/components/fx/helpers.js
@@ -223,7 +223,8 @@ var pointKeyMap = {
locations: 'location',
labels: 'label',
values: 'value',
- 'marker.colors': 'color'
+ 'marker.colors': 'color',
+ parents: 'parent'
};
function getPointKey(astr) {
diff --git a/src/lib/angles.js b/src/lib/angles.js
index 0dfe73f34e4..efb6ee5d518 100644
--- a/src/lib/angles.js
+++ b/src/lib/angles.js
@@ -29,7 +29,7 @@ function rad2deg(rad) { return rad / PI * 180; }
* @return {boolean}
*/
function isFullCircle(aBnds) {
- return Math.abs(aBnds[1] - aBnds[0]) > twoPI - 1e-15;
+ return Math.abs(aBnds[1] - aBnds[0]) > twoPI - 1e-14;
}
/**
diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js
index ce3eadc1b3d..0361cd5a87c 100644
--- a/src/plot_api/plot_api.js
+++ b/src/plot_api/plot_api.js
@@ -2528,6 +2528,7 @@ var traceUIControlPatterns = [
{pattern: /(^|value\.)visible$/, attr: 'legend.uirevision'},
{pattern: /^dimensions\[\d+\]\.constraintrange/},
{pattern: /^node\.(x|y)/}, // for Sankey nodes
+ {pattern: /^level$/}, // for Sunburst traces
// below this you must be in editable: true mode
// TODO: I still put name and title with `trace.uirevision`
@@ -3873,6 +3874,9 @@ function makePlotFramework(gd) {
// single pie layer for the whole plot
fullLayout._pielayer = fullLayout._paper.append('g').classed('pielayer', true);
+ // single sunbursrt layer for the whole plot
+ fullLayout._sunburstlayer = fullLayout._paper.append('g').classed('sunburstlayer', true);
+
// fill in image server scrape-svg
fullLayout._glimages = fullLayout._paper.append('g').classed('glimages', true);
diff --git a/src/plots/plots.js b/src/plots/plots.js
index 4028498c4ee..e2ecb04047a 100644
--- a/src/plots/plots.js
+++ b/src/plots/plots.js
@@ -2723,8 +2723,9 @@ plots.doCalcdata = function(gd, traces) {
gd._hmpixcount = 0;
gd._hmlumcount = 0;
- // for sharing colors across pies (and for legend)
+ // for sharing colors across pies / sunbursts (and for legend)
fullLayout._piecolormap = {};
+ fullLayout._sunburstcolormap = {};
// If traces were specified and this trace was not included,
// then transfer it over from the old calcdata:
diff --git a/src/traces/pie/calc.js b/src/traces/pie/calc.js
index 36b2b42fd0b..c5d1700e3ee 100644
--- a/src/traces/pie/calc.js
+++ b/src/traces/pie/calc.js
@@ -15,14 +15,15 @@ var tinycolor = require('tinycolor2');
var Color = require('../../components/color');
var helpers = require('./helpers');
-exports.calc = function calc(gd, trace) {
+var pieExtendedColorWays = {};
+
+function calc(gd, trace) {
var vals = trace.values;
var hasVals = isArrayOrTypedArray(vals) && vals.length;
var labels = trace.labels;
var colors = trace.marker.colors || [];
var cd = [];
var fullLayout = gd._fullLayout;
- var colorMap = fullLayout._piecolormap;
var allThisTraceLabels = {};
var vTotal = 0;
var hiddenLabels = fullLayout.hiddenlabels || [];
@@ -36,18 +37,7 @@ exports.calc = function calc(gd, trace) {
}
}
- function pullColor(color, label) {
- if(!color) return false;
-
- color = tinycolor(color);
- if(!color.isValid()) return false;
-
- color = Color.addOpacity(color, color.getAlpha());
- if(!colorMap[label]) colorMap[label] = color;
-
- return color;
- }
-
+ var pullColor = makePullColorFn(fullLayout._piecolormap);
var seriesLen = (hasVals ? vals : labels).length;
for(i = 0; i < seriesLen; i++) {
@@ -121,7 +111,21 @@ exports.calc = function calc(gd, trace) {
}
return cd;
-};
+}
+
+function makePullColorFn(colorMap) {
+ return function pullColor(color, id) {
+ if(!color) return false;
+
+ color = tinycolor(color);
+ if(!color.isValid()) return false;
+
+ color = Color.addOpacity(color, color.getAlpha());
+ if(!colorMap[id]) colorMap[id] = color;
+
+ return color;
+ };
+}
/*
* `calc` filled in (and collated) explicit colors.
@@ -130,45 +134,41 @@ exports.calc = function calc(gd, trace) {
* This is done after sorting, so we pick defaults
* in the order slices will be displayed
*/
-exports.crossTraceCalc = function(gd) {
+function crossTraceCalc(gd) {
var fullLayout = gd._fullLayout;
var calcdata = gd.calcdata;
var pieColorWay = fullLayout.piecolorway;
var colorMap = fullLayout._piecolormap;
if(fullLayout.extendpiecolors) {
- pieColorWay = generateExtendedColors(pieColorWay);
+ pieColorWay = generateExtendedColors(pieColorWay, pieExtendedColorWays);
}
var dfltColorCount = 0;
- var i, j, cd, pt;
- for(i = 0; i < calcdata.length; i++) {
- cd = calcdata[i];
+ for(var i = 0; i < calcdata.length; i++) {
+ var cd = calcdata[i];
if(cd[0].trace.type !== 'pie') continue;
- for(j = 0; j < cd.length; j++) {
- pt = cd[j];
+ for(var j = 0; j < cd.length; j++) {
+ var pt = cd[j];
if(pt.color === false) {
// have we seen this label and assigned a color to it in a previous trace?
if(colorMap[pt.label]) {
pt.color = colorMap[pt.label];
- }
- else {
+ } else {
colorMap[pt.label] = pt.color = pieColorWay[dfltColorCount % pieColorWay.length];
dfltColorCount++;
}
}
}
}
-};
+}
/**
* pick a default color from the main default set, augmented by
* itself lighter then darker before repeating
*/
-var extendedColorWays = {};
-
-function generateExtendedColors(colorList) {
+function generateExtendedColors(colorList, extendedColorWays) {
var i;
var colorString = JSON.stringify(colorList);
var pieColors = extendedColorWays[colorString];
@@ -187,3 +187,11 @@ function generateExtendedColors(colorList) {
return pieColors;
}
+
+module.exports = {
+ calc: calc,
+ crossTraceCalc: crossTraceCalc,
+
+ makePullColorFn: makePullColorFn,
+ generateExtendedColors: generateExtendedColors
+};
diff --git a/src/traces/pie/index.js b/src/traces/pie/index.js
index 5e3cbb28de7..f8ec9c13fdf 100644
--- a/src/traces/pie/index.js
+++ b/src/traces/pie/index.js
@@ -19,7 +19,7 @@ var calcModule = require('./calc');
Pie.calc = calcModule.calc;
Pie.crossTraceCalc = calcModule.crossTraceCalc;
-Pie.plot = require('./plot');
+Pie.plot = require('./plot').plot;
Pie.style = require('./style');
Pie.styleOne = require('./style_one');
diff --git a/src/traces/pie/plot.js b/src/traces/pie/plot.js
index 10524a40894..47321269f17 100644
--- a/src/traces/pie/plot.js
+++ b/src/traces/pie/plot.js
@@ -19,7 +19,7 @@ var svgTextUtils = require('../../lib/svg_text_utils');
var helpers = require('./helpers');
var eventData = require('./event_data');
-module.exports = function plot(gd, cdpie) {
+function plot(gd, cdpie) {
var fullLayout = gd._fullLayout;
prerenderTitles(cdpie, gd);
@@ -66,139 +66,11 @@ module.exports = function plot(gd, cdpie) {
var sliceTop = d3.select(this);
var slicePath = sliceTop.selectAll('path.surface').data([pt]);
- // hover state vars
- // have we drawn a hover label, so it should be cleared later
- var hasHoverLabel = false;
- // have we emitted a hover event, so later an unhover event should be emitted
- // note that click events do not depend on this - you can still get them
- // with hovermode: false or if you were earlier dragging, then clicked
- // in the same slice that you moused up in
- var hasHoverEvent = false;
-
- function handleMouseOver() {
- // in case fullLayout or fullData has changed without a replot
- var fullLayout2 = gd._fullLayout;
- var trace2 = gd._fullData[trace.index];
-
- if(gd._dragging || fullLayout2.hovermode === false) return;
-
- var hoverinfo = trace2.hoverinfo;
- if(Array.isArray(hoverinfo)) {
- // super hacky: we need to pull out the *first* hoverinfo from
- // pt.pts, then put it back into an array in a dummy trace
- // and call castHoverinfo on that.
- // TODO: do we want to have Fx.castHoverinfo somehow handle this?
- // it already takes an array for index, for 2D, so this seems tricky.
- hoverinfo = Fx.castHoverinfo({
- hoverinfo: [helpers.castOption(hoverinfo, pt.pts)],
- _module: trace._module
- }, fullLayout2, 0);
- }
-
- if(hoverinfo === 'all') hoverinfo = 'label+text+value+percent+name';
-
- // in case we dragged over the pie from another subplot,
- // or if hover is turned off
- if(trace2.hovertemplate || (hoverinfo !== 'none' && hoverinfo !== 'skip' && hoverinfo)) {
- var rInscribed = getInscribedRadiusFraction(pt, cd0);
- var hoverCenterX = cx + pt.pxmid[0] * (1 - rInscribed);
- var hoverCenterY = cy + pt.pxmid[1] * (1 - rInscribed);
- var separators = fullLayout.separators;
- var thisText = [];
-
- if(hoverinfo && hoverinfo.indexOf('label') !== -1) thisText.push(pt.label);
- pt.text = helpers.castOption(trace2.hovertext || trace2.text, pt.pts);
- if(hoverinfo && hoverinfo.indexOf('text') !== -1) {
- var texti = pt.text;
- if(texti) thisText.push(texti);
- }
- pt.value = pt.v;
- pt.valueLabel = helpers.formatPieValue(pt.v, separators);
- if(hoverinfo && hoverinfo.indexOf('value') !== -1) thisText.push(pt.valueLabel);
- pt.percent = pt.v / cd0.vTotal;
- pt.percentLabel = helpers.formatPiePercent(pt.percent, separators);
- if(hoverinfo && hoverinfo.indexOf('percent') !== -1) thisText.push(pt.percentLabel);
-
- var hoverLabel = trace.hoverlabel;
- var hoverFont = hoverLabel.font;
-
- Fx.loneHover({
- x0: hoverCenterX - rInscribed * cd0.r,
- x1: hoverCenterX + rInscribed * cd0.r,
- y: hoverCenterY,
- text: thisText.join('
'),
- name: (trace2.hovertemplate || hoverinfo.indexOf('name') !== -1) ? trace2.name : undefined,
- idealAlign: pt.pxmid[0] < 0 ? 'left' : 'right',
- color: helpers.castOption(hoverLabel.bgcolor, pt.pts) || pt.color,
- borderColor: helpers.castOption(hoverLabel.bordercolor, pt.pts),
- fontFamily: helpers.castOption(hoverFont.family, pt.pts),
- fontSize: helpers.castOption(hoverFont.size, pt.pts),
- fontColor: helpers.castOption(hoverFont.color, pt.pts),
-
- trace: trace2,
- hovertemplate: helpers.castOption(trace2.hovertemplate, pt.pts),
- hovertemplateLabels: pt,
- eventData: [eventData(pt, trace2)]
- }, {
- container: fullLayout2._hoverlayer.node(),
- outerContainer: fullLayout2._paper.node(),
- gd: gd
- });
-
- hasHoverLabel = true;
- }
-
- gd.emit('plotly_hover', {
- points: [eventData(pt, trace2)],
- event: d3.event
- });
- hasHoverEvent = true;
- }
-
- function handleMouseOut(evt) {
- var fullLayout2 = gd._fullLayout;
- var trace2 = gd._fullData[trace.index];
-
- if(hasHoverEvent) {
- evt.originalEvent = d3.event;
- gd.emit('plotly_unhover', {
- points: [eventData(pt, trace2)],
- event: d3.event
- });
- hasHoverEvent = false;
- }
-
- if(hasHoverLabel) {
- Fx.loneUnhover(fullLayout2._hoverlayer.node());
- hasHoverLabel = false;
- }
- }
-
- function handleClick() {
- // TODO: this does not support right-click. If we want to support it, we
- // would likely need to change pie to use dragElement instead of straight
- // mapbox event binding. Or perhaps better, make a simple wrapper with the
- // right mousedown, mousemove, and mouseup handlers just for a left/right click
- // mapbox would use this too.
- var fullLayout2 = gd._fullLayout;
- var trace2 = gd._fullData[trace.index];
-
- if(gd._dragging || fullLayout2.hovermode === false) return;
-
- gd._hoverdata = [eventData(pt, trace2)];
- Fx.click(gd, d3.event);
- }
-
slicePath.enter().append('path')
.classed('surface', true)
.style({'pointer-events': 'all'});
- sliceTop.select('path.textline').remove();
-
- sliceTop
- .on('mouseover', handleMouseOver)
- .on('mouseout', handleMouseOut)
- .on('click', handleClick);
+ sliceTop.call(attachFxHandlers, gd, cd);
if(trace.pull) {
var pull = +helpers.castOption(trace.pull, pt.pts) || 0;
@@ -363,50 +235,8 @@ module.exports = function plot(gd, cdpie) {
// now make sure no labels overlap (at least within one pie)
if(hasOutsideText) scootLabels(quadrants, trace);
- slices.each(function(pt) {
- if(pt.labelExtraX || pt.labelExtraY) {
- // first move the text to its new location
- var sliceTop = d3.select(this);
- var sliceText = sliceTop.select('g.slicetext text');
-
- sliceText.attr('transform', 'translate(' + pt.labelExtraX + ',' + pt.labelExtraY + ')' +
- sliceText.attr('transform'));
-
- // then add a line to the new location
- var lineStartX = pt.cxFinal + pt.pxmid[0];
- var lineStartY = pt.cyFinal + pt.pxmid[1];
- var textLinePath = 'M' + lineStartX + ',' + lineStartY;
- var finalX = (pt.yLabelMax - pt.yLabelMin) * (pt.pxmid[0] < 0 ? -1 : 1) / 4;
-
- if(pt.labelExtraX) {
- var yFromX = pt.labelExtraX * pt.pxmid[1] / pt.pxmid[0];
- var yNet = pt.yLabelMid + pt.labelExtraY - (pt.cyFinal + pt.pxmid[1]);
-
- if(Math.abs(yFromX) > Math.abs(yNet)) {
- textLinePath +=
- 'l' + (yNet * pt.pxmid[0] / pt.pxmid[1]) + ',' + yNet +
- 'H' + (lineStartX + pt.labelExtraX + finalX);
- } else {
- textLinePath += 'l' + pt.labelExtraX + ',' + yFromX +
- 'v' + (yNet - yFromX) +
- 'h' + finalX;
- }
- } else {
- textLinePath +=
- 'V' + (pt.yLabelMid + pt.labelExtraY) +
- 'h' + finalX;
- }
- sliceTop.append('path')
- .classed('textline', true)
- .call(Color.stroke, trace.outsidetextfont.color)
- .attr({
- 'stroke-width': Math.min(2, trace.outsidetextfont.size / 8),
- d: textLinePath,
- fill: 'none'
- });
- }
- });
+ plotTextLines(slices, trace);
});
});
@@ -422,7 +252,189 @@ module.exports = function plot(gd, cdpie) {
if(s.attr('dy')) s.attr('dy', s.attr('dy'));
});
}, 0);
-};
+}
+
+// TODO add support for transition
+function plotTextLines(slices, trace) {
+ slices.each(function(pt) {
+ var sliceTop = d3.select(this);
+
+ if(!pt.labelExtraX && !pt.labelExtraY) {
+ sliceTop.select('path.textline').remove();
+ return;
+ }
+
+ // first move the text to its new location
+ var sliceText = sliceTop.select('g.slicetext text');
+
+ sliceText.attr('transform', 'translate(' + pt.labelExtraX + ',' + pt.labelExtraY + ')' +
+ sliceText.attr('transform'));
+
+ // then add a line to the new location
+ var lineStartX = pt.cxFinal + pt.pxmid[0];
+ var lineStartY = pt.cyFinal + pt.pxmid[1];
+ var textLinePath = 'M' + lineStartX + ',' + lineStartY;
+ var finalX = (pt.yLabelMax - pt.yLabelMin) * (pt.pxmid[0] < 0 ? -1 : 1) / 4;
+
+ if(pt.labelExtraX) {
+ var yFromX = pt.labelExtraX * pt.pxmid[1] / pt.pxmid[0];
+ var yNet = pt.yLabelMid + pt.labelExtraY - (pt.cyFinal + pt.pxmid[1]);
+
+ if(Math.abs(yFromX) > Math.abs(yNet)) {
+ textLinePath +=
+ 'l' + (yNet * pt.pxmid[0] / pt.pxmid[1]) + ',' + yNet +
+ 'H' + (lineStartX + pt.labelExtraX + finalX);
+ } else {
+ textLinePath += 'l' + pt.labelExtraX + ',' + yFromX +
+ 'v' + (yNet - yFromX) +
+ 'h' + finalX;
+ }
+ } else {
+ textLinePath +=
+ 'V' + (pt.yLabelMid + pt.labelExtraY) +
+ 'h' + finalX;
+ }
+
+ Lib.ensureSingle(sliceTop, 'path', 'textline')
+ .call(Color.stroke, trace.outsidetextfont.color)
+ .attr({
+ 'stroke-width': Math.min(2, trace.outsidetextfont.size / 8),
+ d: textLinePath,
+ fill: 'none'
+ });
+ });
+}
+
+function attachFxHandlers(sliceTop, gd, cd) {
+ var cd0 = cd[0];
+ var trace = cd0.trace;
+ var cx = cd0.cx;
+ var cy = cd0.cy;
+
+ // hover state vars
+ // have we drawn a hover label, so it should be cleared later
+ var hasHoverLabel = false;
+ // have we emitted a hover event, so later an unhover event should be emitted
+ // note that click events do not depend on this - you can still get them
+ // with hovermode: false or if you were earlier dragging, then clicked
+ // in the same slice that you moused up in
+ var hasHoverEvent = false;
+
+ sliceTop.on('mouseover', function(pt) {
+ // in case fullLayout or fullData has changed without a replot
+ var fullLayout2 = gd._fullLayout;
+ var trace2 = gd._fullData[trace.index];
+
+ if(gd._dragging || fullLayout2.hovermode === false) return;
+
+ var hoverinfo = trace2.hoverinfo;
+ if(Array.isArray(hoverinfo)) {
+ // super hacky: we need to pull out the *first* hoverinfo from
+ // pt.pts, then put it back into an array in a dummy trace
+ // and call castHoverinfo on that.
+ // TODO: do we want to have Fx.castHoverinfo somehow handle this?
+ // it already takes an array for index, for 2D, so this seems tricky.
+ hoverinfo = Fx.castHoverinfo({
+ hoverinfo: [helpers.castOption(hoverinfo, pt.pts)],
+ _module: trace._module
+ }, fullLayout2, 0);
+ }
+
+ if(hoverinfo === 'all') hoverinfo = 'label+text+value+percent+name';
+
+ // in case we dragged over the pie from another subplot,
+ // or if hover is turned off
+ if(trace2.hovertemplate || (hoverinfo !== 'none' && hoverinfo !== 'skip' && hoverinfo)) {
+ var rInscribed = pt.rInscribed;
+ var hoverCenterX = cx + pt.pxmid[0] * (1 - rInscribed);
+ var hoverCenterY = cy + pt.pxmid[1] * (1 - rInscribed);
+ var separators = fullLayout2.separators;
+ var thisText = [];
+
+ if(hoverinfo && hoverinfo.indexOf('label') !== -1) thisText.push(pt.label);
+ pt.text = helpers.castOption(trace2.hovertext || trace2.text, pt.pts);
+ if(hoverinfo && hoverinfo.indexOf('text') !== -1) {
+ var texti = pt.text;
+ if(texti) thisText.push(texti);
+ }
+ pt.value = pt.v;
+ pt.valueLabel = helpers.formatPieValue(pt.v, separators);
+ if(hoverinfo && hoverinfo.indexOf('value') !== -1) thisText.push(pt.valueLabel);
+ pt.percent = pt.v / cd0.vTotal;
+ pt.percentLabel = helpers.formatPiePercent(pt.percent, separators);
+ if(hoverinfo && hoverinfo.indexOf('percent') !== -1) thisText.push(pt.percentLabel);
+
+ var hoverLabel = trace.hoverlabel;
+ var hoverFont = hoverLabel.font;
+
+ Fx.loneHover({
+ x0: hoverCenterX - rInscribed * cd0.r,
+ x1: hoverCenterX + rInscribed * cd0.r,
+ y: hoverCenterY,
+ text: thisText.join('
'),
+ name: (trace2.hovertemplate || hoverinfo.indexOf('name') !== -1) ? trace2.name : undefined,
+ idealAlign: pt.pxmid[0] < 0 ? 'left' : 'right',
+ color: helpers.castOption(hoverLabel.bgcolor, pt.pts) || pt.color,
+ borderColor: helpers.castOption(hoverLabel.bordercolor, pt.pts),
+ fontFamily: helpers.castOption(hoverFont.family, pt.pts),
+ fontSize: helpers.castOption(hoverFont.size, pt.pts),
+ fontColor: helpers.castOption(hoverFont.color, pt.pts),
+
+ trace: trace2,
+ hovertemplate: helpers.castOption(trace2.hovertemplate, pt.pts),
+ hovertemplateLabels: pt,
+ eventData: [eventData(pt, trace2)]
+ }, {
+ container: fullLayout2._hoverlayer.node(),
+ outerContainer: fullLayout2._paper.node(),
+ gd: gd
+ });
+
+ hasHoverLabel = true;
+ }
+
+ gd.emit('plotly_hover', {
+ points: [eventData(pt, trace2)],
+ event: d3.event
+ });
+ hasHoverEvent = true;
+ });
+
+ sliceTop.on('mouseout', function(evt) {
+ var fullLayout2 = gd._fullLayout;
+ var trace2 = gd._fullData[trace.index];
+ var pt = d3.select(this).datum();
+
+ if(hasHoverEvent) {
+ evt.originalEvent = d3.event;
+ gd.emit('plotly_unhover', {
+ points: [eventData(pt, trace2)],
+ event: d3.event
+ });
+ hasHoverEvent = false;
+ }
+
+ if(hasHoverLabel) {
+ Fx.loneUnhover(fullLayout2._hoverlayer.node());
+ hasHoverLabel = false;
+ }
+ });
+
+ sliceTop.on('click', function(pt) {
+ // TODO: this does not support right-click. If we want to support it, we
+ // would likely need to change pie to use dragElement instead of straight
+ // mapbox event binding. Or perhaps better, make a simple wrapper with the
+ // right mousedown, mousemove, and mouseup handlers just for a left/right click
+ // mapbox would use this too.
+ var fullLayout2 = gd._fullLayout;
+ var trace2 = gd._fullData[trace.index];
+
+ if(gd._dragging || fullLayout2.hovermode === false) return;
+
+ gd._hoverdata = [eventData(pt, trace2)];
+ Fx.click(gd, d3.event);
+ });
+}
function determineOutsideTextFont(trace, pt, layoutFont) {
var color = helpers.castOption(trace.outsidetextfont.color, pt.pts) ||
@@ -502,16 +514,17 @@ function prerenderTitles(cdpie, gd) {
function transformInsideText(textBB, pt, cd0) {
var textDiameter = Math.sqrt(textBB.width * textBB.width + textBB.height * textBB.height);
var textAspect = textBB.width / textBB.height;
- var halfAngle = Math.PI * Math.min(pt.v / cd0.vTotal, 0.5);
- var ring = 1 - cd0.trace.hole;
- var rInscribed = getInscribedRadiusFraction(pt, cd0);
+ var halfAngle = pt.halfangle;
+ var ring = pt.ring;
+ var rInscribed = pt.rInscribed;
+ var r = cd0.r || pt.rpx1;
// max size text can be inserted inside without rotating it
// this inscribes the text rectangle in a circle, which is then inscribed
// in the slice, so it will be an underestimate, which some day we may want
// to improve so this case can get more use
var transform = {
- scale: rInscribed * cd0.r * 2 / textDiameter,
+ scale: rInscribed * r * 2 / textDiameter,
// and the center position and rotation in this case
rCenter: 1 - rInscribed,
@@ -520,30 +533,30 @@ function transformInsideText(textBB, pt, cd0) {
if(transform.scale >= 1) return transform;
- // max size if text is rotated radially
+ // max size if text is rotated radially
var Qr = textAspect + 1 / (2 * Math.tan(halfAngle));
- var maxHalfHeightRotRadial = cd0.r * Math.min(
+ var maxHalfHeightRotRadial = r * Math.min(
1 / (Math.sqrt(Qr * Qr + 0.5) + Qr),
ring / (Math.sqrt(textAspect * textAspect + ring / 2) + textAspect)
);
var radialTransform = {
scale: maxHalfHeightRotRadial * 2 / textBB.height,
- rCenter: Math.cos(maxHalfHeightRotRadial / cd0.r) -
- maxHalfHeightRotRadial * textAspect / cd0.r,
+ rCenter: Math.cos(maxHalfHeightRotRadial / r) -
+ maxHalfHeightRotRadial * textAspect / r,
rotate: (180 / Math.PI * pt.midangle + 720) % 180 - 90
};
- // max size if text is rotated tangentially
+ // max size if text is rotated tangentially
var aspectInv = 1 / textAspect;
var Qt = aspectInv + 1 / (2 * Math.tan(halfAngle));
- var maxHalfWidthTangential = cd0.r * Math.min(
+ var maxHalfWidthTangential = r * Math.min(
1 / (Math.sqrt(Qt * Qt + 0.5) + Qt),
ring / (Math.sqrt(aspectInv * aspectInv + ring / 2) + aspectInv)
);
var tangentialTransform = {
scale: maxHalfWidthTangential * 2 / textBB.width,
- rCenter: Math.cos(maxHalfWidthTangential / cd0.r) -
- maxHalfWidthTangential / textAspect / cd0.r,
+ rCenter: Math.cos(maxHalfWidthTangential / r) -
+ maxHalfWidthTangential / textAspect / r,
rotate: (180 / Math.PI * pt.midangle + 810) % 180 - 90
};
// if we need a rotated transform, pick the biggest one
@@ -558,8 +571,7 @@ function transformInsideText(textBB, pt, cd0) {
function getInscribedRadiusFraction(pt, cd0) {
if(pt.v === cd0.vTotal && !cd0.trace.hole) return 1;// special case of 100% with no hole
- var halfAngle = Math.PI * Math.min(pt.v / cd0.vTotal, 0.5);
- return Math.min(1 / (1 + 1 / Math.sin(halfAngle)), (1 - cd0.trace.hole) / 2);
+ return Math.min(1 / (1 + 1 / Math.sin(pt.halfangle)), pt.ring / 2);
}
function transformOutsideText(textBB, pt) {
@@ -827,7 +839,6 @@ function scalePies(cdpie, plotSize) {
}
}
}
-
}
function setCoords(cd) {
@@ -874,5 +885,14 @@ function setCoords(cd) {
cdi[lastPt] = currentCoords;
cdi.largeArc = (cdi.v > cd0.vTotal / 2) ? 1 : 0;
+
+ cdi.halfangle = Math.PI * Math.min(cdi.v / cd0.vTotal, 0.5);
+ cdi.ring = 1 - trace.hole;
+ cdi.rInscribed = getInscribedRadiusFraction(cdi, cd0);
}
}
+
+module.exports = {
+ plot: plot,
+ transformInsideText: transformInsideText
+};
diff --git a/src/traces/sunburst/attributes.js b/src/traces/sunburst/attributes.js
new file mode 100644
index 00000000000..52c88da4a4a
--- /dev/null
+++ b/src/traces/sunburst/attributes.js
@@ -0,0 +1,143 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var plotAttrs = require('../../plots/attributes');
+var hovertemplateAttrs = require('../../components/fx/hovertemplate_attributes');
+var domainAttrs = require('../../plots/domain').attributes;
+var pieAtts = require('../pie/attributes');
+
+var extendFlat = require('../../lib/extend').extendFlat;
+
+module.exports = {
+ labels: {
+ valType: 'data_array',
+ editType: 'calc',
+ description: [
+ 'Sets the labels of each of the sunburst sectors.'
+ ].join(' ')
+ },
+ parents: {
+ valType: 'data_array',
+ editType: 'calc',
+ description: [
+ 'Sets the parent sectors for each of the sunburst sectors.',
+ 'Empty string items \'\' are understood to reference',
+ 'the root node in the hierarchy.',
+ 'If `ids` is filled, `parents` items are understood to be "ids" themselves.',
+ 'When `ids` is not set, plotly attempts to find matching items in `labels`,',
+ 'but beware they must be unique.'
+ ].join(' ')
+ },
+
+ values: {
+ valType: 'data_array',
+ editType: 'calc',
+ description: [
+ 'Sets the values associated with each of the sunburst sectors.',
+ 'Use with `branchvalues` to determine how the values are summed.'
+ ].join(' ')
+ },
+ branchvalues: {
+ valType: 'enumerated',
+ values: ['remainder', 'total'],
+ dflt: 'remainder',
+ editType: 'calc',
+ role: 'info',
+ description: [
+ 'Determines how the items in `values` are summed.',
+ 'When set to *total*, items in `values` are taken to be value of all its descendants.',
+ 'When set to *remainder*, items in `values` corresponding to the root and the branches sectors',
+ 'are taken to be the extra part not part of the sum of the values at their leaves.'
+ ].join(' ')
+ },
+
+ level: {
+ valType: 'any',
+ editType: 'plot',
+ role: 'info',
+ description: [
+ 'Sets the level from which this sunburst trace hierarchy is rendered.',
+ 'Set `level` to `\'\'` to start the sunburst from the root node in the hierarchy.',
+ 'Must be an "id" if `ids` is filled in, otherwise plotly attempts to find a matching',
+ 'item in `labels`.'
+ ].join(' ')
+ },
+ maxdepth: {
+ valType: 'integer',
+ editType: 'plot',
+ role: 'info',
+ dflt: -1,
+ description: [
+ 'Sets the number of rendered sunburst rings from any given `level`.',
+ 'Set `maxdepth` to *-1* to render all the levels in the hierarchy.'
+ ].join(' ')
+ },
+
+ marker: {
+ colors: {
+ valType: 'data_array',
+ editType: 'calc',
+ description: [
+ 'Sets the color of each sector of this sunburst chart.',
+ 'If not specified, the default trace color set is used',
+ 'to pick the sector colors.'
+ ].join(' ')
+ },
+
+ // colorinheritance: {
+ // valType: 'enumerated',
+ // values: ['per-branch', 'per-label', false]
+ // },
+
+ line: {
+ color: extendFlat({}, pieAtts.marker.line.color, {
+ dflt: null,
+ description: [
+ 'Sets the color of the line enclosing each sector.',
+ 'Defaults to the `paper_bgcolor` value.'
+ ].join(' ')
+ }),
+ width: extendFlat({}, pieAtts.marker.line.width, {dflt: 1}),
+ editType: 'calc'
+ },
+ editType: 'calc'
+ },
+
+ leaf: {
+ opacity: {
+ valType: 'number',
+ editType: 'style',
+ role: 'style',
+ min: 0,
+ max: 1,
+ dflt: 0.7,
+ description: 'Sets the opacity of the leaves.'
+ },
+ editType: 'plot'
+ },
+
+ text: pieAtts.text,
+ textinfo: extendFlat({}, pieAtts.textinfo, {
+ editType: 'plot',
+ flags: ['label', 'text', 'value']
+ }),
+ textfont: pieAtts.textfont,
+
+ hovertext: pieAtts.hovertext,
+ hoverinfo: extendFlat({}, plotAttrs.hoverinfo, {
+ flags: ['label', 'text', 'value', 'name']
+ }),
+ hovertemplate: hovertemplateAttrs(),
+
+ insidetextfont: pieAtts.insidetextfont,
+ outsidetextfont: pieAtts.outsidetextfont,
+
+ domain: domainAttrs({name: 'sunburst', trace: true, editType: 'calc'})
+};
diff --git a/src/traces/sunburst/base_plot.js b/src/traces/sunburst/base_plot.js
new file mode 100644
index 00000000000..82dddc89d59
--- /dev/null
+++ b/src/traces/sunburst/base_plot.js
@@ -0,0 +1,29 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var Registry = require('../../registry');
+var getModuleCalcData = require('../../plots/get_data').getModuleCalcData;
+
+var name = exports.name = 'sunburst';
+
+exports.plot = function(gd, traces, transitionOpts, makeOnCompleteCallback) {
+ var _module = Registry.getModule(name);
+ var cdmodule = getModuleCalcData(gd.calcdata, _module)[0];
+ _module.plot(gd, cdmodule, transitionOpts, makeOnCompleteCallback);
+};
+
+exports.clean = function(newFullData, newFullLayout, oldFullData, oldFullLayout) {
+ var had = (oldFullLayout._has && oldFullLayout._has(name));
+ var has = (newFullLayout._has && newFullLayout._has(name));
+
+ if(had && !has) {
+ oldFullLayout._sunburstlayer.selectAll('g.trace').remove();
+ }
+};
diff --git a/src/traces/sunburst/calc.js b/src/traces/sunburst/calc.js
new file mode 100644
index 00000000000..4c28d917386
--- /dev/null
+++ b/src/traces/sunburst/calc.js
@@ -0,0 +1,240 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var d3Hierarchy = require('d3-hierarchy');
+var isNumeric = require('fast-isnumeric');
+
+var Lib = require('../../lib');
+var makePullColorFn = require('../pie/calc').makePullColorFn;
+var generateExtendedColors = require('../pie/calc').generateExtendedColors;
+
+var isArrayOrTypedArray = Lib.isArrayOrTypedArray;
+
+var sunburstExtendedColorWays = {};
+
+exports.calc = function(gd, trace) {
+ var fullLayout = gd._fullLayout;
+ var ids = trace.ids;
+ var hasIds = isArrayOrTypedArray(ids);
+ var labels = trace.labels;
+ var parents = trace.parents;
+ var vals = trace.values;
+ var hasVals = isArrayOrTypedArray(vals);
+ var cd = [];
+
+ var parent2children = {};
+ var refs = {};
+ var addToLookup = function(parent, v) {
+ if(parent2children[parent]) parent2children[parent].push(v);
+ else parent2children[parent] = [v];
+ refs[v] = 1;
+ };
+
+ var isValidVal = function(i) {
+ return !hasVals || (isNumeric(vals[i]) && vals[i] >= 0);
+ };
+
+ var len;
+ var isValid;
+ var getId;
+
+ if(hasIds) {
+ len = Math.min(ids.length, parents.length);
+ isValid = function(i) { return ids[i] && isValidVal(i); };
+ getId = function(i) { return String(ids[i]); };
+ } else {
+ len = Math.min(labels.length, parents.length);
+ isValid = function(i) { return labels[i] && isValidVal(i); };
+ // TODO We could allow some label / parent duplication
+ //
+ // From AJ:
+ // It would work OK for one level
+ // (multiple rows with the same name and different parents -
+ // or even the same parent) but if that name is then used as a parent
+ // which one is it?
+ getId = function(i) { return String(labels[i]); };
+ }
+
+ if(hasVals) len = Math.min(len, vals.length);
+
+ for(var i = 0; i < len; i++) {
+ if(isValid(i)) {
+ var id = getId(i);
+ var pid = parents[i] ? String(parents[i]) : '';
+
+ var cdi = {
+ i: i,
+ id: id,
+ pid: pid,
+ label: labels[i] ? String(labels[i]) : ''
+ };
+
+ if(hasVals) cdi.v = +vals[i];
+ cd.push(cdi);
+ addToLookup(pid, id);
+ }
+ }
+
+ if(!parent2children['']) {
+ var impliedRoots = [];
+ var k;
+ for(k in parent2children) {
+ if(!refs[k]) {
+ impliedRoots.push(k);
+ }
+ }
+
+ // if an `id` has no ref in the `parents` array,
+ // take it as being the root node
+
+ if(impliedRoots.length === 1) {
+ k = impliedRoots[0];
+ cd.unshift({
+ id: k,
+ pid: '',
+ label: k
+ });
+ } else {
+ return Lib.warn('Multiple implied roots, cannot build sunburst hierarchy.');
+ }
+ } else if(parent2children[''].length > 1) {
+ var dummyId = Lib.randstr();
+
+ // if multiple rows linked to the root node,
+ // add dummy "root of roots" node to make d3 build the hierarchy successfully
+
+ for(var j = 0; j < cd.length; j++) {
+ if(cd[j].pid === '') {
+ cd[j].pid = dummyId;
+ }
+ }
+
+ cd.unshift({
+ hasMultipleRoots: true,
+ id: dummyId,
+ pid: ''
+ });
+ }
+
+ // TODO might be better to replace stratify() with our own algorithm
+ var root;
+ try {
+ root = d3Hierarchy.stratify()
+ .id(function(d) { return d.id; })
+ .parentId(function(d) { return d.pid; })(cd);
+ } catch(e) {
+ return Lib.warn('Failed to build sunburst hierarchy. Error: ' + e.message);
+ }
+
+ var hierarchy = d3Hierarchy.hierarchy(root);
+ var failed = false;
+
+ if(hasVals) {
+ switch(trace.branchvalues) {
+ case 'remainder':
+ hierarchy.sum(function(d) { return d.data.v; });
+ break;
+ case 'total':
+ hierarchy.each(function(d) {
+ var v = d.data.data.v;
+
+ if(d.children) {
+ var partialSum = d.children.reduce(function(a, c) {
+ return a + c.data.data.v;
+ }, 0);
+ if(v < partialSum) {
+ failed = true;
+ return Lib.warn([
+ 'Total value for node', d.data.data.id,
+ 'is smaller than the sum of its children.'
+ ].join(' '));
+ }
+ }
+
+ d.value = v;
+ });
+ break;
+ }
+ } else {
+ hierarchy.count();
+ }
+
+ if(failed) return;
+
+ // TODO add way to sort by height also?
+ hierarchy.sort(function(a, b) { return b.value - a.value; });
+
+ var colors = trace.marker.colors || [];
+ var pullColor = makePullColorFn(fullLayout._sunburstcolormap);
+
+ // TODO keep track of 'root-children' (i.e. branch) for hover info etc.
+
+ hierarchy.each(function(d) {
+ var cdi = d.data.data;
+ var id = cdi.id;
+ // N.B. this mutates items in `cd`
+ cdi.color = pullColor(colors[cdi.i], id);
+ });
+
+ cd[0].hierarchy = hierarchy;
+
+ return cd;
+};
+
+/*
+ * `calc` filled in (and collated) explicit colors.
+ * Now we need to propagate these explicit colors to other traces,
+ * and fill in default colors.
+ * This is done after sorting, so we pick defaults
+ * in the order slices will be displayed
+ */
+exports.crossTraceCalc = function(gd) {
+ var fullLayout = gd._fullLayout;
+ var calcdata = gd.calcdata;
+ var colorWay = fullLayout.sunburstcolorway;
+ var colorMap = fullLayout._sunburstcolormap;
+
+ if(fullLayout.extendsunburstcolors) {
+ colorWay = generateExtendedColors(colorWay, sunburstExtendedColorWays);
+ }
+ var dfltColorCount = 0;
+
+ function pickColor(d) {
+ var cdi = d.data.data;
+ var id = cdi.id;
+
+ if(cdi.color === false) {
+ if(colorMap[id]) {
+ // have we seen this label and assigned a color to it in a previous trace?
+ cdi.color = colorMap[id];
+ } else if(d.parent) {
+ if(d.parent.parent) {
+ // from third-level on, inherit from parent
+ cdi.color = d.parent.data.data.color;
+ } else {
+ // pick new color for second level
+ colorMap[id] = cdi.color = colorWay[dfltColorCount % colorWay.length];
+ dfltColorCount++;
+ }
+ } else {
+ // root gets no coloring by default
+ cdi.color = 'rgba(0,0,0,0)';
+ }
+ }
+ }
+
+ for(var i = 0; i < calcdata.length; i++) {
+ var cd = calcdata[i];
+ var cd0 = cd[0];
+ if(cd0.trace.type === 'sunburst' && cd0.hierarchy) {
+ cd0.hierarchy.each(pickColor);
+ }
+ }
+};
diff --git a/src/traces/sunburst/constants.js b/src/traces/sunburst/constants.js
new file mode 100644
index 00000000000..fafc4c52c0d
--- /dev/null
+++ b/src/traces/sunburst/constants.js
@@ -0,0 +1,14 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+module.exports = {
+ CLICK_TRANSITION_TIME: 750,
+ CLICK_TRANSITION_EASING: 'linear'
+};
diff --git a/src/traces/sunburst/defaults.js b/src/traces/sunburst/defaults.js
new file mode 100644
index 00000000000..bea2a98da84
--- /dev/null
+++ b/src/traces/sunburst/defaults.js
@@ -0,0 +1,63 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var Lib = require('../../lib');
+var attributes = require('./attributes');
+var handleDomainDefaults = require('../../plots/domain').defaults;
+
+var coerceFont = Lib.coerceFont;
+
+module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) {
+ function coerce(attr, dflt) {
+ return Lib.coerce(traceIn, traceOut, attributes, attr, dflt);
+ }
+
+ var labels = coerce('labels');
+ var parents = coerce('parents');
+
+ if(!labels || !labels.length || !parents || !parents.length) {
+ traceOut.visible = false;
+ return;
+ }
+
+ var vals = coerce('values');
+ if(vals && vals.length) coerce('branchvalues');
+
+ coerce('level');
+ coerce('maxdepth');
+
+ var lineWidth = coerce('marker.line.width');
+ if(lineWidth) coerce('marker.line.color', layout.paper_bgcolor);
+
+ coerce('marker.colors');
+
+ coerce('leaf.opacity');
+
+ var text = coerce('text');
+ coerce('textinfo', Array.isArray(text) ? 'text+label' : 'label');
+
+ coerce('hovertext');
+ coerce('hovertemplate');
+
+ var dfltFont = coerceFont(coerce, 'textfont', layout.font);
+ var insideTextFontDefault = Lib.extendFlat({}, dfltFont);
+ var isTraceTextfontColorSet = traceIn.textfont && traceIn.textfont.color;
+ var isColorInheritedFromLayoutFont = !isTraceTextfontColorSet;
+ if(isColorInheritedFromLayoutFont) {
+ delete insideTextFontDefault.color;
+ }
+ coerceFont(coerce, 'insidetextfont', insideTextFontDefault);
+ coerceFont(coerce, 'outsidetextfont', dfltFont);
+
+ handleDomainDefaults(traceOut, layout, coerce);
+
+ // do not support transforms for now
+ traceOut._length = null;
+};
diff --git a/src/traces/sunburst/index.js b/src/traces/sunburst/index.js
new file mode 100644
index 00000000000..05246586994
--- /dev/null
+++ b/src/traces/sunburst/index.js
@@ -0,0 +1,36 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+module.exports = {
+ moduleType: 'trace',
+ name: 'sunburst',
+ basePlotModule: require('./base_plot'),
+ categories: [],
+ animatable: true,
+
+ attributes: require('./attributes'),
+ layoutAttributes: require('./layout_attributes'),
+ supplyDefaults: require('./defaults'),
+ supplyLayoutDefaults: require('./layout_defaults'),
+
+ calc: require('./calc').calc,
+ crossTraceCalc: require('./calc').crossTraceCalc,
+
+ plot: require('./plot'),
+ style: require('./style').style,
+
+ meta: {
+ description: [
+ 'Visualize hierarchal data spanning outward radially from root to leaves.',
+ 'The sunburst sectors are determined by the entries in *labels* or *ids*',
+ 'and in *parents*.'
+ ].join(' ')
+ }
+};
diff --git a/src/traces/sunburst/layout_attributes.js b/src/traces/sunburst/layout_attributes.js
new file mode 100644
index 00000000000..d29cde69570
--- /dev/null
+++ b/src/traces/sunburst/layout_attributes.js
@@ -0,0 +1,39 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+module.exports = {
+ sunburstcolorway: {
+ valType: 'colorlist',
+ role: 'style',
+ editType: 'calc',
+ description: [
+ 'Sets the default sunburst slice colors. Defaults to the main',
+ '`colorway` used for trace colors. If you specify a new',
+ 'list here it can still be extended with lighter and darker',
+ 'colors, see `extendsunburstcolors`.'
+ ].join(' ')
+ },
+ extendsunburstcolors: {
+ valType: 'boolean',
+ dflt: true,
+ role: 'style',
+ editType: 'calc',
+ description: [
+ 'If `true`, the sunburst slice colors (whether given by `sunburstcolorway` or',
+ 'inherited from `colorway`) will be extended to three times its',
+ 'original length by first repeating every color 20% lighter then',
+ 'each color 20% darker. This is intended to reduce the likelihood',
+ 'of reusing the same color when you have many slices, but you can',
+ 'set `false` to disable.',
+ 'Colors provided in the trace, using `marker.colors`, are never',
+ 'extended.'
+ ].join(' ')
+ }
+};
diff --git a/src/traces/sunburst/layout_defaults.js b/src/traces/sunburst/layout_defaults.js
new file mode 100644
index 00000000000..d54c3bc6919
--- /dev/null
+++ b/src/traces/sunburst/layout_defaults.js
@@ -0,0 +1,20 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var Lib = require('../../lib');
+var layoutAttributes = require('./layout_attributes');
+
+module.exports = function supplyLayoutDefaults(layoutIn, layoutOut) {
+ function coerce(attr, dflt) {
+ return Lib.coerce(layoutIn, layoutOut, layoutAttributes, attr, dflt);
+ }
+ coerce('sunburstcolorway', layoutOut.colorway);
+ coerce('extendsunburstcolors');
+};
diff --git a/src/traces/sunburst/plot.js b/src/traces/sunburst/plot.js
new file mode 100644
index 00000000000..685e2a8af61
--- /dev/null
+++ b/src/traces/sunburst/plot.js
@@ -0,0 +1,805 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var d3 = require('d3');
+var d3Hierarchy = require('d3-hierarchy');
+
+var Registry = require('../../registry');
+var Fx = require('../../components/fx');
+var Color = require('../../components/color');
+var Drawing = require('../../components/drawing');
+var Lib = require('../../lib');
+var Events = require('../../lib/events');
+var svgTextUtils = require('../../lib/svg_text_utils');
+var setCursor = require('../../lib/setcursor');
+var appendArrayPointValue = require('../../components/fx/helpers').appendArrayPointValue;
+
+var transformInsideText = require('../pie/plot').transformInsideText;
+var formatPieValue = require('../pie/helpers').formatPieValue;
+var styleOne = require('./style').styleOne;
+
+var constants = require('./constants');
+
+module.exports = function(gd, cdmodule, transitionOpts, makeOnCompleteCallback) {
+ var fullLayout = gd._fullLayout;
+ var layer = fullLayout._sunburstlayer;
+ var join, onComplete;
+
+ // If transition config is provided, then it is only a partial replot and traces not
+ // updated are removed.
+ var isFullReplot = !transitionOpts;
+ var hasTransition = transitionOpts && transitionOpts.duration > 0;
+
+ join = layer.selectAll('g.trace.sunburst')
+ .data(cdmodule, function(cd) { return cd[0].trace.uid; });
+
+ // using same 'stroke-linejoin' as pie traces
+ join.enter().append('g')
+ .classed('trace', true)
+ .classed('sunburst', true)
+ .attr('stroke-linejoin', 'round');
+
+ join.order();
+
+ if(hasTransition) {
+ if(makeOnCompleteCallback) {
+ // If it was passed a callback to register completion, make a callback. If
+ // this is created, then it must be executed on completion, otherwise the
+ // pos-transition redraw will not execute:
+ onComplete = makeOnCompleteCallback();
+ }
+
+ var transition = d3.transition()
+ .duration(transitionOpts.duration)
+ .ease(transitionOpts.easing)
+ .each('end', function() { onComplete && onComplete(); })
+ .each('interrupt', function() { onComplete && onComplete(); });
+
+ transition.each(function() {
+ // Must run the selection again since otherwise enters/updates get grouped together
+ // and these get executed out of order. Except we need them in order!
+ layer.selectAll('g.trace').each(function(cd) {
+ plotOne(gd, cd, this, transitionOpts);
+ });
+ });
+ } else {
+ join.each(function(cd) {
+ plotOne(gd, cd, this, transitionOpts);
+ });
+ }
+
+ if(isFullReplot) {
+ join.exit().remove();
+ }
+};
+
+function plotOne(gd, cd, element, transitionOpts) {
+ var fullLayout = gd._fullLayout;
+ // We could optimize hasTransition per trace,
+ // as sunburst has no cross-trace logic!
+ var hasTransition = transitionOpts && transitionOpts.duration > 0;
+
+ var gTrace = d3.select(element);
+ var slices = gTrace.selectAll('g.slice');
+
+ var cd0 = cd[0];
+ var trace = cd0.trace;
+ var hierarchy = cd0.hierarchy;
+ var entry = findEntryWithLevel(hierarchy, trace.level);
+ var maxDepth = trace.maxdepth >= 0 ? trace.maxdepth : Infinity;
+
+ var gs = fullLayout._size;
+ var domain = trace.domain;
+ var vpw = gs.w * (domain.x[1] - domain.x[0]);
+ var vph = gs.h * (domain.y[1] - domain.y[0]);
+ var rMax = 0.5 * Math.min(vpw, vph);
+ var cx = cd0.cx = gs.l + gs.w * (domain.x[1] + domain.x[0]) / 2;
+ var cy = cd0.cy = gs.t + gs.h * (1 - domain.y[0]) - vph / 2;
+
+ if(!entry) {
+ return slices.remove();
+ }
+
+ // previous root 'pt' (can be empty)
+ var prevEntry = null;
+ // stash of 'previous' position data used by tweening functions
+ var prevLookup = {};
+
+ if(hasTransition) {
+ // Important: do this before binding new sliceData!
+ slices.each(function(pt) {
+ prevLookup[getPtId(pt)] = {
+ rpx0: pt.rpx0,
+ rpx1: pt.rpx1,
+ x0: pt.x0,
+ x1: pt.x1,
+ transform: pt.transform
+ };
+
+ if(!prevEntry && isEntry(pt)) {
+ prevEntry = pt;
+ }
+ });
+ }
+
+ // N.B. slice data isn't the calcdata,
+ // grab corresponding calcdata item in sliceData[i].data.data
+ var sliceData = partition(entry).descendants();
+ var maxHeight = entry.height + 1;
+ var yOffset = 0;
+ var cutoff = maxDepth;
+
+ // N.B. handle multiple-root special case
+ if(cd0.hasMultipleRoots && isHierachyRoot(entry)) {
+ sliceData = sliceData.slice(1);
+ maxHeight -= 1;
+ yOffset = 1;
+ cutoff += 1;
+ }
+
+ // filter out slices that won't show up on graph
+ sliceData = sliceData.filter(function(pt) { return pt.y1 <= cutoff; });
+
+ // partition span ('y') to sector radial px value
+ var maxY = Math.min(maxHeight, maxDepth);
+ var y2rpx = function(y) { return (y - yOffset) / maxY * rMax; };
+ // (radial px value, partition angle ('x')) to px [x,y]
+ var rx2px = function(r, x) { return [r * Math.cos(x), -r * Math.sin(x)]; };
+ // slice path generation fn
+ var pathSlice = function(d) { return Lib.pathAnnulus(d.rpx0, d.rpx1, d.x0, d.x1, cx, cy); };
+ // slice text translate x/y
+ var transTextX = function(d) { return cx + d.pxmid[0] * d.transform.rCenter + (d.transform.x || 0); };
+ var transTextY = function(d) { return cy + d.pxmid[1] * d.transform.rCenter + (d.transform.y || 0); };
+
+ slices = slices.data(sliceData, function(pt) { return getPtId(pt); });
+
+ slices.enter().append('g')
+ .classed('slice', true);
+
+ if(hasTransition) {
+ slices.exit().transition()
+ .each(function() {
+ var sliceTop = d3.select(this);
+
+ var slicePath = sliceTop.select('path.surface');
+ slicePath.transition().attrTween('d', function(pt2) {
+ var interp = makeExitSliceInterpolator(pt2);
+ return function(t) { return pathSlice(interp(t)); };
+ });
+
+ var sliceTextGroup = sliceTop.select('g.slicetext');
+ sliceTextGroup.attr('opacity', 0);
+ })
+ .remove();
+ } else {
+ slices.exit().remove();
+ }
+
+ slices.order();
+
+ // next x1 (i.e. sector end angle) of previous entry
+ var nextX1ofPrevEntry = null;
+ if(hasTransition && prevEntry) {
+ var prevEntryId = getPtId(prevEntry);
+ slices.each(function(pt) {
+ if(nextX1ofPrevEntry === null && (getPtId(pt) === prevEntryId)) {
+ nextX1ofPrevEntry = pt.x1;
+ }
+ });
+ }
+
+ var updateSlices = slices;
+ if(hasTransition) {
+ updateSlices = updateSlices.transition().each('end', function() {
+ // N.B. gd._transitioning is (still) *true* by the time
+ // transition updates get hare
+ var sliceTop = d3.select(this);
+ setSliceCursor(sliceTop, gd, {isTransitioning: false});
+ });
+ }
+
+ updateSlices.each(function(pt) {
+ var sliceTop = d3.select(this);
+
+ var slicePath = Lib.ensureSingle(sliceTop, 'path', 'surface', function(s) {
+ s.style('pointer-events', 'all');
+ });
+
+ pt.rpx0 = y2rpx(pt.y0);
+ pt.rpx1 = y2rpx(pt.y1);
+ pt.xmid = (pt.x0 + pt.x1) / 2;
+ pt.pxmid = rx2px(pt.rpx1, pt.xmid);
+ pt.midangle = -(pt.xmid - Math.PI / 2);
+ pt.halfangle = 0.5 * Math.min(Lib.angleDelta(pt.x0, pt.x1) || Math.PI, Math.PI);
+ pt.ring = 1 - (pt.rpx0 / pt.rpx1);
+ pt.rInscribed = getInscribedRadiusFraction(pt, trace);
+
+ if(hasTransition) {
+ slicePath.transition().attrTween('d', function(pt2) {
+ var interp = makeUpdateSliceIntepolator(pt2);
+ return function(t) { return pathSlice(interp(t)); };
+ });
+ } else {
+ slicePath.attr('d', pathSlice);
+ }
+
+ sliceTop
+ .call(attachFxHandlers, gd, cd)
+ .call(setSliceCursor, gd, {isTransitioning: gd._transitioning});
+
+ slicePath.call(styleOne, pt, trace);
+
+ var sliceTextGroup = Lib.ensureSingle(sliceTop, 'g', 'slicetext');
+ var sliceText = Lib.ensureSingle(sliceTextGroup, 'text', '', function(s) {
+ // prohibit tex interpretation until we can handle
+ // tex and regular text together
+ s.attr('data-notex', 1);
+ });
+
+ sliceText.text(formatSliceLabel(pt, trace, fullLayout))
+ .classed('slicetext', true)
+ .attr('text-anchor', 'middle')
+ .call(Drawing.font, isHierachyRoot(pt) ?
+ determineOutsideTextFont(trace, pt, fullLayout.font) :
+ determineInsideTextFont(trace, pt, fullLayout.font))
+ .call(svgTextUtils.convertToTspans, gd);
+
+ // position the text relative to the slice
+ var textBB = Drawing.bBox(sliceText.node());
+ pt.transform = transformInsideText(textBB, pt, cd0);
+ pt.translateX = transTextX(pt);
+ pt.translateY = transTextY(pt);
+
+ var strTransform = function(d, textBB) {
+ return 'translate(' + d.translateX + ',' + d.translateY + ')' +
+ (d.transform.scale < 1 ? ('scale(' + d.transform.scale + ')') : '') +
+ (d.transform.rotate ? ('rotate(' + d.transform.rotate + ')') : '') +
+ 'translate(' +
+ (-(textBB.left + textBB.right) / 2) + ',' +
+ (-(textBB.top + textBB.bottom) / 2) +
+ ')';
+ };
+
+ if(hasTransition) {
+ sliceText.transition().attrTween('transform', function(pt2) {
+ var interp = makeUpdateTextInterpolar(pt2);
+ return function(t) { return strTransform(interp(t), textBB); };
+ });
+ } else {
+ sliceText.attr('transform', strTransform(pt, textBB));
+ }
+ });
+
+ function makeExitSliceInterpolator(pt) {
+ var id = getPtId(pt);
+ var prev = prevLookup[id];
+ var entryPrev = prevLookup[getPtId(entry)];
+ var next;
+
+ if(entryPrev) {
+ var a = pt.x1 > entryPrev.x1 ? 2 * Math.PI : 0;
+ // if pt to remove:
+ // - if 'below' where the root-node used to be: shrink it radially inward
+ // - otherwise, collapse it clockwise or counterclockwise which ever is shortest to theta=0
+ next = pt.rpx1 < entryPrev.rpx1 ? {rpx0: 0, rpx1: 0} : {x0: a, x1: a};
+ } else {
+ // this happens when maxdepth is set, when leaves must
+ // be removed and the rootPt is new (i.e. does not have a 'prev' object)
+ var parent;
+ var parentId = getPtId(pt.parent);
+ slices.each(function(pt2) {
+ if(getPtId(pt2) === parentId) {
+ return parent = pt2;
+ }
+ });
+ var parentChildren = parent.children;
+ var ci;
+ parentChildren.forEach(function(pt2, i) {
+ if(getPtId(pt2) === id) {
+ return ci = i;
+ }
+ });
+ var n = parentChildren.length;
+ var interp = d3.interpolate(parent.x0, parent.x1);
+ next = {
+ rpx0: rMax, rpx1: rMax,
+ x0: interp(ci / n), x1: interp((ci + 1) / n)
+ };
+ }
+
+ return d3.interpolate(prev, next);
+ }
+
+ function makeUpdateSliceIntepolator(pt) {
+ var prev0 = prevLookup[getPtId(pt)];
+ var prev;
+ var next = {x0: pt.x0, x1: pt.x1, rpx0: pt.rpx0, rpx1: pt.rpx1};
+
+ if(prev0) {
+ // if pt already on graph, this is easy
+ prev = prev0;
+ } else {
+ // for new pts:
+ if(prevEntry) {
+ // if trace was visible before
+ if(pt.parent) {
+ if(nextX1ofPrevEntry) {
+ // if new branch, twist it in clockwise or
+ // counterclockwise which ever is shorter to
+ // its final angle
+ var a = pt.x1 > nextX1ofPrevEntry ? 2 * Math.PI : 0;
+ prev = {x0: a, x1: a};
+ } else {
+ // if new leaf (when maxdepth is set),
+ // grow it radially and angularly from
+ // its parent node
+ prev = {rpx0: rMax, rpx1: rMax};
+ Lib.extendFlat(prev, interpX0X1FromParent(pt));
+ }
+ } else {
+ // if new root-node, grow it radially
+ prev = {rpx0: 0, rpx1: 0};
+ }
+ } else {
+ // start sector of new traces from theta=0
+ prev = {x0: 0, x1: 0};
+ }
+ }
+
+ return d3.interpolate(prev, next);
+ }
+
+ function makeUpdateTextInterpolar(pt) {
+ var prev0 = prevLookup[getPtId(pt)];
+ var prev;
+ var transform = pt.transform;
+
+ if(prev0) {
+ prev = prev0;
+ } else {
+ prev = {
+ rpx1: pt.rpx1,
+ transform: {
+ scale: 0,
+ rotate: transform.rotate,
+ rCenter: transform.rCenter,
+ x: transform.x,
+ y: transform.y
+ }
+ };
+
+ // for new pts:
+ if(prevEntry) {
+ // if trace was visible before
+ if(pt.parent) {
+ if(nextX1ofPrevEntry) {
+ // if new branch, twist it in clockwise or
+ // counterclockwise which ever is shorter to
+ // its final angle
+ var a = pt.x1 > nextX1ofPrevEntry ? 2 * Math.PI : 0;
+ prev.x0 = prev.x1 = a;
+ } else {
+ // if leaf
+ Lib.extendFlat(prev, interpX0X1FromParent(pt));
+ }
+ } else {
+ // if new root-node
+ prev.x0 = prev.x1 = 0;
+ }
+ } else {
+ // on new traces
+ prev.x0 = prev.x1 = 0;
+ }
+ }
+
+ var rpx1Fn = d3.interpolate(prev.rpx1, pt.rpx1);
+ var x0Fn = d3.interpolate(prev.x0, pt.x0);
+ var x1Fn = d3.interpolate(prev.x1, pt.x1);
+ var scaleFn = d3.interpolate(prev.transform.scale, transform.scale);
+ var rotateFn = d3.interpolate(prev.transform.rotate, transform.rotate);
+
+ // smooth out start/end from entry, to try to keep text inside sector
+ // while keeping transition smooth
+ var pow = transform.rCenter === 0 ? 3 :
+ prev.transform.rCenter === 0 ? 1 / 3 :
+ 1;
+ var _rCenterFn = d3.interpolate(prev.transform.rCenter, transform.rCenter);
+ var rCenterFn = function(t) { return _rCenterFn(Math.pow(t, pow)); };
+
+ return function(t) {
+ var rpx1 = rpx1Fn(t);
+ var x0 = x0Fn(t);
+ var x1 = x1Fn(t);
+ var rCenter = rCenterFn(t);
+
+ var d = {
+ pxmid: rx2px(rpx1, (x0 + x1) / 2),
+ transform: {
+ rCenter: rCenter,
+ x: transform.x,
+ y: transform.y
+ }
+ };
+
+ var out = {
+ rpx1: rpx1Fn(t),
+ translateX: transTextX(d),
+ translateY: transTextY(d),
+ transform: {
+ scale: scaleFn(t),
+ rotate: rotateFn(t),
+ rCenter: rCenter
+ }
+ };
+
+ return out;
+ };
+ }
+
+ function interpX0X1FromParent(pt) {
+ var parent = pt.parent;
+ var parentPrev = prevLookup[getPtId(parent)];
+ var out = {};
+
+ if(parentPrev) {
+ // if parent is visible
+ var parentChildren = parent.children;
+ var ci = parentChildren.indexOf(pt);
+ var n = parentChildren.length;
+ var interp = d3.interpolate(parentPrev.x0, parentPrev.x1);
+ out.x0 = interp(ci / n);
+ out.x1 = interp(ci / n);
+ } else {
+ // w/o visible parent
+ // TODO !!! HOW ???
+ out.x0 = out.x1 = 0;
+ }
+
+ return out;
+ }
+}
+
+// x[0-1] keys are angles [radians]
+// y[0-1] keys are hierarchy heights [integers]
+function partition(entry) {
+ return d3Hierarchy.partition()
+ .size([2 * Math.PI, entry.height + 1])(entry);
+}
+
+function findEntryWithLevel(hierarchy, level) {
+ var out;
+ if(level) {
+ hierarchy.eachAfter(function(pt) {
+ if(getPtId(pt) === level) {
+ return out = pt.copy();
+ }
+ });
+ }
+ return out || hierarchy;
+}
+
+function findEntryWithChild(hierarchy, childId) {
+ var out;
+ hierarchy.eachAfter(function(pt) {
+ var children = pt.children || [];
+ for(var i = 0; i < children.length; i++) {
+ var child = children[i];
+ if(getPtId(child) === childId) {
+ return out = pt.copy();
+ }
+ }
+ });
+ return out || hierarchy;
+}
+
+function isHierachyRoot(pt) {
+ var cdi = pt.data.data;
+ return cdi.pid === '';
+}
+
+function isEntry(pt) {
+ return !pt.parent;
+}
+
+function isLeaf(pt) {
+ return !pt.children;
+}
+
+function getPtId(pt) {
+ var cdi = pt.data.data;
+ return cdi.id;
+}
+
+function setSliceCursor(sliceTop, gd, opts) {
+ var pt = sliceTop.datum();
+ var isTransitioning = (opts || {}).isTransitioning;
+ setCursor(sliceTop, (isTransitioning || isLeaf(pt) || isHierachyRoot(pt)) ? null : 'pointer');
+}
+
+function attachFxHandlers(sliceTop, gd, cd) {
+ var cd0 = cd[0];
+ var trace = cd0.trace;
+
+ // hover state vars
+ // have we drawn a hover label, so it should be cleared later
+ var hasHoverLabel = false;
+ // have we emitted a hover event, so later an unhover event should be emitted
+ // note that click events do not depend on this - you can still get them
+ // with hovermode: false or if you were earlier dragging, then clicked
+ // in the same slice that you moused up in
+ var hasHoverEvent = false;
+
+ sliceTop.on('mouseover', function(pt) {
+ var fullLayoutNow = gd._fullLayout;
+
+ if(gd._dragging || fullLayoutNow.hovermode === false) return;
+
+ var traceNow = gd._fullData[trace.index];
+ var cdi = pt.data.data;
+ var ptNumber = cdi.i;
+
+ var _cast = function(astr) {
+ return Lib.castOption(traceNow, ptNumber, astr);
+ };
+
+ var hovertemplate = _cast('hovertemplate');
+ var hoverinfo = Fx.castHoverinfo(traceNow, fullLayoutNow, ptNumber);
+ var separators = fullLayoutNow.separators;
+
+ if(hovertemplate || (hoverinfo && hoverinfo !== 'none' && hoverinfo !== 'skip')) {
+ var rInscribed = pt.rInscribed;
+ var hoverCenterX = cd0.cx + pt.pxmid[0] * (1 - rInscribed);
+ var hoverCenterY = cd0.cy + pt.pxmid[1] * (1 - rInscribed);
+ var hoverPt = {};
+ var parts = [];
+ var thisText = [];
+ var hasFlag = function(flag) { return parts.indexOf(flag) !== -1; };
+
+ if(hoverinfo) {
+ parts = hoverinfo === 'all' ?
+ traceNow._module.attributes.hoverinfo.flags :
+ hoverinfo.split('+');
+ }
+
+ hoverPt.label = cdi.label;
+ if(hasFlag('label') && hoverPt.label) thisText.push(hoverPt.label);
+
+ if(cdi.hasOwnProperty('v')) {
+ hoverPt.value = cdi.v;
+ hoverPt.valueLabel = formatPieValue(hoverPt.value, separators);
+ if(hasFlag('value')) thisText.push(hoverPt.valueLabel);
+ }
+
+ hoverPt.text = _cast('hovertext') || _cast('text');
+ if(hasFlag('text') && hoverPt.text) thisText.push(hoverPt.text);
+
+ Fx.loneHover({
+ x0: hoverCenterX - rInscribed * pt.rpx1,
+ x1: hoverCenterX + rInscribed * pt.rpx1,
+ y: hoverCenterY,
+ idealAlign: pt.pxmid[0] < 0 ? 'left' : 'right',
+ trace: traceNow,
+ text: thisText.join('
'),
+ name: (hovertemplate || hasFlag('name')) ? traceNow.name : undefined,
+ color: _cast('hoverlabel.bgcolor') || cdi.color,
+ borderColor: _cast('hoverlabel.bordercolor'),
+ fontFamily: _cast('hoverlabel.font.family'),
+ fontSize: _cast('hoverlabel.font.size'),
+ fontColor: _cast('hoverlabel.font.color'),
+ hovertemplate: hovertemplate,
+ hovertemplateLabels: hoverPt,
+ eventData: [makeEventData(pt, traceNow)]
+ }, {
+ container: fullLayoutNow._hoverlayer.node(),
+ outerContainer: fullLayoutNow._paper.node(),
+ gd: gd
+ });
+
+ hasHoverLabel = true;
+ }
+
+ gd.emit('plotly_hover', {
+ points: [makeEventData(pt, traceNow)],
+ event: d3.event
+ });
+ hasHoverEvent = true;
+ });
+
+ sliceTop.on('mouseout', function(evt) {
+ var fullLayoutNow = gd._fullLayout;
+ var traceNow = gd._fullData[trace.index];
+ var pt = d3.select(this).datum();
+
+ if(hasHoverEvent) {
+ evt.originalEvent = d3.event;
+ gd.emit('plotly_unhover', {
+ points: [makeEventData(pt, traceNow)],
+ event: d3.event
+ });
+ hasHoverEvent = false;
+ }
+
+ if(hasHoverLabel) {
+ Fx.loneUnhover(fullLayoutNow._hoverlayer.node());
+ hasHoverLabel = false;
+ }
+ });
+
+ sliceTop.on('click', function(pt) {
+ // TODO: this does not support right-click. If we want to support it, we
+ // would likely need to change pie to use dragElement instead of straight
+ // mapbox event binding. Or perhaps better, make a simple wrapper with the
+ // right mousedown, mousemove, and mouseup handlers just for a left/right click
+ // mapbox would use this too.
+ var fullLayoutNow = gd._fullLayout;
+ var traceNow = gd._fullData[trace.index];
+
+ var clickVal = Events.triggerHandler(gd, 'plotly_sunburstclick', {
+ points: [makeEventData(pt, traceNow)],
+ event: d3.event
+ });
+
+ // 'regular' click event when sunburstclick is disabled or when
+ // clikcin on leaves or the hierarchy root
+ if(clickVal === false || isLeaf(pt) || isHierachyRoot(pt)) {
+ if(fullLayoutNow.hovermode) {
+ gd._hoverdata = [makeEventData(pt, traceNow)];
+ Fx.click(gd, d3.event);
+ }
+ return;
+ }
+
+ // skip if triggered from dragging a nearby cartesian subplot
+ if(gd._dragging) return;
+
+ // skip during transitions, to avoid potential bugs
+ // we could remove this check later
+ if(gd._transitioning) return;
+
+ // store 'old' level in guiEdit stash, so that subsequent Plotly.react
+ // calls with the same uirevision can start from the same entry
+ Registry.call('_storeDirectGUIEdit', traceNow, fullLayoutNow._tracePreGUI[traceNow.uid], {level: traceNow.level});
+
+ var hierarchy = cd0.hierarchy;
+ var id = getPtId(pt);
+ var nextEntry = isEntry(pt) ?
+ findEntryWithChild(hierarchy, id) :
+ findEntryWithLevel(hierarchy, id);
+
+ var frame = {
+ data: [{level: getPtId(nextEntry)}],
+ traces: [trace.index]
+ };
+
+ var animOpts = {
+ frame: {
+ redraw: false,
+ duration: constants.CLICK_TRANSITION_TIME
+ },
+ transition: {
+ duration: constants.CLICK_TRANSITION_TIME,
+ easing: constants.CLICK_TRANSITION_EASING
+ },
+ mode: 'immediate',
+ fromcurrent: true
+ };
+
+ Fx.loneUnhover(fullLayoutNow._hoverlayer.node());
+ Registry.call('animate', gd, frame, animOpts);
+ });
+}
+
+function makeEventData(pt, trace) {
+ var cdi = pt.data.data;
+
+ var out = {
+ curveNumber: trace.index,
+ pointNumber: cdi.i,
+ data: trace._input,
+ fullData: trace,
+
+ // TODO more things like 'children', 'siblings', 'hierarchy?
+ };
+
+ appendArrayPointValue(out, trace, cdi.i);
+
+ return out;
+}
+
+function formatSliceLabel(pt, trace, fullLayout) {
+ var textinfo = trace.textinfo;
+
+ if(!textinfo || textinfo === 'none') {
+ return '';
+ }
+
+ var cdi = pt.data.data;
+ var separators = fullLayout.separators;
+ var parts = textinfo.split('+');
+ var hasFlag = function(flag) { return parts.indexOf(flag) !== -1; };
+ var thisText = [];
+
+ if(hasFlag('label') && cdi.label) thisText.push(cdi.label);
+
+ if(cdi.hasOwnProperty('v') && hasFlag('value')) {
+ thisText.push(formatPieValue(cdi.v, separators));
+ }
+
+ if(hasFlag('text')) {
+ var tx = Lib.castOption(trace, cdi.i, 'text');
+ if(tx) thisText.push(tx);
+ }
+
+ return thisText.join('
');
+}
+
+function determineOutsideTextFont(trace, pt, layoutFont) {
+ var cdi = pt.data.data;
+ var ptNumber = cdi.i;
+
+ var color = Lib.castOption(trace, ptNumber, 'outsidetextfont.color') ||
+ Lib.castOption(trace, ptNumber, 'textfont.color') ||
+ layoutFont.color;
+
+ var family = Lib.castOption(trace, ptNumber, 'outsidetextfont.family') ||
+ Lib.castOption(trace, ptNumber, 'textfont.family') ||
+ layoutFont.family;
+
+ var size = Lib.castOption(trace, ptNumber, 'outsidetextfont.size') ||
+ Lib.castOption(trace, ptNumber, 'textfont.size') ||
+ layoutFont.size;
+
+ return {
+ color: color,
+ family: family,
+ size: size
+ };
+}
+
+function determineInsideTextFont(trace, pt, layoutFont) {
+ var cdi = pt.data.data;
+ var ptNumber = cdi.i;
+
+ var customColor = Lib.castOption(trace, ptNumber, 'insidetextfont.color');
+ if(!customColor && trace._input.textfont) {
+
+ // Why not simply using trace.textfont? Because if not set, it
+ // defaults to layout.font which has a default color. But if
+ // textfont.color and insidetextfont.color don't supply a value,
+ // a contrasting color shall be used.
+ customColor = Lib.castOption(trace._input, ptNumber, 'textfont.color');
+ }
+
+ var family = Lib.castOption(trace, ptNumber, 'insidetextfont.family') ||
+ Lib.castOption(trace, ptNumber, 'textfont.family') ||
+ layoutFont.family;
+
+ var size = Lib.castOption(trace, ptNumber, 'insidetextfont.size') ||
+ Lib.castOption(trace, ptNumber, 'textfont.size') ||
+ layoutFont.size;
+
+ return {
+ color: customColor || Color.contrast(cdi.color),
+ family: family,
+ size: size
+ };
+}
+
+function getInscribedRadiusFraction(pt) {
+ if(pt.rpx0 === 0 && pt.xmid === Math.PI) {
+ // special case of 100% with no hole
+ return 1;
+ } else {
+ return Math.max(0, Math.min(
+ 1 / (1 + 1 / Math.sin(pt.halfangle)),
+ pt.ring / 2
+ ));
+ }
+}
diff --git a/src/traces/sunburst/style.js b/src/traces/sunburst/style.js
new file mode 100644
index 00000000000..687ae7e3be0
--- /dev/null
+++ b/src/traces/sunburst/style.js
@@ -0,0 +1,45 @@
+/**
+* Copyright 2012-2019, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+'use strict';
+
+var d3 = require('d3');
+var Color = require('../../components/color');
+var Lib = require('../../lib');
+
+function style(gd) {
+ gd._fullLayout._sunburstlayer.selectAll('.trace').each(function(cd) {
+ var gTrace = d3.select(this);
+ var cd0 = cd[0];
+ var trace = cd0.trace;
+
+ gTrace.style('opacity', trace.opacity);
+
+ gTrace.selectAll('path.surface').each(function(pt) {
+ d3.select(this).call(styleOne, pt, trace);
+ });
+ });
+}
+
+function styleOne(s, pt, trace) {
+ var cdi = pt.data.data;
+ var isLeaf = !pt.children;
+ var ptNumber = cdi.i;
+ var lineColor = Lib.castOption(trace, ptNumber, 'marker.line.color') || Color.defaultLine;
+ var lineWidth = Lib.castOption(trace, ptNumber, 'marker.line.width') || 0;
+
+ s.style('stroke-width', lineWidth)
+ .call(Color.fill, cdi.color)
+ .call(Color.stroke, lineColor)
+ .style('opacity', isLeaf ? trace.leaf.opacity : null);
+}
+
+module.exports = {
+ style: style,
+ styleOne: styleOne
+};
diff --git a/tasks/test_syntax.js b/tasks/test_syntax.js
index 5c8becc76e2..385f1012594 100644
--- a/tasks/test_syntax.js
+++ b/tasks/test_syntax.js
@@ -143,7 +143,14 @@ function assertSrcContents() {
logs.push(file + ' : contains .' + lastPart + ' (IE failure)');
}
else if(IE_SVG_BLACK_LIST.indexOf(lastPart) !== -1) {
- logs.push(file + ' : contains .' + lastPart + ' (IE failure in SVG)');
+ // add special case for sunburst where we use 'children'
+ // off the d3-hierarchy output
+ var dirParts = path.dirname(file).split(path.sep);
+ var isSunburstFile = dirParts[dirParts.length - 1] === 'sunburst';
+ var isLinkedToObject = ['pt', 'd', 'parent'].indexOf(parts[parts.length - 2]) !== -1;
+ if(!(isSunburstFile && isLinkedToObject)) {
+ logs.push(file + ' : contains .' + lastPart + ' (IE failure in SVG)');
+ }
}
else if(FF_BLACK_LIST.indexOf(lastPart) !== -1) {
logs.push(file + ' : contains .' + lastPart + ' (FF failure)');
diff --git a/test/image/baselines/sunburst_coffee-maxdepth3.png b/test/image/baselines/sunburst_coffee-maxdepth3.png
new file mode 100644
index 00000000000..a83843cbfce
Binary files /dev/null and b/test/image/baselines/sunburst_coffee-maxdepth3.png differ
diff --git a/test/image/baselines/sunburst_coffee.png b/test/image/baselines/sunburst_coffee.png
new file mode 100644
index 00000000000..9299c4e1346
Binary files /dev/null and b/test/image/baselines/sunburst_coffee.png differ
diff --git a/test/image/baselines/sunburst_first.png b/test/image/baselines/sunburst_first.png
new file mode 100644
index 00000000000..b1ba840f2e8
Binary files /dev/null and b/test/image/baselines/sunburst_first.png differ
diff --git a/test/image/baselines/sunburst_flare.png b/test/image/baselines/sunburst_flare.png
new file mode 100644
index 00000000000..13b69ce29a6
Binary files /dev/null and b/test/image/baselines/sunburst_flare.png differ
diff --git a/test/image/baselines/sunburst_level-depth.png b/test/image/baselines/sunburst_level-depth.png
new file mode 100644
index 00000000000..a9fad59b4e4
Binary files /dev/null and b/test/image/baselines/sunburst_level-depth.png differ
diff --git a/test/image/baselines/sunburst_values.png b/test/image/baselines/sunburst_values.png
new file mode 100644
index 00000000000..e3d36289c66
Binary files /dev/null and b/test/image/baselines/sunburst_values.png differ
diff --git a/test/image/mocks/sunburst_coffee-maxdepth3.json b/test/image/mocks/sunburst_coffee-maxdepth3.json
new file mode 100644
index 00000000000..aaa9d8a5754
--- /dev/null
+++ b/test/image/mocks/sunburst_coffee-maxdepth3.json
@@ -0,0 +1,307 @@
+{
+ "data": [
+ {
+ "type": "sunburst",
+ "maxdepth": 3,
+ "ids": [
+ "Aromas",
+ "Tastes",
+ "Aromas-Enzymatic",
+ "Aromas-Sugar Browning",
+ "Aromas-Dry Distillation",
+ "Tastes-Bitter",
+ "Tastes-Salt",
+ "Tastes-Sweet",
+ "Tastes-Sour",
+ "Enzymatic-Flowery",
+ "Enzymatic-Fruity",
+ "Enzymatic-Herby",
+ "Sugar Browning-Nutty",
+ "Sugar Browning-Carmelly",
+ "Sugar Browning-Chocolatey",
+ "Dry Distillation-Resinous",
+ "Dry Distillation-Spicy",
+ "Dry Distillation-Carbony",
+ "Bitter-Pungent",
+ "Bitter-Harsh",
+ "Salt-Sharp",
+ "Salt-Bland",
+ "Sweet-Mellow",
+ "Sweet-Acidy",
+ "Sour-Winey",
+ "Sour-Soury",
+ "Flowery-Floral",
+ "Flowery-Fragrant",
+ "Fruity-Citrus",
+ "Fruity-Berry-like",
+ "Herby-Alliaceous",
+ "Herby-Leguminous",
+ "Nutty-Nut-like",
+ "Nutty-Malt-like",
+ "Carmelly-Candy-like",
+ "Carmelly-Syrup-like",
+ "Chocolatey-Chocolate-like",
+ "Chocolatey-Vanilla-like",
+ "Resinous-Turpeny",
+ "Resinous-Medicinal",
+ "Spicy-Warming",
+ "Spicy-Pungent",
+ "Carbony-Smokey",
+ "Carbony-Ashy",
+ "Pungent-Creosol",
+ "Pungent-Phenolic",
+ "Harsh-Caustic",
+ "Harsh-Alkaline",
+ "Sharp-Astringent",
+ "Sharp-Rough",
+ "Bland-Neutral",
+ "Bland-Soft",
+ "Mellow-Delicate",
+ "Mellow-Mild",
+ "Acidy-Nippy",
+ "Acidy-Piquant",
+ "Winey-Tangy",
+ "Winey-Tart",
+ "Soury-Hard",
+ "Soury-Acrid",
+ "Floral-Coffee Blossom",
+ "Floral-Tea Rose",
+ "Fragrant-Cardamon Caraway",
+ "Fragrant-Coriander Seeds",
+ "Citrus-Lemon",
+ "Citrus-Apple",
+ "Berry-like-Apricot",
+ "Berry-like-Blackberry",
+ "Alliaceous-Onion",
+ "Alliaceous-Garlic",
+ "Leguminous-Cucumber",
+ "Leguminous-Garden Peas",
+ "Nut-like-Roasted Peanuts",
+ "Nut-like-Walnuts",
+ "Malt-like-Balsamic Rice",
+ "Malt-like-Toast",
+ "Candy-like-Roasted Hazelnut",
+ "Candy-like-Roasted Almond",
+ "Syrup-like-Honey",
+ "Syrup-like-Maple Syrup",
+ "Chocolate-like-Bakers",
+ "Chocolate-like-Dark Chocolate",
+ "Vanilla-like-Swiss",
+ "Vanilla-like-Butter",
+ "Turpeny-Piney",
+ "Turpeny-Blackcurrant-like",
+ "Medicinal-Camphoric",
+ "Medicinal-Cineolic",
+ "Warming-Cedar",
+ "Warming-Pepper",
+ "Pungent-Clove",
+ "Pungent-Thyme",
+ "Smokey-Tarry",
+ "Smokey-Pipe Tobacco",
+ "Ashy-Burnt",
+ "Ashy-Charred"
+ ],
+ "labels": [
+ "Aromas",
+ "Tastes",
+ "Enzymatic",
+ "Sugar Browning",
+ "Dry Distillation",
+ "Bitter",
+ "Salt",
+ "Sweet",
+ "Sour",
+ "Flowery",
+ "Fruity",
+ "Herby",
+ "Nutty",
+ "Carmelly",
+ "Chocolatey",
+ "Resinous",
+ "Spicy",
+ "Carbony",
+ "Pungent",
+ "Harsh",
+ "Sharp",
+ "Bland",
+ "Mellow",
+ "Acidy",
+ "Winey",
+ "Soury",
+ "Floral",
+ "Fragrant",
+ "Citrus",
+ "Berry-like",
+ "Alliaceous",
+ "Leguminous",
+ "Nut-like",
+ "Malt-like",
+ "Candy-like",
+ "Syrup-like",
+ "Chocolate-like",
+ "Vanilla-like",
+ "Turpeny",
+ "Medicinal",
+ "Warming",
+ "Pungent",
+ "Smokey",
+ "Ashy",
+ "Creosol",
+ "Phenolic",
+ "Caustic",
+ "Alkaline",
+ "Astringent",
+ "Rough",
+ "Neutral",
+ "Soft",
+ "Delicate",
+ "Mild",
+ "Nippy",
+ "Piquant",
+ "Tangy",
+ "Tart",
+ "Hard",
+ "Acrid",
+ "Coffee Blossom",
+ "Tea Rose",
+ "Cardamon Caraway",
+ "Coriander Seeds",
+ "Lemon",
+ "Apple",
+ "Apricot",
+ "Blackberry",
+ "Onion",
+ "Garlic",
+ "Cucumber",
+ "Garden Peas",
+ "Roasted Peanuts",
+ "Walnuts",
+ "Balsamic Rice",
+ "Toast",
+ "Roasted Hazelnut",
+ "Roasted Almond",
+ "Honey",
+ "Maple Syrup",
+ "Bakers",
+ "Dark Chocolate",
+ "Swiss",
+ "Butter",
+ "Piney",
+ "Blackcurrant-like",
+ "Camphoric",
+ "Cineolic",
+ "Cedar",
+ "Pepper",
+ "Clove",
+ "Thyme",
+ "Tarry",
+ "Pipe Tobacco",
+ "Burnt",
+ "Charred"
+ ],
+ "parents": [
+ "",
+ "",
+ "Aromas",
+ "Aromas",
+ "Aromas",
+ "Tastes",
+ "Tastes",
+ "Tastes",
+ "Tastes",
+ "Aromas-Enzymatic",
+ "Aromas-Enzymatic",
+ "Aromas-Enzymatic",
+ "Aromas-Sugar Browning",
+ "Aromas-Sugar Browning",
+ "Aromas-Sugar Browning",
+ "Aromas-Dry Distillation",
+ "Aromas-Dry Distillation",
+ "Aromas-Dry Distillation",
+ "Tastes-Bitter",
+ "Tastes-Bitter",
+ "Tastes-Salt",
+ "Tastes-Salt",
+ "Tastes-Sweet",
+ "Tastes-Sweet",
+ "Tastes-Sour",
+ "Tastes-Sour",
+ "Enzymatic-Flowery",
+ "Enzymatic-Flowery",
+ "Enzymatic-Fruity",
+ "Enzymatic-Fruity",
+ "Enzymatic-Herby",
+ "Enzymatic-Herby",
+ "Sugar Browning-Nutty",
+ "Sugar Browning-Nutty",
+ "Sugar Browning-Carmelly",
+ "Sugar Browning-Carmelly",
+ "Sugar Browning-Chocolatey",
+ "Sugar Browning-Chocolatey",
+ "Dry Distillation-Resinous",
+ "Dry Distillation-Resinous",
+ "Dry Distillation-Spicy",
+ "Dry Distillation-Spicy",
+ "Dry Distillation-Carbony",
+ "Dry Distillation-Carbony",
+ "Bitter-Pungent",
+ "Bitter-Pungent",
+ "Bitter-Harsh",
+ "Bitter-Harsh",
+ "Salt-Sharp",
+ "Salt-Sharp",
+ "Salt-Bland",
+ "Salt-Bland",
+ "Sweet-Mellow",
+ "Sweet-Mellow",
+ "Sweet-Acidy",
+ "Sweet-Acidy",
+ "Sour-Winey",
+ "Sour-Winey",
+ "Sour-Soury",
+ "Sour-Soury",
+ "Flowery-Floral",
+ "Flowery-Floral",
+ "Flowery-Fragrant",
+ "Flowery-Fragrant",
+ "Fruity-Citrus",
+ "Fruity-Citrus",
+ "Fruity-Berry-like",
+ "Fruity-Berry-like",
+ "Herby-Alliaceous",
+ "Herby-Alliaceous",
+ "Herby-Leguminous",
+ "Herby-Leguminous",
+ "Nutty-Nut-like",
+ "Nutty-Nut-like",
+ "Nutty-Malt-like",
+ "Nutty-Malt-like",
+ "Carmelly-Candy-like",
+ "Carmelly-Candy-like",
+ "Carmelly-Syrup-like",
+ "Carmelly-Syrup-like",
+ "Chocolatey-Chocolate-like",
+ "Chocolatey-Chocolate-like",
+ "Chocolatey-Vanilla-like",
+ "Chocolatey-Vanilla-like",
+ "Resinous-Turpeny",
+ "Resinous-Turpeny",
+ "Resinous-Medicinal",
+ "Resinous-Medicinal",
+ "Spicy-Warming",
+ "Spicy-Warming",
+ "Spicy-Pungent",
+ "Spicy-Pungent",
+ "Carbony-Smokey",
+ "Carbony-Smokey",
+ "Carbony-Ashy",
+ "Carbony-Ashy"
+ ]
+ }
+ ],
+ "layout": {
+ "margin": {"l": 0, "r": 0, "b": 0, "t": 0},
+ "width": 500,
+ "height": 500
+ }
+}
diff --git a/test/image/mocks/sunburst_coffee.json b/test/image/mocks/sunburst_coffee.json
new file mode 100644
index 00000000000..dddc10d91f5
--- /dev/null
+++ b/test/image/mocks/sunburst_coffee.json
@@ -0,0 +1,306 @@
+{
+ "data": [
+ {
+ "type": "sunburst",
+ "ids": [
+ "Aromas",
+ "Tastes",
+ "Aromas-Enzymatic",
+ "Aromas-Sugar Browning",
+ "Aromas-Dry Distillation",
+ "Tastes-Bitter",
+ "Tastes-Salt",
+ "Tastes-Sweet",
+ "Tastes-Sour",
+ "Enzymatic-Flowery",
+ "Enzymatic-Fruity",
+ "Enzymatic-Herby",
+ "Sugar Browning-Nutty",
+ "Sugar Browning-Carmelly",
+ "Sugar Browning-Chocolatey",
+ "Dry Distillation-Resinous",
+ "Dry Distillation-Spicy",
+ "Dry Distillation-Carbony",
+ "Bitter-Pungent",
+ "Bitter-Harsh",
+ "Salt-Sharp",
+ "Salt-Bland",
+ "Sweet-Mellow",
+ "Sweet-Acidy",
+ "Sour-Winey",
+ "Sour-Soury",
+ "Flowery-Floral",
+ "Flowery-Fragrant",
+ "Fruity-Citrus",
+ "Fruity-Berry-like",
+ "Herby-Alliaceous",
+ "Herby-Leguminous",
+ "Nutty-Nut-like",
+ "Nutty-Malt-like",
+ "Carmelly-Candy-like",
+ "Carmelly-Syrup-like",
+ "Chocolatey-Chocolate-like",
+ "Chocolatey-Vanilla-like",
+ "Resinous-Turpeny",
+ "Resinous-Medicinal",
+ "Spicy-Warming",
+ "Spicy-Pungent",
+ "Carbony-Smokey",
+ "Carbony-Ashy",
+ "Pungent-Creosol",
+ "Pungent-Phenolic",
+ "Harsh-Caustic",
+ "Harsh-Alkaline",
+ "Sharp-Astringent",
+ "Sharp-Rough",
+ "Bland-Neutral",
+ "Bland-Soft",
+ "Mellow-Delicate",
+ "Mellow-Mild",
+ "Acidy-Nippy",
+ "Acidy-Piquant",
+ "Winey-Tangy",
+ "Winey-Tart",
+ "Soury-Hard",
+ "Soury-Acrid",
+ "Floral-Coffee Blossom",
+ "Floral-Tea Rose",
+ "Fragrant-Cardamon Caraway",
+ "Fragrant-Coriander Seeds",
+ "Citrus-Lemon",
+ "Citrus-Apple",
+ "Berry-like-Apricot",
+ "Berry-like-Blackberry",
+ "Alliaceous-Onion",
+ "Alliaceous-Garlic",
+ "Leguminous-Cucumber",
+ "Leguminous-Garden Peas",
+ "Nut-like-Roasted Peanuts",
+ "Nut-like-Walnuts",
+ "Malt-like-Balsamic Rice",
+ "Malt-like-Toast",
+ "Candy-like-Roasted Hazelnut",
+ "Candy-like-Roasted Almond",
+ "Syrup-like-Honey",
+ "Syrup-like-Maple Syrup",
+ "Chocolate-like-Bakers",
+ "Chocolate-like-Dark Chocolate",
+ "Vanilla-like-Swiss",
+ "Vanilla-like-Butter",
+ "Turpeny-Piney",
+ "Turpeny-Blackcurrant-like",
+ "Medicinal-Camphoric",
+ "Medicinal-Cineolic",
+ "Warming-Cedar",
+ "Warming-Pepper",
+ "Pungent-Clove",
+ "Pungent-Thyme",
+ "Smokey-Tarry",
+ "Smokey-Pipe Tobacco",
+ "Ashy-Burnt",
+ "Ashy-Charred"
+ ],
+ "labels": [
+ "Aromas",
+ "Tastes",
+ "Enzymatic",
+ "Sugar Browning",
+ "Dry Distillation",
+ "Bitter",
+ "Salt",
+ "Sweet",
+ "Sour",
+ "Flowery",
+ "Fruity",
+ "Herby",
+ "Nutty",
+ "Carmelly",
+ "Chocolatey",
+ "Resinous",
+ "Spicy",
+ "Carbony",
+ "Pungent",
+ "Harsh",
+ "Sharp",
+ "Bland",
+ "Mellow",
+ "Acidy",
+ "Winey",
+ "Soury",
+ "Floral",
+ "Fragrant",
+ "Citrus",
+ "Berry-like",
+ "Alliaceous",
+ "Leguminous",
+ "Nut-like",
+ "Malt-like",
+ "Candy-like",
+ "Syrup-like",
+ "Chocolate-like",
+ "Vanilla-like",
+ "Turpeny",
+ "Medicinal",
+ "Warming",
+ "Pungent",
+ "Smokey",
+ "Ashy",
+ "Creosol",
+ "Phenolic",
+ "Caustic",
+ "Alkaline",
+ "Astringent",
+ "Rough",
+ "Neutral",
+ "Soft",
+ "Delicate",
+ "Mild",
+ "Nippy",
+ "Piquant",
+ "Tangy",
+ "Tart",
+ "Hard",
+ "Acrid",
+ "Coffee Blossom",
+ "Tea Rose",
+ "Cardamon Caraway",
+ "Coriander Seeds",
+ "Lemon",
+ "Apple",
+ "Apricot",
+ "Blackberry",
+ "Onion",
+ "Garlic",
+ "Cucumber",
+ "Garden Peas",
+ "Roasted Peanuts",
+ "Walnuts",
+ "Balsamic Rice",
+ "Toast",
+ "Roasted Hazelnut",
+ "Roasted Almond",
+ "Honey",
+ "Maple Syrup",
+ "Bakers",
+ "Dark Chocolate",
+ "Swiss",
+ "Butter",
+ "Piney",
+ "Blackcurrant-like",
+ "Camphoric",
+ "Cineolic",
+ "Cedar",
+ "Pepper",
+ "Clove",
+ "Thyme",
+ "Tarry",
+ "Pipe Tobacco",
+ "Burnt",
+ "Charred"
+ ],
+ "parents": [
+ "",
+ "",
+ "Aromas",
+ "Aromas",
+ "Aromas",
+ "Tastes",
+ "Tastes",
+ "Tastes",
+ "Tastes",
+ "Aromas-Enzymatic",
+ "Aromas-Enzymatic",
+ "Aromas-Enzymatic",
+ "Aromas-Sugar Browning",
+ "Aromas-Sugar Browning",
+ "Aromas-Sugar Browning",
+ "Aromas-Dry Distillation",
+ "Aromas-Dry Distillation",
+ "Aromas-Dry Distillation",
+ "Tastes-Bitter",
+ "Tastes-Bitter",
+ "Tastes-Salt",
+ "Tastes-Salt",
+ "Tastes-Sweet",
+ "Tastes-Sweet",
+ "Tastes-Sour",
+ "Tastes-Sour",
+ "Enzymatic-Flowery",
+ "Enzymatic-Flowery",
+ "Enzymatic-Fruity",
+ "Enzymatic-Fruity",
+ "Enzymatic-Herby",
+ "Enzymatic-Herby",
+ "Sugar Browning-Nutty",
+ "Sugar Browning-Nutty",
+ "Sugar Browning-Carmelly",
+ "Sugar Browning-Carmelly",
+ "Sugar Browning-Chocolatey",
+ "Sugar Browning-Chocolatey",
+ "Dry Distillation-Resinous",
+ "Dry Distillation-Resinous",
+ "Dry Distillation-Spicy",
+ "Dry Distillation-Spicy",
+ "Dry Distillation-Carbony",
+ "Dry Distillation-Carbony",
+ "Bitter-Pungent",
+ "Bitter-Pungent",
+ "Bitter-Harsh",
+ "Bitter-Harsh",
+ "Salt-Sharp",
+ "Salt-Sharp",
+ "Salt-Bland",
+ "Salt-Bland",
+ "Sweet-Mellow",
+ "Sweet-Mellow",
+ "Sweet-Acidy",
+ "Sweet-Acidy",
+ "Sour-Winey",
+ "Sour-Winey",
+ "Sour-Soury",
+ "Sour-Soury",
+ "Flowery-Floral",
+ "Flowery-Floral",
+ "Flowery-Fragrant",
+ "Flowery-Fragrant",
+ "Fruity-Citrus",
+ "Fruity-Citrus",
+ "Fruity-Berry-like",
+ "Fruity-Berry-like",
+ "Herby-Alliaceous",
+ "Herby-Alliaceous",
+ "Herby-Leguminous",
+ "Herby-Leguminous",
+ "Nutty-Nut-like",
+ "Nutty-Nut-like",
+ "Nutty-Malt-like",
+ "Nutty-Malt-like",
+ "Carmelly-Candy-like",
+ "Carmelly-Candy-like",
+ "Carmelly-Syrup-like",
+ "Carmelly-Syrup-like",
+ "Chocolatey-Chocolate-like",
+ "Chocolatey-Chocolate-like",
+ "Chocolatey-Vanilla-like",
+ "Chocolatey-Vanilla-like",
+ "Resinous-Turpeny",
+ "Resinous-Turpeny",
+ "Resinous-Medicinal",
+ "Resinous-Medicinal",
+ "Spicy-Warming",
+ "Spicy-Warming",
+ "Spicy-Pungent",
+ "Spicy-Pungent",
+ "Carbony-Smokey",
+ "Carbony-Smokey",
+ "Carbony-Ashy",
+ "Carbony-Ashy"
+ ]
+ }
+ ],
+ "layout": {
+ "margin": {"l": 0, "r": 0, "b": 0, "t": 0},
+ "width": 500,
+ "height": 500
+ }
+}
diff --git a/test/image/mocks/sunburst_first.json b/test/image/mocks/sunburst_first.json
new file mode 100644
index 00000000000..3e5d343c1e0
--- /dev/null
+++ b/test/image/mocks/sunburst_first.json
@@ -0,0 +1,19 @@
+{
+ "data": [
+ {
+ "type": "sunburst",
+ "labels": ["Eve", "Cain", "Seth", "Enos", "Noam", "Abel", "Awan", "Enoch", "Azura"],
+ "parents": ["", "Eve", "Eve", "Seth", "Seth", "Eve", "Eve", "Awan", "Eve" ],
+ "domain": {"x": [0, 0.5]}
+ },
+ {
+ "type": "sunburst",
+ "labels": ["Cain", "Seth", "Enos", "Noam", "Abel", "Awan", "Enoch", "Azura"],
+ "parents": ["Eve", "Eve", "Seth", "Seth", "Eve", "Eve", "Awan", "Eve" ],
+ "domain": {"x": [0.5, 1]}
+ }
+ ],
+ "layout": {
+
+ }
+}
diff --git a/test/image/mocks/sunburst_flare.json b/test/image/mocks/sunburst_flare.json
new file mode 100644
index 00000000000..a1429b53aa3
--- /dev/null
+++ b/test/image/mocks/sunburst_flare.json
@@ -0,0 +1,773 @@
+{
+ "data": [
+ {
+ "type": "sunburst",
+ "ids": [
+ "flare",
+ "flare-analytics",
+ "flare-animate",
+ "flare-data",
+ "flare-display",
+ "flare-flex",
+ "flare-physics",
+ "flare-query",
+ "flare-scale",
+ "flare-util",
+ "flare-vis",
+ "analytics-cluster",
+ "analytics-graph",
+ "analytics-optimization",
+ "animate-Easing",
+ "animate-FunctionSequence",
+ "animate-interpolate",
+ "animate-ISchedulable",
+ "animate-Parallel",
+ "animate-Pause",
+ "animate-Scheduler",
+ "animate-Sequence",
+ "animate-Transition",
+ "animate-Transitioner",
+ "animate-TransitionEvent",
+ "animate-Tween",
+ "data-converters",
+ "data-DataField",
+ "data-DataSchema",
+ "data-DataSet",
+ "data-DataSource",
+ "data-DataTable",
+ "data-DataUtil",
+ "display-DirtySprite",
+ "display-LineSprite",
+ "display-RectSprite",
+ "display-TextSprite",
+ "flex-FlareVis",
+ "physics-DragForce",
+ "physics-GravityForce",
+ "physics-IForce",
+ "physics-NBodyForce",
+ "physics-Particle",
+ "physics-Simulation",
+ "physics-Spring",
+ "physics-SpringForce",
+ "query-AggregateExpression",
+ "query-And",
+ "query-Arithmetic",
+ "query-Average",
+ "query-BinaryExpression",
+ "query-Comparison",
+ "query-CompositeExpression",
+ "query-Count",
+ "query-DateUtil",
+ "query-Distinct",
+ "query-Expression",
+ "query-ExpressionIterator",
+ "query-Fn",
+ "query-If",
+ "query-IsA",
+ "query-Literal",
+ "query-Match",
+ "query-Maximum",
+ "query-methods",
+ "query-Minimum",
+ "query-Not",
+ "query-Or",
+ "query-Query",
+ "query-Range",
+ "query-StringUtil",
+ "query-Sum",
+ "query-Variable",
+ "query-Variance",
+ "query-Xor",
+ "scale-IScaleMap",
+ "scale-LinearScale",
+ "scale-LogScale",
+ "scale-OrdinalScale",
+ "scale-QuantileScale",
+ "scale-QuantitativeScale",
+ "scale-RootScale",
+ "scale-Scale",
+ "scale-ScaleType",
+ "scale-TimeScale",
+ "util-Arrays",
+ "util-Colors",
+ "util-Dates",
+ "util-Displays",
+ "util-Filter",
+ "util-Geometry",
+ "util-heap",
+ "util-IEvaluable",
+ "util-IPredicate",
+ "util-IValueProxy",
+ "util-math",
+ "util-Maths",
+ "util-Orientation",
+ "util-palette",
+ "util-Property",
+ "util-Shapes",
+ "util-Sort",
+ "util-Stats",
+ "util-Strings",
+ "vis-axis",
+ "vis-controls",
+ "vis-data",
+ "vis-events",
+ "vis-legend",
+ "vis-operator",
+ "vis-Visualization",
+ "cluster-AgglomerativeCluster",
+ "cluster-CommunityStructure",
+ "cluster-HierarchicalCluster",
+ "cluster-MergeEdge",
+ "graph-BetweennessCentrality",
+ "graph-LinkDistance",
+ "graph-MaxFlowMinCut",
+ "graph-ShortestPaths",
+ "graph-SpanningTree",
+ "optimization-AspectRatioBanker",
+ "interpolate-ArrayInterpolator",
+ "interpolate-ColorInterpolator",
+ "interpolate-DateInterpolator",
+ "interpolate-Interpolator",
+ "interpolate-MatrixInterpolator",
+ "interpolate-NumberInterpolator",
+ "interpolate-ObjectInterpolator",
+ "interpolate-PointInterpolator",
+ "interpolate-RectangleInterpolator",
+ "converters-Converters",
+ "converters-DelimitedTextConverter",
+ "converters-GraphMLConverter",
+ "converters-IDataConverter",
+ "converters-JSONConverter",
+ "methods-add",
+ "methods-and",
+ "methods-average",
+ "methods-count",
+ "methods-distinct",
+ "methods-div",
+ "methods-eq",
+ "methods-fn",
+ "methods-gt",
+ "methods-gte",
+ "methods-iff",
+ "methods-isa",
+ "methods-lt",
+ "methods-lte",
+ "methods-max",
+ "methods-min",
+ "methods-mod",
+ "methods-mul",
+ "methods-neq",
+ "methods-not",
+ "methods-or",
+ "methods-orderby",
+ "methods-range",
+ "methods-select",
+ "methods-stddev",
+ "methods-sub",
+ "methods-sum",
+ "methods-update",
+ "methods-variance",
+ "methods-where",
+ "methods-xor",
+ "methods-_",
+ "heap-FibonacciHeap",
+ "heap-HeapNode",
+ "math-DenseMatrix",
+ "math-IMatrix",
+ "math-SparseMatrix",
+ "palette-ColorPalette",
+ "palette-Palette",
+ "palette-ShapePalette",
+ "palette-SizePalette",
+ "axis-Axes",
+ "axis-Axis",
+ "axis-AxisGridLine",
+ "axis-AxisLabel",
+ "axis-CartesianAxes",
+ "controls-AnchorControl",
+ "controls-ClickControl",
+ "controls-Control",
+ "controls-ControlList",
+ "controls-DragControl",
+ "controls-ExpandControl",
+ "controls-HoverControl",
+ "controls-IControl",
+ "controls-PanZoomControl",
+ "controls-SelectionControl",
+ "controls-TooltipControl",
+ "data-Data",
+ "data-DataList",
+ "data-DataSprite",
+ "data-EdgeSprite",
+ "data-NodeSprite",
+ "data-render",
+ "data-ScaleBinding",
+ "data-Tree",
+ "data-TreeBuilder",
+ "events-DataEvent",
+ "events-SelectionEvent",
+ "events-TooltipEvent",
+ "events-VisualizationEvent",
+ "legend-Legend",
+ "legend-LegendItem",
+ "legend-LegendRange",
+ "operator-distortion",
+ "operator-encoder",
+ "operator-filter",
+ "operator-IOperator",
+ "operator-label",
+ "operator-layout",
+ "operator-Operator",
+ "operator-OperatorList",
+ "operator-OperatorSequence",
+ "operator-OperatorSwitch",
+ "operator-SortOperator",
+ "render-ArrowType",
+ "render-EdgeRenderer",
+ "render-IRenderer",
+ "render-ShapeRenderer",
+ "distortion-BifocalDistortion",
+ "distortion-Distortion",
+ "distortion-FisheyeDistortion",
+ "encoder-ColorEncoder",
+ "encoder-Encoder",
+ "encoder-PropertyEncoder",
+ "encoder-ShapeEncoder",
+ "encoder-SizeEncoder",
+ "filter-FisheyeTreeFilter",
+ "filter-GraphDistanceFilter",
+ "filter-VisibilityFilter",
+ "label-Labeler",
+ "label-RadialLabeler",
+ "label-StackedAreaLabeler",
+ "layout-AxisLayout",
+ "layout-BundledEdgeRouter",
+ "layout-CircleLayout",
+ "layout-CirclePackingLayout",
+ "layout-DendrogramLayout",
+ "layout-ForceDirectedLayout",
+ "layout-IcicleTreeLayout",
+ "layout-IndentedTreeLayout",
+ "layout-Layout",
+ "layout-NodeLinkTreeLayout",
+ "layout-PieLayout",
+ "layout-RadialTreeLayout",
+ "layout-RandomLayout",
+ "layout-StackedAreaLayout",
+ "layout-TreeMapLayout"
+ ],
+ "labels": [
+ "flare",
+ "analytics",
+ "animate",
+ "data",
+ "display",
+ "flex",
+ "physics",
+ "query",
+ "scale",
+ "util",
+ "vis",
+ "cluster",
+ "graph",
+ "optimization",
+ "Easing",
+ "FunctionSequence",
+ "interpolate",
+ "ISchedulable",
+ "Parallel",
+ "Pause",
+ "Scheduler",
+ "Sequence",
+ "Transition",
+ "Transitioner",
+ "TransitionEvent",
+ "Tween",
+ "converters",
+ "DataField",
+ "DataSchema",
+ "DataSet",
+ "DataSource",
+ "DataTable",
+ "DataUtil",
+ "DirtySprite",
+ "LineSprite",
+ "RectSprite",
+ "TextSprite",
+ "FlareVis",
+ "DragForce",
+ "GravityForce",
+ "IForce",
+ "NBodyForce",
+ "Particle",
+ "Simulation",
+ "Spring",
+ "SpringForce",
+ "AggregateExpression",
+ "And",
+ "Arithmetic",
+ "Average",
+ "BinaryExpression",
+ "Comparison",
+ "CompositeExpression",
+ "Count",
+ "DateUtil",
+ "Distinct",
+ "Expression",
+ "ExpressionIterator",
+ "Fn",
+ "If",
+ "IsA",
+ "Literal",
+ "Match",
+ "Maximum",
+ "methods",
+ "Minimum",
+ "Not",
+ "Or",
+ "Query",
+ "Range",
+ "StringUtil",
+ "Sum",
+ "Variable",
+ "Variance",
+ "Xor",
+ "IScaleMap",
+ "LinearScale",
+ "LogScale",
+ "OrdinalScale",
+ "QuantileScale",
+ "QuantitativeScale",
+ "RootScale",
+ "Scale",
+ "ScaleType",
+ "TimeScale",
+ "Arrays",
+ "Colors",
+ "Dates",
+ "Displays",
+ "Filter",
+ "Geometry",
+ "heap",
+ "IEvaluable",
+ "IPredicate",
+ "IValueProxy",
+ "math",
+ "Maths",
+ "Orientation",
+ "palette",
+ "Property",
+ "Shapes",
+ "Sort",
+ "Stats",
+ "Strings",
+ "axis",
+ "controls",
+ "data",
+ "events",
+ "legend",
+ "operator",
+ "Visualization",
+ "AgglomerativeCluster",
+ "CommunityStructure",
+ "HierarchicalCluster",
+ "MergeEdge",
+ "BetweennessCentrality",
+ "LinkDistance",
+ "MaxFlowMinCut",
+ "ShortestPaths",
+ "SpanningTree",
+ "AspectRatioBanker",
+ "ArrayInterpolator",
+ "ColorInterpolator",
+ "DateInterpolator",
+ "Interpolator",
+ "MatrixInterpolator",
+ "NumberInterpolator",
+ "ObjectInterpolator",
+ "PointInterpolator",
+ "RectangleInterpolator",
+ "Converters",
+ "DelimitedTextConverter",
+ "GraphMLConverter",
+ "IDataConverter",
+ "JSONConverter",
+ "add",
+ "and",
+ "average",
+ "count",
+ "distinct",
+ "div",
+ "eq",
+ "fn",
+ "gt",
+ "gte",
+ "iff",
+ "isa",
+ "lt",
+ "lte",
+ "max",
+ "min",
+ "mod",
+ "mul",
+ "neq",
+ "not",
+ "or",
+ "orderby",
+ "range",
+ "select",
+ "stddev",
+ "sub",
+ "sum",
+ "update",
+ "variance",
+ "where",
+ "xor",
+ "_",
+ "FibonacciHeap",
+ "HeapNode",
+ "DenseMatrix",
+ "IMatrix",
+ "SparseMatrix",
+ "ColorPalette",
+ "Palette",
+ "ShapePalette",
+ "SizePalette",
+ "Axes",
+ "Axis",
+ "AxisGridLine",
+ "AxisLabel",
+ "CartesianAxes",
+ "AnchorControl",
+ "ClickControl",
+ "Control",
+ "ControlList",
+ "DragControl",
+ "ExpandControl",
+ "HoverControl",
+ "IControl",
+ "PanZoomControl",
+ "SelectionControl",
+ "TooltipControl",
+ "Data",
+ "DataList",
+ "DataSprite",
+ "EdgeSprite",
+ "NodeSprite",
+ "render",
+ "ScaleBinding",
+ "Tree",
+ "TreeBuilder",
+ "DataEvent",
+ "SelectionEvent",
+ "TooltipEvent",
+ "VisualizationEvent",
+ "Legend",
+ "LegendItem",
+ "LegendRange",
+ "distortion",
+ "encoder",
+ "filter",
+ "IOperator",
+ "label",
+ "layout",
+ "Operator",
+ "OperatorList",
+ "OperatorSequence",
+ "OperatorSwitch",
+ "SortOperator",
+ "ArrowType",
+ "EdgeRenderer",
+ "IRenderer",
+ "ShapeRenderer",
+ "BifocalDistortion",
+ "Distortion",
+ "FisheyeDistortion",
+ "ColorEncoder",
+ "Encoder",
+ "PropertyEncoder",
+ "ShapeEncoder",
+ "SizeEncoder",
+ "FisheyeTreeFilter",
+ "GraphDistanceFilter",
+ "VisibilityFilter",
+ "Labeler",
+ "RadialLabeler",
+ "StackedAreaLabeler",
+ "AxisLayout",
+ "BundledEdgeRouter",
+ "CircleLayout",
+ "CirclePackingLayout",
+ "DendrogramLayout",
+ "ForceDirectedLayout",
+ "IcicleTreeLayout",
+ "IndentedTreeLayout",
+ "Layout",
+ "NodeLinkTreeLayout",
+ "PieLayout",
+ "RadialTreeLayout",
+ "RandomLayout",
+ "StackedAreaLayout",
+ "TreeMapLayout"
+ ],
+ "parents": [
+ "",
+ "flare",
+ "flare",
+ "flare",
+ "flare",
+ "flare",
+ "flare",
+ "flare",
+ "flare",
+ "flare",
+ "flare",
+ "flare-analytics",
+ "flare-analytics",
+ "flare-analytics",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-animate",
+ "flare-data",
+ "flare-data",
+ "flare-data",
+ "flare-data",
+ "flare-data",
+ "flare-data",
+ "flare-data",
+ "flare-display",
+ "flare-display",
+ "flare-display",
+ "flare-display",
+ "flare-flex",
+ "flare-physics",
+ "flare-physics",
+ "flare-physics",
+ "flare-physics",
+ "flare-physics",
+ "flare-physics",
+ "flare-physics",
+ "flare-physics",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-query",
+ "flare-scale",
+ "flare-scale",
+ "flare-scale",
+ "flare-scale",
+ "flare-scale",
+ "flare-scale",
+ "flare-scale",
+ "flare-scale",
+ "flare-scale",
+ "flare-scale",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-util",
+ "flare-vis",
+ "flare-vis",
+ "flare-vis",
+ "flare-vis",
+ "flare-vis",
+ "flare-vis",
+ "flare-vis",
+ "analytics-cluster",
+ "analytics-cluster",
+ "analytics-cluster",
+ "analytics-cluster",
+ "analytics-graph",
+ "analytics-graph",
+ "analytics-graph",
+ "analytics-graph",
+ "analytics-graph",
+ "analytics-optimization",
+ "animate-interpolate",
+ "animate-interpolate",
+ "animate-interpolate",
+ "animate-interpolate",
+ "animate-interpolate",
+ "animate-interpolate",
+ "animate-interpolate",
+ "animate-interpolate",
+ "animate-interpolate",
+ "data-converters",
+ "data-converters",
+ "data-converters",
+ "data-converters",
+ "data-converters",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "query-methods",
+ "util-heap",
+ "util-heap",
+ "util-math",
+ "util-math",
+ "util-math",
+ "util-palette",
+ "util-palette",
+ "util-palette",
+ "util-palette",
+ "vis-axis",
+ "vis-axis",
+ "vis-axis",
+ "vis-axis",
+ "vis-axis",
+ "vis-controls",
+ "vis-controls",
+ "vis-controls",
+ "vis-controls",
+ "vis-controls",
+ "vis-controls",
+ "vis-controls",
+ "vis-controls",
+ "vis-controls",
+ "vis-controls",
+ "vis-controls",
+ "vis-data",
+ "vis-data",
+ "vis-data",
+ "vis-data",
+ "vis-data",
+ "vis-data",
+ "vis-data",
+ "vis-data",
+ "vis-data",
+ "vis-events",
+ "vis-events",
+ "vis-events",
+ "vis-events",
+ "vis-legend",
+ "vis-legend",
+ "vis-legend",
+ "vis-operator",
+ "vis-operator",
+ "vis-operator",
+ "vis-operator",
+ "vis-operator",
+ "vis-operator",
+ "vis-operator",
+ "vis-operator",
+ "vis-operator",
+ "vis-operator",
+ "vis-operator",
+ "data-render",
+ "data-render",
+ "data-render",
+ "data-render",
+ "operator-distortion",
+ "operator-distortion",
+ "operator-distortion",
+ "operator-encoder",
+ "operator-encoder",
+ "operator-encoder",
+ "operator-encoder",
+ "operator-encoder",
+ "operator-filter",
+ "operator-filter",
+ "operator-filter",
+ "operator-label",
+ "operator-label",
+ "operator-label",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout",
+ "operator-layout"
+ ]
+ }
+ ],
+ "layout": {
+ "width": 600,
+ "height": 600
+ }
+}
diff --git a/test/image/mocks/sunburst_level-depth.json b/test/image/mocks/sunburst_level-depth.json
new file mode 100644
index 00000000000..56acbc8c74e
--- /dev/null
+++ b/test/image/mocks/sunburst_level-depth.json
@@ -0,0 +1,21 @@
+{
+ "data": [
+ {
+ "type": "sunburst",
+ "labels": ["Eve", "Cain", "Seth", "Enos", "Noam", "Abel", "Awan", "Enoch", "Azura"],
+ "parents": ["", "Eve", "Eve", "Seth", "Seth", "Eve", "Eve", "Awan", "Eve" ],
+ "level": "Awan",
+ "domain": {"x": [0, 0.5]}
+ },
+ {
+ "type": "sunburst",
+ "labels": ["Cain", "Seth", "Enos", "Noam", "Abel", "Awan", "Enoch", "Azura"],
+ "parents": ["Eve", "Eve", "Seth", "Seth", "Eve", "Eve", "Awan", "Eve" ],
+ "maxdepth": 2,
+ "domain": {"x": [0.5, 1]}
+ }
+ ],
+ "layout": {
+ "sunburstcolorway": ["#8dd3c7", "#ffffb3", "#bebada", "#fb8072", "#80b1d3", "#fdb462", "#b3de69"]
+ }
+}
diff --git a/test/image/mocks/sunburst_values.json b/test/image/mocks/sunburst_values.json
new file mode 100644
index 00000000000..940fe3e2d15
--- /dev/null
+++ b/test/image/mocks/sunburst_values.json
@@ -0,0 +1,47 @@
+{
+ "data": [
+ {
+ "type": "sunburst",
+ "name": "with branchvalues:remainder",
+ "labels": ["Eve", "Cain", "Seth", "Enos", "Noam", "Abel", "Awan", "Enoch", "Azura"],
+ "parents": ["", "Eve", "Eve", "Seth", "Seth", "Eve", "Eve", "Awan", "Eve" ],
+ "values": [10, 14, 12, 10, 2, 6, 6, 1, 4],
+ "domain": {"x": [0, 0.48]},
+ "outsidetextfont": {"size": 20, "color": "#377eb8"},
+ "leaf": {"opacity": 0.4},
+ "marker": {"line": {"width": 2}}
+ },
+ {
+ "type": "sunburst",
+ "name": "with branchvalues:total",
+ "branchvalues": "total",
+ "labels": ["Eve", "Cain", "Seth", "Enos", "Noam", "Abel", "Awan", "Enoch", "Azura"],
+ "parents": ["", "Eve", "Eve", "Seth", "Seth", "Eve", "Eve", "Awan", "Eve" ],
+ "domain": {"x": [0.52, 1]},
+ "values": [65, 14, 12, 10, 2, 6, 6, 1, 4],
+ "outsidetextfont": {"size": 20, "color": "#377eb8"},
+ "leaf": {"opacity": 0.4},
+ "marker": {"line": {"width": 2}}
+ }
+ ],
+ "layout": {
+ "annotations": [{
+ "showarrow": false,
+ "text": "branchvalues: remainder",
+ "x": 0.25,
+ "xanchor": "center",
+ "y": 1.1,
+ "yanchor": "bottom"
+
+ }, {
+ "showarrow": false,
+ "text": "branchvalues: total",
+ "x": 0.75,
+ "xanchor": "center",
+ "y": 1.1,
+ "yanchor": "bottom"
+ }],
+
+ "paper_bgcolor": "#d3d3d3"
+ }
+}
diff --git a/test/jasmine/assets/mock_lists.js b/test/jasmine/assets/mock_lists.js
index 80f9e4b331f..a52e0c7327b 100644
--- a/test/jasmine/assets/mock_lists.js
+++ b/test/jasmine/assets/mock_lists.js
@@ -28,6 +28,7 @@ var svgMockList = [
['range_selector_style', require('@mocks/range_selector_style.json')],
['range_slider_multiple', require('@mocks/range_slider_multiple.json')],
['sankey_energy', require('@mocks/sankey_energy.json')],
+ ['sunburst_coffee', require('@mocks/sunburst_coffee.json')],
['parcats_bad-displayindex', require('@mocks/parcats_bad-displayindex.json')],
['scattercarpet', require('@mocks/scattercarpet.json')],
['shapes', require('@mocks/shapes.json')],
diff --git a/test/jasmine/tests/plot_api_react_test.js b/test/jasmine/tests/plot_api_react_test.js
index 2337ebbd766..a1c864498e1 100644
--- a/test/jasmine/tests/plot_api_react_test.js
+++ b/test/jasmine/tests/plot_api_react_test.js
@@ -1864,6 +1864,42 @@ describe('Plotly.react and uirevision attributes', function() {
_run(fig, editComponents, checkInitial, checkEdited).then(done);
});
+
+ it('preserves sunburst level changes', function(done) {
+ function assertLevel(msg, exp) {
+ expect(gd._fullData[0].level).toBe(exp, msg);
+ }
+
+ Plotly.react(gd, [{
+ type: 'sunburst',
+ labels: ['Eve', 'Cain', 'Seth', 'Enos', 'Noam', 'Abel', 'Awan', 'Enoch', 'Azura'],
+ parents: ['', 'Eve', 'Eve', 'Seth', 'Seth', 'Eve', 'Eve', 'Awan', 'Eve'],
+ uirevision: 1
+ }])
+ .then(function() {
+ assertLevel('no set level at start', undefined);
+ })
+ .then(function() {
+ var nodeSeth = d3.select('.slice:nth-child(2)').node();
+ mouseEvent('click', 0, 0, {element: nodeSeth});
+ })
+ .then(function() {
+ assertLevel('after clicking on Seth sector', 'Seth');
+ })
+ .then(function() {
+ return Plotly.react(gd, [{
+ type: 'sunburst',
+ labels: ['Eve', 'Cain', 'Seth', 'Enos', 'Noam', 'Abel', 'Awan', 'Enoch', 'Azura', 'Joe'],
+ parents: ['', 'Eve', 'Eve', 'Seth', 'Seth', 'Eve', 'Eve', 'Awan', 'Eve', 'Seth'],
+ uirevision: 1
+ }]);
+ })
+ .then(function() {
+ assertLevel('after reacting with new data, but with same uirevision', 'Seth');
+ })
+ .catch(failTest)
+ .then(done);
+ });
});
describe('Test Plotly.react + interactions under uirevision:', function() {
diff --git a/test/jasmine/tests/sunburst_test.js b/test/jasmine/tests/sunburst_test.js
new file mode 100644
index 00000000000..995dd812df0
--- /dev/null
+++ b/test/jasmine/tests/sunburst_test.js
@@ -0,0 +1,1055 @@
+var Plotly = require('@lib');
+var Plots = require('@src/plots/plots');
+var Lib = require('@src/lib');
+var constants = require('@src/traces/sunburst/constants');
+
+var d3 = require('d3');
+var supplyAllDefaults = require('../assets/supply_defaults');
+var createGraphDiv = require('../assets/create_graph_div');
+var destroyGraphDiv = require('../assets/destroy_graph_div');
+var mouseEvent = require('../assets/mouse_event');
+var delay = require('../assets/delay');
+var failTest = require('../assets/fail_test');
+
+var customAssertions = require('../assets/custom_assertions');
+var assertHoverLabelStyle = customAssertions.assertHoverLabelStyle;
+var assertHoverLabelContent = customAssertions.assertHoverLabelContent;
+
+function _mouseEvent(type, gd, v) {
+ return function() {
+ if(Array.isArray(v)) {
+ // px-based position
+ mouseEvent(type, v[0], v[1]);
+ } else {
+ // position from slice number
+ var gd3 = d3.select(gd);
+ var el = gd3.select('.slice:nth-child(' + v + ')').node();
+ mouseEvent(type, 0, 0, {element: el});
+ }
+ };
+}
+
+function hover(gd, v) {
+ return _mouseEvent('mouseover', gd, v);
+}
+
+function unhover(gd, v) {
+ return _mouseEvent('mouseout', gd, v);
+}
+
+function click(gd, v) {
+ return _mouseEvent('click', gd, v);
+}
+
+describe('Test sunburst defaults:', function() {
+ var gd;
+ var fullData;
+
+ function _supply(opts, layout) {
+ gd = {};
+ opts = Array.isArray(opts) ? opts : [opts];
+
+ gd.data = opts.map(function(o) {
+ return Lib.extendFlat({type: 'sunburst'}, o || {});
+ });
+ gd.layout = layout || {};
+
+ supplyAllDefaults(gd);
+ fullData = gd._fullData;
+ }
+
+ it('should set *visible:false* when *labels* or *parents* is missing', function() {
+ _supply([
+ {labels: [1], parents: ['']},
+ {labels: [1]},
+ {parents: ['']}
+ ]);
+
+ expect(fullData[0].visible).toBe(true, 'base');
+ expect(fullData[1].visible).toBe(false, 'no parents');
+ expect(fullData[2].visible).toBe(false, 'no labels');
+ });
+
+ it('should not coerce *branchvalues* when *values* is not set', function() {
+ _supply([
+ {labels: [1], parents: [''], values: [1]},
+ {labels: [1], parents: ['']},
+ ]);
+
+ expect(fullData[0].branchvalues).toBe('remainder', 'base');
+ expect(fullData[1].branchvalues).toBe(undefined, 'no values');
+ });
+
+ it('should use *paper_bgcolor* as *marker.line.color* default', function() {
+ _supply([
+ {labels: [1], parents: [''], marker: {line: {color: 'red'}}},
+ {labels: [1], parents: ['']}
+ ], {
+ paper_bgcolor: 'orange'
+ });
+
+ expect(fullData[0].marker.line.color).toBe('red', 'set color');
+ expect(fullData[1].marker.line.color).toBe('orange', 'using dflt');
+ });
+
+ it('should not coerce *marker.line.color* when *marker.line.width* is 0', function() {
+ _supply([
+ {labels: [1], parents: [''], marker: {line: {width: 0}}},
+ {labels: [1], parents: ['']}
+ ]);
+
+ expect(fullData[0].marker.line.color).toBe(undefined, 'not coerced');
+ expect(fullData[1].marker.line.color).toBe('#fff', 'dflt');
+ });
+
+ it('should include "text" flag in *textinfo* when *text* is set', function() {
+ _supply([
+ {labels: [1], parents: [''], text: ['A']},
+ {labels: [1], parents: ['']}
+ ]);
+
+ expect(fullData[0].textinfo).toBe('text+label', 'with text');
+ expect(fullData[1].textinfo).toBe('label', 'no text');
+ });
+
+ it('should use *layout.colorway* as dflt for *sunburstcolorway*', function() {
+ _supply([
+ {labels: [1], parents: ['']}
+ ], {
+ colorway: ['red', 'blue', 'green']
+ });
+ expect(gd._fullLayout.sunburstcolorway)
+ .toEqual(['red', 'blue', 'green'], 'dflt to layout colorway');
+
+ _supply([
+ {labels: [1], parents: ['']}
+ ], {
+ colorway: ['red', 'blue', 'green'],
+ sunburstcolorway: ['cyan', 'yellow', 'black']
+ });
+ expect(gd._fullLayout.sunburstcolorway)
+ .toEqual(['cyan', 'yellow', 'black'], 'user-defined value');
+ });
+});
+
+describe('Test sunburst calc:', function() {
+ var gd;
+
+ beforeEach(function() {
+ spyOn(Lib, 'warn');
+ });
+
+ function _calc(opts, layout) {
+ gd = {};
+ opts = Array.isArray(opts) ? opts : [opts];
+
+ gd.data = opts.map(function(o) {
+ return Lib.extendFlat({type: 'sunburst'}, o || {});
+ });
+ gd.layout = layout || {};
+
+ supplyAllDefaults(gd);
+ Plots.doCalcdata(gd);
+ }
+
+ function extract(k) {
+ var out = gd.calcdata.map(function(cd) {
+ return cd.map(function(pt) { return pt[k]; });
+ });
+ return out.length > 1 ? out : out[0];
+ }
+
+ function extractPt(k) {
+ var out = gd.calcdata.map(function(cd) {
+ return cd[0].hierarchy.descendants().map(function(pt) {
+ return pt[k];
+ });
+ });
+ return out.length > 1 ? out : out[0];
+ }
+
+ it('should generate *id* when it can', function() {
+ _calc({
+ labels: ['Root', 'A', 'B', 'b'],
+ parents: ['', 'Root', 'Root', 'B']
+ });
+
+ expect(extract('id')).toEqual(['Root', 'A', 'B', 'b']);
+ expect(Lib.warn).toHaveBeenCalledTimes(0);
+ });
+
+ it('should generate "implied root" when it can', function() {
+ _calc({
+ labels: [ 'A', 'B', 'b'],
+ parents: ['Root', 'Root', 'B']
+ });
+
+ expect(extract('id')).toEqual(['Root', 'A', 'B', 'b']);
+ expect(extract('pid')).toEqual(['', 'Root', 'Root', 'B']);
+ expect(extract('label')).toEqual(['Root', 'A', 'B', 'b']);
+ expect(Lib.warn).toHaveBeenCalledTimes(0);
+ });
+
+ it('should warn when there are multiple implied roots', function() {
+ _calc({
+ labels: [ 'A', 'B', 'b'],
+ parents: ['Root1', 'Root22', 'B']
+ });
+
+ expect(Lib.warn).toHaveBeenCalledTimes(1);
+ expect(Lib.warn).toHaveBeenCalledWith('Multiple implied roots, cannot build sunburst hierarchy.');
+ });
+
+ it('should generate "root of roots" when it can', function() {
+ spyOn(Lib, 'randstr').and.callFake(function() {
+ return 'dummy';
+ });
+
+ _calc({
+ labels: [ 'A', 'B', 'b'],
+ parents: ['', '', 'B']
+ });
+
+ expect(extract('id')).toEqual(['dummy', 'A', 'B', 'b']);
+ expect(extract('pid')).toEqual(['', 'dummy', 'dummy', 'B']);
+ expect(extract('label')).toEqual([undefined, 'A', 'B', 'b']);
+ });
+
+ it('should compute hierarchy values', function() {
+ var labels = ['Root', 'A', 'B', 'b'];
+ var parents = ['', 'Root', 'Root', 'B'];
+
+ _calc([
+ {labels: labels, parents: parents},
+ {labels: labels, parents: parents, values: [0, 1, 2, 3]},
+ {labels: labels, parents: parents, values: [30, 20, 10, 5], branchvalues: 'total'}
+ ]);
+
+ expect(extractPt('value')).toEqual([
+ [2, 1, 1, 1],
+ [6, 5, 1, 3],
+ [30, 20, 10, 5]
+ ]);
+ expect(Lib.warn).toHaveBeenCalledTimes(0);
+ });
+
+ it('should warn when values under *branchvalues:total* do not add up and not show trace', function() {
+ _calc({
+ labels: ['Root', 'A', 'B', 'b'],
+ parents: ['', 'Root', 'Root', 'B'],
+ values: [0, 1, 2, 3],
+ branchvalues: 'total'
+ });
+
+ expect(gd.calcdata[0][0].hierarchy).toBe(undefined, 'no computed hierarchy');
+
+ expect(Lib.warn).toHaveBeenCalledTimes(2);
+ expect(Lib.warn.calls.allArgs()[0][0]).toBe('Total value for node Root is smaller than the sum of its children.');
+ expect(Lib.warn.calls.allArgs()[1][0]).toBe('Total value for node B is smaller than the sum of its children.');
+ });
+
+ it('should warn labels/parents lead to ambiguous hierarchy', function() {
+ _calc({
+ labels: ['Root', 'A', 'A', 'B'],
+ parents: ['', 'Root', 'Root', 'A']
+ });
+
+ expect(Lib.warn).toHaveBeenCalledTimes(1);
+ expect(Lib.warn).toHaveBeenCalledWith('Failed to build sunburst hierarchy. Error: ambiguous: A');
+ });
+
+ it('should warn ids/parents lead to ambiguous hierarchy', function() {
+ _calc({
+ labels: ['label 1', 'label 2', 'label 3', 'label 4'],
+ ids: ['a', 'b', 'b', 'c'],
+ parents: ['', 'a', 'a', 'b']
+ });
+
+ expect(Lib.warn).toHaveBeenCalledTimes(1);
+ expect(Lib.warn).toHaveBeenCalledWith('Failed to build sunburst hierarchy. Error: ambiguous: b');
+ });
+});
+
+describe('Test sunburst hover:', function() {
+ var gd;
+
+ var labels0 = ['Eve', 'Cain', 'Seth', 'Enos', 'Noam', 'Abel', 'Awan', 'Enoch', 'Azura'];
+ var parents0 = ['', 'Eve', 'Eve', 'Seth', 'Seth', 'Eve', 'Eve', 'Awan', 'Eve'];
+ var values0 = [10, 14, 12, 10, 2, 6, 6, 1, 4];
+
+ afterEach(destroyGraphDiv);
+
+ function run(spec) {
+ gd = createGraphDiv();
+
+ var data = (spec.traces || [{}]).map(function(t) {
+ t.type = 'sunburst';
+ if(!t.labels) t.labels = labels0.slice();
+ if(!t.parents) t.parents = parents0.slice();
+ return t;
+ });
+
+ var layout = Lib.extendFlat({
+ width: 500,
+ height: 500,
+ margin: {t: 0, b: 0, l: 0, r: 0, pad: 0}
+ }, spec.layout || {});
+
+ var exp = spec.exp || {};
+ var ptData = null;
+
+ return Plotly.plot(gd, data, layout)
+ .then(function() {
+ gd.once('plotly_hover', function(d) { ptData = d.points[0]; });
+ })
+ .then(hover(gd, spec.pos))
+ .then(function() {
+ assertHoverLabelContent(exp.label);
+
+ for(var k in exp.ptData) {
+ expect(ptData[k]).toBe(exp.ptData[k], 'pt event data key ' + k);
+ }
+
+ if(exp.style) {
+ var gd3 = d3.select(gd);
+ assertHoverLabelStyle(gd3.select('.hovertext'), exp.style);
+ }
+ });
+ }
+
+ [{
+ desc: 'base',
+ pos: 2,
+ exp: {
+ label: {
+ nums: 'Seth',
+ },
+ ptData: {
+ curveNumber: 0,
+ pointNumber: 2,
+ label: 'Seth',
+ parent: 'Eve'
+ }
+ }
+ }, {
+ desc: 'with scalar hovertext',
+ traces: [{ hovertext: 'A' }],
+ pos: 3,
+ exp: {
+ label: {
+ nums: 'Cain\nA',
+ },
+ ptData: {
+ curveNumber: 0,
+ pointNumber: 1,
+ label: 'Cain',
+ parent: 'Eve'
+ }
+ }
+ }, {
+ desc: 'with array hovertext',
+ traces: [{
+ hovertext: values0,
+ hoverinfo: 'all'
+ }],
+ pos: 4,
+ exp: {
+ label: {
+ nums: 'Abel\n6',
+ name: 'trace 0'
+ },
+ ptData: {
+ curveNumber: 0,
+ pointNumber: 5,
+ label: 'Abel',
+ parent: 'Eve'
+ }
+ }
+ }, {
+ desc: 'with values',
+ traces: [{
+ values: values0,
+ hoverinfo: 'value'
+ }],
+ pos: 5,
+ exp: {
+ label: {
+ nums: '6'
+ },
+ ptData: {
+ curveNumber: 0,
+ pointNumber: 5,
+ label: 'Abel',
+ parent: 'Eve',
+ value: 6
+ }
+ }
+ }, {
+ desc: 'with values and hovertemplate',
+ traces: [{
+ values: values0,
+ hovertemplate: '%{label} :: %{value:.2f}N.B.'
+ }],
+ pos: 5,
+ exp: {
+ label: {
+ nums: 'Abel :: 6.00',
+ name: 'N.B.'
+ },
+ ptData: {
+ curveNumber: 0,
+ pointNumber: 5,
+ label: 'Abel',
+ parent: 'Eve',
+ value: 6
+ }
+ }
+ }, {
+ desc: 'with array hovertemplate and label styling',
+ traces: [{
+ hovertemplate: parents0.map(function(p) {
+ return p ?
+ '%{label} -| %{parent}' :
+ '%{label}THE ROOT';
+ }),
+ hoverlabel: {
+ bgcolor: 'red',
+ bordercolor: 'blue',
+ font: {
+ size: 20,
+ family: 'Roboto',
+ color: 'orange'
+ }
+ }
+ }],
+ pos: 1,
+ exp: {
+ label: {
+ nums: 'Eve',
+ name: 'THE ROOT'
+ },
+ style: {
+ bgcolor: 'rgb(255, 0, 0)',
+ bordercolor: 'rgb(0, 0, 255)',
+ fontSize: 20,
+ fontFamily: 'Roboto',
+ fontColor: 'rgb(255, 165, 0)'
+ },
+ ptData: {
+ curveNumber: 0,
+ pointNumber: 0,
+ label: 'Eve',
+ parent: ''
+ }
+ }
+ }]
+ .forEach(function(spec) {
+ it('should generate correct hover labels and event data - ' + spec.desc, function(done) {
+ run(spec).catch(failTest).then(done);
+ });
+ });
+});
+
+describe('Test sunburst hover lifecycle:', function() {
+ var gd;
+ var hoverData;
+ var unhoverData;
+ var hoverCnt;
+ var unhoverCnt;
+
+ beforeEach(function() { gd = createGraphDiv(); });
+
+ afterEach(destroyGraphDiv);
+
+ function setupListeners() {
+ hoverData = null;
+ unhoverData = null;
+ hoverCnt = 0;
+ unhoverCnt = 0;
+
+ return function() {
+ gd.on('plotly_hover', function(d) {
+ hoverData = d;
+ hoverCnt++;
+ });
+ gd.on('plotly_unhover', function(d) {
+ unhoverData = d;
+ unhoverCnt++;
+ });
+ };
+ }
+
+ it('should fire the correct events', function(done) {
+ var mock = Lib.extendDeep({}, require('@mocks/sunburst_first.json'));
+
+ Plotly.plot(gd, mock)
+ .then(setupListeners())
+ .then(hover(gd, 1))
+ .then(function() {
+ if(hoverCnt === 1) {
+ expect(hoverData.event).toBeDefined();
+ expect(hoverData.points[0].label).toBe('Eve');
+ } else {
+ fail('did not trigger correct # of plotly_hover events');
+ }
+
+ if(unhoverCnt) {
+ fail('should not have triggered plotly_unhover');
+ }
+ })
+ .then(unhover(gd, 1))
+ .then(hover(gd, 2))
+ .then(function() {
+ if(hoverCnt === 2) {
+ expect(hoverData.event).toBeDefined();
+ expect(hoverData.points[0].label).toBe('Seth');
+ } else {
+ fail('did not trigger correct # of plotly_hover events');
+ }
+
+ if(unhoverCnt === 1) {
+ expect(unhoverData.event).toBeDefined();
+ expect(unhoverData.points[0].label).toBe('Eve');
+ } else {
+ fail('did not trigger correct # of plotly_unhover events');
+ }
+ })
+ .catch(failTest)
+ .then(done);
+ });
+});
+
+describe('Test sunburst clicks:', function() {
+ var gd;
+ var trackers;
+
+ beforeEach(function() {
+ gd = createGraphDiv();
+ trackers = {};
+ });
+
+ afterEach(destroyGraphDiv);
+
+ function setupListeners(opts) {
+ opts = opts || {};
+
+ trackers.sunburstclick = [];
+ trackers.click = [];
+ trackers.animating = [];
+
+ // use `.unshift` that way to latest event data object
+ // will be in entry [0], which is easier to pick out
+
+ return function() {
+ gd.on('plotly_sunburstclick', function(d) {
+ trackers.sunburstclick.unshift(d);
+ if(opts.turnOffAnimation) return false;
+ });
+ gd.on('plotly_click', function(d) {
+ trackers.click.unshift(d);
+ });
+ gd.on('plotly_animating', function() {
+ // N.B. does not emit event data
+ trackers.animating.unshift(true);
+ });
+ };
+ }
+
+ it('should trigger animation when clicking on branches', function(done) {
+ var mock = Lib.extendDeep({}, require('@mocks/sunburst_first.json'));
+
+ Plotly.plot(gd, mock)
+ .then(setupListeners())
+ .then(click(gd, 2))
+ .then(function() {
+ if(trackers.sunburstclick.length === 1) {
+ expect(trackers.sunburstclick[0].event).toBeDefined();
+ expect(trackers.sunburstclick[0].points[0].label).toBe('Seth');
+ } else {
+ fail('incorrect plotly_sunburstclick triggering');
+ }
+
+ if(trackers.click.length) {
+ fail('incorrect plotly_click triggering');
+ }
+
+ if(trackers.animating.length !== 1) {
+ fail('incorrect plotly_animating triggering');
+ }
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should trigger plotly_click event when clicking on root node', function(done) {
+ var mock = Lib.extendDeep({}, require('@mocks/sunburst_first.json'));
+
+ Plotly.plot(gd, mock)
+ .then(setupListeners())
+ .then(click(gd, 1))
+ .then(function() {
+ if(trackers.sunburstclick.length === 1) {
+ expect(trackers.sunburstclick[0].event).toBeDefined();
+ expect(trackers.sunburstclick[0].points[0].label).toBe('Eve');
+ } else {
+ fail('incorrect plotly_sunburstclick triggering');
+ }
+
+ if(trackers.click.length === 1) {
+ expect(trackers.click[0].event).toBeDefined();
+ expect(trackers.click[0].points[0].label).toBe('Eve');
+ } else {
+ fail('incorrect plotly_click triggering');
+ }
+
+ if(trackers.animating.length !== 0) {
+ fail('incorrect plotly_animating triggering');
+ }
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should trigger plotly_click event when clicking on leaf node', function(done) {
+ var mock = Lib.extendDeep({}, require('@mocks/sunburst_first.json'));
+
+ Plotly.plot(gd, mock)
+ .then(setupListeners())
+ .then(click(gd, 8))
+ .then(function() {
+ if(trackers.sunburstclick.length === 1) {
+ expect(trackers.sunburstclick[0].event).toBeDefined();
+ expect(trackers.sunburstclick[0].points[0].label).toBe('Noam');
+ } else {
+ fail('incorrect plotly_sunburstclick triggering');
+ }
+
+ if(trackers.click.length === 1) {
+ expect(trackers.click[0].event).toBeDefined();
+ expect(trackers.click[0].points[0].label).toBe('Noam');
+ } else {
+ fail('incorrect plotly_click triggering');
+ }
+
+ if(trackers.animating.length !== 0) {
+ fail('incorrect plotly_animating triggering');
+ }
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should not trigger animation when graph is transitioning', function(done) {
+ var mock = Lib.extendDeep({}, require('@mocks/sunburst_first.json'));
+
+ // should be same before and after 2nd click
+ function _assertCommon(msg) {
+ if(trackers.click.length) {
+ fail('incorrect plotly_click triggering - ' + msg);
+ }
+ if(trackers.animating.length !== 1) {
+ fail('incorrect plotly_animating triggering - ' + msg);
+ }
+ }
+
+ Plotly.plot(gd, mock)
+ .then(setupListeners())
+ .then(click(gd, 2))
+ .then(function() {
+ var msg = 'after 1st click';
+
+ if(trackers.sunburstclick.length === 1) {
+ expect(trackers.sunburstclick[0].event).toBeDefined(msg);
+ expect(trackers.sunburstclick[0].points[0].label).toBe('Seth', msg);
+ } else {
+ fail('incorrect plotly_sunburstclick triggering - ' + msg);
+ }
+
+ _assertCommon(msg);
+ })
+ .then(click(gd, 4))
+ .then(function() {
+ var msg = 'after 2nd click';
+
+ // should trigger plotly_sunburstclick twice, but not additional
+ // plotly_click nor plotly_animating
+
+ if(trackers.sunburstclick.length === 2) {
+ expect(trackers.sunburstclick[0].event).toBeDefined(msg);
+ expect(trackers.sunburstclick[0].points[0].label).toBe('Awan', msg);
+ } else {
+ fail('incorrect plotly_sunburstclick triggering - ' + msg);
+ }
+
+ _assertCommon(msg);
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should be able to override default click behavior using plotly_sunburstclick handler ()', function(done) {
+ var mock = Lib.extendDeep({}, require('@mocks/sunburst_first.json'));
+
+ Plotly.plot(gd, mock)
+ .then(setupListeners({turnOffAnimation: true}))
+ .then(click(gd, 2))
+ .then(function() {
+ if(trackers.sunburstclick.length === 1) {
+ expect(trackers.sunburstclick[0].event).toBeDefined();
+ expect(trackers.sunburstclick[0].points[0].label).toBe('Seth');
+ } else {
+ fail('incorrect plotly_sunburstclick triggering');
+ }
+
+ if(trackers.click.length === 1) {
+ expect(trackers.click[0].event).toBeDefined();
+ expect(trackers.click[0].points[0].label).toBe('Seth');
+ } else {
+ fail('incorrect plotly_click triggering');
+ }
+
+ if(trackers.animating.length !== 0) {
+ fail('incorrect plotly_animating triggering');
+ }
+ })
+ .catch(failTest)
+ .then(done);
+ });
+});
+
+describe('Test sunburst restyle:', function() {
+ var gd;
+
+ beforeEach(function() { gd = createGraphDiv(); });
+
+ afterEach(destroyGraphDiv);
+
+ function _restyle(updateObj) {
+ return function() { return Plotly.restyle(gd, updateObj); };
+ }
+
+ it('should be able to toggle visibility', function(done) {
+ var mock = Lib.extendDeep({}, require('@mocks/sunburst_first.json'));
+
+ function _assert(msg, exp) {
+ return function() {
+ var layer = d3.select(gd).select('.sunburstlayer');
+ expect(layer.selectAll('.trace').size()).toBe(exp, msg);
+ };
+ }
+
+ Plotly.plot(gd, mock)
+ .then(_assert('base', 2))
+ .then(_restyle({'visible': false}))
+ .then(_assert('both visible:false', 0))
+ .then(_restyle({'visible': true}))
+ .then(_assert('back to visible:true', 2))
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should be able to restyle *maxdepth* and *level* w/o recomputing the hierarchy', function(done) {
+ var mock = Lib.extendDeep({}, require('@mocks/sunburst_coffee.json'));
+
+ function _assert(msg, exp) {
+ return function() {
+ var layer = d3.select(gd).select('.sunburstlayer');
+
+ expect(layer.selectAll('.slice').size()).toBe(exp, msg);
+
+ // editType:plot
+ if(msg !== 'base') {
+ expect(Plots.doCalcdata).toHaveBeenCalledTimes(0);
+ }
+ };
+ }
+
+ Plotly.plot(gd, mock)
+ .then(_assert('base', 96))
+ .then(function() {
+ spyOn(Plots, 'doCalcdata').and.callThrough();
+ })
+ .then(_restyle({maxdepth: 3}))
+ .then(_assert('with maxdepth:3', 26))
+ .then(_restyle({level: 'Aromas'}))
+ .then(_assert('with non-root level', 13))
+ .then(_restyle({maxdepth: null, level: null}))
+ .then(_assert('back to first view', 96))
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should be able to restyle *leaf.opacity*', function(done) {
+ var mock = {
+ data: [{
+ type: 'sunburst',
+ labels: ['Root', 'A', 'B', 'b'],
+ parents: ['', 'Root', 'Root', 'B']
+ }]
+ };
+
+ function _assert(msg, exp) {
+ return function() {
+ var layer = d3.select(gd).select('.sunburstlayer');
+
+ var opacities = [];
+ layer.selectAll('path.surface').each(function() {
+ opacities.push(this.style.opacity);
+ });
+
+ expect(opacities).toEqual(exp, msg);
+
+ // editType:style
+ if(msg !== 'base') {
+ expect(Plots.doCalcdata).toHaveBeenCalledTimes(0);
+ expect(gd._fullData[0]._module.plot).toHaveBeenCalledTimes(0);
+ }
+ };
+ }
+
+ Plotly.plot(gd, mock)
+ .then(_assert('base', ['', '0.7', '', '0.7']))
+ .then(function() {
+ spyOn(Plots, 'doCalcdata').and.callThrough();
+ spyOn(gd._fullData[0]._module, 'plot').and.callThrough();
+ })
+ .then(_restyle({'leaf.opacity': 0.3}))
+ .then(_assert('lower leaf.opacity', ['', '0.3', '', '0.3']))
+ .then(_restyle({'leaf.opacity': 1}))
+ .then(_assert('raise leaf.opacity', ['', '1', '', '1']))
+ .then(_restyle({'leaf.opacity': null}))
+ .then(_assert('back to dflt', ['', '0.7', '', '0.7']))
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should be able to restyle *textinfo*', function(done) {
+ var mock = {
+ data: [{
+ type: 'sunburst',
+ labels: ['Root', 'A', 'B', 'b'],
+ parents: ['', 'Root', 'Root', 'B'],
+ text: ['node0', 'node1', 'node2', 'node3'],
+ values: [0, 1, 2, 3]
+ }]
+ };
+
+ function _assert(msg, exp) {
+ return function() {
+ var layer = d3.select(gd).select('.sunburstlayer');
+ var tx = [];
+
+ layer.selectAll('text.slicetext').each(function() {
+ var lines = d3.select(this).selectAll('tspan');
+
+ if(lines.size()) {
+ var t = [];
+ lines.each(function() {
+ t.push(this.innerHTML);
+ });
+ tx.push(t.join('\n'));
+ } else {
+ tx.push(this.innerHTML);
+ }
+ });
+
+ expect(tx).toEqual(exp, msg);
+
+ // editType:plot
+ if(msg !== 'base') {
+ expect(Plots.doCalcdata).toHaveBeenCalledTimes(0);
+ }
+ };
+ }
+
+ Plotly.plot(gd, mock)
+ .then(_assert('base', ['Root\nnode0', 'B\nnode2', 'A\nnode1', 'b\nnode3']))
+ .then(function() {
+ spyOn(Plots, 'doCalcdata').and.callThrough();
+ })
+ .then(_restyle({textinfo: 'label'}))
+ .then(_assert('just label', ['Root', 'B', 'A', 'b']))
+ .then(_restyle({textinfo: 'value'}))
+ .then(_assert('show input values', ['0', '2', '1', '3']))
+ .then(_restyle({textinfo: 'none'}))
+ .then(_assert('no textinfo', ['', '', '', '']))
+ .then(_restyle({textinfo: 'label+text+value'}))
+ .then(_assert('show everything', ['Root\n0\nnode0', 'B\n2\nnode2', 'A\n1\nnode1', 'b\n3\nnode3']))
+ .then(_restyle({textinfo: null}))
+ .then(_assert('back to dflt', ['Root\nnode0', 'B\nnode2', 'A\nnode1', 'b\nnode3']))
+ .catch(failTest)
+ .then(done);
+ });
+});
+
+describe('Test sunburst tweening:', function() {
+ var gd;
+ var pathTweenFnLookup;
+ var textTweenFnLookup;
+
+ beforeEach(function() {
+ gd = createGraphDiv();
+
+ // hacky way to track tween functions
+ spyOn(d3.transition.prototype, 'attrTween').and.callFake(function(attrName, fn) {
+ var lookup = {d: pathTweenFnLookup, transform: textTweenFnLookup}[attrName];
+ var pt = this[0][0].__data__;
+ var id = pt.data.data.id;
+
+ // we should never tween the same node twice on a given sector click
+ lookup[id] = lookup[id] ? null : fn(pt);
+ });
+ });
+
+ afterEach(destroyGraphDiv);
+
+ function _run(gd, v) {
+ pathTweenFnLookup = {};
+ textTweenFnLookup = {};
+
+ click(gd, v)();
+
+ // 1 second more than the click transition duration
+ return delay(constants.CLICK_TRANSITION_TIME + 1);
+ }
+
+ function trim(s) {
+ return s.replace(/\s/g, '');
+ }
+
+ function _assert(msg, attrName, id, exp) {
+ var lookup = {d: pathTweenFnLookup, transform: textTweenFnLookup}[attrName];
+ var fn = lookup[id];
+ // normalize time in [0, 1] where we'll assert the tweening fn output,
+ // asserting at the mid point *should be* representative enough
+ var t = 0.5;
+ var actual = trim(fn(t));
+
+ // do not assert textBB translate piece,
+ // which isn't tweened and has OS-dependent results
+ if(attrName === 'transform') {
+ actual = actual.split('translate').slice(0, 2).join('translate');
+ }
+
+ // we could maybe to bring in:
+ // https://github.com/hughsk/svg-path-parser
+ // to make these assertions more readable
+
+ expect(actual).toBe(trim(exp), msg + ' | node ' + id + ' @t=' + t);
+ }
+
+ it('should tween sector exit/update (case: branch, no maxdepth)', function(done) {
+ var mock = {
+ data: [{
+ type: 'sunburst',
+ labels: ['Root', 'A', 'B', 'b'],
+ parents: ['', 'Root', 'Root', 'B']
+ }]
+ };
+
+ Plotly.plot(gd, mock)
+ .then(_run(gd, 3))
+ .then(function() {
+ _assert('exit entry radially inward', 'd', 'Root',
+ 'M350,235 A0,00,1,0350,235 A0,00,1,0350,235Z' +
+ 'M372.5,235 A22.5,22.50,1,1327.5,235 A22.5,22.50,1,1372.5,235Z'
+ );
+ _assert('exit A clockwise', 'd', 'A',
+ 'M395,235 L440,235 A90,900,0,0350,145 L350,190 A45,450,0,1395,235Z'
+ );
+ _assert('update B to new position', 'd', 'B',
+ 'M350,212.5 L350,156.25 A78.75,78.750,1,0428.75,235.00000000000003' +
+ 'L372.5,235 A22.5,22.50,1,1350,212.5Z'
+ );
+ _assert('update b to new position', 'd', 'b',
+ 'M350,156.25 L350,100 A135,1350,1,0485,235.00000000000003 L428.75,235.00000000000003' +
+ 'A78.75,78.750,1,1350,156.25Z'
+ );
+ _assert('move B text to new position', 'transform', 'B',
+ 'translate(313.45694251914836,271.54305748085164)'
+ );
+ _assert('move b text to new position', 'transform', 'b',
+ 'translate(274.4279627606877,310.57203723931224)'
+ );
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should tween sector enter/update (case: entry, no maxdepth)', function(done) {
+ var mock = {
+ data: [{
+ type: 'sunburst',
+ labels: ['Root', 'A', 'B', 'b'],
+ parents: ['', 'Root', 'Root', 'B'],
+ level: 'B'
+ }]
+ };
+
+ Plotly.plot(gd, mock)
+ .then(_run(gd, 1))
+ .then(function() {
+ _assert('enter new entry radially outward', 'd', 'Root',
+ 'M350,235 A0,00,1,0350,235 A0,00,1,0350,235Z' +
+ 'M372.5,235 A22.5,22.50,1,1327.5,235 A22.5,22.50,1,1372.5,235Z'
+ );
+ _assert('enter A counterclockwise', 'd', 'A',
+ 'M395,235 L440,235 A90,900,0,0350,145 L350,190 A45,450,0,1395,235Z'
+ );
+ _assert('update B to new position', 'd', 'B',
+ 'M350,212.5 L350,156.25 A78.75,78.750,1,0428.75,235.00000000000003' +
+ 'L372.5,235 A22.5,22.50,1,1350,212.5Z'
+ );
+ _assert('update b to new position', 'd', 'b',
+ 'M350,156.25 L350,100 A135,1350,1,0485,235.00000000000003 L428.75,235.00000000000003' +
+ 'A78.75,78.750,1,1350,156.25Z'
+ );
+ _assert('move B text to new position', 'transform', 'B',
+ 'translate(316.8522926358638,268.1477073641362)'
+ );
+ _assert('move b text to new position', 'transform', 'b',
+ 'translate(274.4279627606877,310.57203723931224)'
+ );
+ })
+ .catch(failTest)
+ .then(done);
+ });
+
+ it('should tween sector enter/update/exit (case: entry, maxdepth=2)', function(done) {
+ var mock = {
+ data: [{
+ type: 'sunburst',
+ labels: ['Root', 'A', 'B', 'b'],
+ parents: ['', 'Root', 'Root', 'B'],
+ maxdepth: 2
+ }]
+ };
+
+ Plotly.plot(gd, mock)
+ .then(_run(gd, 3))
+ .then(function() {
+ _assert('exit entry radially inward', 'd', 'Root',
+ 'M350,235 A0,00,1,0350,235 A0,00,1,0350,235Z' +
+ 'M383.75,235 A33.75,33.750,1,1316.25,235A33.75,33.750,1,1383.75,235Z'
+ );
+ _assert('exit A clockwise', 'd', 'A',
+ 'M417.5,235 L485,235 A135,1350,0,0350,100 L350,167.5 A67.5,67.50,0,1417.5,235Z'
+ );
+ _assert('update B to new position', 'd', 'B',
+ 'M350,201.25 L350,133.75 A101.25,101.250,1,0451.25,235.00000000000003' +
+ 'L383.75,235 A33.75,33.750,1,1350,201.25Z'
+ );
+ _assert('enter b for parent position', 'd', 'b',
+ 'M350,133.75 L350,100 A135,1350,0,0350,370 L350,336.25 A101.25,101.250,0,1350,133.75Z'
+ );
+ _assert('move B text to new position', 'transform', 'B',
+ 'translate(303.0160689531907,281.9839310468093)'
+ );
+ _assert('enter b text to new position', 'transform', 'b',
+ 'translate(248.75,235)'
+ );
+ })
+ .catch(failTest)
+ .then(done);
+ });
+});