sunburst
Version:
For a given tree builds an SVG based SunBurst diagram
270 lines (219 loc) • 8.47 kB
JavaScript
/**
* Copyright 2018 Andrei Kashcha (http://github.com/anvaka)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this
* software and associated documentation files (the "Software"), to deal in the Software
* without restriction, including without limitation the rights to use, copy, modify,
* merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies
* or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
* PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
* FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
* ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
module.exports = getSunBurstPath;
/**
* For a given tree, builds SVG path that renders SunBurst
* diagram
*
* @param {Object} tree - a regular javascript object with single
* property: tree.children - array of tree-children.
*
* @param {Object} options - see below.
*/
function getSunBurstPath(tree, options) {
// TODO: Validate options
options = options || {};
// Radius of the inner circle.
var initialRadius = getNumber(options.initialRadius, 100);
// width of a single level
var levelStep = getNumber(options.levelStep, 10);
// Array of colors. Applied only on the top level.
var colors = options.colors;
if (!colors) colors = ['#f2ad52', '#e99e9b', '#ed684c', '#c03657', '#642b1c', '#132a4e'];
// Initial rotation of the circle in radians.
var startAngle = getNumber(options.startAngle, 0);
var wrap = options.wrap;
var stroke = options.stroke;
var strokeWidth = options.strokeWidth;
var beforeArcClose = options.beforeArcClose;
var beforeLabelClose = options.beforeLabelClose;
// Below is implementation.
countLeaves(tree);
var svgElements = [];
var defs = [];
svgElements.push(circle(initialRadius));
if (tree.label) {
svgElements.push('<text text-anchor="middle" class="center-text" y="8">' + tree.label + '</text>');
}
var path = '0';
tree.path = path; // TODO: Don't really need to do this?
drawChildren(startAngle, Math.PI * 2 + startAngle, tree);
var sunBurstPaths = svgElements.join('\n');
if (wrap) {
return wrapIntoSVG(sunBurstPaths);
}
return sunBurstPaths;
function wrapIntoSVG(paths) {
var depth = getDepth(tree, 0);
var min = depth * levelStep + initialRadius;
var markup = '<svg viewBox="' + [-min, -min, min * 2, min * 2].join(' ') + '" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">';
if (defs.length) {
markup += '<defs>' + defs.join('\n') + '</defs>';
}
markup += '<g id="scene">' + paths + '</g>' + '</svg>';
return markup;
}
function drawChildren(startAngle, endAngle, tree) {
if (!tree.children) return;
var level = tree.path.split(':').length - 1; // TODO: need a better structure to store path?
var arcLength = Math.abs(startAngle - endAngle);
var totalWeight = 0;
// In first pass, we get a sense of distribution of arc lengths at this level
tree.children.forEach(function(child) {
if (child.startAngle === undefined && child.endAngle === undefined) {
totalWeight += child.weight;
}
});
tree.children.forEach(function (child, i) {
var endAngle, thisStartAngle;
if (child.startAngle === undefined && child.endAngle === undefined) {
thisStartAngle = startAngle;
endAngle = startAngle + arcLength * child.weight / totalWeight;
startAngle = endAngle;
} else {
thisStartAngle = child.startAngle;
endAngle = child.endAngle;
// TODO: What should I do with elements that are based on leaves?
}
child.path = tree.path + ':' + i;
if (thisStartAngle !== endAngle) {
var arcPath = pieSlice(initialRadius + level * levelStep, levelStep, thisStartAngle, endAngle);
svgElements.push(arc(arcPath, child, i));
if (child.label) {
drawLabel(child, thisStartAngle, endAngle, level);
}
}
drawChildren(thisStartAngle, endAngle, child);
});
}
function drawLabel(child, thisStartAngle, endAngle, level) {
var key = child.path.replace(/:/g, '_');
var textPath = 0 < thisStartAngle && thisStartAngle < Math.PI ?
arcSegment(
initialRadius + (level + 0.5)* levelStep,
endAngle, thisStartAngle,
0
) :
arcSegment(
initialRadius + (level + 0.5)* levelStep,
thisStartAngle, endAngle,
1
);
var pathMarkup = '<path d="' + textPath.d + '" id="' + key + '"></path>';
defs.push(pathMarkup)
var customAttributes = beforeLabelClose && beforeLabelClose(child);
var textAttributes = (customAttributes && convertToAttributes(customAttributes.text)) || '';
var textPathAttributes = (customAttributes && convertToAttributes(customAttributes.textPath)) || '';
var labelSVGContent = '<text class="label" ' + textAttributes + '>';
labelSVGContent += '<textPath startOffset="50%" text-anchor="middle" xlink:href="#' +
key + '" ' + textPathAttributes + '>' + child.label + '</textPath></text>'
svgElements.push(labelSVGContent);
}
function getColor(element) {
if (element.color) return element.color;
var path = element.path.split(':'); // yeah, that's bad. Need a better structure. Array maybe?
return colors[path[1] % colors.length];
}
function arc(pathData, child, i) {
var color = getColor(child, i);
var pathMarkup = '<path d="' + pathData + '" fill="' + color + '" data-path="' + child.path + '" ';
if (stroke) {
pathMarkup += ' stroke="' + stroke +'" ';
}
if (strokeWidth) {
pathMarkup += ' stroke-width="' + strokeWidth + '" ';
}
if (beforeArcClose) {
pathMarkup += convertToAttributes(beforeArcClose(child));
}
pathMarkup += '></path>'
return pathMarkup;
}
}
function convertToAttributes(obj) {
if (!obj) return '';
var bagOfAttributes = [];
Object.keys(obj).forEach(function(key) {
bagOfAttributes.push(key + '="' + obj[key] + '"');
});
return bagOfAttributes.join(' ');
}
function polarToCartesian(centerX, centerY, radius, angle) {
return {
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle)
};
}
function arcSegment(radius, startAngle, endAngle, forward) {
var cx = 0;
var cy = 0;
forward = forward ? 1 : 0;
var start = polarToCartesian(cx, cy, radius, startAngle);
var end = polarToCartesian(cx, cy, radius, endAngle);
var da = Math.abs(startAngle - endAngle);
var flip = da > Math.PI ? 1 : 0;
var d = ["M", start.x, start.y, "A", radius, radius, 0, flip, forward, end.x, end.y].join(" ");
return {
d: d,
start: start,
end: end
};
}
function pieSlice(r, width, startAngle, endAngle) {
var inner = arcSegment(r, startAngle, endAngle, 1);
var out = arcSegment(r + width, endAngle, startAngle, 0);
return inner.d + 'L' + out.start.x + ' ' + out.start.y + out.d + 'L' + inner.start.x + ' ' + inner.start.y;
}
function circle(r) {
// TODO: Don't hard-code fill?
return '<circle r=' + r + ' cx=0 cy=0 fill="#fafafa" data-path="0"></circle>';
}
function countLeaves(treeNode) {
if (treeNode.weight) return treeNode.weight;
var weight = treeNode.weight || 0;
if (treeNode.weight) {
treeNode.children.forEach(function (child) {
weight += countLeaves(child);
});
} else if (treeNode.children) {
treeNode.children.forEach(function (child) {
weight += countLeaves(child);
});
} else {
weight = 1;
}
treeNode.weight = weight;
return weight;
}
function getDepth(tree) {
var maxDepth = 0;
visit(tree, 0);
return maxDepth;
function visit(tree, depth) {
if (tree.children) {
tree.children.forEach(function(child) {
visit(child, depth + 1);
});
}
if (depth > maxDepth) maxDepth = depth;
}
}
function getNumber(x, defaultNumber) {
return Number.isFinite(x) ? x : defaultNumber;
}