Skip to content

Instantly share code, notes, and snippets.

@nitaku
Last active February 20, 2016 11:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nitaku/f901cba80ef4ffc29d4f to your computer and use it in GitHub Desktop.
Save nitaku/f901cba80ef4ffc29d4f to your computer and use it in GitHub Desktop.
BarChart class (Backbone)

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.

// 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;
}));
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}"
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 */
}
<!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="http://d3js.org/d3.v3.min.js"></script>
<script src="http://underscorejs.org/underscore-min.js"></script>
<script src="http://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>
// 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);
window.Data = Backbone.Model.extend
defaults: () ->
data: [20,6,12,8,4,1,2,2,1,24,7,9,5]
focus: null
// 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);
# 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) )
.bar_chart .focus {
fill: black;
}
.bar_chart .axis {
fill: none;
}
/* minor only */
.bar_chart .axis g {
stroke: #DDD;
}
// 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);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment