block by milroc d22bbf92231876505e5d

bar + sum: d3.js, react.js, & Flux

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 6: This is showcasing the power of combining react.js (& flux) with d3.js and how to use them together creating an abstraction between the visualization (the bar chart) and the rest of the application.

This new example was created to compare React to the other concepts. The best part of working with react.js is that it really requires you to build applications without global state.

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="react.min.js"></script>
    <script src="JSXTransformer.js"></script>
    <script src="es6-promise.min.js"></script>
    <script src="EventEmitter.min.js"></script>
    <script src="//d3js.org/d3.v3.min.js"></script>
    <script src="src.js"></script>
    <script src="flux.js"></script>
  </head>
  <body>
    <script type="text/jsx">
/** @jsx React.DOM */

// Models (Stores in Flux terminology)
var ChartStore = (function() {
  var _data = randomize(),
      _sum = 0;

  var ChartStore = function() {};

  ChartStore.prototype = new EventEmitter();

  ChartStore.prototype.getData = function() {
    return _data;
  };

  ChartStore.prototype.getSum = function() {
    return _sum;
  };

  ChartStore.prototype.emitChange = function(eventType) {
    this.emit(eventType);
  };

  ChartStore.prototype.addChangeListener = function(eventType, callback) {
    this.on(eventType, callback);
  };

  ChartStore.prototype.removeChangeListener = function(eventType, callback) {
    this.removeListener(eventType, callback);
  };

  var exports = new ChartStore(); // a bit of a hack

  function updateData() {
    _data = randomize();
    _sum = 0;
  }

  function updateSum(extent, x) {
    _sum = _data.filter(function(d) {
      return extent[0] <= x(d.x) && x(d.x) + x.rangeBand() <= extent[1];
    })
    .reduce(function(a, b) {
      return a + b.y;
    }, 0);
  }

  AppDispatcher.register(function(payload) {
    var action = payload.action;

    if (action.actionType === 'update') {
      updateData();
      exports.emitChange('range');
    } else if (action.actionType === 'range') {
      updateSum(action.extent, action.x);
    } else {
      return true;
    }

    // A bit convoluted, basically the Stores events and the actions are congruent
    exports.emitChange(action.actionType);

    return true; // No errors.  Needed by promise in Dispatcher.
  });

  return exports;
})();

// you cannot add methods to a React Class that are 'objects' (chainable closures)
// function () {
//   return method.apply(component, arguments);
// }
// figure out scope
var bar = charts.bar();
var RxBarChart = React.createClass({
  render: function() {
    return (
      <div></div>
    );
  },
  getInitialState: function() {
    return { data: ChartStore.getData() };
  },
  componentDidMount: function() {
    // bind events
    ChartStore.addChangeListener('update', this._updateData);
    this._bindBrush();
    this.d3Render();
  },
  shouldComponentUpdate: function() {
    return false;
  },
  _bindBrush: function() {
    bar
      .on('brush', this._updateRange)
      .on('brushend', this._updateRange);
  },
  _updateData: function() {
    // you have to manage re-rendering
    this.setState({ data: ChartStore.getData() }, this.d3Render);
  },
  _updateRange: function() {
    ChartActions.range(d3.event.target.extent(), bar.x());
  },
  d3Render: function() {
    // properties mirroring
    bar.width(this.props.width || 300)
       .height(this.props.height || 300)
    // rendering
    d3.select(this.getDOMNode())
        .datum(this.state.data)
        .call(bar);
  }
});

var SumTotal = React.createClass({
  render: function() {
    return (
      <div className="span2">
        TOTAL: {this.state.sum}
      </div>
    );
  },
  getInitialState: function() {
    return {
      sum: ChartStore.getSum()
    };
  },
  componentDidMount: function() {
    ChartStore.addChangeListener('range', this._onChange);
  },
  componentWillUnmount: function() {
    ChartStore.removeChangeListener('range', this._onChange);
  },
  _onChange: function() {
    this.setState({ sum: ChartStore.getSum() });
  }
})

var app = React.createClass({
  render: function() {
    return (
      <div className="app">
        <div className="row">
          <div className="span2">
            <button className="btn btn-success" onClick={this._onUpdateClick}>update</button>
          </div>
          <SumTotal />
        </div>
        <RxBarChart width={230*4} height={120*4-40} />
      </div>
    );
  },
  getInitialState: function() {
    return {
      data: this.props.data || [],
      sum: 0,
      resetBrush: false
    };
  },
  _onUpdateClick: function() {
    ChartActions.update();
  },
});

React.renderComponent(
  app({data: randomize() }),
  document.body
);
    </script>
  </body>
</html>

EventEmitter.min.js

/*!
 * EventEmitter v4.2.7 - git.io/ee
 * Oliver Caldwell
 * MIT license
 * @preserve
 */
(function(){"use strict";function t(){}function r(t,n){for(var e=t.length;e--;)if(t[e].listener===n)return e;return-1}function n(e){return function(){return this[e].apply(this,arguments)}}var e=t.prototype,i=this,s=i.EventEmitter;e.getListeners=function(n){var r,e,t=this._getEvents();if(n instanceof RegExp){r={};for(e in t)t.hasOwnProperty(e)&&n.test(e)&&(r[e]=t[e])}else r=t[n]||(t[n]=[]);return r},e.flattenListeners=function(t){var e,n=[];for(e=0;e<t.length;e+=1)n.push(t[e].listener);return n},e.getListenersAsObject=function(n){var e,t=this.getListeners(n);return t instanceof Array&&(e={},e[n]=t),e||t},e.addListener=function(i,e){var t,n=this.getListenersAsObject(i),s="object"==typeof e;for(t in n)n.hasOwnProperty(t)&&-1===r(n[t],e)&&n[t].push(s?e:{listener:e,once:!1});return this},e.on=n("addListener"),e.addOnceListener=function(e,t){return this.addListener(e,{listener:t,once:!0})},e.once=n("addOnceListener"),e.defineEvent=function(e){return this.getListeners(e),this},e.defineEvents=function(t){for(var e=0;e<t.length;e+=1)this.defineEvent(t[e]);return this},e.removeListener=function(i,s){var n,e,t=this.getListenersAsObject(i);for(e in t)t.hasOwnProperty(e)&&(n=r(t[e],s),-1!==n&&t[e].splice(n,1));return this},e.off=n("removeListener"),e.addListeners=function(e,t){return this.manipulateListeners(!1,e,t)},e.removeListeners=function(e,t){return this.manipulateListeners(!0,e,t)},e.manipulateListeners=function(r,t,i){var e,n,s=r?this.removeListener:this.addListener,o=r?this.removeListeners:this.addListeners;if("object"!=typeof t||t instanceof RegExp)for(e=i.length;e--;)s.call(this,t,i[e]);else for(e in t)t.hasOwnProperty(e)&&(n=t[e])&&("function"==typeof n?s.call(this,e,n):o.call(this,e,n));return this},e.removeEvent=function(e){var t,r=typeof e,n=this._getEvents();if("string"===r)delete n[e];else if(e instanceof RegExp)for(t in n)n.hasOwnProperty(t)&&e.test(t)&&delete n[t];else delete this._events;return this},e.removeAllListeners=n("removeEvent"),e.emitEvent=function(r,o){var e,i,t,s,n=this.getListenersAsObject(r);for(t in n)if(n.hasOwnProperty(t))for(i=n[t].length;i--;)e=n[t][i],e.once===!0&&this.removeListener(r,e.listener),s=e.listener.apply(this,o||[]),s===this._getOnceReturnValue()&&this.removeListener(r,e.listener);return this},e.trigger=n("emitEvent"),e.emit=function(e){var t=Array.prototype.slice.call(arguments,1);return this.emitEvent(e,t)},e.setOnceReturnValue=function(e){return this._onceReturnValue=e,this},e._getOnceReturnValue=function(){return this.hasOwnProperty("_onceReturnValue")?this._onceReturnValue:!0},e._getEvents=function(){return this._events||(this._events={})},t.noConflict=function(){return i.EventEmitter=s,t},"function"==typeof define&&define.amd?define(function(){return t}):"object"==typeof module&&module.exports?module.exports=t:this.EventEmitter=t}).call(this);

es6-promise.min.js

!function(){var a,b,c,d;!function(){var e={},f={};a=function(a,b,c){e[a]={deps:b,callback:c}},d=c=b=function(a){function c(b){if("."!==b.charAt(0))return b;for(var c=b.split("/"),d=a.split("/").slice(0,-1),e=0,f=c.length;f>e;e++){var g=c[e];if(".."===g)d.pop();else{if("."===g)continue;d.push(g)}}return d.join("/")}if(d._eak_seen=e,f[a])return f[a];if(f[a]={},!e[a])throw new Error("Could not find module "+a);for(var g,h=e[a],i=h.deps,j=h.callback,k=[],l=0,m=i.length;m>l;l++)"exports"===i[l]?k.push(g={}):k.push(b(c(i[l])));var n=j.apply(this,k);return f[a]=g||n}}(),a("promise/all",["./utils","exports"],function(a,b){"use strict";function c(a){var b=this;if(!d(a))throw new TypeError("You must pass an array to all.");return new b(function(b,c){function d(a){return function(b){f(a,b)}}function f(a,c){h[a]=c,0===--i&&b(h)}var g,h=[],i=a.length;0===i&&b([]);for(var j=0;j<a.length;j++)g=a[j],g&&e(g.then)?g.then(d(j),c):f(j,g)})}var d=a.isArray,e=a.isFunction;b.all=c}),a("promise/asap",["exports"],function(a){"use strict";function b(){return function(){process.nextTick(e)}}function c(){var a=0,b=new i(e),c=document.createTextNode("");return b.observe(c,{characterData:!0}),function(){c.data=a=++a%2}}function d(){return function(){j.setTimeout(e,1)}}function e(){for(var a=0;a<k.length;a++){var b=k[a],c=b[0],d=b[1];c(d)}k=[]}function f(a,b){var c=k.push([a,b]);1===c&&g()}var g,h="undefined"!=typeof window?window:{},i=h.MutationObserver||h.WebKitMutationObserver,j="undefined"!=typeof global?global:void 0===this?window:this,k=[];g="undefined"!=typeof process&&"[object process]"==={}.toString.call(process)?b():i?c():d(),a.asap=f}),a("promise/config",["exports"],function(a){"use strict";function b(a,b){return 2!==arguments.length?c[a]:(c[a]=b,void 0)}var c={instrument:!1};a.config=c,a.configure=b}),a("promise/polyfill",["./promise","./utils","exports"],function(a,b,c){"use strict";function d(){var a;a="undefined"!=typeof global?global:"undefined"!=typeof window&&window.document?window:self;var b="Promise"in a&&"resolve"in a.Promise&&"reject"in a.Promise&&"all"in a.Promise&&"race"in a.Promise&&function(){var b;return new a.Promise(function(a){b=a}),f(b)}();b||(a.Promise=e)}var e=a.Promise,f=b.isFunction;c.polyfill=d}),a("promise/promise",["./config","./utils","./all","./race","./resolve","./reject","./asap","exports"],function(a,b,c,d,e,f,g,h){"use strict";function i(a){if(!v(a))throw new TypeError("You must pass a resolver function as the first argument to the promise constructor");if(!(this instanceof i))throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function.");this._subscribers=[],j(a,this)}function j(a,b){function c(a){o(b,a)}function d(a){q(b,a)}try{a(c,d)}catch(e){d(e)}}function k(a,b,c,d){var e,f,g,h,i=v(c);if(i)try{e=c(d),g=!0}catch(j){h=!0,f=j}else e=d,g=!0;n(b,e)||(i&&g?o(b,e):h?q(b,f):a===D?o(b,e):a===E&&q(b,e))}function l(a,b,c,d){var e=a._subscribers,f=e.length;e[f]=b,e[f+D]=c,e[f+E]=d}function m(a,b){for(var c,d,e=a._subscribers,f=a._detail,g=0;g<e.length;g+=3)c=e[g],d=e[g+b],k(b,c,d,f);a._subscribers=null}function n(a,b){var c,d=null;try{if(a===b)throw new TypeError("A promises callback cannot return that same promise.");if(u(b)&&(d=b.then,v(d)))return d.call(b,function(d){return c?!0:(c=!0,b!==d?o(a,d):p(a,d),void 0)},function(b){return c?!0:(c=!0,q(a,b),void 0)}),!0}catch(e){return c?!0:(q(a,e),!0)}return!1}function o(a,b){a===b?p(a,b):n(a,b)||p(a,b)}function p(a,b){a._state===B&&(a._state=C,a._detail=b,t.async(r,a))}function q(a,b){a._state===B&&(a._state=C,a._detail=b,t.async(s,a))}function r(a){m(a,a._state=D)}function s(a){m(a,a._state=E)}var t=a.config,u=(a.configure,b.objectOrFunction),v=b.isFunction,w=(b.now,c.all),x=d.race,y=e.resolve,z=f.reject,A=g.asap;t.async=A;var B=void 0,C=0,D=1,E=2;i.prototype={constructor:i,_state:void 0,_detail:void 0,_subscribers:void 0,then:function(a,b){var c=this,d=new this.constructor(function(){});if(this._state){var e=arguments;t.async(function(){k(c._state,d,e[c._state-1],c._detail)})}else l(this,d,a,b);return d},"catch":function(a){return this.then(null,a)}},i.all=w,i.race=x,i.resolve=y,i.reject=z,h.Promise=i}),a("promise/race",["./utils","exports"],function(a,b){"use strict";function c(a){var b=this;if(!d(a))throw new TypeError("You must pass an array to race.");return new b(function(b,c){for(var d,e=0;e<a.length;e++)d=a[e],d&&"function"==typeof d.then?d.then(b,c):b(d)})}var d=a.isArray;b.race=c}),a("promise/reject",["exports"],function(a){"use strict";function b(a){var b=this;return new b(function(b,c){c(a)})}a.reject=b}),a("promise/resolve",["exports"],function(a){"use strict";function b(a){if(a&&"object"==typeof a&&a.constructor===this)return a;var b=this;return new b(function(b){b(a)})}a.resolve=b}),a("promise/utils",["exports"],function(a){"use strict";function b(a){return c(a)||"object"==typeof a&&null!==a}function c(a){return"function"==typeof a}function d(a){return"[object Array]"===Object.prototype.toString.call(a)}var e=Date.now||function(){return(new Date).getTime()};a.objectOrFunction=b,a.isFunction=c,a.isArray=d,a.now=e}),b("promise/polyfill").polyfill()}();

flux.js

// The Flux architecture style code is in this file
// Please note that these are not split into separate modules due to:
//    1) small nature of this example
//    2) limitations of gists
// Additionally, most of this code is derived from the Flux + React.js TodoMVC
// for more information, and to properly learn about the concepts I recommend
// reviewing the tutorial or code there:
// http://facebook.github.io/react/docs/flux-todo-list.html
// https://github.com/facebook/react/tree/master/examples/todomvc-flux

// Dispatching (Additionally, this could be handled using d3.dispatch
// (e.g: http://bl.ocks.org/milroc/10606529))
var Dispatcher = (function() {
  var _callbacks = [];
  var _promises = [];

  var _addPromise = function(callback, payload) {
    _promises.push(new Promise(function(resolve, reject) {
      if (callback(payload)) {
        resolve(payload);
      } else {
        reject(new Error('Dispatcher callback unsuccessful'));
      }
    }));
  };

  var _clearPromises = function() {
    _promises = [];
  };

  var Dispatcher = function() {};

  Dispatcher.prototype.register = function(callback) {
    _callbacks.push(callback);
    return _callbacks.length - 1; // index
  };

  Dispatcher.prototype.dispatch = function(payload) {
    _callbacks.forEach(function(callback) {
      _addPromise(callback, payload);
    });
    Promise.all(_promises).then(_clearPromises);
  };

  return Dispatcher;
})();

var AppDispatcher = (function() {
  var AppDispatcher = function() {};

  AppDispatcher.prototype = new Dispatcher();

  AppDispatcher.prototype.handleViewAction = function(action) {
    this.dispatch({
      source: 'VIEW_ACTION',
      action: action
    });
  };

  return new AppDispatcher(); // a bit of a hack
})();

// Actions
var ChartActions = {
  range: function(extent, x) {
    AppDispatcher.handleViewAction({
      actionType: 'range',
      extent: extent,
      x: x,
    });
  },
  update: function() {
    AppDispatcher.handleViewAction({
      actionType: 'update',
    });
  }
};

src.js

function randomize(n, y) {
  if (arguments.length < 2) y = 400;
  if (!arguments.length) n = 20;
  var i = 0;
  return d3.range(~~(Math.random()*n) + 1).map(function(d, i) { return {
            x: ++i,
            y: ~~(Math.random()*y)
          }});
}

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