block by nitaku f901cba80ef4ffc29d4f

BarChart class (Backbone)

Full Screen

An attempt to define a configurable Backbone.js view class for bar charts. Based on a previous experiment.

This experiment is used both as an exercise on modularity and as an investigation of the possibility of leveraging Backbone for defining more reusable modules.

index.js

// Generated by CoffeeScript 1.10.0
(function() {
  var d1, d2;

  d1 = new Data;

  d2 = new Data({
    data: [
      {
        value: 3,
        id: 'a'
      }, {
        value: 4,
        id: 'b'
      }, {
        value: 2,
        id: 'c'
      }, {
        value: 1,
        id: 'd'
      }, {
        value: 2,
        id: 'e'
      }, {
        value: 3,
        id: 'f'
      }
    ]
  });

  new BarChart({
    el: "#chart",
    model: d1,
    tooltip: function(d, i) {
      return "Index: " + i + "\nValue: " + d;
    }
  });

  new BarChart({
    el: "#chart2",
    model: d1,
    orientation: 'vertical',
    scales: {
      size: {
        domain: function(data) {
          return [0, Math.ceil(d3.max(data) / 10) * 10];
        }
      }
    }
  });

  new BarChart({
    el: "#chart3",
    model: d2,
    key: function(d) {
      return d.id;
    },
    value: function(d) {
      return d.value;
    },
    scales: {
      color: {
        type: d3.scale.category20b
      }
    },
    tooltip: function(d, i) {
      return "ID: " + d.id + "\nValue: " + d.value;
    }
  });

}).call(this);

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Basic Charts (Backbone)</title>
    <link type="text/css" href="index.css" rel="stylesheet"/>
    
    <!-- dependencies -->
    <script src="//d3js.org/d3.v3.min.js"></script>
    <script src="//underscorejs.org/underscore-min.js"></script>
    <script src="//backbonejs.org/backbone-min.js"></script>
    <script src="backbone.d3view.js"></script>
    
    <script src="views.js"></script>
    <link type="text/css" href="views.css" rel="stylesheet"/>
    
    <script src="models.js"></script>
  </head>
  
  <body>
    <svg id="chart"></svg>
    <div id="side">
      <svg id="chart2"></svg>
      <svg id="chart3"></svg>
    </div>
    <script src="index.js"></script>
  </body>
</html>

backbone.d3view.js

// Backbone.D3View.js 0.3.1
// ---------------

//     (c) 2015 Adam Krebs
//     Backbone.D3View may be freely distributed under the MIT license.
//     For all details and documentation:
//     https://github.com/akre54/Backbone.D3View

(function (factory) {
  if (typeof define === 'function' && define.amd) { define(['backbone', 'd3'], factory);
  } else if (typeof exports === 'object') { module.exports = factory(require('backbone'), require('d3'));
  } else { factory(Backbone, d3); }
}(function (Backbone, d3) {

  // Cached regex to match an opening '<' of an HTML tag, possibly left-padded
  // with whitespace.
  var paddedLt = /^\s*</;

  var ElementProto = (typeof Element !== 'undefined' && Element.prototype) || {};
  var matchesSelector = ElementProto.matches ||
    ElementProto.webkitMatchesSelector ||
    ElementProto.mozMatchesSelector ||
    ElementProto.msMatchesSelector ||
    ElementProto.oMatchesSelector;

  Backbone.D3ViewMixin = {

    // A reference to the d3 selection backing the view.
    d3el: null,

    namespace: d3.ns.prefix.svg,

    $: function(selector) {
      return this.el.querySelectorAll(selector);
    },

    $$: function(selector) {
      return this.d3el.selectAll(selector);
    },

    _removeElement: function() {
      this.undelegateEvents();
      this.d3el.remove();
    },

    _createElement: function(tagName) {
      var ns = typeof this.namespace === 'function' ? this.namespace() : this.namespace;
      return ns ?
         document.createElementNS(ns, tagName) :
         document.createElement(tagName);
    },

    _setElement: function(element) {
      if (typeof element == 'string') {
        if (paddedLt.test(element)) {
          var el = document.createElement('div');
          el.innerHTML = element;
          this.el = el.firstChild;
        } else {
          this.el = document.querySelector(element);
        }
      } else {
        this.el = element;
      }

      this.d3el = d3.select(this.el);
    },

    _setAttributes: function(attributes) {
      this.d3el.attr(attributes);
    },

    // `delegate` supports two- and three-arg forms. The `selector` is optional.
    delegate: function(eventName, selector, listener) {
      if (listener === undefined) {
        listener = selector;
        selector = null;
      }

      var view = this;
      var wrapped = function(event) {
        var node = event.target,
            idx = 0,
            o = d3.event;

        d3.event = event;

        // The `event` object is stored in `d3.event` but Backbone expects it as
        // the first argument to the listener.
        if (!selector) {
          listener.call(view, d3.event, node.__data__, idx++);
          d3.event = o;
          return;
        }

        while (node && node !== view.el) {
          if (matchesSelector.call(node, selector)) {
            listener.call(view, d3.event, node.__data__, idx++);
          }
          node = node.parentNode;
        }
        d3.event = o;
      };

      var map = this._domEvents || (this._domEvents = {});
      var handlers = map[eventName] || (map[eventName] = []);
      handlers.push({selector: selector, listener: listener, wrapped: wrapped});

      this.el.addEventListener(eventName, wrapped, false);
      return this;
    },

    undelegate: function(eventName, selector, listener) {
      if (!this._domEvents || !this._domEvents[eventName]) return;

      if (typeof selector !== 'string') {
        listener = selector;
        selector = null;
      }

      var handlers = this._domEvents[eventName].slice();
      var i = handlers.length;
      while (i--) {
        var handler = handlers[i];

        var match = (listener ? handler.listener === listener : true) &&
            (selector ? handler.selector === selector : true);

        if (!match) continue;

        this.el.removeEventListener(eventName, handler.wrapped, false);
        this._domEvents[eventName].splice(i, 1);
      }
    },

    undelegateEvents: function() {
      var map = this._domEvents, el = this.el;
      if (!el || !map) return;

      Object.keys(map).forEach(function(eventName) {
        map[eventName].forEach(function(handler) {
          el.removeEventListener(eventName, handler.wrapped, false);
        });
      });

      this._domEvents = {};
      return this;
    }
  };

  Backbone.D3View = Backbone.View.extend(Backbone.D3ViewMixin);

  return Backbone.D3View;
}));

index.coffee

d1 = new Data
d2 = new Data
  data: [
    {value: 3, id: 'a'},
    {value: 4, id: 'b'},
    {value: 2, id: 'c'},
    {value: 1, id: 'd'},
    {value: 2, id: 'e'},
    {value: 3, id: 'f'}
  ]

new BarChart
  el: "#chart"
  model: d1
  tooltip: (d,i) -> "Index: #{i}\nValue: #{d}"
  
new BarChart
  el: "#chart2"
  model: d1
  orientation: 'vertical'
  scales:
    size:
      domain: (data) -> [0, Math.ceil(d3.max(data)/10)*10]
  
new BarChart
  el: "#chart3"
  model: d2
  key: (d) -> d.id
  value: (d) -> d.value
  scales:
    color:
      type: d3.scale.category20b
  tooltip: (d,i) -> "ID: #{d.id}\nValue: #{d.value}"
  

index.css

html, body {
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
}


/* flex layout */

body {
  display: flex;
  flex-direction: row;
}

#chart {
  margin: 10px;
  flex-grow: 2;
  width: 0; /* necessary hack. see README */
}
#side {
  flex-grow: 1;
  width: 0;
  
  display: flex;
  flex-direction: column;
  
}
#chart2 {
  margin: 10px;
  flex-grow: 1;
  height: 0; /* necessary hack. see README */
}
#chart3 {
  margin: 10px;
  flex-grow: 1;
  height: 0; /* necessary hack. see README */
}

models.coffee

window.Data = Backbone.Model.extend
  defaults: () ->
    data: [20,6,12,8,4,1,2,2,1,24,7,9,5]
    focus: null
    

models.js

// Generated by CoffeeScript 1.10.0
(function() {
  window.Data = Backbone.Model.extend({
    defaults: function() {
      return {
        data: [20, 6, 12, 8, 4, 1, 2, 2, 1, 24, 7, 9, 5],
        focus: null
      };
    }
  });

}).call(this);

views.coffee

# Base class with features common to all charts
window.Chart = Backbone.D3View.extend
  initialize: (conf) ->
    # store current pixel width and height
    @width = @el.getBoundingClientRect().width
    @height = @el.getBoundingClientRect().height
    
    @d3el.classed 'chart', true
    
# A basic bar chart
window.BarChart = Chart.extend
  initialize: (conf) ->
    Chart.prototype.initialize.call(this, conf)
    
    @d3el.classed 'bar_chart', true
    
    # horizontal is the default orientation
    @orientation = if conf.orientation? then conf.orientation else 'horizontal'
    
    # key function is used to identify data, defaults to array index
    @key = if conf.key? then conf.key else (d,i) -> i
    # value accessor (defaults to d itself)
    @value = if conf.value? then conf.value else (d) -> d
    
    # this is the default configuration for scales
    @scales_config =
      size:
        type: d3.scale.linear
        range: (data) -> [0, if @orientation is 'horizontal' then @width else @height]
        domain: (data) -> [0, d3.max(data, @value)]
      position:
        type: d3.scale.ordinal
        range: (data) -> [0, if @orientation is 'horizontal' then @height else @width]
        domain: (data) -> data.map @key
      color:
        type: d3.scale.category10
        range: (data) -> @scales.color.range() # keeps the same range to support d3 default categorical scales as types
        domain: (data) -> data.map @key
      
    # mix in user-defined scales
    if conf.scales?
      Object.keys(conf.scales).forEach (s) =>
        Object.keys(conf.scales[s]).forEach (p) =>
          @scales_config[s][p] = conf.scales[s][p]
          
    # upcast to functions
    Object.keys(@scales_config).forEach (s) =>
        Object.keys(@scales_config[s]).forEach (p) =>
          @scales_config[s][p] = d3.functor(@scales_config[s][p])
      
    # instantiate scales
    @scales =
      size: @scales_config.size.type()
      position: @scales_config.position.type()
      color: @scales_config.color.type()
    
    # tooltip - default: none
    if conf.tooltip?
      @tooltip = conf.tooltip
      
    # react to changes in data
    @listenTo @model, 'change:data', @render
    # react to changes in focus
    @listenTo @model, 'change:focus', @update_focus
    
    @render()
    
    # a focused item could already be set
    @update_focus()
    
  render: () ->
    data = @model.attributes.data
    
    # update scales
    # FIXME always updating could be heavy to do
    @update_scales(data)
    
    # update axes
    size_axis = d3.svg.axis()
      .scale(@scales.size)
      .tickSize(if @orientation is 'horizontal' then @height else @width)
      .orient(if @orientation is 'horizontal' then 'bottom' else 'right')
    
    @d3el.append 'g'
      .attr
        class: 'axis'
      .call size_axis
    
    # enter / update / exit
    bars = @d3el.selectAll '.bar'
      .data data, @key
      
    enter_bars = bars.enter().append 'rect'
      .attr
        class: 'bar'
        x: 0
      .on 'mouseover', (d,i) =>
        @model.set
          focus: @key(d,i)
      .on 'mouseout', () =>
        @model.set
          focus: null
        
    if @orientation is 'horizontal'
      bars
        .attr
          y: (d,i) => @scales.position( @key(d,i) )
          width: (d,i) => @scales.size( @value(d,i) )
          height: @scales.position.rangeBand()
    else
      bars
        .attr
          x: (d,i) => @scales.position( @key(d,i) )
          height: (d,i) => @scales.size( @value(d,i) )
          y: (d,i) => @height - @scales.size( @value(d,i) )
          width: @scales.position.rangeBand()
        
    bars
      .attr
        fill: (d,i) => @scales.color( @key(d,i) )
        
    bars.exit()
      .remove()
      
    # add tooltips, if defined
    if @tooltip?
      enter_bars.append 'title'
      
      bars.select 'title'
        .text @tooltip
      
  update_focus: () ->
    @d3el.selectAll '.bar'
      .classed 'focus', (d,i) => @key(d,i) is @model.attributes.focus
      
  # reconfigure all the scales according to data
  update_scales: (data) ->
    @scales
      .size
        .domain( @scales_config.size.domain.call(this, data) )
        .range( @scales_config.size.range.call(this, data) )
    
    @scales
      .position
        .domain( @scales_config.position.domain.call(this, data) )
        .rangeRoundBands( @scales_config.position.range.call(this, data), 0.05 )
    @scales
      .color
        .domain( @scales_config.color.domain.call(this, data) )
        .range( @scales_config.color.range.call(this, data) )
      

views.css

.bar_chart .focus {
  fill: black;
}
.bar_chart .axis {
  fill: none;
}
/* minor only */
.bar_chart .axis g {
  stroke: #DDD;
}

views.js

