'use strict'; var d3 = require('@plotly/d3'); var d3Hierarchy = require('d3-hierarchy'); var interpolate = require('d3-interpolate').interpolate; var Drawing = require('../../components/drawing'); var Lib = require('../../lib'); var svgTextUtils = require('../../lib/svg_text_utils'); var uniformText = require('../bar/uniform_text'); var recordMinTextSize = uniformText.recordMinTextSize; var clearMinTextSize = uniformText.clearMinTextSize; var piePlot = require('../pie/plot'); var getRotationAngle = require('../pie/helpers').getRotationAngle; var computeTransform = piePlot.computeTransform; var transformInsideText = piePlot.transformInsideText; var styleOne = require('./style').styleOne; var resizeText = require('../bar/style').resizeText; var attachFxHandlers = require('./fx'); var constants = require('./constants'); var helpers = require('./helpers'); exports.plot = 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 = !fullLayout.uniformtext.mode && helpers.hasTransition(transitionOpts); clearMinTextSize('sunburst', fullLayout); 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(fullLayout.uniformtext.mode) { resizeText(gd, fullLayout._sunburstlayer.selectAll('.trace'), 'sunburst'); } } if(isFullReplot) { join.exit().remove(); } }; function plotOne(gd, cd, element, transitionOpts) { var isStatic = gd._context.staticPlot; var fullLayout = gd._fullLayout; var hasTransition = !fullLayout.uniformtext.mode && helpers.hasTransition(transitionOpts); 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 = helpers.findEntryWithLevel(hierarchy, trace.level); var maxDepth = helpers.getMaxDepth(trace); 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[helpers.getPtId(pt)] = { rpx0: pt.rpx0, rpx1: pt.rpx1, x0: pt.x0, x1: pt.x1, transform: pt.transform }; if(!prevEntry && helpers.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 && helpers.isHierarchyRoot(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; }); var baseX = getRotationAngle(trace.rotation); if(baseX) { sliceData.forEach(function(pt) { pt.x0 += baseX; pt.x1 += baseX; }); } // 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 getTargetX = function(d) { return cx + getTextXY(d)[0] * (d.transform.rCenter || 0) + (d.transform.x || 0); }; var getTargetY = function(d) { return cy + getTextXY(d)[1] * (d.transform.rCenter || 0) + (d.transform.y || 0); }; slices = slices.data(sliceData, helpers.getPtId); 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 = helpers.getPtId(prevEntry); slices.each(function(pt) { if(nextX1ofPrevEntry === null && (helpers.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 here var sliceTop = d3.select(this); helpers.setSliceCursor(sliceTop, gd, { hideOnRoot: true, hideOnLeaves: true, isTransitioning: false }); }); } updateSlices.each(function(pt) { var sliceTop = d3.select(this); var slicePath = Lib.ensureSingle(sliceTop, 'path', 'surface', function(s) { s.style('pointer-events', isStatic ? 'none' : '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.startangle = -(pt.x0 - Math.PI / 2); pt.stopangle = -(pt.x1 - 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 = makeUpdateSliceInterpolator(pt2); return function(t) { return pathSlice(interp(t)); }; }); } else { slicePath.attr('d', pathSlice); } sliceTop .call(attachFxHandlers, entry, gd, cd, { eventDataKeys: constants.eventDataKeys, transitionTime: constants.CLICK_TRANSITION_TIME, transitionEasing: constants.CLICK_TRANSITION_EASING }) .call(helpers.setSliceCursor, gd, { hideOnRoot: true, hideOnLeaves: true, isTransitioning: gd._transitioning }); slicePath.call(styleOne, pt, trace, gd); 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); }); var font = Lib.ensureUniformFontSize(gd, helpers.determineTextFont(trace, pt, fullLayout.font)); sliceText.text(exports.formatSliceLabel(pt, entry, trace, cd, fullLayout)) .classed('slicetext', true) .attr('text-anchor', 'middle') .call(Drawing.font, 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.transform.targetX = getTargetX(pt); pt.transform.targetY = getTargetY(pt); var strTransform = function(d, textBB) { var transform = d.transform; computeTransform(transform, textBB); transform.fontSize = font.size; recordMinTextSize(trace.type, transform, fullLayout); return Lib.getTextTransform(transform); }; if(hasTransition) { sliceText.transition().attrTween('transform', function(pt2) { var interp = makeUpdateTextInterpolator(pt2); return function(t) { return strTransform(interp(t), textBB); }; }); } else { sliceText.attr('transform', strTransform(pt, textBB)); } }); function makeExitSliceInterpolator(pt) { var id = helpers.getPtId(pt); var prev = prevLookup[id]; var entryPrev = prevLookup[helpers.getPtId(entry)]; var next; if(entryPrev) { var a = (pt.x1 > entryPrev.x1 ? 2 * Math.PI : 0) + baseX; // 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 ? {x0: pt.x0, x1: pt.x1, rpx0: 0, rpx1: 0} : {x0: a, x1: a, rpx0: pt.rpx0, rpx1: pt.rpx1}; } 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 = helpers.getPtId(pt.parent); slices.each(function(pt2) { if(helpers.getPtId(pt2) === parentId) { return parent = pt2; } }); var parentChildren = parent.children; var ci; parentChildren.forEach(function(pt2, i) { if(helpers.getPtId(pt2) === id) { return ci = i; } }); var n = parentChildren.length; var interp = interpolate(parent.x0, parent.x1); next = { rpx0: rMax, rpx1: rMax, x0: interp(ci / n), x1: interp((ci + 1) / n) }; } return interpolate(prev, next); } function makeUpdateSliceInterpolator(pt) { var prev0 = prevLookup[helpers.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) + baseX; 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: baseX, x1: baseX}; } } return interpolate(prev, next); } function makeUpdateTextInterpolator(pt) { var prev0 = prevLookup[helpers.getPtId(pt)]; var prev; var transform = pt.transform; if(prev0) { prev = prev0; } else { prev = { rpx1: pt.rpx1, transform: { textPosAngle: transform.textPosAngle, 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 = baseX; } } else { // on new traces prev.x0 = prev.x1 = baseX; } } var textPosAngleFn = interpolate(prev.transform.textPosAngle, pt.transform.textPosAngle); var rpx1Fn = interpolate(prev.rpx1, pt.rpx1); var x0Fn = interpolate(prev.x0, pt.x0); var x1Fn = interpolate(prev.x1, pt.x1); var scaleFn = interpolate(prev.transform.scale, transform.scale); var rotateFn = 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 = 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 pxmid = rx2px(rpx1, (x0 + x1) / 2); var textPosAngle = textPosAngleFn(t); var d = { pxmid: pxmid, rpx1: rpx1, transform: { textPosAngle: textPosAngle, rCenter: rCenter, x: transform.x, y: transform.y } }; recordMinTextSize(trace.type, transform, fullLayout); return { transform: { targetX: getTargetX(d), targetY: getTargetY(d), scale: scaleFn(t), rotate: rotateFn(t), rCenter: rCenter } }; }; } function interpX0X1FromParent(pt) { var parent = pt.parent; var parentPrev = prevLookup[helpers.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 = 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); } exports.formatSliceLabel = function(pt, entry, trace, cd, fullLayout) { var texttemplate = trace.texttemplate; var textinfo = trace.textinfo; if(!texttemplate && (!textinfo || textinfo === 'none')) { return ''; } var separators = fullLayout.separators; var cd0 = cd[0]; var cdi = pt.data.data; var hierarchy = cd0.hierarchy; var isRoot = helpers.isHierarchyRoot(pt); var parent = helpers.getParent(hierarchy, pt); var val = helpers.getValue(pt); if(!texttemplate) { var parts = textinfo.split('+'); var hasFlag = function(flag) { return parts.indexOf(flag) !== -1; }; var thisText = []; var tx; if(hasFlag('label') && cdi.label) { thisText.push(cdi.label); } if(cdi.hasOwnProperty('v') && hasFlag('value')) { thisText.push(helpers.formatValue(cdi.v, separators)); } if(!isRoot) { if(hasFlag('current path')) { thisText.push(helpers.getPath(pt.data)); } var nPercent = 0; if(hasFlag('percent parent')) nPercent++; if(hasFlag('percent entry')) nPercent++; if(hasFlag('percent root')) nPercent++; var hasMultiplePercents = nPercent > 1; if(nPercent) { var percent; var addPercent = function(key) { tx = helpers.formatPercent(percent, separators); if(hasMultiplePercents) tx += ' of ' + key; thisText.push(tx); }; if(hasFlag('percent parent') && !isRoot) { percent = val / helpers.getValue(parent); addPercent('parent'); } if(hasFlag('percent entry')) { percent = val / helpers.getValue(entry); addPercent('entry'); } if(hasFlag('percent root')) { percent = val / helpers.getValue(hierarchy); addPercent('root'); } } } if(hasFlag('text')) { tx = Lib.castOption(trace, cdi.i, 'text'); if(Lib.isValidTextValue(tx)) thisText.push(tx); } return thisText.join('
'); } var txt = Lib.castOption(trace, cdi.i, 'texttemplate'); if(!txt) return ''; var obj = {}; if(cdi.label) obj.label = cdi.label; if(cdi.hasOwnProperty('v')) { obj.value = cdi.v; obj.valueLabel = helpers.formatValue(cdi.v, separators); } obj.currentPath = helpers.getPath(pt.data); if(!isRoot) { obj.percentParent = val / helpers.getValue(parent); obj.percentParentLabel = helpers.formatPercent( obj.percentParent, separators ); obj.parent = helpers.getPtLabel(parent); } obj.percentEntry = val / helpers.getValue(entry); obj.percentEntryLabel = helpers.formatPercent( obj.percentEntry, separators ); obj.entry = helpers.getPtLabel(entry); obj.percentRoot = val / helpers.getValue(hierarchy); obj.percentRootLabel = helpers.formatPercent( obj.percentRoot, separators ); obj.root = helpers.getPtLabel(hierarchy); if(cdi.hasOwnProperty('color')) { obj.color = cdi.color; } var ptTx = Lib.castOption(trace, cdi.i, 'text'); if(Lib.isValidTextValue(ptTx) || ptTx === '') obj.text = ptTx; obj.customdata = Lib.castOption(trace, cdi.i, 'customdata'); return Lib.texttemplateString(txt, obj, fullLayout._d3locale, obj, trace._meta || {}); }; function getInscribedRadiusFraction(pt) { if(pt.rpx0 === 0 && Lib.isFullCircle([pt.x0, pt.x1])) { // 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 )); } } function getTextXY(d) { return getCoords(d.rpx1, d.transform.textPosAngle); } function getCoords(r, angle) { return [r * Math.sin(angle), -r * Math.cos(angle)]; }