d3-flame-graph
Version:
A d3.js library to produce flame graphs.
865 lines (751 loc) • 24.9 kB
JavaScript
import { select } from 'd3-selection'
import { format } from 'd3-format'
import { ascending } from 'd3-array'
import { partition, hierarchy } from 'd3-hierarchy'
import { scaleLinear } from 'd3-scale'
import { easeCubic } from 'd3-ease'
import 'd3-transition'
import { generateColorVector } from './colorUtils'
import { calculateColor } from './colorScheme'
export default function () {
let w = 960 // graph width
let h = null // graph height
let c = 18 // cell height
let selection = null // selection
let tooltip = null // tooltip
let title = '' // graph title
let transitionDuration = 750
let transitionEase = easeCubic // tooltip offset
let sort = false
let inverted = false // invert the graph direction
let clickHandler = null
let hoverHandler = null
let minFrameSize = 0
let detailsElement = null
let searchDetails = null
let selfValue = false
let resetHeightOnZoom = false
let scrollOnZoom = false
let minHeight = null
let computeDelta = false
let colorHue = null
let getName = function (d) {
return d.data.n || d.data.name
}
let getValue = function (d) {
if ('v' in d) {
return d.v
} else {
return d.value
}
}
let getChildren = function (d) {
return d.c || d.children
}
let getLibtype = function (d) {
return d.data.l || d.data.libtype
}
let getDelta = function (d) {
if ('d' in d.data) {
return d.data.d
} else {
return d.data.delta
}
}
let searchHandler = function (searchResults, searchSum, totalValue) {
searchDetails = () => {
if (detailsElement) {
detailsElement.textContent = 'search: ' + searchSum + ' of ' + totalValue + ' total samples ( ' + format('.3f')(100 * (searchSum / totalValue), 3) + '%)'
}
}
searchDetails()
}
const originalSearchHandler = searchHandler
let searchMatch = (d, term, ignoreCase = false) => {
if (!term) {
return false
}
let label = getName(d)
if (ignoreCase) {
term = term.toLowerCase()
label = label.toLowerCase()
}
const re = new RegExp(term)
return typeof label !== 'undefined' && label && label.match(re)
}
const originalSearchMatch = searchMatch
let detailsHandler = function (d) {
if (detailsElement) {
if (d) {
detailsElement.textContent = d
} else {
if (typeof searchDetails === 'function') {
searchDetails()
} else {
detailsElement.textContent = ''
}
}
}
}
const originalDetailsHandler = detailsHandler
let labelHandler = function (d) {
return getName(d) + ' (' + format('.3f')(100 * (d.x1 - d.x0), 3) + '%, ' + getValue(d) + ' samples)'
}
let colorMapper = function (d) {
return d.highlight ? '#E600E6' : colorHash(getName(d), getLibtype(d))
}
const originalColorMapper = colorMapper
function colorHash (name, libtype) {
// Return a color for the given name and library type. The library type
// selects the hue, and the name is hashed to a color in that hue.
// default when libtype is not in use
let hue = colorHue || 'warm'
if (!colorHue && !(typeof libtype === 'undefined' || libtype === '')) {
// Select hue. Order is important.
hue = 'red'
if (typeof name !== 'undefined' && name && name.match(/::/)) {
hue = 'yellow'
}
if (libtype === 'kernel') {
hue = 'orange'
} else if (libtype === 'jit') {
hue = 'green'
} else if (libtype === 'inlined') {
hue = 'aqua'
}
}
const vector = generateColorVector(name)
return calculateColor(hue, vector)
}
function show (d) {
d.data.fade = false
d.data.hide = false
if (d.children) {
d.children.forEach(show)
}
}
function hideSiblings (node) {
let child = node
let parent = child.parent
let children, i, sibling
while (parent) {
children = parent.children
i = children.length
while (i--) {
sibling = children[i]
if (sibling !== child) {
sibling.data.hide = true
}
}
child = parent
parent = child.parent
}
}
function fadeAncestors (d) {
if (d.parent) {
d.parent.data.fade = true
fadeAncestors(d.parent)
}
}
function zoom (d) {
if (tooltip) tooltip.hide()
hideSiblings(d)
show(d)
fadeAncestors(d)
update()
if (scrollOnZoom) {
const chartOffset = select(this).select('svg')._groups[0][0].parentNode.offsetTop
const maxFrames = (window.innerHeight - chartOffset) / c
const frameOffset = (d.height - maxFrames + 10) * c
window.scrollTo({
top: chartOffset + frameOffset,
left: 0,
behavior: 'smooth'
})
}
if (typeof clickHandler === 'function') {
clickHandler(d)
}
}
function searchTree (d, term) {
const results = []
let sum = 0
function searchInner (d, foundParent) {
let found = false
if (searchMatch(d, term)) {
d.highlight = true
found = true
if (!foundParent) {
sum += getValue(d)
}
results.push(d)
} else {
d.highlight = false
}
if (getChildren(d)) {
getChildren(d).forEach(function (child) {
searchInner(child, (foundParent || found))
})
}
}
searchInner(d, false)
return [results, sum]
}
function findTree (d, id) {
if (d.id === id) {
return d
} else {
const children = getChildren(d)
if (children) {
for (let i = 0; i < children.length; i++) {
const found = findTree(children[i], id)
if (found) {
return found
}
}
}
}
}
function clear (d) {
d.highlight = false
if (getChildren(d)) {
getChildren(d).forEach(function (child) {
clear(child)
})
}
}
function doSort (a, b) {
if (typeof sort === 'function') {
return sort(a, b)
} else if (sort) {
return ascending(getName(a), getName(b))
}
}
const p = partition()
function filterNodes (root) {
let nodeList = root.descendants()
if (minFrameSize > 0) {
const kx = w / (root.x1 - root.x0)
nodeList = nodeList.filter(function (el) {
return ((el.x1 - el.x0) * kx) > minFrameSize
})
}
return nodeList
}
function update () {
selection.each(function (root) {
const x = scaleLinear().range([0, w])
const y = scaleLinear().range([0, c])
reappraiseNode(root)
if (sort) root.sort(doSort)
p(root)
const kx = w / (root.x1 - root.x0)
function width (d) { return (d.x1 - d.x0) * kx }
const descendants = filterNodes(root)
const svg = select(this).select('svg')
svg.attr('width', w)
let g = svg.selectAll('g').data(descendants, function (d) { return d.id })
// if height is not set: set height on first update, after nodes were filtered by minFrameSize
if (!h || resetHeightOnZoom) {
const maxDepth = Math.max.apply(null, descendants.map(function (n) { return n.depth }))
h = (maxDepth + 3) * c
if (h < minHeight) h = minHeight
svg.attr('height', h)
}
g.transition()
.duration(transitionDuration)
.ease(transitionEase)
.attr('transform', function (d) { return 'translate(' + x(d.x0) + ',' + (inverted ? y(d.depth) : (h - y(d.depth) - c)) + ')' })
g.select('rect')
.transition()
.duration(transitionDuration)
.ease(transitionEase)
.attr('width', width)
const node = g.enter()
.append('svg:g')
.attr('transform', function (d) { return 'translate(' + x(d.x0) + ',' + (inverted ? y(d.depth) : (h - y(d.depth) - c)) + ')' })
node.append('svg:rect')
.transition()
.delay(transitionDuration / 2)
.attr('width', width)
if (!tooltip) { node.append('svg:title') }
node.append('foreignObject')
.append('xhtml:div')
// Now we have to re-select to see the new elements (why?).
g = svg.selectAll('g').data(descendants, function (d) { return d.id })
g.attr('width', width)
.attr('height', function (d) { return c })
.attr('name', function (d) { return getName(d) })
.attr('class', function (d) { return d.data.fade ? 'frame fade' : 'frame' })
g.select('rect')
.attr('height', function (d) { return c })
.attr('fill', function (d) { return colorMapper(d) })
if (!tooltip) {
g.select('title')
.text(labelHandler)
}
g.select('foreignObject')
.attr('width', width)
.attr('height', function (d) { return c })
.select('div')
.attr('class', 'd3-flame-graph-label')
.style('display', function (d) { return (width(d) < 35) ? 'none' : 'block' })
.transition()
.delay(transitionDuration)
.text(getName)
g.on('click', (_, d) => { zoom(d) })
g.exit()
.remove()
g.on('mouseover', function (_, d) {
if (tooltip) tooltip.show(d, this)
detailsHandler(labelHandler(d))
if (typeof hoverHandler === 'function') {
hoverHandler(d)
}
}).on('mouseout', function () {
if (tooltip) tooltip.hide()
detailsHandler(null)
})
})
}
function merge (data, samples) {
samples.forEach(function (sample) {
const node = data.find(function (element) {
return (element.name === sample.name)
})
if (node) {
node.value += sample.value
if (sample.children) {
if (!node.children) {
node.children = []
}
merge(node.children, sample.children)
}
} else {
data.push(sample)
}
})
}
function forEachNode (node, f) {
f(node)
let children = node.children
if (children) {
const stack = [children]
let count, child, grandChildren
while (stack.length) {
children = stack.pop()
count = children.length
while (count--) {
child = children[count]
f(child)
grandChildren = child.children
if (grandChildren) {
stack.push(grandChildren)
}
}
}
}
}
function adoptNode (node) {
let id = 0
forEachNode(node, function (n) {
n.id = id++
})
}
function reappraiseNode (root) {
let node, children, grandChildren, childrenValue, i, j, child, childValue
const stack = []
const included = []
const excluded = []
const compoundValue = !selfValue
let item = root.data
if (item.hide) {
root.value = 0
children = root.children
if (children) {
excluded.push(children)
}
} else {
root.value = item.fade ? 0 : getValue(item)
stack.push(root)
}
// First DFS pass:
// 1. Update node.value with node's self value
// 2. Populate excluded list with children under hidden nodes
// 3. Populate included list with children under visible nodes
while ((node = stack.pop())) {
children = node.children
if (children && (i = children.length)) {
childrenValue = 0
while (i--) {
child = children[i]
item = child.data
if (item.hide) {
child.value = 0
grandChildren = child.children
if (grandChildren) {
excluded.push(grandChildren)
}
continue
}
if (item.fade) {
child.value = 0
} else {
childValue = getValue(item)
child.value = childValue
childrenValue += childValue
}
stack.push(child)
}
// Here second part of `&&` is actually checking for `node.data.fade`. However,
// checking for node.value is faster and presents more oportunities for JS optimizer.
if (compoundValue && node.value) {
node.value -= childrenValue
}
included.push(children)
}
}
// Postorder traversal to compute compound value of each visible node.
i = included.length
while (i--) {
children = included[i]
childrenValue = 0
j = children.length
while (j--) {
childrenValue += children[j].value
}
children[0].parent.value += childrenValue
}
// Continue DFS to set value of all hidden nodes to 0.
while (excluded.length) {
children = excluded.pop()
j = children.length
while (j--) {
child = children[j]
child.value = 0
grandChildren = child.children
if (grandChildren) {
excluded.push(grandChildren)
}
}
}
}
function processData () {
selection.datum((data) => {
if (data.constructor.name !== 'Node') {
// creating a root hierarchical structure
const root = hierarchy(data, getChildren)
// augumenting nodes with ids
adoptNode(root)
// calculate actual value
reappraiseNode(root)
// store value for later use
root.originalValue = root.value
// computing deltas for differentials
if (computeDelta) {
root.eachAfter((node) => {
let sum = getDelta(node)
const children = node.children
let i = children && children.length
while (--i >= 0) sum += children[i].delta
node.delta = sum
})
}
// setting the bound data for the selection
return root
}
})
}
function chart (s) {
if (!arguments.length) { return chart }
// saving the selection on `.call`
selection = s
// processing raw data to be used in the chart
processData()
// create chart svg
selection.each(function (data) {
if (select(this).select('svg').size() === 0) {
const svg = select(this)
.append('svg:svg')
.attr('width', w)
.attr('class', 'partition d3-flame-graph')
if (h) {
if (h < minHeight) h = minHeight
svg.attr('height', h)
}
svg.append('svg:text')
.attr('class', 'title')
.attr('text-anchor', 'middle')
.attr('y', '25')
.attr('x', w / 2)
.attr('fill', '#808080')
.text(title)
if (tooltip) svg.call(tooltip)
}
})
// first draw
update()
}
chart.height = function (_) {
if (!arguments.length) { return h }
h = _
return chart
}
chart.minHeight = function (_) {
if (!arguments.length) { return minHeight }
minHeight = _
return chart
}
chart.width = function (_) {
if (!arguments.length) { return w }
w = _
return chart
}
chart.cellHeight = function (_) {
if (!arguments.length) { return c }
c = _
return chart
}
chart.tooltip = function (_) {
if (!arguments.length) { return tooltip }
if (typeof _ === 'function') {
tooltip = _
}
return chart
}
chart.title = function (_) {
if (!arguments.length) { return title }
title = _
return chart
}
chart.transitionDuration = function (_) {
if (!arguments.length) { return transitionDuration }
transitionDuration = _
return chart
}
chart.transitionEase = function (_) {
if (!arguments.length) { return transitionEase }
transitionEase = _
return chart
}
chart.sort = function (_) {
if (!arguments.length) { return sort }
sort = _
return chart
}
chart.inverted = function (_) {
if (!arguments.length) { return inverted }
inverted = _
return chart
}
chart.computeDelta = function (_) {
if (!arguments.length) { return computeDelta }
computeDelta = _
return chart
}
chart.setLabelHandler = function (_) {
if (!arguments.length) { return labelHandler }
labelHandler = _
return chart
}
// Kept for backwards compatibility.
chart.label = chart.setLabelHandler
chart.search = function (term) {
const searchResults = []
let searchSum = 0
let totalValue = 0
selection.each(function (data) {
const res = searchTree(data, term)
searchResults.push(...res[0])
searchSum += res[1]
totalValue += data.originalValue
})
searchHandler(searchResults, searchSum, totalValue)
update()
}
chart.findById = function (id) {
if (typeof (id) === 'undefined' || id === null) {
return null
}
let found = null
selection.each(function (data) {
if (found === null) {
found = findTree(data, id)
}
})
return found
}
chart.clear = function () {
detailsHandler(null)
selection.each(function (root) {
clear(root)
update()
})
}
chart.zoomTo = function (d) {
zoom(d)
}
chart.resetZoom = function () {
selection.each(function (root) {
zoom(root) // zoom to root
})
}
chart.onClick = function (_) {
if (!arguments.length) {
return clickHandler
}
clickHandler = _
return chart
}
chart.onHover = function (_) {
if (!arguments.length) {
return hoverHandler
}
hoverHandler = _
return chart
}
chart.merge = function (data) {
if (!selection) { return chart }
// TODO: Fix merge with zoom
// Merging a zoomed chart doesn't work properly, so
// clearing zoom before merge.
// To apply zoom on merge, we would need to set hide
// and fade on new data according to current data.
// New ids are generated for the whole data structure,
// so previous ids might not be the same. For merge to
// work with zoom, previous ids should be maintained.
this.resetZoom()
// Clear search details
// Merge requires a new search, updating data and
// the details handler with search results.
// Since we don't store the search term, can't
// perform search again.
searchDetails = null
detailsHandler(null)
selection.datum((root) => {
merge([root.data], [data])
return root.data
})
processData()
update()
return chart
}
chart.update = function (data) {
if (!selection) { return chart }
if (data) {
selection.datum(data)
processData()
}
update()
return chart
}
chart.destroy = function () {
if (!selection) { return chart }
if (tooltip) {
tooltip.hide()
if (typeof tooltip.destroy === 'function') {
tooltip.destroy()
}
}
selection.selectAll('svg').remove()
return chart
}
chart.setColorMapper = function (_) {
if (!arguments.length) {
colorMapper = originalColorMapper
return chart
}
colorMapper = (d) => {
const originalColor = originalColorMapper(d)
return _(d, originalColor)
}
return chart
}
// Kept for backwards compatibility.
chart.color = chart.setColorMapper
chart.setColorHue = function (_) {
if (!arguments.length) {
colorHue = null
return chart
}
colorHue = _
return chart
}
chart.minFrameSize = function (_) {
if (!arguments.length) { return minFrameSize }
minFrameSize = _
return chart
}
chart.setDetailsElement = function (_) {
if (!arguments.length) { return detailsElement }
detailsElement = _
return chart
}
// Kept for backwards compatibility.
chart.details = chart.setDetailsElement
chart.selfValue = function (_) {
if (!arguments.length) { return selfValue }
selfValue = _
return chart
}
chart.resetHeightOnZoom = function (_) {
if (!arguments.length) { return resetHeightOnZoom }
resetHeightOnZoom = _
return chart
}
chart.scrollOnZoom = function (_) {
if (!arguments.length) { return scrollOnZoom }
scrollOnZoom = _
return chart
}
chart.getName = function (_) {
if (!arguments.length) { return getName }
getName = _
return chart
}
chart.getValue = function (_) {
if (!arguments.length) { return getValue }
getValue = _
return chart
}
chart.getChildren = function (_) {
if (!arguments.length) { return getChildren }
getChildren = _
return chart
}
chart.getLibtype = function (_) {
if (!arguments.length) { return getLibtype }
getLibtype = _
return chart
}
chart.getDelta = function (_) {
if (!arguments.length) { return getDelta }
getDelta = _
return chart
}
chart.setSearchHandler = function (_) {
if (!arguments.length) {
searchHandler = originalSearchHandler
return chart
}
searchHandler = _
return chart
}
chart.setDetailsHandler = function (_) {
if (!arguments.length) {
detailsHandler = originalDetailsHandler
return chart
}
detailsHandler = _
return chart
}
chart.setSearchMatch = function (_) {
if (!arguments.length) {
searchMatch = originalSearchMatch
return chart
}
searchMatch = _
return chart
}
return chart
}