Skip to content

Instantly share code, notes, and snippets.

@markhm
Last active August 17, 2018 08:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save markhm/bf97e7229eabd1e5715eef0873fb6f8d to your computer and use it in GitHub Desktop.
Save markhm/bf97e7229eabd1e5715eef0873fb6f8d to your computer and use it in GitHub Desktop.
Tree with bells and whistles (v4)
license: gpl-3.0
height: 800
scrolling: no
border: yes

Combining various examples into a generic, v4-based environment to explore and visualise tree data, just like I've built in Java based on http://prefuse.org. It is better to open this block in a separate screen.

Based on examples from:

Contributions from:

Many thanks to all involved.

Known issues:

  • Node Info box on the right claims horizontal space, despite that it floats. Should be aligned to top.
  • The block does not really fit in the frame provided.
  • Seach results do not unfold. It seems the path object (in line 118) is created based on a root object that has not yet been processed by the tree algorithm.
  • Node height slider not yet connected, seems unclear where. Needs tweaking based on the the overall container constraints? Could use Fisheye effect, as shown here.
  • Nodes do not expand from themselves, which makes the transition suboptimal. Mike's v3 example is more convincing.
  • Wheel-scroll down (dragging with two fingers down on macOS) zooms out, rather than in. I would like to reverse this to align with the prefuse.org default.
  • Axes implementation (appendAxes() function) needs to be shown in the tree svg, rather than be added in a separate svg (when turned on). This example will be helpful, as well as this one.
  • Vertical space between nodes is not large enough and should be based on the size of the dataset. This example provides help.

Feature backlog:

  • CSS styles to be fixed, fonts to be aligned.
  • Programmatic panning/zooming via button or double click to resize to fit to the visible tree. This example is relevant. This one might be. Another example: van Wijk Smooth Zooming
  • Focus nodes, or being able to select multiple nodes.
  • Breadcrumb at the top for current focus node, just like in this example from David Bumbeishvili.
  • List/dropdown showing all node properties, allowing for easy selection and filtering.
  • Collapsing the visible tree to a subtree based on e.g. property values, search criteria.
  • Coloring tree nodes by property criteria. Example
  • Vertical/horizontal reference lines to support axes, e.g. at 200, 400, 600, 800 px.
  • Programmatically set initial width based on getComputedTextLength() as in this example.
  • Ability to translate from left-right tree to top-bottom tree via button.
  • Degree Of Interest Fisheye Filter, just like in prefuse.org, prefuse.action.filter.FisheyeTreeFilter.

Other relevant examples:

Feedback is appreciated via markhm at gmail dot com or @markhm.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>v4 - Collapsible tree in reusable format using D3 v4</title>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://d3js.org/d3.v4.js"></script>
<script src="search.js"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/select2/3.5.4/select2.min.js"></script>
<link rel="stylesheet" type="text/css" href="//cdnjs.cloudflare.com/ajax/libs/select2/3.5.0/select2.min.css"></link>
<link rel="stylesheet" type="text/css" href="style.css">
<link rel="shortcut icon" type="image/x-icon" href="../images/favicon.ico"/>
<script src="script.js"></script>
</head>
<body>
<div id="menuBar">
<span class="menuBar">&nbsp;Data:&ensp;</span>
<select style="width:150px;" onchange="loadOtherTree(value)">
<option value="flare.json">flare.json</option>
</select>
<input name="expandButton" type="button" value="Expand" onclick="myTree.expandTree()" />
<input name="collapseButton" type="button" value="Collapse" onclick="myTree.collapseTree()" />
<input name="resetSearchButton" type="button" value="Reset" onclick="reloadInitialTree()" />
<input name="centerButton" type="button" value="Center" onclick="centerTree()" />
&ensp;
<span class="menuBar">Node distance:&ensp;</span>
<label for="width.slider" class="menuBar">Width</label>
<input id="width.slider" type="range" name="tree_width" min="100" value="180" max="500" step="1" />
<label for="height.slider" class="menuBar">Height</label>
<input id="height.slider" type="range" name="tree_height" min="0" value="0" max="250" step="1" />
</div>
<br/>
<div id="wrapper" class="wrapper">
<div id="inner">
<div id="content" class="content">
<div id ="chart" />
</div>
<div id="sidebar" class="sidebar">
<p>&ensp;<b>Node info</b></p>
<p id="infoField" class="node">&ensp;name: (click node)</p>
</div>
</div>
<div id="cleared" class="cleared"></div>
<div id="search"></div>
</div>
<script>
var select2_data;
var height = 800 - 4; // 2px border
var width = 960 - 4; // 2px border
var myTree = tree().height(height).width(width);
var div = d3.select("body")
.append("div") // declare the tooltip div
.attr("class", "tooltip")
.style("opacity", 0);
d3.json("https://raw.githubusercontent.com/d3/d3-hierarchy/master/test/data/flare.json", function(error, values)
{
if (error) throw error;
root = values;
// root.x0 = height / 2;
// root.y0 = 0;
myTree.data(root);
select2_data = extract_select2_data(values,[],0)[1];//I know, not the prettiest...
//init search box
$("#search").select2({
data: select2_data,
containerCssClass: "search",
width: '1004px'
});
d3.select('#chart').call(myTree);
});
//attach search box listener
$("#search").on("select2-selecting", function(e)
{
// console.log("Before the path is created, checking contents of root");
// console.log(root);
// root = getCurrentRoot(); <- This does not work. It fully blocks the processing of the search result.
var paths = searchTree(root, e.object.text, []);
// console.log("Before after paths is created, checking contents of paths");
// console.log(paths);
if(typeof(paths) !== "undefined")
{
myTree.openPaths(paths);
}
else
{
alert(e.object.text+" not found!");
}
})
var width_slider = document.getElementById("width.slider");
width_slider.oninput = function()
{
myTree.updateWidth(this.value);
}
var height_slider = document.getElementById("height.slider");
height_slider.oninput = function()
{
myTree.updateHeight(this.value);
}
// appendAxes();
</script>
</body>
</html>
function tree()
{
var data, root, treemap, svg,
i = 0,
duration = 650,
// margin = {top: 20, right: 10, bottom: 20, left: 50},
margin = {top: 0, right: 0, bottom: 80, left: 50},
width = 960 - 4 - margin.left - margin.right, // fitting in block frame
height = 800 - 4 - margin.top - margin.bottom, // fitting in block frame
width_multiplier = 180,
height_extra_space = 0;
// update;
function chart(selection)
{
selection.each(function()
{
height = height - margin.top - margin.bottom;
width = width - margin.left - margin.right;
// append the svg object to the selection
svg = selection.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
// declares a tree layout and assigns the size of the tree
treemap = d3.tree().size([height, width]);
// assign parent, children, height, depth
root = d3.hierarchy(data, function(d) { return d.children });
root.x0 = (height / 2); // left edge of the rectangle
root.y0 = 0; // top edge of the triangle
// collapse after the second level
root.children.forEach(collapse);
update(root);
function collapse(d)
{
if (d.children)
{
d._children = d.children;
d._children.forEach(collapse);
d.children = null;
}
}
});
}
chart.width = function(value)
{
if (!arguments.length) return width;
width = value;
return chart;
};
chart.height = function(value)
{
if (!arguments.length) return height;
height = value;
return chart;
};
chart.margin = function(value)
{
if (!arguments.length) return margin;
margin = value;
return chart;
};
chart.data = function(value)
{
if (!arguments.length) return data;
data = value;
if (typeof updateData === 'function') updateData();
return chart;
};
chart.expandTree = function(value)
{
root.children.forEach(expand);
update(root);
function expand(d)
{
if (d._children)
{
d.children = d._children;
d.children.forEach(expand);
d._children = null;
}
}
};
// collapse the node and all it's children
chart.collapse = function(d)
{
if (d.children)
{
d._children = d.children;
d._children.forEach(collapse);
d.children = null;
}
}
chart.collapseTree = function(value)
{
root.children.forEach(collapse);
update(root);
function collapse(d)
{
if (d.children)
{
d._children = d.children;
d._children.forEach(collapse);
d.children = null;
}
}
};
function update(source)
{
// assigns the x and y position for the nodes
var treeData = treemap(root);
// compute the new tree layout
var nodes = treeData.descendants(),
links = treeData.descendants().slice(1);
// normalise for fixed depth
nodes.forEach(function(d)
{
// d.x = d.depth * 180;
d.y = d.depth * width_multiplier;
// d.x = d.depth * 180;
});
// ****************** Nodes section ***************************
// update the nodes ...
var nodeArray = svg.selectAll('g.node')
.data(nodes, function(d)
{
return d.id || (d.id = ++i);
});
// console.log(nodeArray);
// Enter any new modes at the parent's previous position.
var nodeEnter = nodeArray.enter().append('g')
.attr('class', 'node')
.attr('transform', function(d)
{
return 'translate(' + (source.y0 + margin.top) + ',' + (source.x0 + margin.left) + ')';
// return 'translate(' + (source.y0) + ',' + (source.x0) + ')';
})
.on('click', click);
// Add circle for the nodes, which is filled lightsteelblue for nodes that have hidden children (_children).
nodeEnter.append('circle')
.attr('class', 'node')
.attr('r', 1e-6)
.style('fill', function(d)
{
return d._children ? 'lightsteelblue' : '#fff';
});
// Append the node label (data.name), either to the left or right of the node circle, depending on whether the node has children.
nodeEnter.append("text")
.attr("x", function(d) { return d.children || d._children ? -15 : 15; })
.attr("dy", ".35em")
.attr("style", "node")
.attr("text-anchor", function(d) { return d.children || d._children ? "end" : "start"; })
.text(function(d) {
// console.log(d);
// return (d.children || d._children) ? d.data.name.capitalize() : d.data.name;})
return d.data.name;});
// .style("fill-opacity", 1e-6);
// Add the number of children inside the node circle, whether they are unfolded or not.
nodeEnter.append('text')
.attr('x', 0)
.attr('y', 3)
.attr("text-anchor", "middle")
.attr('cursor', 'pointer')
.style('font-size', '10px')
.text(function(d) {
if (d.children) return d.children.length;
else if (d._children) return d._children.length;
});
// UPDATE
var nodeUpdate = nodeEnter.merge(nodeArray);
// Transition the resulting array to the proper position for the node.
nodeUpdate.transition().duration(duration)
.attr('transform', function(d) {
return 'translate(' + (d.y + margin.top) + ',' + (d.x + margin.left) + ')';
});
// Update the node attributes and style, coloring search results red.
nodeUpdate.select('circle.node')
.attr('r', 9)
.style("fill", function(d)
{
if(d.data.class === "found")
{
return "#ff4136"; //red
}
else if(d._children)
{
return "lightsteelblue";
}
})
.attr('cursor', 'pointer')
.style("stroke", function(d)
{
if (d.data.class === "found")
{
return "#ff4136"; //red
}
;
})
// Remove any exiting nodes
var nodeExit = nodeArray.exit()
.transition().duration(duration)
.attr('transform', function(d)
{
return 'translate(' + (source.y + margin.top) + ',' + (source.x + margin.left) + ')';
})
.remove();
// on exit reduce the node circles size to 0
nodeExit.select('circle')
.attr('r', 1e-6);
// on exit reduce the opacity of text labels
nodeExit.select('text')
.style('fill-opacity', 1e-6);
// adding zoom and panning
d3.select("svg").call(d3.zoom().on("zoom", function()
{
svg.attr("transform", d3.event.transform)
}));
// trying to invert the direction of the zoom wheel
// .on("wheel", function(d){
// var direction = d3.event.wheelDelta < 0 ? 'down' : 'up';
// zoom(direction === 'up' ? d : d.parent);
// });
// ****************** links section ***************************
// update the links
var link = svg.selectAll('path.link').data(links, function(d) { return d.id });
// enter any new links at the parent's previous position
var linkEnter = link.enter().insert('path', 'g')
.attr('class', 'link')
.attr('d', function(d)
{
var o = {x: source.x0, y: source.y0};
return diagonal(o, o);
});
// UPDATE
var linkUpdate = linkEnter.merge(link);
// transition back to the parent element position
linkUpdate.transition().duration(duration)
.attr('d', function(d) { return diagonal(d, d.parent); })
.style("stroke",function(d) {
if(d.data.class==="found")
{
return "#ff4136";
}
});
// remove any exiting links
var linkExit = link.exit()
.transition().duration(duration)
.attr('d', function(d)
{
var o = {x: source.x, y: source.y};
return diagonal(o, o);
})
.remove();
// store the old positions for transition
nodes.forEach(function(d)
{
d.x0 = d.x;
d.y0 = d.y;
});
// creates a curved (diagonal) path from parent to the child nodes
function diagonal(s, d)
{
var path = 'M ' + (s.y + margin.top) + ' ' + (s.x + margin.left) +
'C ' + ((s.y + d.y + (margin.top * 2)) / 2) + ' ' + (s.x + margin.left) +
', ' + ((s.y + d.y + (margin.top * 2)) / 2) + ' ' + (d.x + margin.left) +
', ' + (d.y + margin.top) + ' ' + (d.x + margin.left);
return path;
}
// toggle children on click
function click(d)
{
toggleChildren(d);
printNodeInfo(d);
}
function toggleChildren(d)
{
if (d.children)
{
d._children = d.children;
d.children = null;
} else {
d.children = d._children;
d._children = null;
}
update(d);
}
}
chart.updateWidth = function(value)
{
width_multiplier = value;
update(data);
}
chart.updateHeight = function(value)
{
height_extra_space = value;
update(data);
}
String.prototype.capitalize = function()
{
return this.charAt(0).toUpperCase() + this.slice(1).toLowerCase();
};
function zoom()
{
var scale = d3.event.scale,
translation = d3.event.translate,
tbound = -h * scale,
bbound = h * scale,
lbound = (-w + m[1]) * scale,
rbound = (w - m[3]) * scale;
// limit translation to thresholds
translation = [
Math.max(Math.min(translation[0], rbound), lbound),
Math.max(Math.min(translation[1], bbound), tbound)
];
d3.select(".drawarea")
.attr("transform", "translate(" + translation + ")" +
" scale(" + scale + ")");
}
chart.openPaths = function(paths)
{
for(var i=0; i<paths.length; i++)
{
if(paths[i].id !== "1") //i.e. not root
{
paths[i].class = 'found';
console.log("right after setting class to 'found' ");
if(paths[i]._children)
{ //if children are hidden: open them, otherwise: don't do anything
paths[i].children = paths[i]._children;
paths[i]._children = null;
}
else if(paths[i].children)
{
console.log("There are children here, tralalalala");
}
else
{
console.log("For some reason, I don't discover hidden children");
}
update(paths[i]);
}
}
}
chart.getCurrentRoot = function()
{
return root;
}
chart.centerNode = function(d)
{
// if (active.node() === this) return reset();
// active.classed("active", false);
// active = d3.select(this).classed("active", true);
// var bounds = path.bounds(d),
// dx = bounds[1][0] - bounds[0][0],
// dy = bounds[1][1] - bounds[0][1],
// x = (bounds[0][0] + bounds[1][0]) / 2,
// y = (bounds[0][1] + bounds[1][1]) / 2,
// scale = Math.max(1, Math.min(8, 0.9 / Math.max(dx / width, dy / height))),
// translate = [width / 2 - scale * x, height / 2 - scale * y];
svg.transition().duration(duration)
// .call(zoom.translate(translate).scale(scale).event); // not in d3 v4
.call(zoom.transform, d3.zoomIdentity.translate(translate[0],translate[1]).scale(scale) ); // updated for d3 v4
}
// chart.centerNode = function()
// {
// 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]);
// }
return chart;
}
function printNodeInfo(d)
{
var location = document.getElementById("infoField");
location.innerHTML = "&ensp;name: " + d.data.name+"<br/>"
+ "&ensp;value: " + d.data.value;
}
function loadOtherTree(value)
{
// remove svg
d3.select("svg").remove();
// build new tree object
myTree = tree().height(height).width(width);
// load new data
d3.json(value, function(error, data)
{
if (error) throw error;
root = data;
console.log(root);
root.x0 = height / 2;
root.y0 = 0;
myTree.data(root);
// put new chart in place
d3.select('#chart').call(myTree);
});
}
function reloadInitialTree()
{
loadOtherTree("flare.json");
// $("#search").object.text = "";
}
function centerTree()
{
alert("To be implemented.");
}
// from: https://css-tricks.com/snippets/javascript/get-url-variables/
function getQueryVariable(variable)
{
var query = window.location.search.substring(1);
var vars = query.split("&");
for (var i=0;i<vars.length;i++)
{
var pair = vars[i].split("=");
if(pair[0] == variable){return pair[1];}
}
return(false);
}
function appendAxes()
{
var axis_data_x = [-100, 800],
axis_data_y = [-100, 600];
console.log(d3.select("#chart"));
var svg = d3.select("#chart")
.append("svg")
.attr("width", width)
.attr("height", height);
var xscale = d3.scaleLinear()
.domain([0, d3.max(axis_data_x)])
.range([0, width - 100]);
var yscale = d3.scaleLinear()
.domain([0, d3.max(axis_data_y)])
.range([height/2, 0]);
var x_axis = d3.axisBottom()
.scale(xscale);
var y_axis = d3.axisLeft()
.scale(yscale);
svg.append("g")
.attr("transform", "translate(50, 10)")
.call(y_axis);
var xAxisTranslate = height/2 + 10;
svg.append("g")
.attr("transform", "translate(50, " + xAxisTranslate +")")
.call(x_axis)
}
// var fisheye = d3.fisheye.circular
// .radius(200)
// .distortion(2);
//
// svg.on("mousemove", function()
// {
// fisheye.focus(d3.mouse(this));
//
// node.each(function(d) { d.fisheye = fisheye(d); })
// .attr("cx", function(d) { return d.fisheye.x; })
// .attr("cy", function(d) { return d.fisheye.y; })
// .attr("r", function(d) { return d.fisheye.z * 4.5; });
//
// link.attr("x1", function(d) { return d.source.fisheye.x; })
// .attr("y1", function(d) { return d.source.fisheye.y; })
// .attr("x2", function(d) { return d.target.fisheye.x; })
// .attr("y2", function(d) { return d.target.fisheye.y; });
// });
// basically a way to get the path to an object
function searchTree(obj, search, path)
{
// console.log("entering searchTree");
// console.log(obj);
// console.log(search);
// console.log(path);
if(obj.name === search)
{ //if search is found return, add the object to the path and return it
path.push(obj);
return path;
}
else if(obj.children || obj._children) //if children are collapsed d3 object will have them instantiated as _children
{
var children = (obj.children) ? obj.children : obj._children;
for(var i=0 ; i<children.length ; i++)
{
path.push(obj);// we assume this path is the right one
var found = searchTree(children[i], search, path);
if(found)
{// we were right, this should return the bubbled-up path from the first if statement
return found;
}
else
{//we were wrong, remove this parent from the path and continue iterating
path.pop();
}
}
}
else
{//not the right object, return false so it will continue to iterate in the loop
return false;
}
}
function extract_select2_data(node, leaves, index)
{
if (node.children)
{
for(var i = 0; i < node.children.length; i++)
{
index = extract_select2_data(node.children[i], leaves, index)[0];
}
}
else
{
leaves.push({id:++index, text:node.name});
}
return [index,leaves];
}
/* Node properties */
.node {
cursor: pointer;
}
.node circle {
fill: #fff;
stroke: steelblue;
stroke-width: 3px;
}
.node text {
font: 12px Helvetica;
}
/* Link properties */
.link {
fill: none;
stroke: #ccc;
stroke-width: 2px;
}
.body {
font: 12px Helvetica;
}
.menuBar {
font: 12px "Lucida Grande";
}
/** Slider bar **/
.legend {
background-color: #000;
color: #fff;
padding: 3px 6px;
font: 12px Helvetica;
}
.output {
font: 1rem 'Fira Sans', sans-serif;
}
.label {
margin-top: 1rem;
display: block;
font-size: .8rem;
}
/* Search box */
/*Just to ensure the select2 box is "glued" to the top*/
.search {
width: 100%;
}
.found {
fill: #ff4136;
stroke: #ff4136;
}
/* For having two colums */
.wrapper {
margin-right: 200px;
}
.inner {
}
.content {
float: left;
position: relative;
width: 1000px;
background-color: white;
border: 2px solid black;
}
.sidebar {
position: absolute;
width: 200px;
top: 0;
right: 0;
height: auto;
background-color: lightgrey;
border: 1px solid black;
font: 12px "Lucida Grande";
}
.cleared {
clear: both;
}
.unused{
align: top;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment