-
Notifications
You must be signed in to change notification settings - Fork 36
CUFP 2014 Tutorial Reusable Charts Pattern
Wiki ▸ [CUFP 2014 Tutorial](CUFP 2014 Tutorial) ▸ Reusable Charts Pattern
The goal of D3.js is offer an alternative to imperative models of DOM manipulation, and by extension front-end development. Rather than specifying the incremental modifications that should be applied to a document, D3.js allows you to write code that describes what your document should look like based on the data of your application. In this sense it is a library that promotes a declarative style. Yet out of the box it lacks certain compositional properties.
This section of the tutorial will introduce a simplified version of the "reusable charts" pattern that the D3.js community uses to promote the reuse of code. The pattern will then be pushed all the way down to the level of individual operations, which will imbue the library with much nicer compositional properties and become the basis of the elm-d3 bindings.
Consider the following code:
d3.selectAll('div')
.attr('class', 'container');
While this code does do something useful, it has a few undesirable properties that prevent it from being reusable. First and foremost, this code executes immediately if included in a top-level JavaScript application. This leaves you no opportunity to programmatically manipulate and extend the operations further. By wrapping the operation in a function, you can delay its execution. In addition, doing this allows you to programmatically decide which additional operations—if any—should be to the selection.
var div_op1 = function() {
return d3.selectAll('div')
.attr('class', 'container')
}
To execute the code, you just call the function. This will return a selection that you can apply further operations to:
div_op().style('background-color', 'magenta');
Another limitation of the operation above is that you have no control over which subtree of the document it will apply to; implicit in the selectAll
operation is that it will apply to the root element of the document. The original code is roughly equivalent to the following:
d3.select(document).selectAll('div')
.attr('class', 'container');
d3.select(document)
is the implicit context. Abstracting over the context results in a reusable set of operations that can be applied to any subtree of the document.
var div_op2 = function(context) {
return context.selectAll('div')
.attr('class', 'container');
}
You can now replicate the original functionality of the operations by apply div_op2
to the entirety of the document.
div_op2(d3.select(document))
You can now also apply it to a specific subtree of the document, while also extending it with other operations:
div_op2(d3.select('#sidebar')).attr('background-color', 'teal');
While the pattern so far does promote reuse, you're still left writing large blocks of D3 code as you did before. The boundary of reuse is the function body, but it would be better if the function body itself could themselves be built out of reusable units. They have to be turned into functions that explicitly take their context, just like in the case of div_op2
above. But operators have additional parameters, such as the element name in the case of selectAll
, and you don't want to have a reusable operator specialized for each value that the parameter can take on (e.g., selectAll_div
, selectAll_circle
, etc.). The solution is of course to abstract over those parameters as well.
var selectAll = function(elt) {
return function(context) {
return context.selectAll(elt);
};
}
Each operator name now becomes a function that takes the parameters of the operator, and returns a reusable version of that operator—itself a function that takes a context and then actually applies the operator to the context.
As another example of turing the D3 operators in the this reusable form, consider attr
, which takes the attribute name and value as parameters:
var attr = function(name, val) {
return function(context) {
return context.attr(name, val);
};
}
With these reusable operators, you can rewrite the running example like this:
var div_op3 = function(context) {
return attr('class', 'container')
(selectAll('div')(context));
}
While the above uses nothing but reusable operators, it's unfortunately completely unreadable. Method chaining has been replaced by function application, resulting in a complete inversion of the flow of control in the program: the result of the first operation is now taken as an argument to the second, forcing you to read the code from the inside out in order to make sense of it. This is a problem that needs to be addressed, and it can be easily addresses through the use of combinators.
As each operator is now a reified value, you can pass them around as values. In particular you can pass them as arguments to functions, where those functions can combine them in whatever way they find appropriate. In particular, you can define a function that takes two operators and returns a new operator that is the result of method chaining its arguments:
function chain(op1, op2) {
return function(context) {
return op2(op1(context));
};
}
Using this newly-defined chain
operator, you can rewrite div_op3
so that the form of the expression more closely mirrors the flow of control of the operator. Not only that, but you no longer need to use any function expressions in its definition:
var div_op4 =
chain(selectAll('div'),
attr('class', 'container'));
In addition to method chaining, you can also define a combinator that will sequentially compose operators. The first operator will be applied to the context
, after which the result is discarded and the second operator is applied to the same context.
function seq(op1, op2) {
return function(selection) {
op1(selection);
return op2(selection);
};
}
Translate the following D3.js code into the declarative style described above. Use <;>
for the seq
operator, and imagine that JavaScript has a left-associative |.
infix operator, where expr1 |. expr2
is equivalent to chain(expr1, expr2)
. Then write the final line of code that will execute the operations on the root of the current document. It may help readability if name intermediate subexpressions.
var circles = d3.selectAll('circle')
.data([8, 3, 15, 23, 12]);
rect.enter().append('circle')
.style('fill', 'steelBlue')
.attr('cx', function(d, i) { return 25 + 50 * i; })
.attr('cy', 150)
rect
.attr('r', function(d, i) { return d; })
rect.exit().remove();