Skip to content

Instantly share code, notes, and snippets.

@ORBAT
Last active December 13, 2017 11:52
Show Gist options
  • Save ORBAT/94c28e6833c03c2a965bd0d8ec10ed2f to your computer and use it in GitHub Desktop.
Save ORBAT/94c28e6833c03c2a965bd0d8ec10ed2f to your computer and use it in GitHub Desktop.
Zoomable sunburst with explanation text

Zoomable sunburst implementation in D3. A combination of multiple different sunburst implementations, but I can't remember which ones.

Usage

Load a CSV file using the "Browse..." button (or whatever it's called in your browser). Click on a segment to zoom in on it, click in the middle to zoom out.

The "min to show" value determines the minimum number of items required to show a branch. If you want to change it, you must do it before loading a CSV file; it can't be modified after a file has been loaded.

To load a new file, you must reload the page.

<head>
<meta charset="utf-8">
<title>Zoomable Sunburst</title>
<style>
body {
font-family: Sans-serif;
font-size: 11px;
}
.slice {
cursor: pointer;
}
.slice .main-arc {
stroke: #fff;
stroke-width: 1px;
}
.slice .hidden-arc {
fill: none;
}
body {
font-family: 'Open Sans', sans-serif;
font-size: 12px;
font-weight: 400;
background-color: #fff;
width: 960px;
height: 700px;
margin-top: 10px;
}
#totalStats {
font-size: 2.0em;
}
#parentStats {
font-size: 1.8em;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js"></script>
</head>
<body>
<div id="sidebar">
min to show <input type="number" id="minToShow" value="5"/>
<input type="file" id="fileSelector" name="file"/>
</div>
<div id="main">
<div id="chart">
<div id="explanation">
<span id="totalStats"></span>
<br/>
<span id="parentStats"></span>
</div>
</div>
</div>
<script>
let totalSize = 0;
function handleFileSelect(evt) {
console.log(JSON.stringify(d3.event.target));
console.log(JSON.stringify(d3.event.target.files[0]))
var reader = new FileReader();
reader.onload = function (event) {
console.log("file read");
let text = event.target.result;
var csv = d3.csvParseRows(text);
console.log("csv parsed");
var json = buildHierarchy(csv);
totalSize = json.totalSize();
console.log("totalSize", totalSize);
createVisualization(json);
};
reader.readAsText(d3.event.target.files[0])
}
d3.select("#fileSelector").on("change", handleFileSelect);
const width = window.innerWidth,
height = window.innerHeight,
maxRadius = (Math.min(width, height) / 2) - 5;
const formatPercent = d3.format(".1%");
const formatNumber = d3.format(".1f");
const x = d3.scaleLinear()
.range([0, 2 * Math.PI])
.clamp(true);
const y = d3.scaleSqrt()
.range([maxRadius * .1, maxRadius]);
const partition = d3.partition();
const arc = d3.arc()
.startAngle(d => x(d.x0))
.endAngle(d => x(d.x1))
.innerRadius(d => Math.max(0, y(d.y0)))
.outerRadius(d => Math.max(0, y(d.y1)));
const middleArcLine = d => {
const halfPi = Math.PI / 2;
const angles = [x(d.x0) - halfPi, x(d.x1) - halfPi];
const r = Math.max(0, (y(d.y0) + y(d.y1)) / 2);
const middleAngle = (angles[1] + angles[0]) / 2;
const invertDirection = middleAngle > 0 && middleAngle < Math.PI; // On lower quadrants write text ccw
if (invertDirection) {
angles.reverse();
}
const path = d3.path();
path.arc(0, 0, r, angles[0], angles[1], invertDirection);
return path.toString();
};
const textFits = d => {
const CHAR_SPACE = 6;
const deltaAngle = x(d.x1) - x(d.x0);
const r = Math.max(0, (y(d.y0) + y(d.y1)) / 2);
const perimeter = r * deltaAngle;
return d.data.name.length * CHAR_SPACE < perimeter;
};
const svg = d3.select('#chart').append('svg')
.style('width', '90vw')
.style('height', '90vh')
.attr('viewBox', `${-width / 2} ${-height / 2} ${width} ${height}`)
.attr("id", "container")
.on('click', () => focusOn()); // Reset zoom on canvas click
function createVisualization(root) {
var minToShow = d3.select("#minToShow").node().value;
console.log("createVisualization minToShow", minToShow);
root.filterSmall(Number(minToShow) || 5);
root.sort();
root = d3.hierarchy(root);
root.sum(d => d.size);
const slice = svg.selectAll('g.slice')
.data(partition(root).descendants());
slice.exit().remove();
const newSlice = slice.enter()
.append('g').attr('class', 'slice')
.on('click', d => {
d3.event.stopPropagation();
focusOn(d);
})
.on('mouseover', mouseover)
;
newSlice.append('path')
.attr('class', 'main-arc')
.style('fill', d => stringToColor(d.data.name))
.attr('d', arc);
newSlice.append('path')
.attr('class', 'hidden-arc')
.attr('id', (_, i) => `hiddenArc${i}`)
.attr('d', middleArcLine);
}
function formatTotal(d) {
return `${d.data.name} ${formatPercent(d.value / totalSize)} (${formatNumber(d.value)}/${formatNumber(totalSize)})`
}
function formatParent(d) {
if(!d.parent) {
return "All events";
}
return `Parent: ${d.parent.data.name} ${formatPercent(d.value / d.parent.value)} (${formatNumber(d.value)}/${formatNumber(d.parent.value)})`
}
function mouseover(d) {
if (!d) return;
d3.select("#totalStats")
.text(formatTotal(d));
d3.select("#parentStats")
.text(formatParent(d));
}
function focusOn(d = {x0: 0, x1: 1, y0: 0, y1: 1}) {
// Reset to top-level if no data point specified
const transition = svg.transition()
.duration(250)
.tween('scale', () => {
const xd = d3.interpolate(x.domain(), [d.x0, d.x1]),
yd = d3.interpolate(y.domain(), [d.y0, 1]);
return t => {
x.domain(xd(t));
y.domain(yd(t));
};
});
transition.selectAll('path.main-arc')
.attrTween('d', d => () => arc(d));
transition.selectAll('path.hidden-arc')
.attrTween('d', d => () => middleArcLine(d));
moveStackToFront(d);
function moveStackToFront(elD) {
svg.selectAll('.slice').filter(d => d === elD)
.each(function (d) {
this.parentNode.appendChild(this);
if (d.parent) {
moveStackToFront(d.parent);
}
})
}
}
function SBNode(name, size) {
this.name = name;
this.children = [];
this._chNameMap = new Map();
this.size = size;
}
SBNode.prototype.addPath = function (seq, size) {
let head = _.head(seq);
// base case, head is leaf
if (seq.length === 1) {
this.addChild(head, size);
return this;
}
// head is a branch
let branch = this.getChild();
if (!branch) {
branch = this.addChild(head, 0)
}
branch.addPath(_.tail(seq), size);
return this;
};
SBNode.prototype.getChild = function (name) {
return this._chNameMap.get(name);
};
SBNode.prototype.addChild = function (name, size) {
let child = this._chNameMap.get(name);
if (!child) {
child = new SBNode(name, size);
this.children.push(child);
this._chNameMap.set(name, child);
} else {
child.size += size;
}
return child;
};
SBNode.prototype.totalSize = function () {
return this.size + this.sumChildren();
};
SBNode.prototype.sort = function () {
this.children.sort((a, b) => a.name.localeCompare(b.name));
this.children.forEach(c => c.sort());
};
SBNode.prototype.totalChildren = function () {
return this.children.length + this.children.reduce((acc, c) => acc + c.totalChildren(), 0);
};
SBNode.prototype.sumChildren = function () {
return this.children.reduce((acc, c) => acc + c.totalSize(), 0);
};
SBNode.prototype.lostAtThisPoint = function () {
return this.size - this.sumChildren();
};
SBNode.prototype.filterSmall = function (n) {
let childrenElided = 0;
this.children
.filter(ch => ch.totalSize() < n)
.forEach(ch => {
this.addChild("...", ch.totalSize());
childrenElided += ch.totalChildren();
this.children = this.children.filter(child => child.name !== ch.name);
this._chNameMap.delete(ch.name);
});
if (childrenElided > 0) {
this.getChild("...").name += ` (${childrenElided})`
}
this.children.forEach(ch => ch.filterSmall(n));
};
// Take a CSV and transform it into a hierarchical structure suitable
// for a partition layout. The first n columns are a sequence of step names, from
// root to leaf. The n+1-th column is a count of how often that sequence occurred.
function buildHierarchy(csv) {
console.log("buildHierarchy");
// console.log(csv);
var root = new SBNode("root", 0);
for (let rowidx = 0; rowidx < csv.length; rowidx++) {
let row = csv[rowidx];
let sequence = row.slice(0, row.length - 1).filter(it => it != null && it != "").map(ev => ev.replace("event_", ""));
const size = +row[row.length - 1];
if (isNaN(size)) { // e.g. if this is a header row
continue;
}
root.addPath(sequence, size);
}
console.log("buildHierarchy done");
return root;
}
function stringToColor(str) {
if (str.includes("...")) {
return '#FF0000'
}
var hash = 0;
for (var i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
var colour = '#';
for (var i = 0; i < 3; i++) {
var value = (hash >> (i * 8)) & 0xFF;
colour += ('00' + value.toString(16)).substr(-2);
}
return colour;
}
</script>
</body>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment