Skip to content

Instantly share code, notes, and snippets.

@nitaku
Last active August 29, 2015 14:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nitaku/564b91e8e9e7cbc7821f to your computer and use it in GitHub Desktop.
Save nitaku/564b91e8e9e7cbc7821f to your computer and use it in GitHub Desktop.
Treemap navigator (flare)

A combination of a treemap with a node-link diagram, showing the flare software package hierarchy. Click on a treemap region to create a new node displaying the corresponding subtree. Subtrees can be dragged around, and the whole visualization can be navigated with zoom and pan.

This experiment tries to address a known issue of treemaps: while they are very compact and good for presenting a tree's overview, they fail to unambiguosly and clearly represent hierarchical structure. Node-link diagrams, on the other hand, are very good at this, but fail to scale to big trees and do not provide a nice overview.

The presented technique, while developed independently, is similar to Elastic Hierarchies, described in Zhao et al. 2005.

subtrees_data = []
# layout, behaviors and scales
SCALE = 200
PADDING = 8
treemap = d3.layout.treemap()
.size([SCALE, SCALE])
.value((node) -> node.size)
# define a drag behavior
drag = d3.behavior.drag()
.origin((d) -> d)
drag.on 'dragstart', () ->
# silence other listeners (disbale pan when dragging)
# see https://github.com/mbostock/d3/wiki/Drag-Behavior
d3.event.sourceEvent.stopPropagation()
drag.on 'drag', (d) ->
# update the datum of the dragged node
d.x = d3.event.x
d.y = d3.event.y
redraw()
svg = d3.select('svg')
width = svg.node().getBoundingClientRect().width
height = svg.node().getBoundingClientRect().height
# translate the viewBox to have (0,0) at the center of the vis
svg
.attr
viewBox: "#{-width/2} #{-height/2} #{width} #{height}"
# append a group for zoomable content
zoomable_layer = svg.append('g')
# define a zoom behavior
zoom = d3.behavior.zoom()
.scaleExtent([0.1,10]) # min-max zoom
.on 'zoom', () ->
# GEOMETRIC ZOOM
zoomable_layer
.attr
transform: "translate(#{zoom.translate()})scale(#{zoom.scale()})"
# bind the zoom behavior to the main SVG
svg.call(zoom)
# group the visualization
vis = zoomable_layer.append('g')
.attr
transform: "translate(#{-SCALE/2},#{-SCALE/2})"
d3.json 'http://wafi.iit.cnr.it/webvis/tmp/flare.json', (tree) ->
treemap.nodes(tree)
subtree_data = {tree: tree, x: 0, y: 0}
tree.subtree = subtree_data
subtrees_data.push subtree_data
redraw()
redraw = (duration) ->
duration = if duration? then duration else 0
# links
links = vis.selectAll('.link')
.data(subtrees_data.filter((subtree_data) -> subtree_data.tree.depth isnt 0))
links.enter().append('path')
.attr
class: 'link'
links.transition().duration(duration)
.attr
d: (subtree_data) ->
parent = subtree_data.tree.parent.subtree
return "M#{parent.x+subtree_data.tree.x+subtree_data.tree.dx/2} #{parent.y+subtree_data.tree.y+subtree_data.tree.dy/2} L#{subtree_data.x+subtree_data.tree.x+subtree_data.tree.dx/2} #{subtree_data.y+subtree_data.tree.y+subtree_data.tree.dy/2}"
# subtrees
subtrees = vis.selectAll('.subtree')
.data(subtrees_data)
enter_subtrees = subtrees.enter().append('g')
.call(drag)
.attr
class: 'subtree'
enter_subtrees.append('rect')
.attr
class: 'handle'
x: (subtree_data) -> subtree_data.tree.x - PADDING
y: (subtree_data) -> subtree_data.tree.y - PADDING
width: (subtree_data) -> subtree_data.tree.dx + 2*PADDING
height: (subtree_data) -> subtree_data.tree.dy + 2*PADDING
enter_subtrees.append('text')
.text((subtree_data) -> subtree_data.tree.name)
.attr
class: 'label'
x: (subtree_data) -> subtree_data.tree.x + subtree_data.tree.dx/2
y: (subtree_data) -> subtree_data.tree.y
dy: '-1em'
subtrees.transition().duration(duration)
.attr
transform: (subtree_data) -> "translate(#{subtree_data.x},#{subtree_data.y})"
nodes = subtrees.selectAll('.node')
.data((subtree_data) -> if subtree_data.tree.children? then subtree_data.tree.children else [])
enter_nodes = nodes.enter().append('rect')
.attr
class: 'node'
x: (node) -> node.x
y: (node) -> node.y
width: (node) -> node.dx
height: (node) -> node.dy
fill: 'transparent'
enter_nodes.on 'click', (node) ->
return if node.exploded is true
node.exploded = true
x = d3.select(this.parentNode).datum().x
y = d3.select(this.parentNode).datum().y
subtree_data = {tree: node, x: x, y: y}
node.subtree = subtree_data
subtrees_data.push subtree_data
redraw()
# push new subtrees away from the point of creation
subtree_data.x += 30
subtree_data.y += 30
window.requestAnimationFrame () -> redraw(1000)
enter_nodes.append('title')
.text((node) -> node.name)
nodes.classed('exploded', (node) -> node.exploded)
svg {
background: white;
}
.node {
stroke-width: 1px;
shape-rendering: crispEdges;
vector-effect: non-scaling-stroke;
stroke: #BBB;
fill: white;
}
.handle {
stroke-width: 1px;
shape-rendering: crispEdges;
vector-effect: non-scaling-stroke;
fill: white;
stroke: #333;
}
.label {
text-anchor: middle;
font-family: sans-serif;
/*text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white;*/
text-shadow: -2px 0 white, 0 2px white, 2px 0 white, 0 -2px white, -1px -1px white, 1px -1px white, 1px 1px white, -1px 1px white;
}
.link {
fill: none;
stroke: gray;
stroke-dasharray: 3 3;
vector-effect: non-scaling-stroke;
}
.node:hover {
fill: #EEE;
cursor: pointer;
}
.node.exploded {
fill: #EEE;
}
.handle:hover {
stroke-width: 2px;
cursor: move;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="description" content="Treemap navigator (flare)" />
<title>Treemap navigator (flare)</title>
<link type="text/css" href="index.css" rel="stylesheet"/>
<script src="http://d3js.org/d3.v3.min.js"></script>
</head>
<body>
<svg height="500" width="960"></svg>
<script src="index.js"></script>
</body>
</html>
(function() {
var PADDING, SCALE, drag, height, redraw, subtrees_data, svg, treemap, vis, width, zoom, zoomable_layer;
subtrees_data = [];
SCALE = 200;
PADDING = 8;
treemap = d3.layout.treemap().size([SCALE, SCALE]).value(function(node) {
return node.size;
});
drag = d3.behavior.drag().origin(function(d) {
return d;
});
drag.on('dragstart', function() {
return d3.event.sourceEvent.stopPropagation();
});
drag.on('drag', function(d) {
d.x = d3.event.x;
d.y = d3.event.y;
return redraw();
});
svg = d3.select('svg');
width = svg.node().getBoundingClientRect().width;
height = svg.node().getBoundingClientRect().height;
svg.attr({
viewBox: "" + (-width / 2) + " " + (-height / 2) + " " + width + " " + height
});
zoomable_layer = svg.append('g');
zoom = d3.behavior.zoom().scaleExtent([0.1, 10]).on('zoom', function() {
return zoomable_layer.attr({
transform: "translate(" + (zoom.translate()) + ")scale(" + (zoom.scale()) + ")"
});
});
svg.call(zoom);
vis = zoomable_layer.append('g').attr({
transform: "translate(" + (-SCALE / 2) + "," + (-SCALE / 2) + ")"
});
d3.json('http://wafi.iit.cnr.it/webvis/tmp/flare.json', function(tree) {
var subtree_data;
treemap.nodes(tree);
subtree_data = {
tree: tree,
x: 0,
y: 0
};
tree.subtree = subtree_data;
subtrees_data.push(subtree_data);
return redraw();
});
redraw = function(duration) {
var enter_nodes, enter_subtrees, links, nodes, subtrees;
duration = duration != null ? duration : 0;
links = vis.selectAll('.link').data(subtrees_data.filter(function(subtree_data) {
return subtree_data.tree.depth !== 0;
}));
links.enter().append('path').attr({
"class": 'link'
});
links.transition().duration(duration).attr({
d: function(subtree_data) {
var parent;
parent = subtree_data.tree.parent.subtree;
return "M" + (parent.x + subtree_data.tree.x + subtree_data.tree.dx / 2) + " " + (parent.y + subtree_data.tree.y + subtree_data.tree.dy / 2) + " L" + (subtree_data.x + subtree_data.tree.x + subtree_data.tree.dx / 2) + " " + (subtree_data.y + subtree_data.tree.y + subtree_data.tree.dy / 2);
}
});
subtrees = vis.selectAll('.subtree').data(subtrees_data);
enter_subtrees = subtrees.enter().append('g').call(drag).attr({
"class": 'subtree'
});
enter_subtrees.append('rect').attr({
"class": 'handle',
x: function(subtree_data) {
return subtree_data.tree.x - PADDING;
},
y: function(subtree_data) {
return subtree_data.tree.y - PADDING;
},
width: function(subtree_data) {
return subtree_data.tree.dx + 2 * PADDING;
},
height: function(subtree_data) {
return subtree_data.tree.dy + 2 * PADDING;
}
});
enter_subtrees.append('text').text(function(subtree_data) {
return subtree_data.tree.name;
}).attr({
"class": 'label',
x: function(subtree_data) {
return subtree_data.tree.x + subtree_data.tree.dx / 2;
},
y: function(subtree_data) {
return subtree_data.tree.y;
},
dy: '-1em'
});
subtrees.transition().duration(duration).attr({
transform: function(subtree_data) {
return "translate(" + subtree_data.x + "," + subtree_data.y + ")";
}
});
nodes = subtrees.selectAll('.node').data(function(subtree_data) {
if (subtree_data.tree.children != null) {
return subtree_data.tree.children;
} else {
return [];
}
});
enter_nodes = nodes.enter().append('rect').attr({
"class": 'node',
x: function(node) {
return node.x;
},
y: function(node) {
return node.y;
},
width: function(node) {
return node.dx;
},
height: function(node) {
return node.dy;
},
fill: 'transparent'
});
enter_nodes.on('click', function(node) {
var subtree_data, x, y;
if (node.exploded === true) {
return;
}
node.exploded = true;
x = d3.select(this.parentNode).datum().x;
y = d3.select(this.parentNode).datum().y;
subtree_data = {
tree: node,
x: x,
y: y
};
node.subtree = subtree_data;
subtrees_data.push(subtree_data);
redraw();
subtree_data.x += 30;
subtree_data.y += 30;
return window.requestAnimationFrame(function() {
return redraw(1000);
});
});
enter_nodes.append('title').text(function(node) {
return node.name;
});
return nodes.classed('exploded', function(node) {
return node.exploded;
});
};
}).call(this);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment