d3.js is a popular JavaScript library mainly used for front-end visualization. There are a lot of examples on how to use it, but there are very few resources that talks about d3.js app structure, i.e. how to break down the app into modular components. In this blog post, I will use my project for visualizing contributions on GitHub to demonstrate the d3.js app structure that I like to use.
The proper d3.js app structure
A typical web app follows MVC framework (or its variants) with 3 components:
- M – Model: deals with data structures and manipulation
- V – View: controls interface
- C – Controller: coordinate between model and view, handle user interaction.
Now we add d3.js library into this picture. d3.js itself has methods that retrieves data like d3.json() or d3.csv() . This is not a big problem, we can use these methods in the model component.
However, d3.js has its signature general update pattern and selection methods, which works directly on data and output to HTML or SVG.
In my case, I want to make circles with radii that is proportional to square root of the number of commits in the data. Typically I would end up with the following code in the view component:
1 2 3 4 5 6 7 |
selection.data(data) .selectAll("circle") .enter() .append("circle") .attr("r", function(d) { return 3.5 * Math.sqrt(d.commits.length); }); |
This code spans over both model and view components. The UI code for modifying the radius needs to know the underlying data structure used by the model. This is how d3.js is designed to be used (data-driven). However, it does not allow separation of concerns as data and UI logic are tightly coupled.
In order to de-couple the code, we need move the data accessing logic to the model, which can provide an accessor function, which retrieves the data and return it to the view component.
If we write the model component as a module (name as data), it would look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
"use strict"; var data = (function() { var module = {}; // ... module.sizeAccessor = function(d) { if (d.commits && Array.isArray(d.commits)) { return d.commits.length; } else { return 0; } } return module; })(); |
Update: A better way to write modular code is to use a standard like commonJS, see my new post for more details.
And we can modify the previous view component as follow:
1 2 3 4 5 6 7 |
selection.data(data) .selectAll("circle") .enter() .append("circle") .attr("r", function(d) { return 3.5 * Math.sqrt(data.sizeAccessor(d)); }); |
Notice that the method name sizeAccessor is a generic name that is not tightly to the nature of the data, making it suitable to be used in the view component.
By refactoring the data accessor logic into the model, we make the code less coupled as the view component no longer needs to know the data structure in the model, all it needs to do is to retrieve the size used for calculating the radius.
Within the model component, we can also have other accessors for text to be displayed in the tooltips:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
module.MAX_COMMITS = 5; module.getPrimaryTooltipData = function(d) { return d.username + "(" + d.name + ") " + formatDateNice(d.date); } module.getAdditionalTooltipData = function(d) { return "... and " + (d.commits.length - module.MAX_COMMITS) + " more commits"; } module.getSecondaryTooltipDataByIndex = function(d, i) { return _getCommitTime(d.commits[i]) + " " + _getStatsContent(d.commits[i]) + " " + _getCommitMessage(d.commits[i]); } function _getCommitTime(commit) { return formatTime(commit.date); } function _getCommitMessage(commit) { return commit["commit"]["message"]; } function _getStatsContent(commit) { return "<span class=\"addition\">+" + commit.stats.additions + "</span> <span class=\"deletion\">-" + commit.stats.deletions + "</span>"; } |
By having these accessor methods for primary, secondary and additional data, the view component can customize and display the tooltips easily without deep knowledge of the data being presented.
There is still some issues with this approach as can be seen in the last function. In order to style for addition and deletion, we need to add a special class for them. However, the view component is not supposed to know such specific needs. Hence, I used a hack here in the model component to do the styling. A better but slightly more tedious approach is perhaps to provide a more flexible and powerful interface between model and view to display data with complex styling requirements.
The source code for the example is on GitHub.