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); + }); +});