sunburst
Version:
For a given tree builds an SVG based SunBurst diagram
408 lines (335 loc) • 12.1 kB
JavaScript
(function(){function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s}return e})()({1:[function(require,module,exports){
/**
* This script builds a sunburst chart for any page in your browser.
*/
var makeChart = require('../../index');
// How we should render each tag
var colors = {
a: '#f2ad52',
li: '#e99e9b',
ul: '#e99e9b',
ol: '#e99e9b',
div: '#ed684c',
img: '#c03657',
span: '#642b1c',
tr: '#132a4e',
table: '#132a4e',
tbody: '#132a4e',
td: '#132a4e',
p: '#1164e6'
};
// All other tags:
var defaultColor = '#a8a8a8';
var tree = makeTree(document.body);
var svg = makeChart(tree, {
wrap: true,
beforeArcClose: appendDomAttributes
});
render(svg);
printStats(tree);
function render(svg) {
document.body.innerHTML = svg;
document.body.style.background = 'white';
var svgEl = document.body.querySelector('svg');
svgEl.style.width = '100%';
svgEl.style.height = '100%';
svgEl.style.position = 'absolute';
svgEl.style.top = '0';
svgEl.style.left = '0';
}
function printStats(tree) {
var maxDepth = getMaxDepth(tree);
var commonTags = getCommonTags(tree);
var i = document.createElement('iframe');
i.style.display = 'none';
document.body.appendChild(i);
i.contentWindow.console.log('%cSunburst statistics: ', 'font-size: 42px');
i.contentWindow.console.log('Max tree depth: ', maxDepth);
i.contentWindow.console.log('Total tags: ', tree.leaves);
i.contentWindow.console.log('Most common tags:\n', commonTags);
i.contentWindow.console.log('%cLegend', 'font-size: 24px');
printLegend();
}
function printLegend() {
Object.keys(colors).forEach(function(tagName) {
console.log('%c %c - ' + tagName, 'background-color: ' + colors[tagName], 'background-color: white;');
})
}
function getCommonTags(tree) {
var counter = new Map();
visit(tree);
var flatCounts = Array.from(counter).sort(function(pairA, pairB) {
// Sort in decreasing count order
return pairB[1] - pairA[1];
})
.slice(0, 10) // take only top N
.map(function(x) {
// return in human readable format:
return '\t' + x[0] + ' - ' + x[1]
})
.join('\n');
return flatCounts;
function visit(tree) {
if (tree.tagName) {
counter.set(tree.tagName,(counter.get(tree.tagName) || 0) + 1);
}
if (tree.children) {
tree.children.forEach(visit);
}
}
}
function getMaxDepth(tree) {
var maxDepth = 0;
visit(tree, 0);
return maxDepth;
function visit(tree, currentDepth) {
if (currentDepth > maxDepth) maxDepth = currentDepth;
if (tree.children) {
tree.children.forEach(function(child) {
visit(child, currentDepth + 1);
});
}
}
}
function appendDomAttributes(treeElement) {
return {
tag: treeElement.tagName
};
}
function makeTree(root) {
var tree = visit(root);
tree.label = window.location.host;
return tree;
function visit(node) {
var children = [];
for (var i = 0; i < node.children.length; ++i) {
children.push(visit(node.children[i]));
}
return {
color: getColor(node),
tagName: node.tagName,
children: children.length && children
}
}
function getColor(node) {
var tagName = node.tagName.toLowerCase();
return colors[tagName] || defaultColor;
}
}
},{"../../index":2}],2:[function(require,module,exports){
/**
* 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 totalLeaves = 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) {
totalLeaves += child.leaves;
}
});
tree.children.forEach(function (child, i) {
var endAngle, thisStartAngle;
if (child.startAngle === undefined && child.endAngle === undefined) {
thisStartAngle = startAngle;
endAngle = startAngle + arcLength * child.leaves / totalLeaves;
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.leaves) return treeNode.leaves;
var leaves = 0;
if (treeNode.children) {
treeNode.children.forEach(function (child) {
leaves += countLeaves(child);
});
} else {
leaves = 1;
}
treeNode.leaves = leaves;
return leaves;
}
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;
}
},{}]},{},[1]);