├── .gitignore ├── .npmignore ├── Gulpfile.js ├── LICENSE ├── README.md ├── bower.json ├── circle.yml ├── demo ├── data │ └── profile.json ├── index.html ├── resources │ ├── favicon.png │ └── octocat.png └── src │ └── demo.coffee ├── flame-graph-screenshot.png ├── package.json ├── scripts ├── deploy-demo.sh └── release-demo.sh ├── src ├── d3-flame-graph.coffee └── d3-flame-graph.scss └── test ├── augment.coffee ├── data └── profile-test.json └── hide.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | demo/data/*.json 2 | build/ 3 | dist/ 4 | node_modules/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | scripts/ 2 | build/ 3 | demo/ 4 | circle.yml 5 | bower.json 6 | flame-graph-screenshot.png -------------------------------------------------------------------------------- /Gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var coffee = require('gulp-coffee'); 3 | var concat = require('gulp-concat'); 4 | var uglify = require('gulp-uglify'); 5 | var sass = require('gulp-sass'); 6 | var sourcemaps = require('gulp-sourcemaps'); 7 | var browserSync = require('browser-sync'); 8 | var reload = browserSync.reload; 9 | var del = require('del'); 10 | 11 | var paths = { 12 | scripts: ['src/**/*.coffee'], 13 | styles: ['src/**/*.scss'], 14 | demoResources: ['demo/**/*.*', 'dist/**/*.*', '!demo/src/**'], 15 | demoScripts: ['demo/src/**/*.coffee'], 16 | dist: 'dist', 17 | demoOut: 'build' 18 | }; 19 | 20 | gulp.task('clean', function(cb) { 21 | del([paths.dist, paths.demoOut], cb); 22 | }); 23 | 24 | // Create the distributable artifacts of the plugin. 25 | gulp.task('dist:main', function () { 26 | return gulp.src(paths.scripts) 27 | .pipe(coffee()) 28 | .pipe(concat('d3-flame-graph.js')) 29 | .pipe(gulp.dest(paths.dist)) 30 | }); 31 | gulp.task('dist:min', function () { 32 | return gulp.src(paths.scripts) 33 | .pipe(coffee()) 34 | .pipe(uglify()) 35 | .pipe(concat('d3-flame-graph.min.js')) 36 | .pipe(gulp.dest(paths.dist)); 37 | }); 38 | gulp.task('dist:styles', function () { 39 | return gulp.src(paths.styles) 40 | .pipe(sass().on('error', sass.logError)) 41 | .pipe(gulp.dest(paths.dist)) 42 | }); 43 | 44 | gulp.task('dist', ['dist:min', 'dist:main', 'dist:styles']); 45 | 46 | // building the demo page 47 | gulp.task('demo-scripts', ['dist'], function() { 48 | // Minify and copy all JavaScript (except vendor scripts) 49 | // with sourcemaps all the way down 50 | return gulp.src(paths.demoScripts) 51 | .pipe(sourcemaps.init()) 52 | .pipe(coffee()) 53 | .pipe(concat('demo.js')) 54 | .pipe(sourcemaps.write()) 55 | .pipe(gulp.dest(paths.demoOut)); 56 | }); 57 | 58 | gulp.task('demo-copy', ['demo-scripts'], function() { 59 | return gulp.src(paths.demoResources) 60 | .pipe(gulp.dest(paths.demoOut)); 61 | }); 62 | 63 | // Rerun the task when a file changes 64 | gulp.task('demo-watch', ['demo-copy'], function() { 65 | gulp.watch(paths.scripts, ['demo-copy']); 66 | gulp.watch(paths.styles, ['demo-copy']); 67 | gulp.watch(paths.demoResources, ['demo-copy']); 68 | gulp.watch(paths.demoScripts, ['demo-copy']); 69 | }); 70 | 71 | gulp.task('serve', ['demo-watch'], function() { 72 | browserSync({ server: { baseDir: paths.demoOut } }); 73 | gulp.watch(['*.html', '*.css', '*.js'], { cwd: paths.demoOut }, reload); 74 | }); 75 | 76 | // The default task (called when you run `gulp` from cli) 77 | gulp.task('default', ['serve']); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Alex Ciminian 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## What is this? 2 | 3 | This is a d3.js plugin that renders flame graphs from hierarchical data. 4 | 5 | > Flame graphs are a visualization of profiled software, allowing the most frequent code-paths to be identified quickly and accurately. They can be generated using my open source programs on [github.com/brendangregg/FlameGraph](http://github.com/brendangregg/FlameGraph), which create interactive SVGs. See the Updates section for other implementations. 6 | > 7 | > -- [Flame Graphs](http://www.brendangregg.com/flamegraphs.html), Brendan Gregg 8 | 9 | ## [See the demo!](http://cimi.github.io/d3-flame-graphs/) 10 | 11 | [![Flame Graph Representation](flame-graph-screenshot.png?raw=true "See the demo!")](http://cimi.github.io/d3-flame-graphs/) 12 | 13 | ## Build status 14 | [![Circle CI](https://circleci.com/gh/cimi/d3-flame-graphs/tree/master.svg?style=svg)](https://circleci.com/gh/cimi/d3-flame-graphs/tree/master) [![npm version](https://badge.fury.io/js/d3-flame-graphs.svg)](https://badge.fury.io/js/d3-flame-graphs) [![bower version](https://badge.fury.io/bo/d3-flame-graphs.svg)](https://badge.fury.io/bo/d3-flame-graphs) 15 | 16 | ## Features 17 | 18 | * __Efficient rendering of large profiles__ - large profiles may use up a lot of CPU and memory to render if all samples get represented on the DOM. This plugin only draws samples that would be visible to the user. The performance improvement in the case of very large profiles is in the range of 10x-20x. 19 | * __Zooming__ - on click, the container re-renders the subgraph associated with the clicked node. The previous roots are rendered at the bottom of the graph and are clickable - you can revert to a previous state. An optional callback can be provided if you want to trigger other changes when zooming. 20 | * __Tooltips__ - when hovering over nodes a tooltip can be displayed. The tooltip's contents are parametrizable. 21 | * __Filtering__ - nodes can be selected by name using regex. This enables name-based navigation, highlighting or adding other custom behaviour to certain nodes. See the demo for examples. 22 | * __Hiding across the stack__ - some call paterns only add noise to a graph (like `Object.wait` or `Unsafe.park`, for example). The plugin offers the capability to hide node selections across the stack (their value is subtracted from the parent nodes and their children are hidden). This leads to clearer views of the state of the world. 23 | 24 | ## How was this made? 25 | 26 | This plugin was built using gulp and coffeescript. CircleCI runs tests on every push and manages releasing the demo page and the library to npm and bower. The demo page is hosted on GitHub pages. 27 | 28 | ## API Reference 29 | 30 | The methods on the flame graph plugin follow the [d3.js conventions](http://bost.ocks.org/mike/chart/) on method chaining and accessors. 31 | 32 | # d3.flameGraph(_selector_, _data_) 33 | 34 | Constructs a new flame graph. 35 | 36 | The selector value is required, it defines the DOM element to which the SVG will be appended. Prior to rendering, any svg elements present in the given container will be cleared. 37 | 38 | The data value is also required, it should be the root of the profile under analysis. There is no need to partition the data prior to feeding in to the plugin as partitioning is done internally. Any operation on the data (zooming, selecting, hiding) will use this value as start of traversal. 39 | 40 | # flameGraph.__size__([_[width, height]_]) 41 | 42 | If _[width, height]_ are specified, sets the svg rendering width and height to the pixel values provided in the array. If _[width, height]_ is not specified, returns the current width. Defaults to _[1200, 600]_. 43 | 44 | # flameGraph.__margin__([_{top: , right: , bottom:, left: }]_]) 45 | 46 | If the values are specified, follows the [d3 conventions on margins](http://bl.ocks.org/mbostock/3019563) when rendering the chart. If the values are not specified, returns the current margins object. Defaults to _{ top: 0, right: 0, bottom: 0, left: 0}_. 47 | 48 | # flameGraph.__cellHeight__([_cellHeight_]) 49 | 50 | If _cellHeight_ is specified, sets the height of the rectangles in the flame graph to the provided value. If _cellHeight_ is not specified, returns the current value. Defaults to 20. The graph height should be divisible by the cell height so the nodes align properly. 51 | 52 | # flameGraph.__color__([_[color(d)]_]) 53 | 54 | If the _color_ function is specified, it will be used when determining the color for a particular node. The function should expect one parameter, the data element associated with the node. If _color_ is not specified, returns the current function. 55 | 56 | The default function uses a hash of the node's short name to generate the color. The letters are weighted (first letters matter more), the hash only uses the first six characters of the name. 57 | 58 | # flameGraph.__data__([_data_]) 59 | 60 | The data the flame graph is rendered from. It expects nested data in the form: 61 | 62 | ``` 63 | { 64 | "name": "", 65 | "value": , 66 | "children": [, , ...] 67 | } 68 | ``` 69 | 70 | The data is augmented with 'filler nodes' by the plugin, due to the fact that D3 considers the value of a node to be the sum of its children rather than its explicit value. More details in [this issue](https://github.com/mbostock/d3/pull/574). This should be transparent to clients as the filler node augmentation is done internally. Because of the filler node augmentation, __the children property needs to be defined, even if the array is empty.__ 71 | 72 | # flameGraph.__zoomEnabled__(_enabled_) 73 | 74 | If _enabled_ is truthy, zooming will be enabled - clicking a node or calling the zoom method programatically will re-render the graph with that node as root. The default value is _true_. 75 | 76 | # flameGraph.__zoomAction__(_function_) 77 | 78 | If a _function_ is provided, on every zoom - clicking a node or calling the zoom method programatically - the function will be called after the graph is re-rendered. The function receives a data node as its parameter and its return value is ignored. 79 | 80 | # flameGraph.__zoom__(_node_) 81 | 82 | If zoom is enabled, re-renders the graph with the given node as root. The previous roots are drawn at the bottom of the graph, by clicking on it them you can revert back to previous states. Prior to zooming, any svg elements present in the given container will be cleared. 83 | 84 | If zoom is disabled, this method will throw an error. 85 | 86 | [See the demo code](https://github.com/cimi/d3-flame-graphs/blob/master/demo/src/demo.coffee#L69) for an example. 87 | 88 | # flameGraph.__tooltip__(_function_) 89 | 90 | If a _function_ is provided, a tooltip will be shown on mouseover for each cell. Ancestor nodes do not get a tooltip. The function receives a data node as its parameter and needs to return an HTML string that will be rendered inside the tooltip. The d3-tip plugin is responsible for rendering the tooltip. If set to false or not called, the tooltip is disabled and nothing is rendered on mouseover. 91 | 92 | # flameGraph.__select__(_predicate_, [_isDisplayed_]) 93 | 94 | Selects the elements from the current dataset which match the given _predicate_ function. If _isDisplayed_ is set to false, it will search all the nodes starting from the root passed to the flame graph constructor and return an array of data nodes. _isDisplayed_ defaults to true, in that case it will only search the currently displayed elements and returns a d3 selection of DOM elements. 95 | 96 | [The demo code contains a usage example](https://github.com/cimi/d3-flame-graphs/blob/master/demo/src/demo.coffee). 97 | 98 | # flameGraph.__hide__(_predicate_, [_unhide_]) 99 | 100 | Hides elements that match the given _predicate_ function from the current dataset. The targeted elements and their children are hidden. The value of the target is subtracted from its ancestors so that it's effect is removed across the stack. 101 | 102 | If _unhide_ is set to true, it will perform the reverse operation and re-add the previously subtracted values. _unhide_ defaults to false. 103 | 104 | As this operation needs to traverse the subtree for all matched items, it can potentially be slow on generic queries over large datasets. 105 | 106 | [The demo code contains a usage example](https://github.com/cimi/d3-flame-graphs/blob/master/demo/src/demo.coffee). 107 | 108 | # flameGraph.__render__() 109 | 110 | Triggers a repaint of the flame graph, using the values previously fed in as parameters. This is the only method besides zoom and hide that triggers repaint so you need to call it after changing other parameters like size or cell-height in order to see the changes take effect. 111 | 112 | ### Sample usage: 113 | 114 | The example below is taken from the demo source. Although it is written in CoffeeScript, the plugin can be used from vanilla JS without any issues. 115 | 116 | ``` 117 | d3.json "data/profile.json", (err, data) -> 118 | profile = convert(data.profile) 119 | tooltip = (d) -> "#{d.name}

120 | #{d.value} samples
121 | #{((d.value / profile.value) * 100).toFixed(2)}% of total" 122 | flameGraph = d3.flameGraph('#d3-flame-graph', profile) 123 | .size([1200, 600]) 124 | .cellHeight(20) 125 | .zoomEnabled(true) 126 | .zoomAction((d) -> console.log(d)) 127 | .tooltip(tooltip) 128 | .render() 129 | 130 | d3.select('#highlight') 131 | .on 'click', () -> 132 | nodes = flameGraph.select((d) -> /java\.util.*/.test(d.name)) 133 | nodes.classed("highlight", (d, i) -> not d3.select(@).classed("highlight")) 134 | 135 | d3.select('#zoom') 136 | .on 'click', () -> 137 | # jump to the first java.util.concurrent method we can find 138 | node = flameGraph.select(((d) -> /CountDownLatch\.await$/.test(d.name)), false)[0] 139 | flameGraph.zoom(node) 140 | 141 | unhide = false 142 | d3.select('#hide') 143 | .on 'click', () -> 144 | flameGraph.hide ((d) -> /Unsafe\.park$/.test(d.name) or /Object\.wait$/.test(d.name)), unhide 145 | unhide = !unhide 146 | ``` 147 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-flame-graphs", 3 | "version": "3.1.1", 4 | "homepage": "https://github.com/cimi/d3-flame-graphs", 5 | "authors": [ 6 | "Alex Ciminian " 7 | ], 8 | "description": "D3.js plugin for rendering flame graphs", 9 | "main": "dist/d3-flame-graphs.js", 10 | "keywords": [ 11 | "d3", 12 | "flame", 13 | "graph", 14 | "visualization", 15 | "d3", 16 | "plugin" 17 | ], 18 | "license": "MIT", 19 | "ignore": [ 20 | "**/.*", 21 | "node_modules", 22 | "bower_components", 23 | "test", 24 | "tests" 25 | ], 26 | "dependencies": { 27 | "d3": "~3.5.5", 28 | "d3-tip": "~0.6.7" 29 | }, 30 | "resolutions": { 31 | "d3": "3.5.5" 32 | } 33 | } -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | general: 2 | branches: 3 | ignore: 4 | - gh-pages 5 | deployment: 6 | demo: 7 | tag: /demo/ 8 | commands: 9 | - ./scripts/deploy-demo.sh 10 | release: 11 | tag: /v\d+\.\d+\.\d+.*/ 12 | commands: 13 | - echo -e "$NPM_USERNAME\n$NPM_PASSWORD\n$EMAIL" | npm login 14 | - gulp dist 15 | - npm publish -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | D3 Flame Graph 6 | 7 | 8 | 9 | 10 | 21 | 22 | 23 |
24 | 63 | 64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /demo/resources/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cimi/d3-flame-graphs/45d5555d25c7253d6562bb4aed427ae8179334c3/demo/resources/favicon.png -------------------------------------------------------------------------------- /demo/resources/octocat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cimi/d3-flame-graphs/45d5555d25c7253d6562bb4aed427ae8179334c3/demo/resources/octocat.png -------------------------------------------------------------------------------- /demo/src/demo.coffee: -------------------------------------------------------------------------------- 1 | runnableVals = [] 2 | convert = (rawData, valueFunc) -> 3 | 4 | node = 5 | name: rawData.n, 6 | value: valueFunc(rawData), 7 | children: [] 8 | 9 | # the a field is the list of children 10 | return node if not rawData.a 11 | for child in rawData.a 12 | subTree = convert(child, valueFunc) 13 | if subTree 14 | node.children.push(subTree) 15 | node 16 | 17 | d3.json "data/profile.json", (err, data) -> 18 | allStates = (node) -> 19 | value = 0 20 | for state in ['RUNNABLE', 'BLOCKED', 'TIMED_WAITING', 'WAITING'] 21 | value += node.c[state] if not isNaN(node.c[state]) 22 | value 23 | 24 | 25 | profile = convert(data.profile, allStates) 26 | tooltip = (d) -> "#{d.name}

