block by nitaku 6210bd80bdd20181e1f4

Isometric word cloud

Full Screen

A mix of isometric and word cloud “treemaps”.

index.js

// Generated by CoffeeScript 1.4.0
(function() {
  var color, correct_x, correct_y, data, enter_labels, enter_labels_g, 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],
      fb: fb,
      mlb: mlb,
      nb: nb,
      mrb: mrb,
      ft: ft,
      mlt: mlt,
      nt: nt,
      mrt: mrt
    };
    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(4).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]);

  data = d3.range(30).map(function() {
    return {
      word: randstring["new"](),
      area: Math.random(),
      dh: Math.random() * 150
    };
  });

  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(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);
    }
  });

}).call(this);

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Isometric Word Cloud</title>
    <link type="text/css" href="index.css" rel="stylesheet"/>
    <script src="//d3js.org/d3.v3.min.js"></script>
    <script src="randstring.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],
    fb: fb,
    mlb: mlb,
    nb: nb,
    mrb: mrb,
    ft: ft,
    mlt: mlt,
    nt: nt,
    mrt: mrt
  }

  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(4)
  .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])

data = d3.range(30).map () -> {word: randstring.new(), area: Math.random(), dh: Math.random()*150}
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(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)

index.css

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

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

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

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

randstring.coffee

window.randstring = {}

syllables = ['bi','bo','bu','ta','se','tri','su','ke','ka','flo','ko','pi','pe','no','go','zo','fu','fo','si','pa','ar','es','i','kya','kyu','fle','o','ne','na','le','lu','ma','an']


randlen = () -> 3+Math.floor(Math.random()*2)

randsy = () -> syllables[Math.floor(Math.random()*syllables.length)]

randstring.new = () -> (randsy() for j in [0...randlen()]).join('')

randstring.js

// Generated by CoffeeScript 1.4.0
(function() {
  var randlen, randsy, syllables;

  window.randstring = {};

  syllables = ['bi', 'bo', 'bu', 'ta', 'se', 'tri', 'su', 'ke', 'ka', 'flo', 'ko', 'pi', 'pe', 'no', 'go', 'zo', 'fu', 'fo', 'si', 'pa', 'ar', 'es', 'i', 'kya', 'kyu', 'fle', 'o', 'ne', 'na', 'le', 'lu', 'ma', 'an'];

  randlen = function() {
    return 3 + Math.floor(Math.random() * 2);
  };

  randsy = function() {
    return syllables[Math.floor(Math.random() * syllables.length)];
  };

  randstring["new"] = function() {
    var j;
    return ((function() {
      var _i, _ref, _results;
      _results = [];
      for (j = _i = 0, _ref = randlen(); 0 <= _ref ? _i < _ref : _i > _ref; j = 0 <= _ref ? ++_i : --_i) {
        _results.push(randsy());
      }
      return _results;
    })()).join('');
  };

}).call(this);