D3 js Drag and Drop Zoomable Tree. Zoomable, Panning, Collapsible Tree with Auto-sizing.
Below you can see an example of a D3.js Drag and Drop Zoomable Tree which is collapsible, panning and auto-sizing.
I recently had a need for this functionality for a project and was unable to find any previous examples of such on the internet. Using the awesome D3.js library by Mike Bostock, I was able to get exactly what I needed with a bit of trial and error. Here I open up my efforts for others to leverage. It is certainly a visually pleasing and nifty tool. You can theme it with CSS as you would anything else.
This example pulls together various examples of work with trees in D3.js.
The panning functionality can certainly be improved in my opinion and I would be thrilled to see better solutions contributed.
One can do all manner of housekeeping or server related calls on the drop event to manage a remote tree dataset for example. However here I simply append any dropped node as a child to the node being dropped upon.
Dragging can be performed on any node other than root (flare). Dropping can be done on any node.
Panning can either be done by dragging an empty part of the SVG around or dragging a node towards an edge.
Zooming is performed by either double clicking on an empty part of the SVG or by scrolling the mouse-wheel. To Zoom out hold shift when double-clicking.
Expanding and collapsing of nodes is achieved by clicking on the desired node.
The tree auto-calculates its sizes both horizontally and vertically so it can adapt between many nodes being present in the view to very few whilst making the view manageable and pleasing on the eye.
The source code can be found here: https://gist.github.com/robschmuecker/7880033
And an example of it working with code in the same view is here:- http://bl.ocks.org/robschmuecker/7880033
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 |
/* Copyright (c) 2013-2016, Rob Schmuecker All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * The name Rob Schmuecker may not be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ // Get JSON data treeJSON = d3.json("flare.json", function(error, treeData) { // Calculate total nodes, max label length var totalNodes = 0; var maxLabelLength = 0; // variables for drag/drop var selectedNode = null; var draggingNode = null; // panning variables var panSpeed = 200; var panBoundary = 20; // Within 20px from edges will pan when dragging. // Misc. variables var i = 0; var duration = 750; var root; // size of the diagram var viewerWidth = $(document).width(); var viewerHeight = $(document).height(); var tree = d3.layout.tree() .size([viewerHeight, viewerWidth]); // define a d3 diagonal projection for use by the node paths later on. var diagonal = d3.svg.diagonal() .projection(function(d) { return [d.y, d.x]; }); // A recursive helper function for performing some setup by walking through all nodes function visit(parent, visitFn, childrenFn) { if (!parent) return; visitFn(parent); var children = childrenFn(parent); if (children) { var count = children.length; for (var i = 0; i < count; i++) { visit(children[i], visitFn, childrenFn); } } } // Call visit function to establish maxLabelLength visit(treeData, function(d) { totalNodes++; maxLabelLength = Math.max(d.name.length, maxLabelLength); }, function(d) { return d.children && d.children.length > 0 ? d.children : null; }); // sort the tree according to the node names function sortTree() { tree.sort(function(a, b) { return b.name.toLowerCase() < a.name.toLowerCase() ? 1 : -1; }); } // Sort the tree initially incase the JSON isn't in a sorted order. sortTree(); // TODO: Pan function, can be better implemented. function pan(domNode, direction) { var speed = panSpeed; if (panTimer) { clearTimeout(panTimer); translateCoords = d3.transform(svgGroup.attr("transform")); if (direction == 'left' || direction == 'right') { translateX = direction == 'left' ? translateCoords.translate[0] + speed : translateCoords.translate[0] - speed; translateY = translateCoords.translate[1]; } else if (direction == 'up' || direction == 'down') { translateX = translateCoords.translate[0]; translateY = direction == 'up' ? translateCoords.translate[1] + speed : translateCoords.translate[1] - speed; } scaleX = translateCoords.scale[0]; scaleY = translateCoords.scale[1]; scale = zoomListener.scale(); svgGroup.transition().attr("transform", "translate(" + translateX + "," + translateY + ")scale(" + scale + ")"); d3.select(domNode).select('g.node').attr("transform", "translate(" + translateX + "," + translateY + ")"); zoomListener.scale(zoomListener.scale()); zoomListener.translate([translateX, translateY]); panTimer = setTimeout(function() { pan(domNode, speed, direction); }, 50); } } // Define the zoom function for the zoomable tree function zoom() { svgGroup.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")"); } // define the zoomListener which calls the zoom function on the "zoom" event constrained within the scaleExtents var zoomListener = d3.behavior.zoom().scaleExtent([0.1, 3]).on("zoom", zoom); function initiateDrag(d, domNode) { draggingNode = d; d3.select(domNode).select('.ghostCircle').attr('pointer-events', 'none'); d3.selectAll('.ghostCircle').attr('class', 'ghostCircle show'); d3.select(domNode).attr('class', 'node activeDrag'); svgGroup.selectAll("g.node").sort(function(a, b) { // select the parent and sort the path's if (a.id != draggingNode.id) return 1; // a is not the hovered element, send "a" to the back else return -1; // a is the hovered element, bring "a" to the front }); // if nodes has children, remove the links and nodes if (nodes.length > 1) { // remove link paths links = tree.links(nodes); nodePaths = svgGroup.selectAll("path.link") .data(links, function(d) { return d.target.id; }).remove(); // remove child nodes nodesExit = svgGroup.selectAll("g.node") .data(nodes, function(d) { return d.id; }).filter(function(d, i) { if (d.id == draggingNode.id) { return false; } return true; }).remove(); } // remove parent link parentLink = tree.links(tree.nodes(draggingNode.parent)); svgGroup.selectAll('path.link').filter(function(d, i) { if (d.target.id == draggingNode.id) { return true; } return false; }).remove(); dragStarted = null; } // define the baseSvg, attaching a class for styling and the zoomListener var baseSvg = d3.select("#tree-container").append("svg") .attr("width", viewerWidth) .attr("height", viewerHeight) .attr("class", "overlay") .call(zoomListener); // Define the drag listeners for drag/drop behaviour of nodes. dragListener = d3.behavior.drag() .on("dragstart", function(d) { if (d == root) { return; } dragStarted = true; nodes = tree.nodes(d); d3.event.sourceEvent.stopPropagation(); // it's important that we suppress the mouseover event on the node being dragged. Otherwise it will absorb the mouseover event and the underlying node will not detect it d3.select(this).attr('pointer-events', 'none'); }) .on("drag", function(d) { if (d == root) { return; } if (dragStarted) { domNode = this; initiateDrag(d, domNode); } // get coords of mouseEvent relative to svg container to allow for panning relCoords = d3.mouse($('svg').get(0)); if (relCoords[0] < panBoundary) { panTimer = true; pan(this, 'left'); } else if (relCoords[0] > ($('svg').width() - panBoundary)) { panTimer = true; pan(this, 'right'); } else if (relCoords[1] < panBoundary) { panTimer = true; pan(this, 'up'); } else if (relCoords[1] > ($('svg').height() - panBoundary)) { panTimer = true; pan(this, 'down'); } else { try { clearTimeout(panTimer); } catch (e) { } } d.x0 += d3.event.dy; d.y0 += d3.event.dx; var node = d3.select(this); node.attr("transform", "translate(" + d.y0 + "," + d.x0 + ")"); updateTempConnector(); }).on("dragend", function(d) { if (d == root) { return; } domNode = this; if (selectedNode) { // now remove the element from the parent, and insert it into the new elements children var index = draggingNode.parent.children.indexOf(draggingNode); if (index > -1) { draggingNode.parent.children.splice(index, 1); } if (typeof selectedNode.children !== 'undefined' || typeof selectedNode._children !== 'undefined') { if (typeof selectedNode.children !== 'undefined') { selectedNode.children.push(draggingNode); } else { selectedNode._children.push(draggingNode); } } else { selectedNode.children = []; selectedNode.children.push(draggingNode); } // Make sure that the node being added to is expanded so user can see added node is correctly moved expand(selectedNode); sortTree(); endDrag(); } else { endDrag(); } }); function endDrag() { selectedNode = null; d3.selectAll('.ghostCircle').attr('class', 'ghostCircle'); d3.select(domNode).attr('class', 'node'); // now restore the mouseover event or we won't be able to drag a 2nd time d3.select(domNode).select('.ghostCircle').attr('pointer-events', ''); updateTempConnector(); if (draggingNode !== null) { update(root); centerNode(draggingNode); draggingNode = null; } } // Helper functions for collapsing and expanding nodes. function collapse(d) { if (d.children) { d._children = d.children; d._children.forEach(collapse); d.children = null; } } function expand(d) { if (d._children) { d.children = d._children; d.children.forEach(expand); d._children = null; } } var overCircle = function(d) { selectedNode = d; updateTempConnector(); }; var outCircle = function(d) { selectedNode = null; updateTempConnector(); }; // Function to update the temporary connector indicating dragging affiliation var updateTempConnector = function() { var data = []; if (draggingNode !== null && selectedNode !== null) { // have to flip the source coordinates since we did this for the existing connectors on the original tree data = [{ source: { x: selectedNode.y0, y: selectedNode.x0 }, target: { x: draggingNode.y0, y: draggingNode.x0 } }]; } var link = svgGroup.selectAll(".templink").data(data); link.enter().append("path") .attr("class", "templink") .attr("d", d3.svg.diagonal()) .attr('pointer-events', 'none'); link.attr("d", d3.svg.diagonal()); link.exit().remove(); }; // Function to center node when clicked/dropped so node doesn't get lost when collapsing/moving with large amount of children. function centerNode(source) { scale = zoomListener.scale(); x = -source.y0; y = -source.x0; x = x * scale + viewerWidth / 2; y = y * scale + viewerHeight / 2; d3.select('g').transition() .duration(duration) .attr("transform", "translate(" + x + "," + y + ")scale(" + scale + ")"); zoomListener.scale(scale); zoomListener.translate([x, y]); } // Toggle children function function toggleChildren(d) { if (d.children) { d._children = d.children; d.children = null; } else if (d._children) { d.children = d._children; d._children = null; } return d; } // Toggle children on click. function click(d) { if (d3.event.defaultPrevented) return; // click suppressed d = toggleChildren(d); update(d); centerNode(d); } function update(source) { // Compute the new height, function counts total children of root node and sets tree height accordingly. // This prevents the layout looking squashed when new nodes are made visible or looking sparse when nodes are removed // This makes the layout more consistent. var levelWidth = [1]; var childCount = function(level, n) { if (n.children && n.children.length > 0) { if (levelWidth.length <= level + 1) levelWidth.push(0); levelWidth[level + 1] += n.children.length; n.children.forEach(function(d) { childCount(level + 1, d); }); } }; childCount(0, root); var newHeight = d3.max(levelWidth) * 25; // 25 pixels per line tree = tree.size([newHeight, viewerWidth]); // Compute the new tree layout. var nodes = tree.nodes(root).reverse(), links = tree.links(nodes); // Set widths between levels based on maxLabelLength. nodes.forEach(function(d) { d.y = (d.depth * (maxLabelLength * 10)); //maxLabelLength * 10px // alternatively to keep a fixed scale one can set a fixed depth per level // Normalize for fixed-depth by commenting out below line // d.y = (d.depth * 500); //500px per level. }); // Update the nodes… node = svgGroup.selectAll("g.node") .data(nodes, function(d) { return d.id || (d.id = ++i); }); // Enter any new nodes at the parent's previous position. var nodeEnter = node.enter().append("g") .call(dragListener) .attr("class", "node") .attr("transform", function(d) { return "translate(" + source.y0 + "," + source.x0 + ")"; }) .on('click', click); nodeEnter.append("circle") .attr('class', 'nodeCircle') .attr("r", 0) .style("fill", function(d) { return d._children ? "lightsteelblue" : "#fff"; }); nodeEnter.append("text") .attr("x", function(d) { return d.children || d._children ? -10 : 10; }) .attr("dy", ".35em") .attr('class', 'nodeText') .attr("text-anchor", function(d) { return d.children || d._children ? "end" : "start"; }) .text(function(d) { return d.name; }) .style("fill-opacity", 0); // phantom node to give us mouseover in a radius around it nodeEnter.append("circle") .attr('class', 'ghostCircle') .attr("r", 30) .attr("opacity", 0.2) // change this to zero to hide the target area .style("fill", "red") .attr('pointer-events', 'mouseover') .on("mouseover", function(node) { overCircle(node); }) .on("mouseout", function(node) { outCircle(node); }); // Update the text to reflect whether node has children or not. node.select('text') .attr("x", function(d) { return d.children || d._children ? -10 : 10; }) .attr("text-anchor", function(d) { return d.children || d._children ? "end" : "start"; }) .text(function(d) { return d.name; }); // Change the circle fill depending on whether it has children and is collapsed node.select("circle.nodeCircle") .attr("r", 4.5) .style("fill", function(d) { return d._children ? "lightsteelblue" : "#fff"; }); // Transition nodes to their new position. var nodeUpdate = node.transition() .duration(duration) .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; }); // Fade the text in nodeUpdate.select("text") .style("fill-opacity", 1); // Transition exiting nodes to the parent's new position. var nodeExit = node.exit().transition() .duration(duration) .attr("transform", function(d) { return "translate(" + source.y + "," + source.x + ")"; }) .remove(); nodeExit.select("circle") .attr("r", 0); nodeExit.select("text") .style("fill-opacity", 0); // Update the links… var link = svgGroup.selectAll("path.link") .data(links, function(d) { return d.target.id; }); // Enter any new links at the parent's previous position. link.enter().insert("path", "g") .attr("class", "link") .attr("d", function(d) { var o = { x: source.x0, y: source.y0 }; return diagonal({ source: o, target: o }); }); // Transition links to their new position. link.transition() .duration(duration) .attr("d", diagonal); // Transition exiting nodes to the parent's new position. link.exit().transition() .duration(duration) .attr("d", function(d) { var o = { x: source.x, y: source.y }; return diagonal({ source: o, target: o }); }) .remove(); // Stash the old positions for transition. nodes.forEach(function(d) { d.x0 = d.x; d.y0 = d.y; }); } // Append a group which holds all nodes and which the zoom Listener can act upon. var svgGroup = baseSvg.append("g"); // Define the root root = treeData; root.x0 = viewerHeight / 2; root.y0 = 0; // Layout the tree initially and center on the root node. update(root); centerNode(root); }); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
.node { cursor: pointer; } .overlay{ background-color:#EEE; } .node circle { fill: #fff; stroke: steelblue; stroke-width: 1.5px; } .node text { font-size:10px; font-family:sans-serif; } .link { fill: none; stroke: #ccc; stroke-width: 1.5px; } .templink { fill: none; stroke: red; stroke-width: 3px; } .ghostCircle.show{ display:block; } .ghostCircle, .activeDrag .ghostCircle{ display: none; } |
Edit 14/10/2015
Hi Rob!
I tried, and I almost there but…
Regarding the horizontal distance between nodes. Your function:
nodes.forEach(function(d) {
d.y = (d.depth * (maxLabelLength*5));
});
is fine for all my tree but not for the last (deeper) names/items that are shown on the left side of the node/circle – the space taken is too big and don’t look well, So I tried the following:
nodes.forEach(function(d) {
d.y = d.children || d._children ? (d.depth * (maxLabelLength*5)) : d.depth*50;
});
But those last items are positioned on the wrong place… What am I missing?
thanks in advance!
😉
Hey!
awesome! great job!
how can I set the tree to work horizontally? that it will start from top to bottom instead of left to right.
thanks!
Hi Rob,
This is awesome! It’s exactly what I was looking to do, but didn’t have the technical expertise to execute. Your code works wonderfully! Was wondering if you can think of a way to highlight a path along the tree (or if that’s even possible in the d3 library). For instance, in your example, if I were to click the branch between “flare” and “scale”, then that branch, as well as every branch between “scale” and its children, would get highlighted. (and if the “scale” node’s children had children, then those branches would get highlighted, as so on…).
thanks!
Faizal
Hi,
I am having issues getting this to work locally. I have linked all the files correctly, it just seems to not be working. Has anyone else had this issue?
Thanks,
Leonie
I say thank you for sharing this nice example!
Hi Rob,
This is awesome and works exactly as I want to it, thank you!
One thing I’m struggling to do however is highlight the path (and make it say, Red) back to the root node when a node with no children is clicked on. Is this something you’d be able to assist with?
Thanks,
Jack