An isometric word cloud showing depth (as height) and number of instances (as area) of the nodes of DBpedia’s hierarchical ontology. Although it represents a tree and uses a treemap algorithm, this is not a real treemap: Hierarchical relations are not represented at all, since nodes are ordered only by depth and number of instances (i.e., siblings may fall into different parts of the diagram).
Unfortunately, the treemap algorithm does not succeed in making good aspect ratios for certain rectangles with a small instance count. In a 2D treemap, this would have completely no visible effect (except for hiding the class, of course), but with 2.5D we get ugly parallelepipedons with a really elongated top face. Also, some labels are barely readable because of a mismatch between string length and aspect ratio.
Despite these considerations, the diagram can be used to see the relative size of DBpedia’s classes (in term of their child instances) while intuitively take their depth into account.
// Generated by CoffeeScript 1.4.0
(function() {
var color, correct_x, correct_y, height, iso_layout, isometric, ordering, parallelepipedon, path_generator, svg, treemap, vis, width, zoom, zoomable_layer;
svg = d3.select('svg');
width = svg.node().getBoundingClientRect().width;
height = svg.node().getBoundingClientRect().height;
zoomable_layer = svg.append('g');
zoom = d3.behavior.zoom().scaleExtent([1, 100]).on('zoom', function() {
return zoomable_layer.attr({
transform: "translate(" + (zoom.translate()) + ")scale(" + (zoom.scale()) + ")"
});
});
svg.call(zoom);
vis = zoomable_layer.append('g').attr({
"class": 'vis',
transform: "translate(" + (width / 2) + "," + (height / 3 - 90) + ")"
});
isometric = function(_3d_p) {
return [-Math.sqrt(3) / 2 * _3d_p[0] + Math.sqrt(3) / 2 * _3d_p[1], +0.5 * _3d_p[0] + 0.5 * _3d_p[1] - _3d_p[2]];
};
parallelepipedon = function(d) {
var fb, ft, mlb, mlt, mrb, mrt, nb, nt;
if (!(d.x != null)) {
d.x = 0;
}
if (!(d.y != null)) {
d.y = 0;
}
if (!(d.h != null)) {
d.h = 0;
}
if (!(d.dx != null)) {
d.dx = 10;
}
if (!(d.dy != null)) {
d.dy = 10;
}
if (!(d.dh != null)) {
d.dh = 10;
}
fb = isometric([d.x, d.y, d.h], mlb = isometric([d.x + d.dx, d.y, d.h], nb = isometric([d.x + d.dx, d.y + d.dy, d.h], mrb = isometric([d.x, d.y + d.dy, d.h], ft = isometric([d.x, d.y, d.h + d.dh], mlt = isometric([d.x + d.dx, d.y, d.h + d.dh], nt = isometric([d.x + d.dx, d.y + d.dy, d.h + d.dh], mrt = isometric([d.x, d.y + d.dy, d.h + d.dh]))))))));
d.iso = {
face_bottom: [fb, mrb, nb, mlb],
face_left: [mlb, mlt, nt, nb],
face_right: [nt, mrt, mrb, nb],
face_top: [ft, mrt, nt, mlt],
outline: [ft, mrt, mrb, nb, mlb, mlt],
fb: fb,
mlb: mlb,
nb: nb,
mrb: mrb,
ft: ft,
mlt: mlt,
nt: nt,
mrt: mrt
};
return d;
};
ordering = function(a, b) {
if (b.dh !== a.dh) {
return b.dh - a.dh;
}
if (b.leaf_count !== a.leaf_count) {
return b.leaf_count - a.leaf_count;
}
return b.i - a.i;
};
iso_layout = function(data, shape, scale) {
if (!(scale != null)) {
scale = 1;
}
data.forEach(function(d) {
return shape(d, scale);
});
return data.sort(ordering);
};
path_generator = function(d) {
return 'M' + d.map(function(p) {
return p.join(' ');
}).join('L') + 'z';
};
treemap = d3.layout.treemap().size([400, 400]).value(function(d) {
return d.leaf_count;
}).sort(function(a, b) {
return ordering(b, a);
}).ratio(4).round(false);
color = d3.scale.category20b();
correct_x = d3.scale.linear().domain([0, width]).range([0, width * 1.05]);
correct_y = d3.scale.linear().domain([0, height]).range([0, height * 3 / 4]);
d3.json('ontology_canonical.json', function(tree) {
var data, enter_labels, enter_labels_g, enter_pipedons, pipedons, walk;
data = [];
walk = function(n, depth) {
var child, name_arr, word, _i, _len, _ref, _results;
if (n.leaf_count > 0) {
name_arr = n.name.split('/');
word = name_arr[name_arr.length - 1];
data.push({
word: word,
leaf_count: n.leaf_count,
level: depth,
dh: 6 * depth
});
}
if (n.children != null) {
_ref = n.children;
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
child = _ref[_i];
_results.push(walk(child, depth + 1));
}
return _results;
}
};
walk(tree, 0);
data.forEach(function(d, i) {
return d.i = i;
});
data = treemap.nodes({
children: data
}).filter(function(n) {
return n.depth === 1;
});
iso_layout(data, parallelepipedon);
data.forEach(function(d, i) {
return d.template_color = d3.hcl(color(d.i));
});
pipedons = vis.selectAll('.pipedon').data(data);
enter_pipedons = pipedons.enter().append('g').attr({
"class": 'pipedon'
});
enter_pipedons.append('path').attr({
"class": 'iso face bottom',
d: function(d) {
return path_generator(d.iso.face_bottom);
}
});
enter_pipedons.append('path').attr({
"class": 'iso face left',
d: function(d) {
return path_generator(d.iso.face_left);
},
fill: function(d) {
return d.template_color;
}
});
enter_pipedons.append('path').attr({
"class": 'iso face right',
d: function(d) {
return path_generator(d.iso.face_right);
},
fill: function(d) {
return d3.hcl(d.template_color.h, d.template_color.c, d.template_color.l - 12);
}
});
enter_pipedons.append('path').attr({
"class": 'iso face top',
d: function(d) {
return path_generator(d.iso.face_top);
},
fill: function(d) {
return d3.hcl(d.template_color.h, d.template_color.c, d.template_color.l + 12);
}
});
enter_labels_g = enter_pipedons.append('g');
enter_labels = enter_labels_g.append('svg').attr({
"class": 'label'
});
enter_labels.append('text').text(function(d) {
return d.word.toUpperCase();
}).attr({
dy: '.35em'
}).each(function(node) {
var bbox, bbox_aspect, node_bbox, node_bbox_aspect, rotate;
bbox = this.getBBox();
bbox_aspect = bbox.width / bbox.height;
node_bbox = {
width: node.dx,
height: node.dy
};
node_bbox_aspect = node_bbox.width / node_bbox.height;
rotate = bbox_aspect >= 1 && node_bbox_aspect < 1 || bbox_aspect < 1 && node_bbox_aspect >= 1;
node.label_bbox = {
x: bbox.x + (bbox.width - correct_x(bbox.width)) / 2,
y: bbox.y + (bbox.height - correct_y(bbox.height)) / 2,
width: correct_x(bbox.width),
height: correct_y(bbox.height)
};
if (rotate) {
node.label_bbox = {
x: node.label_bbox.y,
y: node.label_bbox.x,
width: node.label_bbox.height,
height: node.label_bbox.width
};
return d3.select(this).attr('transform', 'rotate(90) translate(0,1)');
}
});
enter_labels.each(function(d) {
d.iso_x = isometric([d.x + d.dx / 2, d.y + d.dy / 2, d.h + d.dh])[0] - d.dx / 2;
return d.iso_y = isometric([d.x + d.dx / 2, d.y + d.dy / 2, d.h + d.dh])[1] - d.dy / 2;
});
enter_labels.attr({
x: function(d) {
return d.iso_x;
},
y: function(d) {
return d.iso_y;
},
width: function(node) {
return node.dx;
},
height: function(node) {
return node.dy;
},
viewBox: function(node) {
return "" + node.label_bbox.x + " " + node.label_bbox.y + " " + node.label_bbox.width + " " + node.label_bbox.height;
},
preserveAspectRatio: 'none',
fill: function(d) {
return d3.hcl(d.template_color.h, d.template_color.c, d.template_color.l - 12);
}
});
enter_labels_g.attr({
transform: function(d) {
return "translate(" + (d.iso_x + d.dx / 2) + "," + (d.iso_y + d.dy / 2) + ") scale(1, " + (1 / Math.sqrt(3)) + ") rotate(-45) translate(" + (-(d.iso_x + d.dx / 2)) + "," + (-(d.iso_y + d.dy / 2)) + ")";
}
});
enter_pipedons.append('path').attr({
"class": 'iso outline',
d: function(d) {
return path_generator(d.iso.outline);
}
});
return enter_pipedons.append('title').text(function(d) {
return "" + d.word + " class\n" + (d3.format(',d')(d.leaf_count)) + " child instances\nLevel " + d.level;
});
});
}).call(this);
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>DBpedia isometric "treemap"</title>
<link type="text/css" href="index.css" rel="stylesheet"/>
<script src="//d3js.org/d3.v3.min.js"></script>
</head>
<body>
<svg width="960px" height="500px"></svg>
<script src="index.js"></script>
</body>
</html>
svg = d3.select('svg')
width = svg.node().getBoundingClientRect().width
height = svg.node().getBoundingClientRect().height
# append a group for zoomable content
zoomable_layer = svg.append('g')
# define a zoom behavior
zoom = d3.behavior.zoom()
.scaleExtent([1,100]) # 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)
vis = zoomable_layer.append('g')
.attr
class: 'vis'
transform: "translate(#{width/2},#{height/3-90})"
# [x, y, h] -> [-Math.sqrt(3)/2*x+Math.sqrt(3)/2*y, 0.5*x+0.5*y-h]
isometric = (_3d_p) -> [-Math.sqrt(3)/2*_3d_p[0]+Math.sqrt(3)/2*_3d_p[1], +0.5*_3d_p[0]+0.5*_3d_p[1]-_3d_p[2]]
parallelepipedon = (d) ->
d.x = 0 if not d.x?
d.y = 0 if not d.y?
d.h = 0 if not d.h?
d.dx = 10 if not d.dx?
d.dy = 10 if not d.dy?
d.dh = 10 if not d.dh?
fb = isometric [d.x, d.y, d.h],
mlb = isometric [d.x+d.dx, d.y, d.h],
nb = isometric [d.x+d.dx, d.y+d.dy, d.h],
mrb = isometric [d.x, d.y+d.dy, d.h],
ft = isometric [d.x, d.y, d.h+d.dh],
mlt = isometric [d.x+d.dx, d.y, d.h+d.dh],
nt = isometric [d.x+d.dx, d.y+d.dy, d.h+d.dh],
mrt = isometric [d.x, d.y+d.dy, d.h+d.dh]
d.iso = {
face_bottom: [fb, mrb, nb, mlb],
face_left: [mlb, mlt, nt, nb],
face_right: [nt, mrt, mrb, nb],
face_top: [ft, mrt, nt, mlt],
outline: [ft, mrt, mrb, nb, mlb, mlt],
fb: fb,
mlb: mlb,
nb: nb,
mrb: mrb,
ft: ft,
mlt: mlt,
nt: nt,
mrt: mrt
}
return d
ordering = (a,b) ->
# order by level
if b.dh isnt a.dh
return b.dh - a.dh
# if equal, order by leaf_count
if b.leaf_count isnt a.leaf_count
return b.leaf_count - a.leaf_count
# if all else fails, use the original ordering
return b.i - a.i
iso_layout = (data, shape, scale) ->
scale = 1 if not scale?
data.forEach (d) ->
shape(d, scale)
# this uses the treemap ordering in some way... (!!!)
# also, use the index to obtain a total ordering
data.sort ordering
path_generator = (d) -> 'M' + d.map((p)->p.join(' ')).join('L') + 'z'
treemap = d3.layout.treemap()
.size([400, 400])
.value((d) -> d.leaf_count)
.sort((a,b) -> ordering(b,a)) # same as before, but inverted
.ratio(4)
.round(false) # bugfix: d3 wrong ordering
color = d3.scale.category20b()
correct_x = d3.scale.linear()
.domain([0, width])
.range([0, width*1.05])
correct_y = d3.scale.linear()
.domain([0, height])
.range([0, height*3/4])
d3.json 'ontology_canonical.json', (tree) ->
data = []
walk = (n, depth) ->
# skip empty nodes
if n.leaf_count > 0
name_arr = n.name.split('/')
word = name_arr[name_arr.length-1]
data.push {
word: word,
leaf_count: n.leaf_count,
level: depth,
dh: 6*depth
}
if n.children?
for child in n.children
walk(child, depth+1)
walk(tree, 0)
data.forEach (d, i) ->
d.i = i
data = treemap.nodes({children: data}).filter (n) -> n.depth is 1
iso_layout(data, parallelepipedon)
data.forEach (d, i) ->
# save the template color
d.template_color = d3.hcl(color(d.i))
pipedons = vis.selectAll('.pipedon')
.data(data)
enter_pipedons = pipedons.enter().append('g')
.attr
class: 'pipedon'
enter_pipedons.append('path')
.attr
class: 'iso face bottom'
d: (d) -> path_generator(d.iso.face_bottom)
enter_pipedons.append('path')
.attr
class: 'iso face left'
d: (d) -> path_generator(d.iso.face_left)
fill: (d) -> d.template_color
enter_pipedons.append('path')
.attr
class: 'iso face right'
d: (d) -> path_generator(d.iso.face_right)
fill: (d) -> d3.hcl(d.template_color.h, d.template_color.c, d.template_color.l-12)
enter_pipedons.append('path')
.attr
class: 'iso face top'
d: (d) -> path_generator(d.iso.face_top)
fill: (d) -> d3.hcl(d.template_color.h, d.template_color.c, d.template_color.l+12)
enter_labels_g = enter_pipedons.append('g')
enter_labels = enter_labels_g.append('svg')
.attr
class: 'label'
enter_labels.append('text')
.text((d) -> d.word.toUpperCase())
.attr
dy: '.35em'
.each (node) ->
bbox = this.getBBox()
bbox_aspect = bbox.width / bbox.height
node_bbox = {width: node.dx, height: node.dy}
node_bbox_aspect = node_bbox.width / node_bbox.height
rotate = bbox_aspect >= 1 and node_bbox_aspect < 1 or bbox_aspect < 1 and node_bbox_aspect >= 1
node.label_bbox = {
x: bbox.x+(bbox.width-correct_x(bbox.width))/2,
y: bbox.y+(bbox.height-correct_y(bbox.height))/2,
width: correct_x(bbox.width),
height: correct_y(bbox.height)
}
if rotate
node.label_bbox = {
x: node.label_bbox.y,
y: node.label_bbox.x,
width: node.label_bbox.height,
height: node.label_bbox.width
}
d3.select(this).attr('transform', 'rotate(90) translate(0,1)')
enter_labels
.each (d) ->
d.iso_x = isometric([d.x+d.dx/2, d.y+d.dy/2, d.h+d.dh])[0]-d.dx/2
d.iso_y = isometric([d.x+d.dx/2, d.y+d.dy/2, d.h+d.dh])[1]-d.dy/2
enter_labels
.attr
x: (d) -> d.iso_x
y: (d) -> d.iso_y
width: (node) -> node.dx
height: (node) -> node.dy
viewBox: (node) -> "#{node.label_bbox.x} #{node.label_bbox.y} #{node.label_bbox.width} #{node.label_bbox.height}"
preserveAspectRatio: 'none'
fill: (d) -> d3.hcl(d.template_color.h, d.template_color.c, d.template_color.l-12)
enter_labels_g
.attr
transform: (d) -> "translate(#{d.iso_x+d.dx/2},#{d.iso_y+d.dy/2}) scale(1, #{1/Math.sqrt(3)}) rotate(-45) translate(#{-(d.iso_x+d.dx/2)},#{-(d.iso_y+d.dy/2)})"
enter_pipedons.append('path')
.attr
class: 'iso outline'
d: (d) -> path_generator(d.iso.outline)
enter_pipedons.append('title')
.text((d) -> "#{d.word} class\n#{d3.format(',d')(d.leaf_count)} child instances\nLevel #{d.level}")
.iso.outline {
stroke: #333;
fill: none;
vector-effect: non-scaling-stroke;
}
.label {
pointer-events: none;
text-anchor: middle;
font-family: Impact;
}
.pipedon:hover .label {
fill: black;
}