Graphing Calculator, Part 3
Welcome! This is the third and final installation of my exploration of d3.js by creating a small graphing calculator app. (If you missed part 1 or part 2, go on back and check them out; I'll wait here.)
We won't actually spend too much time on d3 in this lesson: I will spend most of the time making the previous weeks' code more modular. However, I will introduce an exciting new JS library, so stay tuned.
A Little Less Conversation
The previous weeks' demos (week 1 and week 2) used an immediately-invoked <script> tag in the body of the document. This was OK because I cared more about the output than making the calculator reusable. But if we want to be able to change the dimensions of the graph, or display different functions, we'll first have to refactor this into a reusable function.
I am assuming that you are more or less capable with Javascript — likely more so than I — and will just display the differences from week 2's example here, rather than posting the entire code. This function goes inside a <script> block in the <head> element of the page.
function draw(el_name, _bounds, _fx) {
var default_bounds = { x_min: -10, x_max: 10, y_min: -10, y_max: 10, w: 500, h: 250},
plot_bounds = _bounds || {},
fx = _fx || Math.sin;
// copy defaults to plot_bounds without overwriting
for (var key in default_bounds) {
if (default_bounds.hasOwnProperty(key) && !plot_bounds.hasOwnProperty(key)) {
plot_bounds[key] = default_bounds[key]
}
}
var svg = d3.select(el_name).append('svg:svg'),
w = plot_bounds.w || default_bounds.w,
h = plot_bounds.h || default_bounds.h;
svg.attr('width', w).attr('height', h);
var plot_data = [];
var dx = plot_bounds.dx || 1;
for (var i=plot_bounds.x_min; i<=plot_bounds.x_max; i+= dx) {
plot_data.push([i, fx(i)]);
}
var padding = 20,
xMax = d3.max(plot_data, function(d) { return d[0]; }),
yMax = d3.max(plot_data, function(d) { return d[1]; }),
xScale = d3.scale.linear()
.domain([plot_bounds.x_min, plot_bounds.x_max])
.range([padding, w - padding]),
yScale = d3.scale.linear()
.domain([plot_bounds.y_min, plot_bounds.y_max])
.range([h - padding, padding]),
...
}
As you can see, I've allowed the user to customize the DOM element to which the graph will be appended (el_name), the bounds of the graph (_bounds, which gets some defaults if the user chooses not to include them), and the function which is plotted (_fx, which is also assigned a default of Math.sin if not provided.)
Let's invoke this function using the defaults and see what we get:
<body onload="draw('body');">
Very nice! Let's zoom in a little bit:
<body onload="draw('body', {y_min: -1.5, y_max: +1.5});">
See how easy that was? We can move the "window" of the graph around as we will, and even change the width and height of the svg element using the w and h parameters.
Putting On The Fancy Shoes
We have a couple aesthetic tweaks to make before we handle customization. First, see how the path goes all the way to the left edge of the graph but stops short of the right edge? This is a fencepost error and easy to fix with a ≤ instead of <:
for (var i=plot_bounds.x_min; i<=plot_bounds.x_max; i+= dx) {
...
Second, we should do something about the axes — it makes little sense for them to stay on the left and bottom. We could just move them to the middle of the graph:
svg.append('svg:g')
.attr('class', "axis")
.attr('transform', "translate(0," + (h / 2) + ")") // only changed this line
.call(xAxis);
svg.append('svg:g')
.attr('class', "axis")
.attr('transform', "translate(" + (w / 2) + ",0)") // and this one
.call(yAxis);
but that would be somewhat naive:
<body onload="draw('body', {y_min: -0, y_max: +1.5});">
As you can see, the x-axis stays in the middle, crossing y at +0.75. We would rather have the axes cross at (0, 0) regardless of whether that's in the middle. This requires calculating where it is in the x and y domains and finding the location on the graph which corresponds to that fraction of the width or height. Fortunately, that's exactly what the scale functions we created as part of Scott Murray's tutorial are designed to do.
svg.append('svg:g')
.attr('class', "axis")
.attr('transform', "translate(0," + yScale(0) + ")")
.call(xAxis);
svg.append('svg:g')
.attr('class', "axis")
.attr('transform', "translate(" + xScale(0) + ",0)")
.call(yAxis);
(Note that the x-axis is translated along the y-scale, and the y-axis along the x-scale. Note also that I'm mixing camelCase and under_score style variable names in my Javascript. Don't do this at home, kids: pick one and stick with it.)
One last improvement: let's get rid of the zero-value tick labels as they're just cluttering things up.
This turns out to be a wee bit hairy: it looks from the d3 axis API as if calling axis.tickValues() without any arguments will return the current tick values, which we could filter for nonzero values. However, if no explicit tick values have been set, it returns null which means to use the scale's tick generator. In the d3 Scale API documentation we find that scale.ticks(count) will generate an array of ticks if given a number. Where do we get that number, though? Back to the axis documentation and it's simply axis.ticks()!
So our tick value generation looks like this:
xAxis.tickValues(xScale.ticks(xAxis.ticks()).filter(function(x) { return x !== 0; }));
yAxis.tickValues(yScale.ticks(yAxis.ticks()).filter(function(x) { return x !== 0; }));
Got that? We're calling the ticks() method on the axis to get the number of ticks desired. (We could customize this if we want but we're passing on the default of 10.) We then call the ticks() method on the scale to subdivide the axis into that many sections and return the locations of the "fenceposts". Finally, we filter this array of ticks to remove zero, and pass the whole array to the axis's tickValues function. [1] [2]
The Final Function
Now we're finally ready to allow our users to graph whatever they like, wherever they like. We will set up a page with inputs for the minimum and maximum x and y, the width and height of the graph, and the function to be graphed, passing those to our draw() function. This is straightforward Javascript and HTML so you can view the source of the demo instead of cluttering up this space.
There is one large exception, however: the function to be graphed. How do we let the user enter this themselves? We could have them enter Javascript code and eval() it, but I'd prefer to be able to use pure mathematical notation. [3] Fortunately, there's math.js which contains a fully-featured function parser. Here's how it works:
function validate_and_draw(form) {
... // snip code for validating window bounds and display size
var func;
try {
func = math.eval("function f(x) = " + form.elements['func'].value);
} catch (err) {
alert("Error: " + err.message);
return false;
}
document.getElementById('graph').innerHTML = "";
draw('#graph', bounds, func);
return false;
}
It's that simple! We catch and display any errors returned by math.eval (for example, if the user tries to define a function of y), and it works exactly like you would expect.
Wrapping Up
You can see this calculator in action at gc-demo3.html. If I turn this into an actual app, there are plenty of fixes to make (try graphing tan(x), for example) and tons of features I'd like to add: think panning, zooming, multiple functions, etc. — but this will do for now.
Hope you've enjoyed this three-part series as much as I have! The final project is on Github so feel free to clone or fork.
Footnotes
[1] | As a dog owner, saying "ticks" so many times makes me think of something else entirely. I will resolve to think of this instead. |
[2] | I am far from a Javascript guru, but it would seem to make sense to me, in the chained function calls for the d3 axis/scale/etc. objects, to bind this to the object so you could do xAxis.tickValues(xScale.ticks(this.ticks())). However, this doesn't seem to work — this is bound to the global object. Anyone know why d3 chose to do it this way? |
[3] | Also, it runs against every grain in my body to use eval() on user-supplied input, even if it's only on the client and can't be used for XSS. |