block by nitaku ce13928409f2bc476ea9

DBpedia isometric "treemap"

Full Screen

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.

index.js

// 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);

index.html

<!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>

index.coffee

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}")

index.css

.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;
}