|
<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> |