block by milroc 5520449

bar + sum: d3.js & ember.js

Full Screen

This is a six part series, taking you through stages of designing and creating reusable visualizations with d3.js

All visualizations have the same functionality, showcase the individual points with a bar chart and sum up the selected bars.

Part 4: This is showcasing the power of combining ember.js with d3.js and how to bind them together creating an abstraction between the visualization (the bar chart) and the rest of the application.

These are examples created for a talk (slides and video).

Cheers,

Miles @milr0c

index.html

<!DOCTYPE html>
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" type="text/css" href="//littlesparkvt.com/flatstrap/assets/css/bootstrap.css"/>
    <link rel="stylesheet" type="text/css" href="style.css"/>
    <script src="//code.jquery.com/jquery-2.0.0.min.js"></script>
    <script src="handlebars.min.js"></script>
    <script src="ember.min.js"></script>
    <script src="//d3js.org/d3.v3.min.js"></script>
    <script src="src.js"></script>
  </head>
  <body>
    <script type="text/x-handlebars" data-template-name="application">
        <div class="row">
          <div class="span2">
            <button class="btn btn-success" {{action update target="view" }}>update</button>
          </div>
          <div class="span2" id="sum">
            TOTAL: {{view.total}}
          </div>
      </div>
      <div class="row">
        {{view App.barView contentBinding="view.barValues"}}
      </div>
    </script>
    <script type="text/javascript">
App = Ember.Application.create({});

// Controllers
App.mainController = Ember.ArrayProxy.create({
    content: [],
    total: 0,
    init: function() {
      this.update();
    },
    randomize: function(n, y) {
      if (arguments.length < 2) y = 400;
      if (!arguments.length) n = 20;
      var i = 0;
      this.set('content', d3.range(~~(Math.random()*n) + 1).map(function(d, i) { return { x: ++i, y: ~~(Math.random()*y) };
      }));
    },
    update: function() {
      this.randomize();
      this.set('total', 0);
    },
    sum: function(data, extent, x) {
      if (!arguments.length) return 0;
      var total = 0;
      data.forEach(function(d) {
        if (extent[0] <= x(d.x) && x(d.x) + x.rangeBand() <= extent[1]) {
          total += d.y;
        }
      });
      // weird namespacing issue, need to learn more
      this.App.mainController.set('total', total);
    }
});

// Views
App.ApplicationView = Ember.View.extend({
    templateName: 'application',
    barValuesBinding: 'App.mainController.content',
    totalBinding: 'App.mainController.total',
    update: function(event) {
      App.mainController.update();
    }
});

App.barView = Ember.View.extend({
  sumBinding: 'App.mainController.sum',
  change: function() {
    var elementId = this.get('elementId'),
        data = this.get('content'),
        sum = this.get('sum'),
        bar = this.get('bar') || charts.bar();
    bar.on('brush', function(data) {
      var extent = d3.event.target.extent(),
          x = bar.x();
      sum(data, extent, x);
    });
    bar.on('brushend', function(data) {
      var extent = d3.event.target.extent(),
          x = bar.x();
      sum(data, extent, x);
    });
    d3.select('#'+elementId)
          .datum(data)
          .call(bar);
    this.set('bar', bar);
  }.observes('content'),
  didInsertElement: function() {
    this.change();
  }
});

// Routes
App.Router = Ember.Router.extend({
    root: Ember.Route.extend({
        index: Ember.Route.extend({
            route: '/'
        })
    })
});
    </script>
  </body>
</html>

src.js

charts = {};

charts.bar = function() {
  // basic data
  var margin = {top: 0, bottom: 20, left: 0, right: 0},
      width = 400,
      height = 400,
      // accessors
      xValue = function(d) { return d.x; },
      yValue = function(d) { return d.y; },
      // chart underpinnings
      brush = d3.svg.brush(),
      xAxis = d3.svg.axis().orient('bottom'),
      yAxis = d3.svg.axis().orient('left'),
      x = d3.scale.ordinal(),
      y = d3.scale.linear(),
      // chart enhancements
      elastic = {
        margin: true,
        x: true,
        y: true
      },
      convertData = true,
      duration = 500,
      formatNumber = d3.format(',d');

  function render(selection) {
    selection.each(function(data) {
      // setup the basics
      if (elastic.margin) margin.left = formatNumber(d3.max(data, function(d) { return d.y; })).length * 14;
      var w = width - margin.left - margin.right,
          h = height - margin.top - margin.bottom;

      // if needed convert the data
      if (convertData) {
        data = data.map(function(d, i) {
          return {
            x: xValue.call(data, d, i),
            y: yValue.call(data, d, i)
          };
        });
      }

      // set scales
      if (elastic.x) x.domain(data.map(function(d) { return d.x; }));
      if (elastic.y) y.domain([0, d3.max(data, function(d) { return d.y; })]);
      x.rangeRoundBands([0, w], .1);
      y.range([h, 0]);


      // reset axes and brush
      xAxis.scale(x);
      yAxis.scale(y);
      brush.x(x)
          .on('brushstart.chart', brushstart)
          .on('brush.chart', brushmove)
          .on('brushend.chart', brushend);
      brush.clear();

      var svg = selection.selectAll('svg').data([data]),
          chartEnter = svg.enter().append('svg')
                                  .append('g')
                                    .attr('width', w)
                                    .attr('height', h)
                                    .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
                                    .classed('chart', true),
          chart = svg.select('.chart');

      chartEnter.append('g')
                .classed('x axis', true)
                .attr('transform', 'translate(' + 0 + ',' + h + ')');
      chartEnter.append('g')
                .classed('y axis', true)
      chartEnter.append('g').classed('barGroup', true);
      
      chart.selectAll('.brush').remove();
      chart.selectAll('.selected').classed('selected', false);
      
      chart.append('g')
                .classed('brush', true)
                .call(brush)
              .selectAll('rect')
                .attr('height', h);

      bars = chart.select('.barGroup').selectAll('.bar').data(data);

      bars.enter()
            .append('rect')
              .classed('bar', true)
              .attr('x', w) // start here for object constancy
              .attr('width', x.rangeBand())
              .attr('y', function(d, i) { return y(d.y); })
              .attr('height', function(d, i) { return h - y(d.y); });

      bars.transition()
            .duration(duration)
              .attr('width', x.rangeBand())
              .attr('x', function(d, i) { return x(d.x); })
              .attr('y', function(d, i) { return y(d.y); })
              .attr('height', function(d, i) { return h - y(d.y); });

      bars.exit()
            .transition()
                .duration(duration)
                    .style('opacity', 0)
                    .remove();

      chart.select('.x.axis')
            .transition()
                .duration(duration)
                  .call(xAxis);
      chart.select('.y.axis')
            .transition()
                .duration(duration)
                  .call(yAxis);  

      function brushstart() {
        chart.classed("selecting", true);
      }

      function brushmove() {
        var extent = d3.event.target.extent();
        bars.classed("selected", function(d) { return extent[0] <= x(d.x) && x(d.x) + x.rangeBand() <= extent[1]; });
      }

      function brushend() {
        chart.classed("selecting", !d3.event.target.empty());
      } 
    });
  }

  // basic data
  render.margin = function(_) {
    if (!arguments.length) return margin;
    margin = _;
    return render;
  };
  render.width = function(_) {
    if (!arguments.length) return width;
    width = _;
    return render;
  };
  render.height = function(_) {
    if (!arguments.length) return height;
    height = _;
    return render;
  };

  // accessors
  render.xValue = function(_) {
    if (!arguments.length) return xValue;
    xValue = _;
    return render;
  };
  render.yValue = function(_) {
    if (!arguments.length) return yValue;
    yValue = _;
    return render;
  };

  // chart underpinnings
  render.brush = function(_) {
    if (!arguments.length) return brush;
    brush = _;
    return render;
  };
  render.xAxis = function(_) {
    if (!arguments.length) return xAxis;
    xAxis = _;
    return render;
  };
  render.yAxis = function(_) {
    if (!arguments.length) return yAxis;
    yAxis = _;
    return render;
  };
  render.x = function(_) {
    if (!arguments.length) return x;
    x = _;
    return render;
  };
  render.y = function(_) {
    if (!arguments.length) return y;
    y = _;
    return render;
  };
  
  // chart enhancements
  render.elastic = function(_) {
    if (!arguments.length) return elastic;
    elastic = _;
    return render;
  };
  render.convertData = function(_) {
    if (!arguments.length) return convertData;
    convertData = _;
    return render;
  };
  render.duration = function(_) {
    if (!arguments.length) return duration;
    duration = _;
    return render;
  };
  render.formatNumber = function(_) {
    if (!arguments.length) return formatNumber;
    formatNumber = _;
    return render;
  };

  return d3.rebind(render, brush, 'on');
};

style.css

body {
  font: 14px helvetica;
  color: #f0f0f0;
  background-color: #333;
}

.row {
  padding: 5px;
  margin: 0px;
}

.axis path,
.axis line {
  fill: none;
  stroke: #f0f0f0;
  shape-rendering: crispEdges;
}

.axis text {
  fill: #f0f0f0;
}

.brush .extent {
  stroke: #f0f0f0;
  fill-opacity: .125;
  shape-rendering: crispEdges;
}

.bar {
  fill: #5EB4E3;
}
.selected {
  fill: #78C656;
}