// Generated by CoffeeScript 1.10.0
(function() {
  window.Chart = Backbone.D3View.extend({
    initialize: function(conf) {
      this.width = this.el.getBoundingClientRect().width;
      this.height = this.el.getBoundingClientRect().height;
      return this.d3el.classed('chart', true);
    }
  });

  window.BarChart = Chart.extend({
    initialize: function(conf) {
      Chart.prototype.initialize.call(this, conf);
      this.d3el.classed('bar_chart', true);
      this.orientation = conf.orientation != null ? conf.orientation : 'horizontal';
      this.key = conf.key != null ? conf.key : function(d, i) {
        return i;
      };
      this.value = conf.value != null ? conf.value : function(d) {
        return d;
      };
      this.scales_config = {
        size: {
          type: d3.scale.linear,
          range: function(data) {
            return [0, this.orientation === 'horizontal' ? this.width : this.height];
          },
          domain: function(data) {
            return [0, d3.max(data, this.value)];
          }
        },
        position: {
          type: d3.scale.ordinal,
          range: function(data) {
            return [0, this.orientation === 'horizontal' ? this.height : this.width];
          },
          domain: function(data) {
            return data.map(this.key);
          }
        },
        color: {
          type: d3.scale.category10,
          range: function(data) {
            return this.scales.color.range();
          },
          domain: function(data) {
            return data.map(this.key);
          }
        }
      };
      if (conf.scales != null) {
        Object.keys(conf.scales).forEach((function(_this) {
          return function(s) {
            return Object.keys(conf.scales[s]).forEach(function(p) {
              return _this.scales_config[s][p] = conf.scales[s][p];
            });
          };
        })(this));
      }
      Object.keys(this.scales_config).forEach((function(_this) {
        return function(s) {
          return Object.keys(_this.scales_config[s]).forEach(function(p) {
            return _this.scales_config[s][p] = d3.functor(_this.scales_config[s][p]);
          });
        };
      })(this));
      this.scales = {
        size: this.scales_config.size.type(),
        position: this.scales_config.position.type(),
        color: this.scales_config.color.type()
      };
      if (conf.tooltip != null) {
        this.tooltip = conf.tooltip;
      }
      this.listenTo(this.model, 'change:data', this.render);
      this.listenTo(this.model, 'change:focus', this.update_focus);
      this.render();
      return this.update_focus();
    },
    render: function() {
      var bars, data, enter_bars, size_axis;
      data = this.model.attributes.data;
      this.update_scales(data);
      size_axis = d3.svg.axis().scale(this.scales.size).tickSize(this.orientation === 'horizontal' ? this.height : this.width).orient(this.orientation === 'horizontal' ? 'bottom' : 'right');
      this.d3el.append('g').attr({
        "class": 'axis'
      }).call(size_axis);
      bars = this.d3el.selectAll('.bar').data(data, this.key);
      enter_bars = bars.enter().append('rect').attr({
        "class": 'bar',
        x: 0
      }).on('mouseover', (function(_this) {
        return function(d, i) {
          return _this.model.set({
            focus: _this.key(d, i)
          });
        };
      })(this)).on('mouseout', (function(_this) {
        return function() {
          return _this.model.set({
            focus: null
          });
        };
      })(this));
      if (this.orientation === 'horizontal') {
        bars.attr({
          y: (function(_this) {
            return function(d, i) {
              return _this.scales.position(_this.key(d, i));
            };
          })(this),
          width: (function(_this) {
            return function(d, i) {
              return _this.scales.size(_this.value(d, i));
            };
          })(this),
          height: this.scales.position.rangeBand()
        });
      } else {
        bars.attr({
          x: (function(_this) {
            return function(d, i) {
              return _this.scales.position(_this.key(d, i));
            };
          })(this),
          height: (function(_this) {
            return function(d, i) {
              return _this.scales.size(_this.value(d, i));
            };
          })(this),
          y: (function(_this) {
            return function(d, i) {
              return _this.height - _this.scales.size(_this.value(d, i));
            };
          })(this),
          width: this.scales.position.rangeBand()
        });
      }
      bars.attr({
        fill: (function(_this) {
          return function(d, i) {
            return _this.scales.color(_this.key(d, i));
          };
        })(this)
      });
      bars.exit().remove();
      if (this.tooltip != null) {
        enter_bars.append('title');
        return bars.select('title').text(this.tooltip);
      }
    },
    update_focus: function() {
      return this.d3el.selectAll('.bar').classed('focus', (function(_this) {
        return function(d, i) {
          return _this.key(d, i) === _this.model.attributes.focus;
        };
      })(this));
    },
    update_scales: function(data) {
      this.scales.size.domain(this.scales_config.size.domain.call(this, data)).range(this.scales_config.size.range.call(this, data));
      this.scales.position.domain(this.scales_config.position.domain.call(this, data)).rangeRoundBands(this.scales_config.position.range.call(this, data), 0.05);
      return this.scales.color.domain(this.scales_config.color.domain.call(this, data)).range(this.scales_config.color.range.call(this, data));
    }
  });

}).call(this);