block by nitaku b82c356f1969911042d6

Isometric treemap with tree colors (flare)

Full Screen

An isometric treemap using tree colors to help in differentiating subtrees and perceiving hierarchy depth.

index.js

// Generated by CoffeeScript 1.4.0
(function() {
  var DH, PAD, color, correct_x, correct_y, height, iso_layout, isometric, ordering, parallelepipedon, path_generator, svg, treechroma, treehue, treelum, treemap, vis, width, zoom, zoomable_layer;

  treehue = function(node, hue_range, fraction, rev) {
    var child, child_hue_ranges, half_n, i, n, r, ri, _i, _j, _len, _ref, _results;
    r = hue_range[1] - hue_range[0];
    node.hue = hue_range[0] + r / 2;
    if (node.children != null) {
      n = node.children.length;
      ri = r / n;
      child_hue_ranges = [];
      half_n = Math.floor(n / 2);
      for (i = _i = 0; 0 <= half_n ? _i < half_n : _i > half_n; i = 0 <= half_n ? ++_i : --_i) {
        child_hue_ranges.push([hue_range[0] + ri * i + ri * (1 - fraction) / 2, hue_range[0] + ri * (i + 1) - ri * (1 - fraction) / 2]);
        child_hue_ranges.push([hue_range[0] + ri * (i + half_n) + ri * (1 - fraction) / 2, hue_range[0] + ri * (i + 1 + half_n) - ri * (1 - fraction) / 2]);
      }
      if (n % 2 === 1) {
        child_hue_ranges.push([hue_range[0] + ri * half_n + ri * (1 - fraction) / 2, hue_range[0] + ri * (1 + half_n) - ri * (1 - fraction) / 2]);
      }
      if (rev) {
        child_hue_ranges.reverse();
      }
      _ref = node.children;
      _results = [];
      for (i = _j = 0, _len = _ref.length; _j < _len; i = ++_j) {
        child = _ref[i];
        _results.push(treehue(child, child_hue_ranges[i], fraction, i % 2 === 0));
      }
      return _results;
    }
  };

  treelum = d3.scale.linear().range([80, 30]);

  treechroma = d3.scale.sqrt().range([0, 70]);

  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 - 112) + ")"
  });

  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) {
    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';
  };

  DH = 5;

  PAD = 4;

  treemap = d3.layout.treemap().size([400, 400]).value(function(d) {
    return d.size;
  }).sort(function(a, b) {
    return ordering(b, a);
  }).padding(PAD).round(false);

  color = d3.scale.category20c();

  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('flare.json', function(tree) {
    var data, enter_labels, enter_labels_g, enter_pipedons, i, pipedons, walk, walk_i;
    walk = function(n, depth) {
      var child, _i, _len, _ref;
      n.depth = depth;
      n.dh = DH;
      n.h = DH * depth;
      if (n.children != null) {
        _ref = n.children;
        for (_i = 0, _len = _ref.length; _i < _len; _i++) {
          child = _ref[_i];
          walk(child, depth + 1);
        }
        n.children.sort(function(a, b) {
          return a.size - b.size;
        });
        return n.size = d3.sum(n.children, function(d) {
          return d.size;
        });
      }
    };
    walk(tree, 0);
    i = 0;
    walk_i = function(n) {
      var child, _i, _len, _ref;
      if (n.children != null) {
        _ref = n.children;
        for (_i = 0, _len = _ref.length; _i < _len; _i++) {
          child = _ref[_i];
          walk_i(child);
        }
      }
      n.i = i;
      return i += 1;
    };
    walk_i(tree);
    treehue(tree, [180, 720], 0.7);
    treelum.domain([0, 3]);
    treechroma.domain([0, 3]);
    data = treemap.nodes(tree);
    iso_layout(data, parallelepipedon);
    data.forEach(function(d, i) {
      return d.template_color = d3.hcl(d.hue, treechroma(d.depth), treelum(d.depth));
    });
    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').classed('hidden', function(d) {
      return d.children != null;
    });
    enter_labels = enter_labels_g.append('svg').attr({
      "class": 'label'
    });
    enter_labels.append('text').text(function(d) {
      return d.name.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.name;
    });
  });

}).call(this);

index.html

<!doctype html>
<html lang="en">
  <head>
	<meta charset="utf-8">
	<title>Isometric treemap with tree colors (flare)</title>
	<script src="//d3js.org/d3.v3.min.js"></script>
	<link rel="stylesheet" href="index.css">
  </head>
  <body>
	<svg width="960px" height="500px"></svg>
	<script src="index.js"></script>
  </body>
</html>

index.coffee

treehue = (node, hue_range, fraction, rev) ->
  # 0 <= hue_range[0] <= hue_range[1]
  r = hue_range[1]-hue_range[0]
  node.hue = hue_range[0]+r/2
  
  if node.children?
    n = node.children.length
    ri = r/n
    child_hue_ranges = []
    half_n = Math.floor(n/2)
    for i in [0...half_n]
      child_hue_ranges.push [
        hue_range[0] + ri*i + ri*(1-fraction)/2,
        hue_range[0] + ri*(i+1) - ri*(1-fraction)/2
      ]
      child_hue_ranges.push [
        hue_range[0] + ri*(i+half_n) + ri*(1-fraction)/2,
        hue_range[0] + ri*(i+1+half_n) - ri*(1-fraction)/2
      ]
    
    # if n is odd we need to push the middle item
    if n%2 is 1
      child_hue_ranges.push [
        hue_range[0] + ri*(half_n) + ri*(1-fraction)/2,
        hue_range[0] + ri*(1+half_n) - ri*(1-fraction)/2
      ]
    
    child_hue_ranges.reverse() if rev
    
    for child, i in node.children
      treehue(child, child_hue_ranges[i], fraction, i % 2 is 0)
    
treelum = d3.scale.linear()
  .range([80,30])

treechroma = d3.scale.sqrt()
  .range([0,70])

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

# [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) -> 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'

DH = 5
PAD = 4
treemap = d3.layout.treemap()
  .size([400, 400])
  .value((d) -> d.size)
  .sort((a,b) -> ordering(b,a)) # same as before, but inverted
  .padding(PAD)
  .round(false) # bugfix: d3 wrong ordering

color = d3.scale.category20c()

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 'flare.json', (tree) ->
  walk = (n, depth) ->
    n.depth = depth

    n.dh = DH
    n.h = DH*depth

    if n.children?
      for child in n.children
        walk(child, depth+1)
      n.children.sort (a,b) -> a.size - b.size
      n.size = d3.sum n.children, (d) -> d.size

  walk(tree, 0)
  
  # depth-first enumeration
  i = 0
  walk_i = (n) ->
    if n.children?
      for child in n.children
        walk_i(child)
    n.i = i
    i += 1

  walk_i(tree)
  
  treehue(tree, [180,720], 0.7)
  treelum
    .domain([0, 3])
  treechroma
    .domain([0, 3])
  
  data = treemap.nodes(tree)
  iso_layout(data, parallelepipedon)

  data.forEach (d, i) ->
    # save the template color
    d.template_color = d3.hcl(d.hue, treechroma(d.depth), treelum(d.depth))
  
  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')
  	.classed('hidden', (d) -> d.children?)

  enter_labels = enter_labels_g.append('svg')
    .attr
      class: 'label'

  enter_labels.append('text')
    .text((d) -> d.name.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.name)

index.css

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

.label {
  pointer-events: none;
  text-anchor: middle;
  font-family: Impact;
}

.pipedon:hover .label {
  fill: black;
}
.pipedon:hover .face {
  fill: yellow;
}

.hidden {
  display: none;
}