Graphing Calculator, Part 1
Welcome! I am teaching myself d3.js and have decided to create a rudimentary graphing calculator as a proof of concept. I'm detailing the learning process as I go so there will be pitfalls and backtracks — in other words, this is more of a whiteboard demo than a tutorial.
I am not starting from scratch — I went through Scott Murray's excellent d3 tutorial to familiarize myself with d3, and will build on that. This demo assumes familiarity with the concepts covered in his tutorial, so if you haven't gone through that yet, please do. I'll wait here.
Setting Up the Basics
Let's get started by setting up an SVG element.
var svg = d3.select('body').append('svg:svg'),
w = 500, h = 250;
svg.attr('width', w).attr('height', h);
This is just a blank canvas. Let's put some axes and scales on [1]:
var padding = 20,
xMax = 100,
yMax = 10,
xScale = d3.scale.linear()
.domain([0, xMax])
.range([padding, w - padding]),
yScale = d3.scale.linear()
.domain([0, yMax])
.range([h - padding, padding]);
xAxis = d3.svg.axis()
.scale(xScale)
.orient('bottom'),
yAxis = d3.svg.axis()
.scale(yScale)
.orient('left');
svg.append('svg:g')
.attr('class', "axis")
.attr('transform', "translate(0," + (h - 20) + ")")
.call(xAxis);
svg.append('svg:g')
.attr('class', "axis")
.attr('transform', "translate(20,0)")
.call(yAxis);
and style them:
<style type="text/css">
svg .axis path, svg .axis line {
fill: none;
stroke: black;
shape-rendering: crispEdges;
}
svg .axis text {
font-family: sans-serif;
font-size: 11px;
text-anchor: middle;
}
</style>
(Note that the minimums and maximums for the axes are hardcoded. This will change shortly when we start to enter data.)
Adding a Function
Scott's final example displays random points which change each time you load the page. I want to adapt that to display the points along the curve of a mathematical function. I'm going to use Math.sqrt since it's easy to display and fits nicely with the domains I hardcoded for the axes.
var plotfunc = Math.sqrt,
plotdata = [];
var lowX = 0, highX = 100, dX = 1;
for (var i=lowX; i<highX; i+= dX) {
plotdata.push([i, plotfunc(i)]);
}
// check that points were entered correctly
for (var i=0; i<plotdata.length; i+=10) {
console.log("(" + plotdata[i][0] + ", " + plotdata[i][1] + ")");
}
// console output:
(0, 0)
(10, 3.1622776601683795)
(20, 4.47213595499958)
(30, 5.477225575051661)
(40, 6.324555320336759)
(50, 7.0710678118654755)
(60, 7.745966692414834)
(70, 8.366600265340756)
(80, 8.94427190999916)
(90, 9.486832980505138)
Looks like our data are valid. Now let's display them. Since I just finished Scott's scatterplot tutorial [2], I have the code left to display circles at x/y coordinates, so I will just adapt that for now.
svg.selectAll('circle')
.data(plotdata)
.enter()
.append('svg:circle')
.attr('cx', function(d, i) { return xScale(d[0]); })
.attr('cy', function(d, i) { return yScale(d[1]); })
.attr('r', 2);
Very nice!
Dys-function
Let's try it with a different function to see how extensible it is:
var plotfunc = Math.exp,
...
Oops! What's happening here? Let's try a function which doesn't grow quite as quickly.
var plotfunc = function(x) { return x; },
...
Aha, I see what's happening. Looks like my hardcoded domains of 0-100 for x and 0-10 for y aren't sufficient for a function whose y-value gets greater than 10. Fortunately, changing the maximums on the domains is easy.
...
xMax = d3.max(plotdata, function(d) { return d[0]; }),
yMax = d3.max(plotdata, function(d) { return d[1]; }),
...
There it is. I am going to change the function back to sqrt for the remainder of this exercise — the dynamic maximums for my x and y domains should still work fine.
Getting In Line
The last thing I want to do today is change the way the function is plotted from a series of points to a series of lines. This seems pretty straightforward but as you will see there are some snags.
Whereas the SVG circle element requires three plot attributes (cx, cy, and r), the line element takes four: x1, y1, x2, y2. This makes sense: it's a simple line segment which is defined by two (x, y) endpoints, just like in freshman geometry.
Let's use item n in plotdata as point 1 and item n + 1 as point 2. This will obviously cause problems when item n is the final item in plotdata, so let's simply exclude that item from our data() call:
// Replace svg.selectAll('circle') and its chain with:
svg.selectAll('line')
.data(plotdata.slice(0, plotdata.length - 1))
.enter()
.append('svg:line')
.attr("x1", function(d, i) { return xScale(d[0]); })
.attr("y1", function(d, i) { return yScale(d[1]); })
.attr("x2", function(d, i) { return xScale(plotdata[i+1][0]); })
.attr("y2", function(d, i) { return yScale(plotdata[i+1][1]); });
.style('stroke', "rgb(6, 120, 155)")
.style('fill', "rgb(6, 120, 155)");
Note that I also added stroke and fill styles to liven up the plot a bit. I could also have done this in the CSS block, which is arguably more correct because it generates less SVG code.
Huh? What happened here? I do indeed have a line now, but only the points with x > 20 are being plotted. But the only thing I changed was the plot elements, from circles to lines. What is going on?
It turns out that the problem is with svg.selectAll('line'). d3's selectAll() uses CSS-style selectors to select everything inside the parent element which matches the pattern, similar to jQuery. However, there are already line elements inside the svg element. They are the ticks for the axes — 10 each. (The axes themselves are path elements so are not selected.)
Thus, when d3 performs svg.selectAll('line') and gets a preƫxisting set of 20 line segments, its enter() method excludes those and only begins appending elements which it considers to be new. [3]
The solution is simple, like most riddles when you see the answer. Since we don't expect the initial selector to return anything more than the empty set anyway, change it to something guaranteed not to exist inside the <svg> element.
svg.selectAll('#line_not_in_axis_tick')
.data(plotdata.slice(0, plotdata.length - 1))
...
And there it is! Our prettily-plotted square root curve.
Wrapping Up
The full code for this demo can be found here. There is plenty yet to do — for example, SVG provides a path element which is better suited to drawing continuous line segments than line is. I also need to work with making the domain and range even more dynamic. Look for part 2 coming soon!
Footnotes
[1] | Refreshers from Scott's tutorial on axes and scales. |
[2] | Scatterplot tutorial is at http://alignedleft.com/tutorials/d3/making-a-scatterplot/. |
[3] | selectAll() and enter() really confused me when I started working with d3. I didn't get deconfused until I read Mike Bostock's Thinking With Joins. |