d3-flame-graphs
Version:
D3.js plugin for rendering flame graphs
368 lines (321 loc) • 12.1 kB
text/coffeescript
d3 = if then else require('d3')
throw new Error("d3.js needs to be loaded") if not d3
d3.flameGraphUtils =
# augments each node in the tree with the maximum distance
# it is from a terminal node, the list of parents linking
# it to the root and filler nodes that balance the representation
augment: (node, location) ->
children = node.children
# d3.partition adds the reverse (depth), here we store the distance
# between a node and its furthest leaf
return node if node.augmented
node.originalValue = node.value
node.level = if node.children then 1 else 0
node.hidden = []
node.location = location
if not children?.length
node.augmented = true
return node
childSum = children.reduce ((sum, child) -> sum + child.value), 0
if childSum < node.value
children.push({ value: node.value - childSum, filler: true })
children.forEach((child, idx) ->
d3.flameGraphUtils.augment(child, location + "." + idx))
node.level += children.reduce ((max, child) -> Math.max(child.level, max)), 0
node.augmented = true
node
partition: (data) ->
d3.layout.partition()
.sort (a,b) ->
return 1 if a.filler # move fillers to the right
return -1 if b.filler # move fillers to the right
a.name.localeCompare(b.name)
.nodes(data)
hide: (nodes, unhide = false) ->
sum = (arr) -> arr.reduce ((acc, val) -> acc + val), 0
remove = (arr, val) ->
# we need to remove precisely one occurrence of initial value
pos = arr.indexOf(val)
arr.splice(pos, 1) if pos >= 0
process = (node, val) ->
if unhide
remove(node.hidden, val)
else
node.hidden.push(val)
node.value = Math.max(node.originalValue - sum(node.hidden), 0)
processChildren = (node, val) ->
return if not node.children
node.children.forEach (child) ->
process(child, val)
processChildren(child, val)
processParents = (node, val) ->
while node.parent
process(node.parent, val)
node = node.parent
nodes.forEach (node) ->
val = node.originalValue
processParents(node, val)
process(node, val)
processChildren(node, val)
d3.flameGraph = (selector, root, debug = false) ->
getClassAndMethodName = (fqdn) ->
return "" if not fqdn
tokens = fqdn.split(".")
tokens.slice(tokens.length - 2).join(".")
# Return a vector (0.0 -> 1.0) that is a hash of the input string.
# The hash is computed to favor early characters over later ones, so
# that strings with similar starts have similar vectors. Only the first
# 6 characters are considered.
hash = (name) ->
[result, maxHash, weight, mod] = [0, 0, 1, 10]
name = getClassAndMethodName(name).slice(0, 6)
for i in [0..(name.length-1)]
result += weight * (name.charCodeAt(i) % mod)
maxHash += weight * (mod - 1)
weight *= 0.7
if maxHash > 0 then result / maxHash else result
class FlameGraph
constructor: (selector, root) ->
= selector
= []
# enable logging only if explicitly specified
if debug
= window.console
else
=
log: ->
time: ->
timeEnd: ->
# defaults
= [1200, 800]
= 20
= { top: 0, right: 0, bottom: 0, left: 0 }
= (d) ->
val = hash(d.name)
r = 200 + Math.round(55 * val)
g = 0 + Math.round(230 * (1 - val))
b = 0 + Math.round(55 * (1 - val))
"rgb(#{r}, #{g}, #{b})"
= true
= true
= d3.tip() if and d3.tip
# initial processing of data
.time('augment')
= d3.flameGraphUtils.augment(root, '0')
.timeEnd('augment')
size: (size) ->
return if not size
= size
d3.select().select('.flame-graph')
.attr('width', [0])
.attr('height', [1])
@
root: (root) ->
return if not root
.time('partition')
= root
= d3.flameGraphUtils.partition()
.timeEnd('partition')
@
hide: (predicate, unhide = false) ->
matches =
return if not matches.length
d3.flameGraphUtils.hide(matches, unhide)
# re-partition the data prior to rendering
= d3.flameGraphUtils.partition()
zoom: (node, event) ->
throw new Error("Zoom is disabled!") if not
.hide() if
if node in
= .slice(0, .indexOf(node))
else
.push()
.render()
?(node, event)
@
width: () -> [0] - (.left + .right)
height: () -> [1] - (.top + .bottom)
label: (d) ->
return "" if not d?.name
label = getClassAndMethodName(d.name)
label.substr(0, Math.round( / ( / 10 * 4)))
select: (predicate, onlyVisible = true) ->
if onlyVisible
return .selectAll('.node').filter(predicate)
else
# re-partition original and filter that
result = d3.flameGraphUtils.partition().filter(predicate)
return result
render: () ->
throw new Error("No DOM element provided") if not
.time('render')
if not
# reset size and scales
= ( / 10) * 0.4
= d3.scale.linear()
.domain([0, d3.max(, (d) -> d.x + d.dx)])
.range([0, ])
visibleCells = Math.floor( / )
maxLevels = .level
= d3.scale.quantize()
.domain([d3.max(, (d) -> d.y), 0])
.range(d3.range(maxLevels)
.map((cell) => ((cell + visibleCells) - (.length + maxLevels)) * ))
# JOIN
data = .filter((d) => > 0.4 and >= 0 and not d.filler)
renderNode =
x: (d) =>
y: (d) =>
width: (d) =>
height: (d) =>
text: (d) => if d.name and > 40
existingContainers =
.selectAll('.node')
.data(data, (d) -> d.location)
.attr('class', 'node')
# UPDATE
existingContainers, renderNode
# ENTER
newContainers = existingContainers.enter()
.append('g')
.attr('class', 'node')
newContainers, renderNode, true
# EXIT
existingContainers.exit().remove()
._enableNavigation() if
if
.timeEnd('render')
.log("Processed #{@_data.length} items")
.log("Rendered #{@container.selectAll('.node')[0]?.length} elements")
@
_createContainer: () ->
# remove any previously existing svg
d3.select().select('svg').remove()
# create main svg container
svg = d3.select()
.append('svg')
.attr('class', 'flame-graph')
.attr('width', [0])
.attr('height', [1])
# we set an offset based on the margin
offset = "translate(#{@margin().left}, #{@margin().top})"
# will hold all our nodes
= svg.append('g')
.attr('transform', offset)
# this rectangle draws the border around the flame graph
# has to be appended after the container so that the border is visible
# we also need to apply the same translation
svg.append('rect')
.attr('width', [0] - (.left + .right))
.attr('height', [1] - (.top + .bottom))
.attr('transform', offset)
.attr('class', 'border-rect')
_renderNodes: (containers, attrs, enter = false) ->
targetRects = containers.selectAll('rect') if not enter
targetRects = containers.append('rect') if enter
targetRects
.attr('fill', (d) => )
.transition()
.attr('width', attrs.width)
.attr('height', )
.attr('x', attrs.x)
.attr('y', attrs.y)
targetLabels = containers.selectAll('text') if not enter
targetLabels = containers.append('text') if enter
containers.selectAll('text')
.attr('class', 'label')
.style('font-size', "#{@fontSize}em")
.transition()
.attr('dy', "#{@fontSize / 2}em")
.attr('x', (d) => attrs.x(d) + 2)
.attr('y', (d, idx) => attrs.y(d, idx) + / 2)
.text(attrs.text)
@
_renderTooltip: () ->
return @ if not or not
=
.attr('class', 'd3-tip')
.html()
.direction (d) =>
return 'w' if + / 2 > - 100
return 'e' if + / 2 < 100
return 's' # otherwise
.offset (d) =>
x = + / 2
xOffset = Math.max(Math.ceil( / 2), 5)
yOffset = Math.ceil( / 2)
return [0, -xOffset] if - 100 < x
return [0, xOffset] if x < 100
return [ yOffset, 0]
.call()
.selectAll('.node')
.on 'mouseover', (d) => .show(d, d3.event.currentTarget)
.on 'mouseout', .hide
.selectAll('.label')
.on 'mouseover', (d) => .show(d, d3.event.currentTarget.parentNode)
.on 'mouseout', .hide
@
_renderAncestors: () ->
if not .length
ancestors = .selectAll('.ancestor').remove()
return @
# FIXME: this is pretty ugly, but we need to add links between ancestors
ancestorData = .map((ancestor, idx) ->
{ name: ancestor.name, value: idx + 1, location: ancestor.location })
for ancestor, idx in ancestorData
prev = ancestorData[idx - 1]
prev.children = [ancestor] if prev
renderAncestor =
x: (d) => 0
y: (d) => return - (d.value * )
width:
height:
text: (d) => "↩ #{getClassAndMethodName(d.name)}"
# JOIN
ancestors =
.selectAll('.ancestor')
.data(d3.layout.partition().nodes(ancestorData[0]), (d) -> d.location)
# UPDATE
ancestors, renderAncestor
# ENTER
newAncestors = ancestors
.enter()
.append('g')
.attr('class', 'ancestor')
newAncestors, renderAncestor, true
# EXIT
ancestors.exit().remove()
@
_enableNavigation: () ->
clickable = (d) => Math.round( - ) > 0 and d.children?.length
.selectAll('.node')
.classed('clickable', (d) => clickable(d))
.on 'click', (d) =>
.hide() if
if clickable(d)
.selectAll('.ancestor')
.on 'click', (d, idx) =>
.hide() if
@
_generateAccessors: (accessors) ->
for accessor in accessors
@[accessor] = do (accessor) ->
(newValue) ->
return @["_#{accessor}"] if not arguments.length
@["_#{accessor}"] = newValue
return @
return new FlameGraph(selector, root)