27 | #{d.value} samples
28 | #{((d.value / profile.value) * 100).toFixed(2)}% of total" 29 | flameGraph = d3.flameGraph('#d3-flame-graph', profile, true) 30 | .size([1200, 600]) 31 | .cellHeight(20) 32 | .zoomEnabled(true) 33 | .zoomAction((node, event) -> console.log(node, event)) 34 | .tooltip(tooltip) 35 | .render() 36 | 37 | d3.select('#highlight') 38 | .on 'click', () -> 39 | nodes = flameGraph.select((d) -> /java\.util.*/.test(d.name)) 40 | nodes.classed("highlight", (d, i) -> not d3.select(@).classed("highlight")) 41 | 42 | d3.select('#zoom') 43 | .on 'click', () -> 44 | # jump to the first java.util.concurrent method we can find 45 | node = flameGraph.select(((d) -> /CountDownLatch\.await$/.test(d.name)), false)[0] 46 | flameGraph.zoom(node) 47 | 48 | unhide = false 49 | d3.select('#hide') 50 | .on 'click', () -> 51 | flameGraph.hide ((d) -> /Unsafe\.park$/.test(d.name) or /Object\.wait$/.test(d.name)), unhide 52 | unhide = !unhide 53 | 54 | d3.select('#runnable') 55 | .on 'click', () -> 56 | profile = convert(data.profile, ((node) -> if node.c['RUNNABLE'] then node.c['RUNNABLE'] else 0)) 57 | flameGraph = d3.flameGraph('#d3-flame-graph', profile) 58 | .size([1200, 600]) 59 | .cellHeight(20) 60 | .zoomEnabled(true) 61 | .zoomAction((node, event) -> console.log(node, event)) 62 | .tooltip(tooltip) 63 | .render() 64 | 65 | d3.select('#rasta') 66 | .on 'click', () -> 67 | rastaMode = (d) -> 68 | cells = 600 / 20 69 | return '#1E9600' if 0 <= d.depth < cells / 3 70 | return '#FFF200' if cells / 3 <= d.depth < cells * 2 / 3 71 | return '#FF0000' if cells * 2 / 3 <= d.depth < cells 72 | flameGraph.color(rastaMode).render() -------------------------------------------------------------------------------- /flame-graph-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cimi/d3-flame-graphs/45d5555d25c7253d6562bb4aed427ae8179334c3/flame-graph-screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-flame-graphs", 3 | "version": "3.1.1", 4 | "description": "D3.js plugin for rendering flame graphs", 5 | "repository": "cimi/d3-flame-graphs", 6 | "main": "dist/d3-flame-graph.js", 7 | "scripts": { 8 | "test": "mocha --require coffee-script --compilers coffee:coffee-script/register --recursive ./test", 9 | "publish-npm": "publish" 10 | }, 11 | "keywords": [ 12 | "d3", 13 | "flame", 14 | "graph", 15 | "visualization", 16 | "d3", 17 | "plugin", 18 | "performance" 19 | ], 20 | "author": { 21 | "name": "Alex Ciminian", 22 | "email": "alex.ciminian@gmail.com" 23 | }, 24 | "license": "MIT", 25 | "dependencies": { 26 | "d3": "^3.5.6", 27 | "d3-tip": "^0.6.7" 28 | }, 29 | "devDependencies": { 30 | "browser-sync": "^2.8.2", 31 | "chai": "^3.3.0", 32 | "chai-things": "^0.2.0", 33 | "coffee-script": "^1.10.0", 34 | "connect-livereload": "^0.5.3", 35 | "del": "^1.2.1", 36 | "gulp": "^3.9.0", 37 | "gulp-cached": "^1.1.0", 38 | "gulp-changed": "^1.3.0", 39 | "gulp-coffee": "^2.3.1", 40 | "gulp-concat": "^2.6.0", 41 | "gulp-livereload": "^3.8.0", 42 | "gulp-newer": "^0.5.1", 43 | "gulp-remember": "^0.3.0", 44 | "gulp-sass": "^2.0.4", 45 | "gulp-sourcemaps": "^1.5.2", 46 | "gulp-uglify": "^1.4.0", 47 | "gulp-util": "^3.0.6", 48 | "mocha": "^2.3.3", 49 | "reload": "^0.4.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /scripts/deploy-demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # set username to the CI provider 4 | git config --global user.email circleci@circleci 5 | git config --global user.name CircleCI 6 | 7 | # this task builds the demo page inside the build/ directory 8 | gulp demo-copy 9 | 10 | # checkout the demo branch and commit built artifacts to it 11 | git checkout gh-pages 12 | ls | grep -v build | grep -v node_modules | xargs rm -rf 13 | mv build/* ./ && rm -r build 14 | git status && git add --all . 15 | git commit -m "Update (`date '+%F %T %Z'`) [ci skip]" 16 | 17 | # deploy! 18 | git push origin gh-pages -------------------------------------------------------------------------------- /scripts/release-demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # pushes the current branch to GitHub so CircleCI has what to build 4 | git config push.default current 5 | git push origin 6 | git config push.default simple 7 | 8 | # removes the existing demo tag, creates it again and pushes to GitHub 9 | git tag -d demo 10 | git tag demo 11 | git push -f origin demo 12 | -------------------------------------------------------------------------------- /src/d3-flame-graph.coffee: -------------------------------------------------------------------------------- 1 | d3 = if @d3 then @d3 else require('d3') 2 | throw new Error("d3.js needs to be loaded") if not d3 3 | 4 | d3.flameGraphUtils = 5 | # augments each node in the tree with the maximum distance 6 | # it is from a terminal node, the list of parents linking 7 | # it to the root and filler nodes that balance the representation 8 | augment: (node, location) -> 9 | children = node.children 10 | # d3.partition adds the reverse (depth), here we store the distance 11 | # between a node and its furthest leaf 12 | return node if node.augmented 13 | node.originalValue = node.value 14 | node.level = if node.children then 1 else 0 15 | node.hidden = [] 16 | node.location = location 17 | if not children?.length 18 | node.augmented = true 19 | return node 20 | 21 | childSum = children.reduce ((sum, child) -> sum + child.value), 0 22 | if childSum < node.value 23 | children.push({ value: node.value - childSum, filler: true }) 24 | 25 | children.forEach((child, idx) -> 26 | d3.flameGraphUtils.augment(child, location + "." + idx)) 27 | node.level += children.reduce ((max, child) -> Math.max(child.level, max)), 0 28 | node.augmented = true 29 | node 30 | 31 | partition: (data) -> 32 | d3.layout.partition() 33 | .sort (a,b) -> 34 | return 1 if a.filler # move fillers to the right 35 | return -1 if b.filler # move fillers to the right 36 | a.name.localeCompare(b.name) 37 | .nodes(data) 38 | 39 | hide: (nodes, unhide = false) -> 40 | sum = (arr) -> arr.reduce ((acc, val) -> acc + val), 0 41 | remove = (arr, val) -> 42 | # we need to remove precisely one occurrence of initial value 43 | pos = arr.indexOf(val) 44 | arr.splice(pos, 1) if pos >= 0 45 | process = (node, val) -> 46 | if unhide 47 | remove(node.hidden, val) 48 | else 49 | node.hidden.push(val) 50 | node.value = Math.max(node.originalValue - sum(node.hidden), 0) 51 | processChildren = (node, val) -> 52 | return if not node.children 53 | node.children.forEach (child) -> 54 | process(child, val) 55 | processChildren(child, val) 56 | processParents = (node, val) -> 57 | while node.parent 58 | process(node.parent, val) 59 | node = node.parent 60 | 61 | nodes.forEach (node) -> 62 | val = node.originalValue 63 | processParents(node, val) 64 | process(node, val) 65 | processChildren(node, val) 66 | 67 | d3.flameGraph = (selector, root, debug = false) -> 68 | 69 | getClassAndMethodName = (fqdn) -> 70 | return "" if not fqdn 71 | tokens = fqdn.split(".") 72 | tokens.slice(tokens.length - 2).join(".") 73 | 74 | # Return a vector (0.0 -> 1.0) that is a hash of the input string. 75 | # The hash is computed to favor early characters over later ones, so 76 | # that strings with similar starts have similar vectors. Only the first 77 | # 6 characters are considered. 78 | hash = (name) -> 79 | [result, maxHash, weight, mod] = [0, 0, 1, 10] 80 | name = getClassAndMethodName(name).slice(0, 6) 81 | for i in [0..(name.length-1)] 82 | result += weight * (name.charCodeAt(i) % mod) 83 | maxHash += weight * (mod - 1) 84 | weight *= 0.7 85 | if maxHash > 0 then result / maxHash else result 86 | 87 | class FlameGraph 88 | constructor: (selector, root) -> 89 | @_selector = selector 90 | @_generateAccessors([ 91 | 'margin', 92 | 'cellHeight', 93 | 'zoomEnabled', 94 | 'zoomAction', 95 | 'tooltip', 96 | 'tooltipPlugin', 97 | 'color']) 98 | @_ancestors = [] 99 | 100 | # enable logging only if explicitly specified 101 | if debug 102 | @console = window.console 103 | else 104 | @console = 105 | log: -> 106 | time: -> 107 | timeEnd: -> 108 | 109 | # defaults 110 | @_size = [1200, 800] 111 | @_cellHeight = 20 112 | @_margin = { top: 0, right: 0, bottom: 0, left: 0 } 113 | @_color = (d) -> 114 | val = hash(d.name) 115 | r = 200 + Math.round(55 * val) 116 | g = 0 + Math.round(230 * (1 - val)) 117 | b = 0 + Math.round(55 * (1 - val)) 118 | "rgb(#{r}, #{g}, #{b})" 119 | @_tooltipEnabled = true 120 | @_zoomEnabled = true 121 | @_tooltipPlugin = d3.tip() if @_tooltipEnabled and d3.tip 122 | 123 | # initial processing of data 124 | @console.time('augment') 125 | @original = d3.flameGraphUtils.augment(root, '0') 126 | @console.timeEnd('augment') 127 | @root(@original) 128 | 129 | size: (size) -> 130 | return @_size if not size 131 | @_size = size 132 | d3.select(@_selector).select('.flame-graph') 133 | .attr('width', @_size[0]) 134 | .attr('height', @_size[1]) 135 | @ 136 | 137 | root: (root) -> 138 | return @_root if not root 139 | @console.time('partition') 140 | @_root = root 141 | @_data = d3.flameGraphUtils.partition(@_root) 142 | @console.timeEnd('partition') 143 | @ 144 | 145 | hide: (predicate, unhide = false) -> 146 | matches = @select(predicate, false) 147 | return if not matches.length 148 | d3.flameGraphUtils.hide(matches, unhide) 149 | # re-partition the data prior to rendering 150 | @_data = d3.flameGraphUtils.partition(@_root) 151 | @render() 152 | 153 | zoom: (node, event) -> 154 | throw new Error("Zoom is disabled!") if not @zoomEnabled() 155 | @tip.hide() if @tip 156 | if node in @_ancestors 157 | @_ancestors = @_ancestors.slice(0, @_ancestors.indexOf(node)) 158 | else 159 | @_ancestors.push(@_root) 160 | @root(node).render() 161 | @_zoomAction?(node, event) 162 | @ 163 | 164 | width: () -> @size()[0] - (@margin().left + @margin().right) 165 | 166 | height: () -> @size()[1] - (@margin().top + @margin().bottom) 167 | 168 | label: (d) -> 169 | return "" if not d?.name 170 | label = getClassAndMethodName(d.name) 171 | label.substr(0, Math.round(@x(d.dx) / (@cellHeight() / 10 * 4))) 172 | 173 | select: (predicate, onlyVisible = true) -> 174 | if onlyVisible 175 | return @container.selectAll('.node').filter(predicate) 176 | else 177 | # re-partition original and filter that 178 | result = d3.flameGraphUtils.partition(@original).filter(predicate) 179 | return result 180 | 181 | render: () -> 182 | throw new Error("No DOM element provided") if not @_selector 183 | @console.time('render') 184 | 185 | @_createContainer() if not @container 186 | 187 | # reset size and scales 188 | @fontSize = (@cellHeight() / 10) * 0.4 189 | 190 | @x = d3.scale.linear() 191 | .domain([0, d3.max(@_data, (d) -> d.x + d.dx)]) 192 | .range([0, @width()]) 193 | 194 | visibleCells = Math.floor(@height() / @cellHeight()) 195 | maxLevels = @_root.level 196 | @y = d3.scale.quantize() 197 | .domain([d3.max(@_data, (d) -> d.y), 0]) 198 | .range(d3.range(maxLevels) 199 | .map((cell) => ((cell + visibleCells) - (@_ancestors.length + maxLevels)) * @cellHeight())) 200 | 201 | # JOIN 202 | data = @_data.filter((d) => @x(d.dx) > 0.4 and @y(d.y) >= 0 and not d.filler) 203 | renderNode = 204 | x: (d) => @x(d.x) 205 | y: (d) => @y(d.y) 206 | width: (d) => @x(d.dx) 207 | height: (d) => @cellHeight() 208 | text: (d) => @label(d) if d.name and @x(d.dx) > 40 209 | existingContainers = @container 210 | .selectAll('.node') 211 | .data(data, (d) -> d.location) 212 | .attr('class', 'node') 213 | 214 | # UPDATE 215 | @_renderNodes existingContainers, renderNode 216 | 217 | # ENTER 218 | newContainers = existingContainers.enter() 219 | .append('g') 220 | .attr('class', 'node') 221 | @_renderNodes newContainers, renderNode, true 222 | 223 | # EXIT 224 | existingContainers.exit().remove() 225 | 226 | @_renderAncestors()._enableNavigation() if @zoomEnabled() 227 | @_renderTooltip() if @tooltip() 228 | 229 | @console.timeEnd('render') 230 | @console.log("Processed #{@_data.length} items") 231 | @console.log("Rendered #{@container.selectAll('.node')[0]?.length} elements") 232 | @ 233 | 234 | _createContainer: () -> 235 | # remove any previously existing svg 236 | d3.select(@_selector).select('svg').remove() 237 | # create main svg container 238 | svg = d3.select(@_selector) 239 | .append('svg') 240 | .attr('class', 'flame-graph') 241 | .attr('width', @_size[0]) 242 | .attr('height', @_size[1]) 243 | # we set an offset based on the margin 244 | offset = "translate(#{@margin().left}, #{@margin().top})" 245 | # @container will hold all our nodes 246 | @container = svg.append('g') 247 | .attr('transform', offset) 248 | 249 | # this rectangle draws the border around the flame graph 250 | # has to be appended after the container so that the border is visible 251 | # we also need to apply the same translation 252 | svg.append('rect') 253 | .attr('width', @_size[0] - (@_margin.left + @_margin.right)) 254 | .attr('height', @_size[1] - (@_margin.top + @_margin.bottom)) 255 | .attr('transform', offset) 256 | .attr('class', 'border-rect') 257 | 258 | _renderNodes: (containers, attrs, enter = false) -> 259 | targetRects = containers.selectAll('rect') if not enter 260 | targetRects = containers.append('rect') if enter 261 | targetRects 262 | .attr('fill', (d) => @_color(d)) 263 | .transition() 264 | .attr('width', attrs.width) 265 | .attr('height', @cellHeight()) 266 | .attr('x', attrs.x) 267 | .attr('y', attrs.y) 268 | 269 | targetLabels = containers.selectAll('text') if not enter 270 | targetLabels = containers.append('text') if enter 271 | containers.selectAll('text') 272 | .attr('class', 'label') 273 | .style('font-size', "#{@fontSize}em") 274 | .transition() 275 | .attr('dy', "#{@fontSize / 2}em") 276 | .attr('x', (d) => attrs.x(d) + 2) 277 | .attr('y', (d, idx) => attrs.y(d, idx) + @cellHeight() / 2) 278 | .text(attrs.text) 279 | @ 280 | 281 | _renderTooltip: () -> 282 | return @ if not @_tooltipPlugin or not @_tooltipEnabled 283 | @tip = @_tooltipPlugin 284 | .attr('class', 'd3-tip') 285 | .html(@tooltip()) 286 | .direction (d) => 287 | return 'w' if @x(d.x) + @x(d.dx) / 2 > @width() - 100 288 | return 'e' if @x(d.x) + @x(d.dx) / 2 < 100 289 | return 's' # otherwise 290 | .offset (d) => 291 | x = @x(d.x) + @x(d.dx) / 2 292 | xOffset = Math.max(Math.ceil(@x(d.dx) / 2), 5) 293 | yOffset = Math.ceil(@cellHeight() / 2) 294 | return [0, -xOffset] if @width() - 100 < x 295 | return [0, xOffset] if x < 100 296 | return [ yOffset, 0] 297 | 298 | @container.call(@tip) 299 | @container 300 | .selectAll('.node') 301 | .on 'mouseover', (d) => @tip.show(d, d3.event.currentTarget) 302 | .on 'mouseout', @tip.hide 303 | .selectAll('.label') 304 | .on 'mouseover', (d) => @tip.show(d, d3.event.currentTarget.parentNode) 305 | .on 'mouseout', @tip.hide 306 | @ 307 | 308 | _renderAncestors: () -> 309 | if not @_ancestors.length 310 | ancestors = @container.selectAll('.ancestor').remove() 311 | return @ 312 | 313 | # FIXME: this is pretty ugly, but we need to add links between ancestors 314 | ancestorData = @_ancestors.map((ancestor, idx) -> 315 | { name: ancestor.name, value: idx + 1, location: ancestor.location }) 316 | for ancestor, idx in ancestorData 317 | prev = ancestorData[idx - 1] 318 | prev.children = [ancestor] if prev 319 | 320 | renderAncestor = 321 | x: (d) => 0 322 | y: (d) => return @height() - (d.value * @cellHeight()) 323 | width: @width() 324 | height: @cellHeight() 325 | text: (d) => "↩ #{getClassAndMethodName(d.name)}" 326 | 327 | # JOIN 328 | ancestors = @container 329 | .selectAll('.ancestor') 330 | .data(d3.layout.partition().nodes(ancestorData[0]), (d) -> d.location) 331 | # UPDATE 332 | @_renderNodes ancestors, renderAncestor 333 | 334 | # ENTER 335 | newAncestors = ancestors 336 | .enter() 337 | .append('g') 338 | .attr('class', 'ancestor') 339 | @_renderNodes newAncestors, renderAncestor, true 340 | 341 | # EXIT 342 | ancestors.exit().remove() 343 | @ 344 | 345 | _enableNavigation: () -> 346 | clickable = (d) => Math.round(@width() - @x(d.dx)) > 0 and d.children?.length 347 | @container 348 | .selectAll('.node') 349 | .classed('clickable', (d) => clickable(d)) 350 | .on 'click', (d) => 351 | @tip.hide() if @tip 352 | @zoom(d, d3.event) if clickable(d) 353 | @container 354 | .selectAll('.ancestor') 355 | .on 'click', (d, idx) => 356 | @tip.hide() if @tip 357 | @zoom(@_ancestors[idx], d3.event) 358 | @ 359 | 360 | _generateAccessors: (accessors) -> 361 | for accessor in accessors 362 | @[accessor] = do (accessor) -> 363 | (newValue) -> 364 | return @["_#{accessor}"] if not arguments.length 365 | @["_#{accessor}"] = newValue 366 | return @ 367 | 368 | return new FlameGraph(selector, root) -------------------------------------------------------------------------------- /src/d3-flame-graph.scss: -------------------------------------------------------------------------------- 1 | $graphBorderColor: #0E0E0E; 2 | $graphBackgroundColor: #FFF; 3 | 4 | $tooltipBackground: rgba(100, 50, 0, 0.8); 5 | $tooltipTextColor: #FFF; 6 | 7 | $itemBorderColor: #EEE; 8 | $itemHighlightColor: #FFD700; 9 | 10 | .flame-graph { 11 | background-color: $graphBackgroundColor; 12 | 13 | .label { 14 | font-family: Verdana; 15 | font-weight: lighter; 16 | cursor: default; 17 | } 18 | 19 | .border-rect { 20 | stroke: $graphBorderColor; 21 | fill: none; 22 | } 23 | 24 | .node { 25 | &.clickable, &.clickable .label { 26 | cursor: pointer; 27 | } 28 | 29 | /* root is not clickable */ 30 | &.root { 31 | cursor: auto; 32 | 33 | /* clicking the base node is disabled as it does not make sense to zoom on it */ 34 | :hover { 35 | cursor: auto; 36 | } 37 | } 38 | } 39 | 40 | .ancestor { 41 | cursor: pointer; 42 | opacity: 0.6; 43 | } 44 | 45 | rect { 46 | stroke: $itemBorderColor; 47 | fill-opacity: .8; 48 | 49 | &.overlay { 50 | fill-opacity: 0; 51 | stroke-opacity: 0; 52 | } 53 | } 54 | 55 | g { 56 | &.highlight, :hover { 57 | rect:first-child { 58 | fill: $itemHighlightColor; 59 | fill-opacity: 1; 60 | } 61 | } 62 | } 63 | } 64 | 65 | .d3-tip { 66 | 67 | line-height: 1; 68 | font-family: Verdana; 69 | font-size: 12px; 70 | padding: 12px; 71 | background: $tooltipBackground; 72 | color: $tooltipTextColor; 73 | border-radius: 7px; 74 | pointer-events: none; 75 | 76 | /* Creates a small triangle extender for the tooltip */ 77 | &:after { 78 | box-sizing: border-box; 79 | display: inline; 80 | font-size: 10px; 81 | width: 100%; 82 | line-height: 1; 83 | color: $tooltipBackground; 84 | position: absolute; 85 | pointer-events: none; 86 | } 87 | 88 | /* Northward tooltips */ 89 | &.n:after { 90 | content: "\25BC"; 91 | margin: -1px 0 0 0; 92 | top: 100%; 93 | left: 0; 94 | text-align: center; 95 | } 96 | 97 | /* Eastward tooltips */ 98 | &.e:after { 99 | content: "\25C0"; 100 | margin: -4px 0 0 0; 101 | top: 50%; 102 | left: -8px; 103 | } 104 | 105 | /* Southward tooltips */ 106 | &.s:after { 107 | content: "\25B2"; 108 | margin: 0 0 1px 0; 109 | top: -8px; 110 | left: 0; 111 | text-align: center; 112 | } 113 | 114 | /* Westward tooltips */ 115 | &.w:after { 116 | content: "\25B6"; 117 | margin: -4px 0 0 -1px; 118 | top: 50%; 119 | left: 100%; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /test/augment.coffee: -------------------------------------------------------------------------------- 1 | require('coffee-script/register'); 2 | path = require('path') 3 | exec = require('child_process').exec 4 | d3 = require('d3') 5 | chai = require('chai') 6 | expect = chai.expect 7 | chai.use(require('chai-things')) 8 | 9 | # the flame graph plugin does not have an exporter 10 | # as it augments the existing d3 object 11 | require('../src/d3-flame-graph') 12 | 13 | describe 'd3.flameGraph.augment', -> 14 | root = undefined 15 | describe 'when provided with a simple tree', -> 16 | beforeEach (done) -> 17 | data = { value: 45, name:"root", children: [] } 18 | data.children.push({name: "c1", value: 10}, {name: "c2", value: 20}) 19 | root = d3.flameGraphUtils.augment(data, [0]) 20 | done() 21 | 22 | it 'should mark the root as augmented', -> 23 | expect(root.augmented).to.be.true 24 | 25 | it 'should mark all the children as augmented', -> 26 | expect(root.children).to.all.have.property('augmented', true) 27 | 28 | it 'augments them with filler nodes', -> 29 | expect(root.children).to.have.length(3) 30 | filler = root.children[2] 31 | expect(filler).has.property('filler', true) 32 | expect(filler).has.property('value', 15) 33 | 34 | it 'augments them with their level', -> 35 | expect(root).has.property('level', 1) 36 | expect(root.children).to.all.have.property('level', 0) 37 | 38 | it 'saves the original value in a separate field', -> 39 | expect(root).has.property('originalValue', 45) 40 | expect(root.children).to.all.have.property('originalValue') 41 | 42 | it 'should add location info to all nodes', -> 43 | expect(root).has.property('location').that.has.members([0]) 44 | expect(root.children[0]).to.have.property('location') 45 | expect(root.children[1]).to.have.property('location') 46 | 47 | describe 'when provided with a multilevel tree', -> 48 | beforeEach (done) -> 49 | data = { value: 45, name:"root", children: [] } 50 | firstChild = {name: "c11", value: 10, children: [{name: "c21", value: 1}]} 51 | secondChild = {name: "c12", value: 20} 52 | data.children.push(firstChild, secondChild) 53 | root = d3.flameGraphUtils.augment(data, [0]) 54 | done() 55 | 56 | it 'augments the root with the correct level', -> 57 | expect(root).has.property('level', 2) 58 | 59 | it 'augments the children with the correct level', -> 60 | expect(root.children).to.include.an.item.that.has.property('level', 1) 61 | expect(root.children).to.include.an.item.that.has.property('level', 0) 62 | 63 | it 'augments the grandchildren with the correct level', -> 64 | grandchildren = root.children[0].children 65 | expect(grandchildren).to.all.have.property('level', 0) 66 | 67 | -------------------------------------------------------------------------------- /test/data/profile-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ALL", 3 | "value": 100, 4 | "children": [ 5 | { 6 | "name": "C0", 7 | "value": 30, 8 | "children": [ 9 | { 10 | "name": "C00", 11 | "value": 10, 12 | "children": [] 13 | }, 14 | { 15 | "name": "C01", 16 | "value": 3, 17 | "children": [] 18 | } 19 | ] 20 | }, 21 | { 22 | "name": "C1", 23 | "value": 60, 24 | "children": [ 25 | { 26 | "name": "C10", 27 | "value": 40, 28 | "children": [ 29 | { 30 | "name": "C100", 31 | "value": 20, 32 | "children": [ 33 | { 34 | "name": "C1000", 35 | "value": 5, 36 | "children": [] 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | ] 43 | } 44 | ] 45 | } -------------------------------------------------------------------------------- /test/hide.coffee: -------------------------------------------------------------------------------- 1 | require('coffee-script/register'); 2 | path = require('path') 3 | exec = require('child_process').exec 4 | d3 = require('d3') 5 | chai = require('chai') 6 | expect = chai.expect 7 | chai.use(require('chai-things')) 8 | 9 | # the flame graph plugin does not have an exporter 10 | # as it augments the existing d3 object 11 | require('../src/d3-flame-graph') 12 | 13 | getNode = (data, positions) -> 14 | positions.reduce ((node, pos) -> node.children[pos]), data 15 | 16 | describe 'd3.flameGraph.hide', -> 17 | original = require('./data/profile-test.json') 18 | data = undefined 19 | describe 'when hiding nodes', -> 20 | beforeEach (done) -> 21 | # deep copy because of require caching 22 | data = JSON.parse(JSON.stringify(original)) 23 | data = d3.flameGraphUtils.augment(data, [0]) 24 | d3.flameGraphUtils.partition(data) 25 | done() 26 | 27 | it 'should set node values to 0', -> 28 | target = [data.children[1].children[0]] 29 | d3.flameGraphUtils.hide(target) 30 | expect(target[0]).to.have.property('value', 0) 31 | 32 | it 'should subtract value from all parents', -> 33 | d3.flameGraphUtils.hide([data.children[1].children[0]]) 34 | expect(data.children[1]).to.have.property('value', 20) 35 | expect(data).to.have.property('value', 60) 36 | 37 | it 'should set the value of all children to 0', -> 38 | d3.flameGraphUtils.hide([data.children[1]]) 39 | expect(data.children[1].children).to.all.have.property('value', 0) 40 | 41 | it 'should correctly compute values when more than one node is hidden', -> 42 | d3.flameGraphUtils.hide([data.children[0].children[0], data.children[1].children[0]]) 43 | expect(data).to.have.property('value', 50) 44 | expect(data.children[0]).to.have.property('value', 20) 45 | expect(data.children[1]).to.have.property('value', 20) 46 | 47 | it 'should store the value that was hidden in a list on each parent', -> 48 | d3.flameGraphUtils.hide([data.children[0].children[0], data.children[1].children[0]]) 49 | expect(data).to.have.property('hidden').to.have.members([10, 40]) 50 | expect(data.children[0]).to.have.property('hidden').to.have.members([10]) 51 | expect(data.children[1]).to.have.property('hidden').to.have.members([40]) 52 | 53 | describe 'when unhiding nodes', -> 54 | beforeEach (done) -> 55 | # deep copy because of require caching 56 | data = JSON.parse(JSON.stringify(original)) 57 | data = d3.flameGraphUtils.augment(data, [0]) 58 | d3.flameGraphUtils.partition(data) 59 | done() 60 | 61 | it 'should reset target nodes to their original values', -> 62 | target = [data.children[1].children[0]] 63 | d3.flameGraphUtils.hide(target) 64 | d3.flameGraphUtils.hide(target, true) 65 | expect(target[0]).to.have.property('value', 40) 66 | 67 | it 'should reset child nodes to their original values', -> 68 | d3.flameGraphUtils.hide([data.children[0]]) 69 | d3.flameGraphUtils.hide([data.children[0]], true) 70 | expect(data.children[0].children[0]).to.have.property('value', 10) 71 | expect(data.children[0].children[1]).to.have.property('value', 3) 72 | 73 | it 'should add back the value of the hidden children to the parent nodes', -> 74 | d3.flameGraphUtils.hide([getNode(data, [1]), getNode(data, [1, 0])]) 75 | d3.flameGraphUtils.hide([getNode(data, [1])], true) 76 | expect(data).to.have.property('value', 60) 77 | 78 | it 'should keep hidden children hidden after unhiding the parent', -> 79 | d3.flameGraphUtils.hide([getNode(data, [1]), getNode(data, [1, 0, 0])]) 80 | d3.flameGraphUtils.hide([getNode(data, [1])], true) 81 | expect(getNode(data, [1])).to.have.property('value', 40) 82 | expect(getNode(data, [1, 0])).to.have.property('value', 20) 83 | expect(getNode(data, [1, 0, 0])).to.have.property('value', 0) 84 | expect(getNode(data, [1, 0, 0, 0])).to.have.property('value', 0) 85 | 86 | --------------------------------------------------------------------------------