plotly.js
Version:
The open source javascript graphing library that powers plotly
636 lines (545 loc) • 22.1 kB
JavaScript
'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('<br>');
}
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)];
}