├── .gitignore ├── tree_radial.png ├── tree_rect.png ├── js ├── q2_phylogram.js ├── phylogram_d3.js ├── lib │ └── tooltip.js └── utils.js ├── css └── styles.css ├── README.md └── docs └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tree_radial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConstantinoSchillebeeckx/phylogram_d3/HEAD/tree_radial.png -------------------------------------------------------------------------------- /tree_rect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConstantinoSchillebeeckx/phylogram_d3/HEAD/tree_rect.png -------------------------------------------------------------------------------- /js/q2_phylogram.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | TODO 4 | 5 | */ 6 | 7 | 8 | /* initialize tree 9 | 10 | This function is meant to be used with q2_phylogram 11 | since QIIME2 does not allow cross-origin loading of 12 | data. This function must be loaded after the initial 13 | init() defined in js/phylogram.js and will thus replace 14 | it. 15 | 16 | It is expected then, that the Newick file contents are 17 | wrapped in the d3.jsonp.readNewick() callback. Furthemore, 18 | the contents of the mapping file will be stored in the var 19 | options.mapping_dat. 20 | 21 | Function called from front-end with all user-defined 22 | opts to format the tree. Will validate input 23 | Newick tree, show a loading spinner, and then 24 | render the tree 25 | 26 | Parameters: 27 | ========== 28 | - dat : string 29 | filepath for input Newick tre 30 | - div : string 31 | div id (with included #) in which to generated tree 32 | - options: obj 33 | options object with potential keys and values 34 | 35 | options obj: 36 | - mapping_file: path to OTU mapping file (if there is one) 37 | - hideRuler: (bool) if true, background distance ruler is not rendered TODO 38 | - skipBranchLengthScaling: (bool) if true, tree will not be scaled by distance TODO 39 | - skipLabels: (bool) if true, leaves will not be labeled by name or distance 40 | - treeType: either rectangular or radial 41 | TODO 42 | 43 | */ 44 | 45 | 46 | function init(dat, div, options) { 47 | 48 | // ensure a file was passed 49 | if (!dat) { 50 | var msg = 'Please ensure that, at a minimum, a Newick file is passed to the init() call!'; 51 | displayErrMsg(msg, div); 52 | return false; 53 | } 54 | 55 | // ensure options is obj if not passed as such 56 | if (!(options !== null && typeof options === 'object')){ 57 | options = {}; 58 | } 59 | 60 | renderDiv = div; 61 | 62 | // show loading spinner 63 | showSpinner(renderDiv, true) 64 | 65 | //d3.text(dat, function(error, fileStr) { 66 | d3.jsonp(dat + '?callback=d3.jsonp.readNewick', function(fileStr) { 67 | 68 | if (fileStr == '' || fileStr == null) { 69 | var msg = 'Input file ' + dat + ' could not be parsed, ensure it is a proper Newick tree file!'; 70 | displayErrMsg(msg, renderDiv); 71 | } 72 | 73 | // process Newick tree 74 | newick = processNewick(fileStr); 75 | 76 | 77 | // render tree 78 | buildTree(renderDiv, newick, options, function() { resizeSVG(); }); 79 | }); 80 | } 81 | 82 | -------------------------------------------------------------------------------- /css/styles.css: -------------------------------------------------------------------------------- 1 | .render { 2 | font-family: -apple-system, BlinkMacSystemFont, "Open Sans", Helvetica, sans-serif; 3 | font-size: 11px; 4 | } 5 | 6 | .legend { 7 | font-size: 11px; 8 | } 9 | 10 | /* branch tip styling */ 11 | .leaf circle { 12 | fill: greenYellow; 13 | stroke: yellowGreen; 14 | stroke-width: 2px; 15 | } 16 | 17 | .leaf text { 18 | font-size: 10px; 19 | fill: black; 20 | } 21 | 22 | .leaf rect { 23 | fill: steelblue; 24 | opacity: 1e-6; /* initially hide leaf background */ 25 | } 26 | /* branch tip styling */ 27 | 28 | 29 | /* background distance rulers */ 30 | text.rule { 31 | font-size: 8px; 32 | fill: #ccc; 33 | text-decoration: underline; 34 | } 35 | 36 | .ruleGroup { 37 | stroke: #ddd; 38 | fill: none; 39 | stroke-width: 1px; 40 | } 41 | /* background distance rulers */ 42 | 43 | 44 | .root { 45 | fill : steelblue; 46 | stroke-width: 2px; 47 | stroke: #369; 48 | } 49 | 50 | 51 | /* inside of tree */ 52 | .inner text { 53 | font-size: 8px; 54 | fill: #ccc; 55 | } 56 | 57 | 58 | /* branches of tree */ 59 | path.link { 60 | fill: none; 61 | stroke: #aaa; 62 | stroke-width: 2px; 63 | } 64 | 65 | /* extra small dropdown */ 66 | .form-control.input-xs { 67 | height: 20px; 68 | } 69 | 70 | .node.inner { 71 | fill: #aaa; 72 | cursor: pointer; 73 | } 74 | 75 | /* tooltip */ 76 | .d3-tip { 77 | line-height: 1; 78 | padding: 12px; 79 | background: rgba(0, 0, 0, 0.8); 80 | color: #fff; 81 | border-radius: 2px; 82 | line-height: 130%; 83 | font-size: 12px; 84 | font-family: "Open Sans", Helvetica, sans-serif; 85 | max-width: 500px; 86 | word-wrap: break-word; 87 | z-index: 10; 88 | } 89 | 90 | .d3-tip .tip-title { 91 | font-size: 16px; 92 | margin-bottom: 20px; 93 | } 94 | 95 | .d3-tip .tip-name { 96 | color: red; 97 | } 98 | 99 | .tip-meta-title { 100 | } 101 | 102 | .tip-meta-name { 103 | } 104 | 105 | 106 | text { 107 | font-family: "Open Sans"; 108 | } 109 | 110 | @media print { 111 | #gui { display:none; } 112 | svg { 113 | width: 9in; 114 | height: 6.5in; 115 | margin: 0 0 0 0; 116 | } 117 | } 118 | 119 | 120 | /* manually added because external CSS 121 | doesn't seem to be loading well when 122 | saving SVG 123 | */ 124 | .lead { 125 | margin-bottom: 20px; 126 | font-size: 16px; 127 | font-weight: 300; 128 | line-height: 1.4 129 | } 130 | 131 | @media (min-width:768px) { 132 | .lead { 133 | font-size: 21px 134 | } 135 | } 136 | 137 | 138 | /* slider */ 139 | .noUi-horizontal { 140 | height: 10px; 141 | margin-top: 3px; 142 | margin-bottom: 3px; 143 | } 144 | 145 | .noUi-horizontal .noUi-handle { 146 | width: 18px; 147 | height: 15px; 148 | top: -4px; 149 | left: -6px; 150 | } 151 | 152 | .noUi-handle:before, 153 | .noUi-handle:after { 154 | content: none; 155 | } 156 | 157 | /* hide scrolls bar since everything is fit to screen */ 158 | body { 159 | overflow: hidden; 160 | } 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phylogram_d3 2 | 3 | This project is an adaptation of the [GitHubGist](https://gist.github.com/) created by [kueda](https://gist.github.com/kueda/1036776). Given a Newick formatted file as well as an OTU mapping file containing metadata for the leaves in the tree, the script is used to generate a rooted phylogenetic tree: 4 | > A phylogenetic tree or evolutionary tree is a branching diagram or "tree" showing the inferred evolutionary relationships among various biological species or other entities—their phylogeny—based upon similarities and differences in their physical or genetic characteristics. The taxa joined together in the tree are implied to have descended from a common ancestor. 5 | 6 | [- Wikipedia](https://en.wikipedia.org/wiki/Phylogenetic_tree#Unrooted_tree) 7 | 8 | --- 9 | A [index.html](docs/index.html) file has been provided to render the tree as an example. 10 | 11 | A working demo can be found [here](http://constantinoschillebeeckx.github.io/phylogram_d3/) which was used to generate the following: 12 | ![Render](https://rawgit.com/ConstantinoSchillebeeckx/phylogram_d3/master/tree_rect.png "Rectangular tree type") 13 | ![Render](https://rawgit.com/ConstantinoSchillebeeckx/phylogram_d3/master/tree_radial.png "Radial tree type") 14 | 15 | ## Dependencies 16 | * [Twitter Bootstrap](https://getbootstrap.com/) 17 | 18 | * [jQuery](https://jquery.com/) 19 | 20 | * [Colorbrewer.js](https://bl.ocks.org/mbostock/5577023) 21 | 22 | * [Newick.js](https://github.com/jasondavies/newick.js) 23 | 24 | * [d3.js](https://d3js.org/) 25 | 26 | * [D3-tip](https://github.com/emiguevara/d3-tip) 27 | 28 | * [noUiSlider](http://refreshless.com/nouislider/) 29 | 30 | ## Usage 31 | 32 | **in development** 33 | 34 | Rendering the phylogram begins with the function [`init()`][1] which you can simply call in the `body` tag of your HTML page using the [onload](https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onload) event handle: ``. Currently, the function takes three parameters: 35 | 36 | 1. String of the Newick tree used to build the phylogram. 37 | 38 | 2. String identifier for the div id into which to generate the phylogram, note that it must contain the '#' in it. For example, if you want to render your tree in a div with the id 'phylogram' you'd pass '#phylogram' to the function and you'd need the following in your HTML: `
`. 39 | 40 | 3. an **optional** object with tree parameters, this should be passed as a javascript object with the following optional keys 41 | * mapping_dat : `(obj)` list of objects where the object keys are metadata and the object values are the metadata values. This is used to color the phylogram. 42 | * treeType : `(str)` either rectangular or radial [default: rectangular] 43 | * hideRuler : `(bool)` if True, the background ruler will be hidden [default: show ruler] 44 | * skipBranchLengthScaling : `(bool)` if True, a [cladogram](https://en.wikipedia.org/wiki/Cladogram) will be displayed instead of a [phylogram](https://en.wikipedia.org/wiki/Phylogenetic_tree) [default: phylogram] 45 | * skipLabels : `(bool)` if True, all node labels will be hidden [default: show labels] 46 | 47 | 48 | ##### Notes on the `mapping_dat` 49 | The `mapping_dat` is similar to a standard QIIME mapping file in that it associates additional metadata to the objects of interest; however in this case the objects of interest are OTUs instead of samples. This file has the following caveats: 50 | 51 | * each object must have a key of `OTUID` which must exist as a leaf in the Newick tree 52 | 53 | * if providing taxonomy data, format it in the QIIME default manner (e.g. *k__bacteria_2;p__firmicutes;c__negativicutes;o__selenomonadales;f__veillonellaceae;g__megasphaera;s__elsdenii*). If done correctly, this format will be parsed and cleaned up automatically 54 | 55 | * if using categorical data (e.g. GroupA, GroupB) the legend will render a row for every category value 56 | 57 | * if passing ordinal data (e.g. 0.4, 1.90) the legend will render a color bar with min/max values. Therefore, if you have categorical data labeled with numbers (e.g. Site 1, 2, 3), ensure that you format the values with alphanumeric characters (e.g. Site1, Site2, Site3) 58 | 59 | [1]: https://github.com/ConstantinoSchillebeeckx/phylogram_d3/blob/master/js/phylogram_d3.js#L126 60 | -------------------------------------------------------------------------------- /js/phylogram_d3.js: -------------------------------------------------------------------------------- 1 | 2 | // GLOBALS 3 | // -------------- 4 | var options = {}; 5 | var mapParse, colorScales, mappingFile; 6 | 7 | // use margin convention 8 | // https://bl.ocks.org/mbostock/3019563 9 | var margin = {top: 0, right: 10, bottom: 10, left: 10}; 10 | var startW = 800, startH = 600; 11 | var width = startW - margin.left - margin.right; 12 | var height = startH - margin.top - margin.bottom; 13 | var nodes, links, node, link; 14 | var newick; 15 | var shiftX = 0; 16 | var shiftY = 0; 17 | var zoom = d3.behavior.zoom() 18 | // tree defaults 19 | var treeType = 'rectangular'; // rectangular or circular [currently rendered treeType] 20 | var scale = true; // if true, tree will be scaled by distance metric 21 | 22 | // scale for adjusting legend 23 | // text color based on background 24 | // [background HSL -> L value] 25 | // domain is L (0,1) 26 | // range is RBG 27 | var legendColorScale = d3.scale.linear().domain([0.5,1]).range([255,0]) 28 | 29 | // tooltip 30 | var tip = d3.tip() 31 | .attr('class', 'd3-tip') 32 | .offset([0,20]) 33 | .html(function(d) { 34 | return formatTooltip(d, options.mapping); 35 | }) 36 | var outerRadius = startW / 2, 37 | innerRadius = outerRadius - 170; 38 | 39 | // setup radial tree 40 | var radialTree = d3.layout.cluster() 41 | .size([360, innerRadius]) 42 | .children(function(d) { return d.branchset; }) 43 | 44 | // setup rectangular tree 45 | var rectTree = d3.layout.cluster() 46 | .children(function(node) { 47 | return node.branchset 48 | }) 49 | .size([height, width]); 50 | 51 | var duration = 1000; 52 | 53 | 54 | 55 | 56 | // -------------- 57 | // GLOBALS 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | /* initialize tree 66 | 67 | Function called from front-end with all user-defined 68 | opts to format the tree. Will validate input 69 | Newick tree, show a loading spinner, and then 70 | render the tree 71 | 72 | Parameters: 73 | ========== 74 | - dat : string 75 | Newick tree as javascript var 76 | - div : string 77 | div id (with included #) in which to generated tree 78 | - options: obj 79 | options object with potential keys and values 80 | 81 | options obj: 82 | - mapping_file: path to OTU mapping file (if there is one) 83 | - hideRuler: (bool) if true, background distance ruler is not rendered TODO 84 | - skipBranchLengthScaling: (bool) if true, tree will not be scaled by distance TODO 85 | - skipLabels: (bool) if true, leaves will not be labeled by name or distance 86 | - treeType: either rectangular or radial 87 | 88 | */ 89 | 90 | 91 | function init(dat, div, options) { 92 | 93 | // show loading spinner 94 | showSpinner(div, true) 95 | 96 | validateInputs(dat, options); 97 | 98 | // process Newick tree 99 | newick = processNewick(dat); 100 | 101 | // render tree 102 | buildTree(div, newick, options, function() { updateTree(); }); 103 | 104 | 105 | } 106 | 107 | 108 | 109 | /* Primary tree building function 110 | 111 | Will do an initial render of all SVG elements 112 | including the GUI and the initial layout of 113 | the tree. Subsequent updating in both style 114 | and format of the tree is done through updateTree() 115 | 116 | 117 | Parameters: 118 | =========== 119 | - div : string 120 | div id (with included #) in which to generated tree 121 | - newick : Newick obj 122 | return of function processNewick() 123 | - opts: obj 124 | opts object with potential keys and values 125 | 126 | 127 | Retrurns: 128 | ========= 129 | - nothing 130 | 131 | */ 132 | 133 | function buildTree(div, newick, opts, callback) { 134 | 135 | if ('mapping_dat' in opts) { 136 | var parsed = parseMapping(opts.mapping_dat); 137 | mapParse = parsed[0]; 138 | colorScales = parsed[1]; 139 | options.mapping = mapParse; 140 | options.colorScale = colorScales; 141 | } 142 | 143 | // check opts, if not set, set to default 144 | if (!('treeType' in opts)) { 145 | opts['treeType'] = treeType; 146 | } else { 147 | treeType = opts.treeType; 148 | } 149 | if (!('skipBranchLengthScaling' in opts)) { 150 | opts['skipBranchLengthScaling'] = !scale; 151 | } else { 152 | scale = opts.skipBranchLengthScaling; 153 | } 154 | 155 | // add bootstrap container class 156 | d3.select(div) 157 | .attr("class","container-fluid render") 158 | 159 | // build GUI 160 | var gui = buildGUI(div, opts); 161 | 162 | var tmp = d3.select(div).append("div") 163 | .attr("class","row") 164 | .attr("id","canvas") 165 | 166 | // NOTE: size of SVG and SVG g are updated in fitTree() 167 | svg = tmp.append("div") 168 | .attr("class", "col-sm-12") 169 | .attr("id","tree") 170 | .append("svg:svg") 171 | .attr("xmlns","http://www.w3.org/2000/svg") 172 | .attr("id","SVGtree") 173 | .call(zoom.on("zoom", panZoom)) 174 | .append("g") // svg g group is translated by fitTree() 175 | .attr("id",'canvasSVG') 176 | .attr("transform","translate(" + margin.left + "," + margin.top + ")") 177 | 178 | svg.append("g") 179 | .attr("id","rulerSVG") 180 | svg.append("g") 181 | .attr("id","treeSVG") 182 | 183 | 184 | // generate intial layout and all tree elements 185 | d3.select("#canvasSVG") 186 | if (opts.treeType == 'rectangular') { 187 | layoutTree(rectTree, newick, opts); 188 | } else if (opts.treeType == 'radial') { 189 | layoutTree(radialTree, newick, opts); 190 | } 191 | 192 | svg.call(tip); 193 | showSpinner(null, false); // hide spinner 194 | callback(); // calls updateTree 195 | 196 | } 197 | 198 | /* will layout tree elements including nodes and links 199 | 200 | Assumes globals (nodes, links) exist 201 | 202 | Parameters: 203 | ----------- 204 | - tree : d3.tree layout 205 | - newick : Newick obj 206 | return of function processNewick() 207 | - opts: obj 208 | opts object with potential keys and values 209 | 210 | */ 211 | function layoutTree(tree, newick, opts) { 212 | d3.selectAll("g.ruleGroup").remove() // remove ruler 213 | 214 | nodes = tree.nodes(newick); 215 | if (!opts.skipBranchLengthScaling) { var yscale = scaleBranchLengths(nodes); } 216 | if (opts.treeType == 'rectangular') { var xscale = scaleLeafSeparation(tree, nodes); } 217 | links = tree.links(nodes); 218 | 219 | formatTree(nodes, links, yscale, xscale, height, opts); 220 | } 221 | 222 | 223 | 224 | /* Function used to update existing tree 225 | 226 | Function called from front-end everytime GUI 227 | is changed; this will redraw the tree based 228 | on GUI settings. 229 | 230 | */ 231 | function updateTree() { 232 | 233 | 234 | getGUIoptions(); // set our globals 235 | 236 | 237 | 238 | // adjust physical positioning 239 | if (options.typeChange || options.skipBranchLengthScaling != scale) { 240 | 241 | layoutTree( options.treeType == 'rectangular' ? rectTree : radialTree, newick, options); 242 | 243 | // reset rotation to 0 (rect) or to previous pos (radial) 244 | d3.select('#treeSVG').attr('transform', function(d) { 245 | if (options.treeType == 'rectangular') { 246 | return 'rotate(0)'; 247 | } else { 248 | return 'rotate(' + rotationSlider.noUiSlider.get() + ')'; 249 | } 250 | }) 251 | 252 | scale = options.skipBranchLengthScaling; 253 | } 254 | 255 | // enable/disable sliders 256 | if (treeType == 'radial') { 257 | rotationSlider.removeAttribute('disabled'); 258 | scaleHSlider.setAttribute('disabled',true); 259 | } else { 260 | rotationSlider.setAttribute('disabled', true); 261 | scaleHSlider.removeAttribute('disabled'); 262 | } 263 | 264 | 265 | // if leaf radius becomes too large, adjust vertical scale 266 | if (options.sliderLeafR * 2 > options.sliderScaleV && treeType == 'rectangular') { scaleHSlider.noUiSlider.set( 2 * options.sliderLeafR ); } 267 | 268 | // adjust vertical scale 269 | if (options.treeType == 'rectangular') { 270 | var xscale = scaleLeafSeparation(rectTree, nodes, options.sliderScaleV); // this will update x-pos 271 | 272 | // update ruler length 273 | var treeH = getTreeBox().height + 32; // +32 extends rulers outside treeSVG 274 | d3.selectAll(".ruleGroup line") 275 | .attr("y2", treeH + margin.top + margin.bottom) // TODO doesn't work quite right with large scale 276 | 277 | 278 | // scale vertical pos 279 | svg.selectAll("g.node") 280 | .data(nodes) 281 | .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; }); 282 | svg.selectAll("path.link") 283 | .data(links) 284 | .attr("d", elbow); 285 | } 286 | 287 | // scale leaf radius 288 | svg.selectAll("g.leaf circle") 289 | .attr("r", options.sliderLeafR); 290 | orientTreeLabels(); 291 | 292 | 293 | // toggle leaf labels 294 | svg.selectAll('g.leaf.node text') 295 | .style('fill-opacity', options.skipLeafLabel? 1e-6 : 1 ) 296 | 297 | // toggle distance labels 298 | svg.selectAll('g.inner.node text') 299 | .style('fill-opacity', options.skipDistanceLabel? 1e-6 : 1 ) 300 | 301 | svg.selectAll('g.leaf.node text') 302 | .text(function(d) { 303 | if (options.skipDistanceLabel) { 304 | return d.name; 305 | } else { 306 | if (options.leafText == 'distance' || !mapParse) { 307 | return d.name + ' ('+d.length+')'; 308 | } else { 309 | return d.name + ' (' + mapParse.get(options.leafText).get(d.name) + ')'; 310 | } 311 | } 312 | }); 313 | 314 | 315 | if ('mapping' in options) { 316 | updateLegend(); // will reposition legend as well 317 | } else { 318 | d3.select('svg').attr("viewBox", "0 0 " + parseInt(window.innerWidth) + " " + parseInt(window.innerHeight)); // set viewbox 319 | } 320 | 321 | } 322 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Phylogram d3 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 156 | 157 | 158 |
159 | 160 | 161 |
162 |
163 | 164 | 165 | -------------------------------------------------------------------------------- /js/lib/tooltip.js: -------------------------------------------------------------------------------- 1 | // d3.tip 2 | // Copyright (c) 2013 Justin Palmer 3 | // 4 | // Tooltips for d3.js SVG visualizations 5 | // https://github.com/emiguevara/d3-tip/blob/master/index.js 6 | 7 | (function (root, factory) { 8 | if (typeof define === 'function' && define.amd) { 9 | // AMD. Register as an anonymous module with d3 as a dependency. 10 | define(['d3'], factory) 11 | } else if (typeof module === 'object' && module.exports) { 12 | // CommonJS 13 | module.exports = function(d3) { 14 | d3.tip = factory(d3) 15 | return d3.tip 16 | } 17 | } else { 18 | // Browser global. 19 | root.d3.tip = factory(root.d3) 20 | } 21 | }(this, function (d3) { 22 | 23 | // Public - contructs a new tooltip 24 | // 25 | // Returns a tip 26 | return function() { 27 | var direction = d3_tip_direction, 28 | offset = d3_tip_offset, 29 | html = d3_tip_html, 30 | node = initNode(), 31 | svg = null, 32 | point = null, 33 | target = null 34 | 35 | function tip(vis) { 36 | svg = getSVGNode(vis) 37 | point = svg.createSVGPoint() 38 | document.body.appendChild(node) 39 | } 40 | 41 | // Public - show the tooltip on the screen 42 | // 43 | // Returns a tip 44 | tip.show = function(d, idx, absolute) { 45 | var args = Array.prototype.slice.call(arguments) 46 | if(args[args.length - 1] instanceof SVGElement) target = args.pop() 47 | 48 | var content = html.apply(this, args), 49 | poffset = offset.apply(this, args), 50 | dir = direction.apply(this, args), 51 | nodel = getNodeEl(), 52 | i = directions.length, 53 | coords, 54 | scrollTop = document.documentElement.scrollTop || document.body.scrollTop, 55 | scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft 56 | 57 | nodel.html(content) 58 | .style({ opacity: 1, 'pointer-events': 'all' }) 59 | 60 | // set direction based on where mouse is w.r.t entire screen 61 | // NOTE: this will not work if absolute is being used 62 | var x = d3.event.pageX; 63 | var y = d3.event.pageY; 64 | 65 | if ( x <= window.innerWidth / 2.0) { // left side of screen 66 | if (y <= window.innerHeight / 2.0) { // top of screen 67 | dir = 'se'; 68 | } else { // bottom of screen 69 | dir = 'ne'; 70 | } 71 | } else { // right side of screen 72 | if (y <= window.innerHeight / 2.0) { // top of screen 73 | dir = 'sw'; 74 | } else { // bottom of screen 75 | dir = 'nw'; 76 | } 77 | } 78 | direction(dir); 79 | 80 | while(i--) nodel.classed(directions[i], false) 81 | if (!absolute || absolute === false) { 82 | coords = direction_callbacks.get(dir).apply(this); 83 | nodel.classed(dir, true).style({ 84 | top: (coords.top + poffset[0]) + scrollTop + 'px', 85 | left: (coords.left + poffset[1]) + scrollLeft + 'px' 86 | }); 87 | } 88 | else { 89 | coords = direction_callbacks.get(dir).apply(this); 90 | nodel.classed(dir, true).style({ 91 | top: (absolute[0] + poffset[0]) + 'px', 92 | left: (absolute[1] + poffset[1]) + 'px' 93 | }); 94 | } 95 | 96 | return tip 97 | } 98 | 99 | // Public - hide the tooltip 100 | // 101 | // Returns a tip 102 | tip.hide = function() { 103 | var nodel = getNodeEl() 104 | nodel.style({ opacity: 0, 'pointer-events': 'none' }) 105 | return tip 106 | } 107 | 108 | // Public: Proxy attr calls to the d3 tip container. Sets or gets attribute value. 109 | // 110 | // n - name of the attribute 111 | // v - value of the attribute 112 | // 113 | // Returns tip or attribute value 114 | tip.attr = function(n, v) { 115 | if (arguments.length < 2 && typeof n === 'string') { 116 | return getNodeEl().attr(n) 117 | } else { 118 | var args = Array.prototype.slice.call(arguments) 119 | d3.selection.prototype.attr.apply(getNodeEl(), args) 120 | } 121 | 122 | return tip 123 | } 124 | 125 | // Public: Proxy style calls to the d3 tip container. Sets or gets a style value. 126 | // 127 | // n - name of the property 128 | // v - value of the property 129 | // 130 | // Returns tip or style property value 131 | tip.style = function(n, v) { 132 | if (arguments.length < 2 && typeof n === 'string') { 133 | return getNodeEl().style(n) 134 | } else { 135 | var args = Array.prototype.slice.call(arguments) 136 | d3.selection.prototype.style.apply(getNodeEl(), args) 137 | } 138 | 139 | return tip 140 | } 141 | 142 | // Public: Set or get the direction of the tooltip 143 | // 144 | // v - One of n(north), s(south), e(east), or w(west), nw(northwest), 145 | // sw(southwest), ne(northeast) or se(southeast) 146 | // 147 | // Returns tip or direction 148 | tip.direction = function(v) { 149 | if (!arguments.length) return direction 150 | direction = v == null ? v : d3.functor(v) 151 | 152 | return tip 153 | } 154 | 155 | // Public: Sets or gets the offset of the tip 156 | // 157 | // v - Array of [x, y] offset 158 | // 159 | // Returns offset or 160 | tip.offset = function(v) { 161 | if (!arguments.length) return offset 162 | offset = v == null ? v : d3.functor(v) 163 | 164 | return tip 165 | } 166 | 167 | // Public: sets or gets the html value of the tooltip 168 | // 169 | // v - String value of the tip 170 | // 171 | // Returns html value or tip 172 | tip.html = function(v) { 173 | if (!arguments.length) return html 174 | html = v == null ? v : d3.functor(v) 175 | 176 | return tip 177 | } 178 | 179 | // Public: destroys the tooltip and removes it from the DOM 180 | // 181 | // Returns a tip 182 | tip.destroy = function() { 183 | if(node) { 184 | getNodeEl().remove(); 185 | node = null; 186 | } 187 | return tip; 188 | } 189 | 190 | function d3_tip_direction() { return 'n' } 191 | function d3_tip_offset() { return [0, 0] } 192 | function d3_tip_html() { return ' ' } 193 | function d3_tip_absolute() { return [0, 0] } 194 | 195 | var direction_callbacks = d3.map({ 196 | n: direction_n, 197 | s: direction_s, 198 | e: direction_e, 199 | w: direction_w, 200 | nw: direction_nw, 201 | ne: direction_ne, 202 | sw: direction_sw, 203 | se: direction_se 204 | }), 205 | 206 | directions = direction_callbacks.keys() 207 | 208 | function direction_n() { 209 | var bbox = getScreenBBox() 210 | return { 211 | top: bbox.n.y - node.offsetHeight, 212 | left: bbox.n.x - node.offsetWidth / 2 213 | } 214 | } 215 | 216 | function direction_s() { 217 | var bbox = getScreenBBox() 218 | return { 219 | top: bbox.s.y, 220 | left: bbox.s.x - node.offsetWidth / 2 221 | } 222 | } 223 | 224 | function direction_e() { 225 | var bbox = getScreenBBox() 226 | return { 227 | top: bbox.e.y - node.offsetHeight / 2, 228 | left: bbox.e.x 229 | } 230 | } 231 | 232 | function direction_w() { 233 | var bbox = getScreenBBox() 234 | return { 235 | top: bbox.w.y - node.offsetHeight / 2, 236 | left: bbox.w.x - node.offsetWidth 237 | } 238 | } 239 | 240 | function direction_nw() { 241 | var bbox = getScreenBBox() 242 | return { 243 | top: bbox.nw.y - node.offsetHeight, 244 | left: bbox.nw.x - node.offsetWidth 245 | } 246 | } 247 | 248 | function direction_ne() { 249 | var bbox = getScreenBBox() 250 | return { 251 | top: bbox.ne.y - node.offsetHeight, 252 | left: bbox.ne.x 253 | } 254 | } 255 | 256 | function direction_sw() { 257 | var bbox = getScreenBBox() 258 | return { 259 | top: bbox.sw.y, 260 | left: bbox.sw.x - node.offsetWidth 261 | } 262 | } 263 | 264 | function direction_se() { 265 | var bbox = getScreenBBox() 266 | return { 267 | top: bbox.se.y, 268 | left: bbox.e.x 269 | } 270 | } 271 | 272 | function initNode() { 273 | var node = d3.select(document.createElement('div')) 274 | node.style({ 275 | position: 'absolute', 276 | top: 0, 277 | opacity: 0, 278 | 'pointer-events': 'none', 279 | 'box-sizing': 'border-box' 280 | }) 281 | 282 | return node.node() 283 | } 284 | 285 | function getSVGNode(el) { 286 | el = el.node() 287 | if(el.tagName.toLowerCase() === 'svg') 288 | return el 289 | 290 | return el.ownerSVGElement 291 | } 292 | 293 | function getNodeEl() { 294 | if(node === null) { 295 | node = initNode(); 296 | // re-add node to DOM 297 | document.body.appendChild(node); 298 | }; 299 | return d3.select(node); 300 | } 301 | 302 | // Private - gets the screen coordinates of a shape 303 | // 304 | // Given a shape on the screen, will return an SVGPoint for the directions 305 | // n(north), s(south), e(east), w(west), ne(northeast), se(southeast), nw(northwest), 306 | // sw(southwest). 307 | // 308 | // +-+-+ 309 | // | | 310 | // + + 311 | // | | 312 | // +-+-+ 313 | // 314 | // Returns an Object {n, s, e, w, nw, sw, ne, se} 315 | function getScreenBBox() { 316 | var targetel = target || d3.event.target; 317 | 318 | while ('undefined' === typeof targetel.getScreenCTM && 'undefined' === targetel.parentNode) { 319 | targetel = targetel.parentNode; 320 | } 321 | 322 | var bbox = {}, 323 | matrix = targetel.getScreenCTM(), 324 | tbbox = targetel.getBBox(), 325 | width = tbbox.width, 326 | height = tbbox.height, 327 | x = tbbox.x, 328 | y = tbbox.y 329 | 330 | point.x = x 331 | point.y = y 332 | bbox.nw = point.matrixTransform(matrix) 333 | point.x += width 334 | bbox.ne = point.matrixTransform(matrix) 335 | point.y += height 336 | bbox.se = point.matrixTransform(matrix) 337 | point.x -= width 338 | bbox.sw = point.matrixTransform(matrix) 339 | point.y -= height / 2 340 | bbox.w = point.matrixTransform(matrix) 341 | point.x += width 342 | bbox.e = point.matrixTransform(matrix) 343 | point.x -= width / 2 344 | point.y -= height / 2 345 | bbox.n = point.matrixTransform(matrix) 346 | point.y += height 347 | bbox.s = point.matrixTransform(matrix) 348 | 349 | return bbox 350 | } 351 | 352 | return tip 353 | }; 354 | 355 | })); 356 | -------------------------------------------------------------------------------- /js/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Helper utilities; supports tidy code organization 4 | 5 | */ 6 | 7 | 8 | /* Ensure leaf nodes are not overlapping 9 | 10 | Finds the smallest vertical distance between leaves 11 | and scales things to a minimum distance so that 12 | branches don't overlap. 13 | 14 | Note that this will update the node.x positions 15 | for all nodes found in passed var 'nodes' as well 16 | as update the global 'links' var. 17 | 18 | Parameters: 19 | =========== 20 | - tree : d3.tree layout (cluster) 21 | - nodes : d3.tree nodes 22 | - minSeparation : int (default: 22) 23 | mininum distance between leaf nodes 24 | 25 | Returns: 26 | ======== 27 | - xscale : d3.scale 28 | scale for leaf height separation; given the 29 | svg height, it will scale properly so leaf 30 | nodes have minimum separation 31 | */ 32 | function scaleLeafSeparation(tree, nodes, minSeparation=22) { 33 | 34 | var traverseTree = function(root, callback) { 35 | callback(root); 36 | if (root.children) { 37 | for (var i = root.children.length - 1; i >= 0; i--){ 38 | traverseTree(root.children[i], callback) 39 | }; 40 | } 41 | } 42 | 43 | // get all leaf X positions 44 | leafXpos = []; 45 | traverseTree(nodes[0], function(node) { 46 | if (!node.children) { 47 | leafXpos.push(node.x); 48 | } 49 | }); 50 | 51 | // calculate leaf vertical distances 52 | leafXdist = []; 53 | leafXpos = leafXpos.sort(function(a, b) { return a-b }); 54 | leafXpos.forEach( function(x,i) { 55 | if (i + 1 != leafXpos.length) { 56 | var dist = leafXpos[i + 1] - x; 57 | if (dist) { 58 | leafXdist.push(dist); 59 | } 60 | } 61 | }) 62 | 63 | var xScale = d3.scale.linear() 64 | .range([0, minSeparation]) 65 | .domain([0, d3.min(leafXdist)]) 66 | 67 | // update node x pos & links 68 | traverseTree(nodes[0], function(node) { 69 | node.x = xScale(node.x) 70 | }) 71 | 72 | links = tree.links(nodes); 73 | 74 | return xScale; 75 | } 76 | 77 | 78 | /* Scale tree by distance metric 79 | 80 | Will iterate through tree and set the attribute 81 | rootDist (at each node) and will adjust the 82 | y-pos of the tree properly 83 | 84 | Parameters: 85 | =========== 86 | - nodes : d3.tree nodes 87 | - width : int 88 | svg width 89 | 90 | Returns: 91 | ======== 92 | - yscale : d3.scale 93 | horizontal scale for svg 94 | */ 95 | function scaleBranchLengths(nodes) { 96 | 97 | // Visit all nodes and adjust y pos width distance metric 98 | var visitPreOrder = function(root, callback) { 99 | callback(root) 100 | if (root.children) { 101 | for (var i = root.children.length - 1; i >= 0; i--){ 102 | visitPreOrder(root.children[i], callback) 103 | }; 104 | } 105 | } 106 | visitPreOrder(nodes[0], function(node) { 107 | node.rootDist = (node.parent ? node.parent.rootDist : 0) + (node.length || 0) 108 | }) 109 | var rootDists = nodes.map(function(n) { return n.rootDist; }); 110 | 111 | var yscale = d3.scale.linear() 112 | .domain([0, d3.max(rootDists)]) 113 | .range([0, width]); 114 | 115 | visitPreOrder(nodes[0], function(node) { 116 | node.y = yscale(node.rootDist) 117 | }) 118 | return yscale 119 | } 120 | 121 | 122 | 123 | 124 | // https://bl.ocks.org/mbostock/c034d66572fd6bd6815a 125 | // Like d3.svg.diagonal.radial, but with square corners. 126 | function step(startAngle, startRadius, endAngle, endRadius) { 127 | var c0 = Math.cos(startAngle = (startAngle - 90) / 180 * Math.PI), 128 | s0 = Math.sin(startAngle), 129 | c1 = Math.cos(endAngle = (endAngle - 90) / 180 * Math.PI), 130 | s1 = Math.sin(endAngle); 131 | return "M" + startRadius * c0 + "," + startRadius * s0 132 | + (endAngle === startAngle ? "" : "A" + startRadius + "," + startRadius + " 0 0 " + (endAngle > startAngle ? 1 : 0) + " " + startRadius * c1 + "," + startRadius * s1) 133 | + "L" + endRadius * c1 + "," + endRadius * s1; 134 | } 135 | 136 | 137 | // http://bl.ocks.org/mbostock/2429963 138 | // draw right angle links to join nodes 139 | function elbow(d, i) { 140 | return "M" + d.source.y + "," + d.source.x 141 | + "V" + d.target.x + "H" + d.target.y; 142 | } 143 | 144 | 145 | 146 | 147 | 148 | 149 | /* Master format tree function 150 | 151 | Parameters: 152 | =========== 153 | - nodes : d3 tree nodes 154 | - links : d3 tree links 155 | - yscale : quantitative scale 156 | horizontal scaling factor for distance 157 | if null, ruler is not drawn 158 | - xscale : quantitative scale 159 | vertical scale 160 | if null, ruler is not drawn 161 | - height : int 162 | height of svg 163 | - opts : obj 164 | tree opts, see documentation for keys 165 | 166 | */ 167 | function formatTree(nodes, links, yscale=null, xscale=null, height, opts) { 168 | 169 | /* Format links (branches) of tree 170 | formatLinks 171 | 172 | Will render the lines connecting nodes (links) 173 | with right angle elbows. 174 | 175 | Parameters: 176 | =========== 177 | - svg : svg selctor 178 | svg HTML element into which to render 179 | - links : d3.tree.links 180 | - opts : obj 181 | tree opts, see documentation for keys 182 | 183 | 184 | */ 185 | 186 | // set to global! 187 | link = d3.select('#treeSVG').selectAll("path.link") 188 | .data(links) 189 | .enter().append("path") 190 | .attr("class","link") 191 | .style("fill","none") // setting style inline otherwise AI doesn't render properly 192 | .style("stroke","#aaa") 193 | .style("stroke-width","2px") 194 | 195 | d3.selectAll('.link') 196 | .attr("d", function(d) { return opts.tree == 'rectangular' ? elbow(d) : step(d.source.x, d.source.y, d.target.x, d.target.y); }) 197 | 198 | 199 | /* Render and format tree nodes 200 | formatNodes 201 | 202 | Will render all tree nodes as well as format them 203 | with color, shape, size; additionally all leaf 204 | nodes and internal nodes will get labels by default. 205 | 206 | A node is a generalized group which can contain shapes 207 | (circle) as well as labels (text). 208 | 209 | Parameters: 210 | =========== 211 | - svg : svg selctor 212 | svg HTML element into which to render 213 | - nodes : d3.tree.nodes 214 | - opts : obj 215 | tree opts, see documentation for keys 216 | 217 | 218 | */ 219 | 220 | // set default leaf radius if not present 221 | if (!('sliderLeafR' in opts)) { 222 | opts['sliderLeafR'] = 5; 223 | } 224 | 225 | node = d3.select('#treeSVG').selectAll("g.node") 226 | .data(nodes) 227 | .enter().append("g") 228 | .attr("class", function(n) { 229 | if (n.children) { 230 | if (n.depth == 0) { 231 | return "root node" 232 | } else { 233 | return "inner node" 234 | } 235 | } else { 236 | return "leaf node" 237 | } 238 | }) 239 | .attr("id", function(d) { 240 | if (!d.children) { 241 | var name = d.name.replace(new RegExp('\\.', 'g'), '_'); 242 | return 'leaf_' + name; 243 | } 244 | }) 245 | 246 | d3.selectAll('.node') 247 | .attr("transform", function(d) { 248 | if (opts.treeType == 'rectangular') { 249 | return "translate(" + d.y + "," + d.x + ")"; 250 | } else if (opts.treeType == 'radial') { 251 | return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")"; 252 | } 253 | }) 254 | 255 | d3.selectAll('.leaf') 256 | .on('mouseover', tip.show) 257 | .on('mouseout', tip.hide) 258 | 259 | // node backgrounds 260 | node.append("rect") 261 | .attr('width', 0 ) // width is set when choosing background color 262 | .attr('height', 10 + opts.sliderLeafR * 2) 263 | .attr('y', -opts.sliderLeafR - 5) 264 | .attr("opacity", function(d) { return d.children ? 1e-6 : 1 }); 265 | 266 | // node circles 267 | node.append("circle") 268 | .attr("r", function(d) { 269 | if (!d.children || d.depth == 0) { 270 | return opts.sliderLeafR; 271 | } else { 272 | return 3; 273 | } 274 | }); 275 | 276 | d3.selectAll('.inner.node circle') 277 | .on("mouseover", function() { 278 | d3.select(this) 279 | .transition() 280 | .duration(100) 281 | .attr("r",6); 282 | }) 283 | .on("mouseout", function() { 284 | d3.select(this) 285 | .transition() 286 | .duration(100) 287 | .attr("r",3); 288 | }) 289 | 290 | 291 | // node label 292 | node.append("text") 293 | .attr("class",function(d) { return d.children ? "distanceLabel" : "leafLabel" }) 294 | .attr("dy", function(d) { return d.children ? -6 : 3 }) 295 | .text(function(d) { 296 | if (d.children) { 297 | if (d.length && d.length.toFixed(2) > 0.01) { 298 | return d.length.toFixed(2); 299 | } else { 300 | return ''; 301 | } 302 | } else { 303 | if (opts['leafText']) { 304 | return d.name + ' (' + mapParse.get(opts['leafText']).get(d.name) + ')'; 305 | } else { 306 | return d.name + ' (' + d.length + ')'; 307 | } 308 | } 309 | }) 310 | .attr("opacity", function(d) { return opts.skipLabels ? 1e-6 : 1; }); 311 | 312 | orientTreeLabels(); 313 | 314 | 315 | 316 | /* Render and format background rules 317 | formatRuler 318 | 319 | Parameters: 320 | =========== 321 | - id : id selector 322 | id (with #) into which to render ruler 323 | - yscale : quantitative scale 324 | horizontal scaling factor for distance 325 | - xscale : quantitative scale 326 | vertical scale 327 | - height : int 328 | height of svg 329 | - opts : obj 330 | tree opts, expects a key hideRuler; 331 | if true, rules won't be drawn. also 332 | expects a key treeType (rectangular/radial) 333 | 334 | */ 335 | 336 | 337 | if (!opts.hideRuler && yscale != null) { 338 | 339 | if (opts.treeType == 'rectangular') { 340 | 341 | rulerG = d3.select('#rulerSVG').selectAll("g") 342 | .data(yscale.ticks(10)) 343 | .enter().append("g") 344 | .attr("class", "ruleGroup") 345 | .append('svg:line') 346 | .attr("class", "rule") 347 | .attr('y1', 0) 348 | .attr('y2', getTreeBox().height + margin.top + margin.bottom) 349 | .attr('x1', yscale) 350 | .attr('x2', yscale) 351 | 352 | 353 | } else if (opts.treeType == 'radial') { 354 | 355 | rulerG = d3.select('#rulerSVG').selectAll("g") 356 | .data(yscale.ticks(10)) 357 | .enter().append("g") 358 | .attr("class", "ruleGroup") 359 | .append('circle') 360 | .attr("class","rule") 361 | .attr('r', yscale); 362 | 363 | } 364 | } 365 | } 366 | 367 | 368 | /* Display error message 369 | 370 | Will display an error messages within 371 | a bootstrap3 alert div with a message 372 | 373 | Parameters: 374 | ========== 375 | - msg : string 376 | message to display within div, formatted as html 377 | - div : div 378 | div into which to render message 379 | 380 | Returns: 381 | ======= 382 | - nothing 383 | 384 | */ 385 | function displayErrMsg(msg, div) { 386 | 387 | showSpinner(null, false); 388 | 389 | d3.select(div).append('div') 390 | .attr('class','alert alert-danger lead col-sm-8 col-sm-offset-2') 391 | .style('margin-top','20px') 392 | .attr('role','alert') 393 | .html(msg); 394 | } 395 | 396 | 397 | 398 | /* When called, will display a div with a spinner 399 | 400 | Parameters: 401 | ========== 402 | - div : string 403 | div id (with included #) in which to generated tree 404 | - show : bool (default true) 405 | optional boolean to show div, when false the spinner 406 | will be removed 407 | */ 408 | 409 | function showSpinner(div, show=true) { 410 | 411 | if (!show) { 412 | d3.select('#spinner').remove(); 413 | } else { 414 | 415 | // give user a spinner for feedback 416 | var spinner = d3.select(div).append('div') 417 | .attr('id','spinner') 418 | .attr('class','lead alert alert-info col-sm-3 col-sm-offset-4') 419 | .style('margin-top','20px'); 420 | 421 | spinner.append('i') 422 | .attr('class','fa fa-cog fa-spin fa-3x fa-fw') 423 | spinner.append('span') 424 | .text('Reading file...'); // TODO center vertically 425 | } 426 | } 427 | 428 | 429 | 430 | 431 | /* Parse Newick tree 432 | 433 | Will process a Newick tree string into 434 | a format that d3 can parse. 435 | 436 | Parameters: 437 | ========== 438 | - fileStr : str 439 | a Newick tree read in as a string 440 | 441 | Returns: 442 | - returns parsed Newick object 443 | see: https://github.com/jasondavies/newick.js 444 | 445 | */ 446 | function processNewick(fileStr) { 447 | 448 | var newick = Newick.parse(fileStr) 449 | var newickNodes = [] 450 | function buildNewickNodes(node, callback) { 451 | newickNodes.push(node) 452 | if (node.branchset) { 453 | for (var i=0; i < node.branchset.length; i++) { 454 | buildNewickNodes(node.branchset[i]) 455 | } 456 | } 457 | } 458 | 459 | return newick; 460 | } 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | /* Process mapping file into useable format 470 | 471 | Function responsible for parsing the TSV mapping 472 | file which contains metadata for formatting the 473 | tree as well as generating color scales 474 | for use in the legend and coloring the tree. 475 | 476 | Note that any QIIME formatted taxonomy data 477 | found will automatically be cleaned up removing 478 | the level prefix and splitting the taxonomy on 479 | each level into its own metadata category. This 480 | allows users to color by a specific taxonomic 481 | level. 482 | 483 | It is assumed that the first column in the mapping 484 | file has the same values as the leaf names. 485 | 486 | Parameters: 487 | =========== 488 | - data: d3.tsv() parsed data 489 | input mapping file processed by d3.tsv; will be 490 | an array (where each row in the TSV is an array 491 | value) of objects where objects have col headers 492 | as keys and file values as values 493 | 494 | 495 | Returns: 496 | ======== 497 | - array 498 | returns an array of length 2: 499 | 0: d3.map() of parsed TSV data with file column 500 | headers as the keys and the values are a d3.map() 501 | where leaf names are keys (TSV rows) and values 502 | are the row/column values in the file. 503 | 1: d3.map() as colorScales where keys are file 504 | column headers and values are the color scales. 505 | scales take as input the leaf name (file row) 506 | */ 507 | function parseMapping(data) { 508 | 509 | // get mapping file column headers 510 | // we assume first column is the leaf ID 511 | var colTSV = d3.map(data[0]).keys(); 512 | var id = colTSV[0]; 513 | colTSV.shift(); // remove first col (ID) 514 | 515 | var mapParse = d3.map(); // {colHeader: { ID1: val, ID2: val } } 516 | 517 | taxaDat = {}; 518 | data.forEach(function(row) { 519 | var leafName = row[id]; 520 | colTSV.forEach( function(col, i) { 521 | var colVal = cleanTaxa(row[col]); 522 | if (!mapParse.has(col)) { 523 | var val = d3.map(); 524 | } else { 525 | var val = mapParse.get(col); 526 | } 527 | 528 | if (typeof colVal === 'object') { // if data was taxa info, it comes back as an obj 529 | for (var level in colVal) { 530 | var taxa = colVal[level]; 531 | if (!mapParse.has(level)) { 532 | var val = d3.map(); 533 | } else { 534 | var val = mapParse.get(level); 535 | } 536 | val.set(leafName, taxa); 537 | mapParse.set(level, val); 538 | } 539 | } else { 540 | val.set(leafName, colVal); 541 | mapParse.set(col, val); 542 | } 543 | }) 544 | }) 545 | 546 | // setup color scales for mapping columns 547 | // keys are mapping column headers and values are scales 548 | // for converting column value to a color 549 | var colorScales = d3.map(); 550 | mapParse.forEach(function(k, v) { // v is a d3.set of mapping column values, with leaf ID has key 551 | 552 | // check if values for mapping column are string or numbers 553 | // strings are turned into ordinal scales, numbers into quantitative 554 | // we simply check the first value in the obj 555 | var vals = autoSort(v.values(), true); 556 | var scale; 557 | if (typeof vals[0] === 'string' || vals[0] instanceof String) { // ordinal scale 558 | var tmp = d3.scale.category10(); 559 | if (vals.length > 10) { 560 | tmp = d3.scale.category10();; 561 | } 562 | scale = tmp.domain(vals); 563 | } else { // quantitative scale 564 | scale = d3.scale.quantize() 565 | .domain(d3.extent(vals)) 566 | .range(colorbrewer.Spectral[11]); 567 | } 568 | colorScales.set(k, scale); 569 | }) 570 | 571 | return [mapParse, colorScales]; 572 | } 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | /* Clean-up a QIIME formatted taxa string 583 | 584 | Will clean-up a QIIME formatted taxonomy string 585 | by removing the class prefix and returning the 586 | original taxa string as an object split into taxonomic 587 | levels e.g. {"Kingdom":"bacteria", ... } 588 | 589 | NOTE: any taxa level with an assignment "unassigned" 590 | will be thrown out - this way the tree will not 591 | color by this level (tree can only be colored by 592 | defined taxa) 593 | 594 | Parameters: 595 | =========== 596 | - taxa : string 597 | QIIME formatted string 598 | 599 | Returns: 600 | ======== 601 | - cleaned string 602 | 603 | */ 604 | function cleanTaxa(taxa) { 605 | 606 | if ((typeof taxa === 'string' || taxa instanceof String) && taxa.slice(0, 2) == 'k_') { 607 | 608 | var str = taxa.replace(/.__/g, ""); 609 | 610 | // some taxa strings end in ';' some don't, 611 | // remove it if it exists 612 | if (str.substr(str.length - 1) == ';') { 613 | str = str.substring(0, str.length - 1); 614 | } 615 | 616 | var clean = str.split(";"); 617 | 618 | var ret = {}; 619 | 620 | // construct object 621 | var taxaLevels = ['Taxa [Kingdom]','Taxa [Phylum]','Taxa [Class]','Taxa [Order]','Taxa [Family]','Taxa [Genus]','Taxa [Species]']; 622 | clean.forEach(function(taxa, i) { 623 | if (taxa != 'unassigned') { 624 | ret[taxaLevels[i]] = taxa; 625 | } 626 | }) 627 | 628 | return ret; 629 | 630 | } else { 631 | 632 | return taxa; 633 | 634 | } 635 | 636 | } 637 | 638 | // get the viewBox attribute of the outermost svg in 639 | // format {x0, y0, x1, y1} 640 | function getViewBox() { 641 | var vb = jQuery('svg')[0].getAttribute('viewBox'); 642 | 643 | if (vb) { 644 | var arr = vb.split(' ').map(function(d) { return parseInt(d); }) 645 | return {'x0':arr[0], 'y0':arr[1], 'x1':arr[2], 'y1':arr[3]}; 646 | } else { 647 | return false; 648 | } 649 | } 650 | 651 | 652 | 653 | 654 | /* Fit the SVG viewBox to browser size 655 | 656 | function called by "center view" button in GUI 657 | 658 | Will adjust the X-Y position as well as the zoom 659 | so that the entire tree (width & height) are fit 660 | on the screen. It then aligns the left-most 661 | and top-most elements with the window. 662 | 663 | */ 664 | function fitTree() { 665 | 666 | var y1 = window.innerHeight; 667 | var x1 = window.innerWidth; 668 | 669 | d3.select('svg').attr("viewBox", "0 0 " + parseInt(x1) + " " + parseInt(y1)); // fit viewbox 670 | 671 | // reset position 672 | d3.select('#canvasSVG') 673 | .attr('transform','translate(0,0) scale(1)') 674 | 675 | // get bounding box of content to fit 676 | if (treeType == 'rectangular') { 677 | var content = d3.select('#canvasSVG').node().getBoundingClientRect(); 678 | } else { 679 | var content = d3.select('#treeSVG').node().getBoundingClientRect(); 680 | var root = d3.select('.root').node().getBoundingClientRect(); 681 | console.log(content, d3.select('#treeSVG').node().getBoundingClientRect()) 682 | } 683 | 684 | var zoomScale = d3.min([ 685 | (jQuery('#gui').outerWidth() - margin.left - margin.right) / content.width, 686 | (y1 - jQuery('#gui').outerHeight(true) - margin.bottom - margin.top) / content.height 687 | ]); 688 | 689 | svg.call(zoom.event); 690 | 691 | zoom.scale(zoomScale); 692 | if (treeType == 'rectangular') { 693 | zoom.translate([margin.left,margin.top]); 694 | } else { 695 | zoom.translate([x1 / 2, (root.bottom - content.top) * zoom.scale()]); 696 | } 697 | 698 | svg.transition().duration(750).call(zoom.event); 699 | 700 | } 701 | 702 | 703 | 704 | 705 | // get the transform values of selection 706 | // returns array [X,Y] 707 | function getTransform(sel) { 708 | 709 | var transf = d3.select(sel).attr('transform').replace('translate(','').replace(')',''); 710 | var tmp = transf.split(','); 711 | 712 | return [parseInt(tmp[0]), parseInt(tmp[1])]; 713 | } 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | // get BoundingClientRect of tree 722 | function getTreeBox() { 723 | 724 | if (treeType == 'rectangular') { 725 | var tmp_height = d3.extent(nodes.map(function(d) { return d.x })); 726 | var tmp_width = d3.extent(nodes.map(function(d) { return d.y })); // note width will be off since it doesn't take into account the label text 727 | return {'height':tmp_height[1] - tmp_height[0], 'width':tmp_width[1] - tmp_width[0] }; 728 | } else { 729 | 730 | return d3.select('#treeSVG').node().getBoundingClientRect(); 731 | } 732 | 733 | 734 | } 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | /* Generated front-end GUI controls 744 | 745 | Build all the HTML elements that serve as GUI controls for 746 | editing the tree format. 747 | 748 | If a mapping file is provided, function will generate 749 | dropdowns for the mapping file columns; one for leaf color 750 | and one for leaf background. 751 | 752 | Parameters: 753 | ========== 754 | - selector : string 755 | div ID (with '#') into which to place GUI controls 756 | - opts : obj 757 | opts obj, same as passed to init() 758 | if present mapping file key present, two 759 | select dropdowns are generated with the columns 760 | of the file. one dropdown is for coloring the 761 | leaf nodes, the other for the leaf backgrounds. 762 | The first index of the output of parseMapping() 763 | should be used here. 764 | */ 765 | 766 | function buildGUI(selector, opts) { 767 | 768 | var collapse = d3.select(selector).append('div') 769 | .attr("class","panel panel-default") 770 | .attr("id", "gui") 771 | .style("margin-top","20px"); 772 | 773 | collapse.append("div") 774 | .attr("class","panel-heading") 775 | .style("overflow","auto") 776 | .attr("role","tab") 777 | .append("h4") 778 | .attr("class","panel-title") 779 | .append("a") 780 | .attr("role","button") 781 | .attr("data-toggle","collapse") 782 | .attr("data-parent","#accordion") 783 | .attr("href","#collapseGUI") 784 | .attr("aria-expanded","true") 785 | .attr("aria-controls","collapseGUI") 786 | //.html(" Controls") 787 | .html('Controls') 788 | .append("button") 789 | .attr('class', 'btn btn-success pull-right btn') 790 | .style('padding','1px 7px') 791 | .on("click",saveSVG) 792 | .append('i') 793 | .attr('class','fa fa-floppy-o') 794 | .attr('title','Save image') 795 | 796 | var gui = collapse.append("div") 797 | .attr("id","collapseGUI") 798 | .attr("class","panel-collapse collapse in") 799 | .attr("role","tabpanel") 800 | .attr("aria-labelledby","headingOne") 801 | .append("div") 802 | .attr("class","panel-body") 803 | 804 | 805 | var guiRow1 = gui.append("div") 806 | .attr("class","row") 807 | 808 | var col1 = guiRow1.append("div") 809 | .attr("class","col-sm-2") 810 | 811 | var tmp = col1.append("div") 812 | .attr("class","btn-toolbar btn-group") 813 | 814 | tmp.append("button") 815 | .attr("class","btn btn-info") 816 | .attr("id","rectangular") 817 | .attr("title","Generate a rectangular layout") 818 | .attr("onclick","options.treeType = this.id; updateTree();") 819 | .html('') 820 | 821 | tmp.append("button") 822 | .attr("class","btn btn-warning") 823 | .attr("id","radial") 824 | .attr("title","Generate a radial layout") 825 | .attr("onclick","options.treeType = this.id; updateTree();") 826 | .html('') 827 | 828 | tmp.append("button") 829 | .attr("class","btn btn-success") 830 | .attr("id","reset") 831 | .attr("title","Reset view") 832 | .attr("onclick","fitTree();") 833 | .html('') 834 | 835 | var check1 = col1.append("div") 836 | .attr("class","checkbox") 837 | .append("label") 838 | 839 | check1.append("input") 840 | .attr("type","checkbox") 841 | .attr("id","toggle_distance") 842 | .attr("checked","") 843 | .attr("onclick","updateTree()") 844 | 845 | check1.append('text') 846 | .text("Toggle distance labels") 847 | 848 | var check2 = col1.append("div") 849 | .attr("class","checkbox") 850 | .append("label") 851 | 852 | check2.append("input") 853 | .attr("type","checkbox") 854 | .attr("id","toggle_leaf") 855 | .attr("checked","") 856 | .attr("onclick","updateTree()") 857 | 858 | check2.append('text') 859 | .text("Toggle leaf labels") 860 | 861 | var check3 = col1.append("div") 862 | .attr("class","checkbox") 863 | .append("label") 864 | 865 | check3.append("input") 866 | .attr("type","checkbox") 867 | .attr("id","scale_distance") 868 | .attr("checked","") 869 | .attr("onclick","updateTree()") 870 | 871 | check3.append('text') 872 | .text("Scale by distance") 873 | 874 | 875 | // if mapping file was passed 876 | if (mapParse && !mapParse.empty() && typeof mapParse != 'undefined') { 877 | 878 | // select for leaf color 879 | var col2 = guiRow1.append("div") 880 | .attr("class","col-sm-2 form-group") 881 | 882 | col2.append("label") 883 | .text("Leaf node color") 884 | 885 | var select1 = col2.append("select") 886 | .attr('onchange','updateTree();') 887 | .attr('id','leafColor') 888 | .attr("class","form-control") 889 | 890 | select1.selectAll("option") 891 | .data(mapParse.keys()).enter() 892 | .append("option") 893 | .attr('value',function(d) { return d; }) 894 | .text(function(d) { return d; }) 895 | 896 | select1.append("option") 897 | .attr("selected","") 898 | .attr("value","") 899 | .text('None'); 900 | // select for leaf color 901 | 902 | 903 | // select for background color 904 | col2.append("label") 905 | .text("Leaf background color") 906 | 907 | var select2 = col2.append("select") 908 | .attr('onchange','updateTree()') 909 | .attr('id','backgroundColor') 910 | .attr("class","form-control") 911 | 912 | select2.selectAll("option") 913 | .data(mapParse.keys()).enter() 914 | .append("option") 915 | .text(function(d) { return d; }) 916 | 917 | select2.append("option") 918 | .attr("selected","") 919 | .attr("value","") 920 | .text('None'); 921 | // select for background color 922 | 923 | 924 | // select for leaf text 925 | var col3 = guiRow1.append("div") 926 | .attr("class","col-sm-2 form-group") 927 | 928 | col3.append("label") 929 | .text("Leaf node label") 930 | 931 | var select3 = col3.append("select") 932 | .attr('onchange','updateTree();') 933 | .attr('id','leafText') 934 | .attr("class","form-control") 935 | 936 | select3.selectAll("option") 937 | .data(mapParse.keys()).enter() 938 | .append("option") 939 | .attr('value',function(d) { return d; }) 940 | .text(function(d) { return d; }) 941 | 942 | select3.append("option") 943 | .attr("selected","") 944 | .attr("value","distance") 945 | .text('Distance'); 946 | // select for leaf text 947 | } 948 | var col4 = guiRow1.append("div") 949 | .attr("class","col-sm-2") 950 | 951 | col4.append("label") 952 | .text("Vertical scale") 953 | 954 | col4.append("div") 955 | .attr("id","scaleH") 956 | 957 | scaleHSlider = document.getElementById('scaleH') 958 | 959 | noUiSlider.create(scaleHSlider, { 960 | start: 22, 961 | step: 0.05, 962 | connect: [true, false], 963 | range: { 964 | 'min': 5, 965 | 'max': 100 966 | } 967 | }); 968 | 969 | scaleHSlider.noUiSlider.on('slide', function(){ 970 | updateTree(); 971 | }); 972 | 973 | col4.append("label") 974 | .text("Leaf radius") 975 | 976 | col4.append("div") 977 | .attr("id","leafR") 978 | 979 | leafRSlider = document.getElementById('leafR') 980 | 981 | noUiSlider.create(leafRSlider, { 982 | start: 5, 983 | step: 0.05, 984 | connect: [true, false], 985 | range: { 986 | 'min': 1, 987 | 'max': 20 988 | } 989 | }); 990 | 991 | leafRSlider.noUiSlider.on('slide', function(){ 992 | updateTree(); 993 | }); 994 | 995 | col4.append("label") 996 | .text("Rotation") 997 | 998 | col4.append("div") 999 | .attr("id","rotation") 1000 | 1001 | rotationSlider = document.getElementById('rotation') 1002 | 1003 | noUiSlider.create(rotationSlider, { 1004 | start: 0, 1005 | connect: [true, false], 1006 | range: { 1007 | 'min': -180, 1008 | 'max': 180 1009 | } 1010 | }); 1011 | 1012 | rotationSlider.noUiSlider.on('slide', function(){ 1013 | rotateTree(); 1014 | }); 1015 | rotationSlider.noUiSlider.on('end', function(){ 1016 | updateTree(); 1017 | orientTreeLabels(); 1018 | }); 1019 | 1020 | } 1021 | 1022 | 1023 | 1024 | 1025 | 1026 | /* Automatically sort an array 1027 | 1028 | Given an array of strings, were the string 1029 | could be a float (e.g. "1.2") or an int 1030 | (e.g. "5"), this function will convert the 1031 | array if all strings are ints or floats and 1032 | sort it (either alphabetically or numerically 1033 | ascending). 1034 | 1035 | Parameters: 1036 | =========== 1037 | - arr: array of strings 1038 | an array of strings 1039 | - unique: bool 1040 | default: false 1041 | if true, only unique values will be returned 1042 | 1043 | Returns: 1044 | - sorted, converted array, will be either 1045 | all strings or all numbers 1046 | 1047 | */ 1048 | function autoSort(arr, unique=false) { 1049 | 1050 | // get unique values of array 1051 | // by converting to d3.set() 1052 | if (unique) { arr = d3.set(arr).values(); } 1053 | 1054 | var vals = arr.map(filterTSVval); // convert to int or float if needed 1055 | var sorted = (typeof vals[0] === 'string' || vals[0] instanceof String) ? vals.sort() : vals.sort(function(a,b) { return a - b; }).reverse(); 1056 | 1057 | return sorted; 1058 | 1059 | } 1060 | 1061 | 1062 | 1063 | 1064 | 1065 | 1066 | 1067 | 1068 | // helper function for filtering input TSV values 1069 | // will automatically detect if value is int, float or string and return 1070 | // it as such 1071 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseFloat 1072 | function filterTSVval(value) { 1073 | if (parseFloat(value)) { // if float 1074 | return parseFloat(value); 1075 | } else if (parseInt(value)) { // if int 1076 | return parseInt(value); 1077 | } 1078 | 1079 | // ignore blank values 1080 | if (value != '') { 1081 | return value; 1082 | } else { 1083 | return null; 1084 | } 1085 | } 1086 | 1087 | 1088 | 1089 | 1090 | /* Function for styling tooltip content 1091 | 1092 | Parameters: 1093 | ========== 1094 | - d : node attributes 1095 | 1096 | - mapParse : obj (optional) 1097 | optional parsed mapping file; keys are mapping file 1098 | column headers, values are d3 map obj with key as 1099 | node name and value as file value 1100 | 1101 | Returns: 1102 | ======== 1103 | - formatted HTML with all node data 1104 | 1105 | */ 1106 | function formatTooltip(d, mapParse) { 1107 | var html = "
Leaf " + d.name + "
"; 1108 | 1109 | if (mapParse) { 1110 | html += '
'; 1111 | mapParse.keys().forEach(function(col) { 1112 | html += '

- ' + col + ': ' + mapParse.get(col).get(d.name) + '

'; 1113 | }) 1114 | } 1115 | 1116 | return html; 1117 | } 1118 | 1119 | 1120 | // when called, will open a new tab with the SVG 1121 | // which can then be right-clicked and 'save as...' 1122 | function saveSVG(){ 1123 | 1124 | var viewX = getViewBox().x1; 1125 | var viewY = d3.select("#canvasSVG").node().getBoundingClientRect().height; 1126 | 1127 | d3.select('svg') 1128 | .attr('width', viewX) 1129 | .attr('height', viewY) 1130 | .attr('viewBox',null); 1131 | 1132 | // get styles from all stylesheets 1133 | // http://www.coffeegnome.net/converting-svg-to-png-with-canvg/ 1134 | var style = "\n"; 1135 | for (var i=0; i 180 && treeType == 'radial' ? "rotate(180)" : "" }) 1461 | .attr("text-anchor", function(d) { return addAngles(d.x, deg) > 180 && treeType == 'radial' ? "end" : "start" }) 1462 | .attr("dx", function(d) { 1463 | if (d.children) { // if inner node 1464 | return treeType == 'radial' && addAngles(deg, d.x) > 180 ? 20 : -20; 1465 | } else { // if leaf node 1466 | return treeType == 'radial' && addAngles(deg, d.x) > 180 ? -(5 + rad) : (5 + rad); 1467 | } 1468 | }) 1469 | 1470 | 1471 | } 1472 | 1473 | // given two angles, will return the sum clamped to [0, 360] 1474 | function addAngles(a,b) { 1475 | 1476 | var sum = parseFloat(a) + parseFloat(b); 1477 | 1478 | if (sum > 360) { 1479 | return sum - 360; 1480 | } else if (sum < 0) { 1481 | return sum + 360; 1482 | } else { 1483 | return sum; 1484 | } 1485 | } 1486 | 1487 | 1488 | /* Set options global 1489 | 1490 | Should be called everytime the tree needs to be updated due to 1491 | changes in the GUI 1492 | 1493 | */ 1494 | function getGUIoptions() { 1495 | 1496 | // set tree type if GUI was updated 1497 | // by anything other than tree type 1498 | // buttons 1499 | if (!('treeType' in options)) { 1500 | options.treeType = treeType; 1501 | } 1502 | 1503 | // somewhere in the code, global var 'options' is 1504 | // being emptied ({}) so we are resetting the 1505 | // mapping info here 1506 | if (typeof mappingFile != 'undefined') { 1507 | options.mapping = mapParse; 1508 | options.colorScale = colorScales; 1509 | } 1510 | 1511 | if (options.treeType != treeType) { 1512 | var typeChange = true; 1513 | } else { 1514 | var typeChange = false; 1515 | } 1516 | options.typeChange = typeChange; 1517 | treeType = options.treeType; // update current tree type 1518 | 1519 | 1520 | // get checkbox state 1521 | options.skipDistanceLabel = !jQuery('#toggle_distance').is(':checked'); 1522 | options.skipLeafLabel = !jQuery('#toggle_leaf').is(':checked'); 1523 | options.skipBranchLengthScaling = !jQuery('#scale_distance').is(':checked'); 1524 | 1525 | // get slider vals 1526 | options.sliderScaleV = parseInt(scaleHSlider.noUiSlider.get()); 1527 | options.sliderLeafR = parseInt(leafRSlider.noUiSlider.get()); 1528 | 1529 | // get dropdown values 1530 | var leafColor, backgroundColor; 1531 | if ('mapping' in options && !options.mapping.empty()) { 1532 | var e = document.getElementById("leafColor"); 1533 | options['leafColor'] = e.options[e.selectedIndex].value; 1534 | var e = document.getElementById("leafText"); 1535 | options['leafText'] = e.options[e.selectedIndex].value; 1536 | var e = document.getElementById("backgroundColor"); 1537 | options['backgroundColor'] = e.options[e.selectedIndex].value; 1538 | } 1539 | 1540 | } 1541 | 1542 | 1543 | /* Generate tree legend if needed 1544 | */ 1545 | function updateLegend() { 1546 | 1547 | // remove legend if one exists so we can update 1548 | d3.selectAll("#legendID g").remove() 1549 | 1550 | // update leaf node 1551 | if (options.leafColor != '') { 1552 | var colorScale = options.colorScale.get(options.leafColor); // color scale 1553 | var mapVals = options.mapping.get(options.leafColor); // d3.map() obj with leaf name as key 1554 | 1555 | // fill out legend 1556 | generateLegend(options.leafColor, mapVals, colorScale, 'circle'); 1557 | 1558 | // update node styling 1559 | svg.selectAll('g.leaf.node circle') 1560 | .transition() 1561 | .style('fill', function(d) { 1562 | //console.log(d.name, mapVals.get(d.name), colorScale(mapVals.get(d.name))) 1563 | return mapVals.get(d.name) ? dimColor(colorScale(mapVals.get(d.name))) : 'white' 1564 | }) 1565 | .style('stroke', function(d) { 1566 | return mapVals.get(d.name) ? colorScale(mapVals.get(d.name)) : 'gray' 1567 | }) 1568 | 1569 | } else if (options.leafColor == '') { 1570 | svg.selectAll('g.leaf.node circle') 1571 | .transition() 1572 | .attr("style",""); 1573 | } 1574 | 1575 | // update leaf background 1576 | if (options.backgroundColor != '') { 1577 | var colorScale = colorScales.get(options.backgroundColor) // color scale 1578 | var mapVals = mapParse.get(options.backgroundColor) // d3.map() obj with leaf name as key 1579 | 1580 | 1581 | // fill out legend 1582 | var offset = 25; 1583 | generateLegend(options.backgroundColor, mapVals, colorScale, 'rect'); 1584 | 1585 | // update node background style 1586 | svg.selectAll('g.leaf.node rect') 1587 | .transition() 1588 | .duration(500) 1589 | .attr("width", function(d) { 1590 | var name = d.name.replace(new RegExp('\\.', 'g'), '_'); 1591 | var textWidth = d3.select('#leaf_' + name + ' text').node().getComputedTextLength(); 1592 | var radius = d3.select('#leaf_' + name + ' circle').node().getBBox().height / 2.0; 1593 | return textWidth + radius + 10; // add extra so background is wider than label 1594 | }) 1595 | .style('fill', function(d) { 1596 | return mapVals.get(d.name) ? colorScale(mapVals.get(d.name)) : 'none' 1597 | }) 1598 | .style('opacity',1) 1599 | } else if (options.backgroundColor == '') { 1600 | svg.selectAll('g.leaf.node rect') 1601 | .transition() 1602 | .duration(500) 1603 | .attr('width','0') 1604 | .style('opacity','1e-6') 1605 | } 1606 | 1607 | if (options.backgroundColor != '' || options.leafColor != '') { 1608 | positionLegend(); 1609 | } 1610 | 1611 | 1612 | d3.select('svg').attr("viewBox", "0 0 " + parseInt(window.innerWidth) + " " + parseInt(window.innerHeight)); // set viewbox 1613 | 1614 | } 1615 | --------------------------------------------------------------------------------