block by nitaku e03a1a5c1a4a95f06c3b

Isometric "treemap"

Full Screen

A treemap algorithm is used to area-encode an array of random values, while an isometric projection is used to length-encode a second random value. Occlusion is extremely limited by the ordering of the treemap algorithm, making the diagram fairly readable (compare with a bar chart example that does not exhibit this property). This is possible because parallelepipedons are drawn by following the same ordering used by the treemap (to be honest, we tried and succedeed in using this method, but we are not aware of the internals of the treemap ordering algorithm that makes this possible).

An interaction tecnique is also put into place to let users focus on specific parallelepipedons by making the other ones translucent. This can be used to better evaluate their height. Because there are no fully occluded parallelepipedons, there is always a way to select a specific one.

Implementation note: we formerly used z as the name for the z axis, but this conflicted with the way d3.js handles sticky treemaps, so we renamed it as h.

index.js

// Generated by CoffeeScript 1.4.0
(function() {
  var color, data, enter_pipedons, height, iso_layout, isometric, parallelepipedon, path_generator, pipedons, 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, 10]).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) + ")"
  });

  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],
      far_point: fb
    };
    return d;
  };

  iso_layout = function(data, shape, scale) {
    if (!(scale != null)) {
      scale = 1;
    }
    data.forEach(function(d) {
      return shape(d, scale);
    });
    return data.sort(function(a, b) {
      return b.dh - a.dh;
    });
  };

  path_generator = function(d) {
    return 'M' + d.map(function(p) {
      return p.join(' ');
    }).join('L') + 'z';
  };

  treemap = d3.layout.treemap().size([300, 300]).value(function(d) {
    return d.area;
  }).sort(function(a, b) {
    return a.dh - b.dh;
  }).ratio(1).round(false);

  color = d3.scale.category20();

  data = d3.range(30).map(function() {
    return {
      area: Math.random(),
      dh: Math.random() * 150
    };
  });

  data = treemap.nodes({
    children: data
  }).filter(function(n) {
    return n.depth === 1;
  });

  iso_layout(data, parallelepipedon);

  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 template',
    d: function(d) {
      return path_generator(d.iso.face_left);
    },
    fill: function(d, i) {
      return color(i);
    }
  });

  enter_pipedons.append('path').attr({
    "class": 'iso face right',
    d: function(d) {
      return path_generator(d.iso.face_right);
    },
    fill: function(d) {
      color = d3.hcl(d3.select(this.parentNode).select('.template').style('fill'));
      return d3.hcl(color.h, color.c, 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) {
      color = d3.hcl(d3.select(this.parentNode).select('.template').style('fill'));
      return d3.hcl(color.h, color.c, color.l + 12);
    }
  });

  enter_pipedons.append('path').attr({
    "class": 'iso outline',
    d: function(d) {
      return path_generator(d.iso.outline);
    }
  });

}).call(this);

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Isometric Projection</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,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)

vis = zoomable_layer.append('g')
  .attr
    class: 'vis'
    transform: "translate(#{width/2},#{height/3})"

# [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]
    far_point: fb # used to control the z-index of iso objects
  }

  return d

iso_layout = (data, shape, scale) ->
  scale = 1 if not scale?

  data.forEach (d) ->
    shape(d, scale)

  # this uses the treemap ordering in some way... (!!!)
  data.sort (a,b) -> b.dh - a.dh

path_generator = (d) -> 'M' + d.map((p)->p.join(' ')).join('L') + 'z'

treemap = d3.layout.treemap()
  .size([300, 300])
  .value((d) -> d.area)
  .sort((a,b) -> a.dh-b.dh)
  .ratio(1)
  .round(false) # bugfix: d3 wrong ordering

color = d3.scale.category20()

data = d3.range(30).map () -> {area: Math.random(), dh: Math.random()*150}
data = treemap.nodes({children: data}).filter (n) -> n.depth is 1
iso_layout(data, parallelepipedon)

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 template'
    d: (d) -> path_generator(d.iso.face_left)
    fill: (d, i) -> color(i)

enter_pipedons.append('path')
  .attr
    class: 'iso face right'
    d: (d) -> path_generator(d.iso.face_right)
    fill: (d) ->
      # right face is darker than the template (left face)
      color = d3.hcl(d3.select(this.parentNode).select('.template').style('fill'))
      return d3.hcl(color.h, color.c, color.l-12)

enter_pipedons.append('path')
  .attr
    class: 'iso face top'
    d: (d) -> path_generator(d.iso.face_top)
    fill: (d) ->
      # right face is brighter than the template (left face)
      color = d3.hcl(d3.select(this.parentNode).select('.template').style('fill'))
      return d3.hcl(color.h, color.c, color.l+12)

enter_pipedons.append('path')
  .attr
    class: 'iso outline'
    d: (d) -> path_generator(d.iso.outline)

index.css

.iso.face.bottom {
  fill: brown;
}

.iso.outline {
  stroke: white;
  fill: none;
  vector-effect: non-scaling-stroke;
}

.vis:hover .pipedon:not(:hover) * {
  opacity: 0.3;
}