Introduction to D3.js (Part 3)

September 12, 2017 0 Comments

Introduction to D3.js (Part 3)

 

 

Welcome back! If you missed Part 1 and/or Part 2, just follow the links to check them out! 

Charts are more meaningful when they have axes around them. The axes tell the quantity of a particular datum represented on the plane. This a how-to-post on creating the axes around a D3 chart.

D3 provides a .json() method to load the JSON data from a URL. This method takes two parameters – first, a URL that returns a JSON data and second, an anonymous method that takes action on the data coming from the server (current directory in my case). This anonymous method is asynchronous by nature and takes at most two parameters. The following lines load the data from a JSON file from my current directory:

var svgHeight = 500;
var svgWidth = 800;
var paddingH = 50;
var paddingV = 50; var dataset = undefined;
var maxX, xScale, yScale, radiusScale, colorXScale, colorYScale;
/----- Working with the D3 rendering -----/
var svg = d3.select('body') .append('svg') .attr('width', svgWidth) .attr('height', svgHeight);
d3.json('points.json', function ( error, data ) {
if(error == null || error == undefined) {
dataset = data;
renderTheGraph(); } else { window.alert('Something wrong happened while loading the data from JSON file. Try again.'); }
});

The anonymous method, here, takes two arguments, error and data. If data doesn’t load from the server then error will have a value in it and if it loads successfully then it will be assigned to the data. After data has loaded, it is assigned to a global variable datatset which can be further used to represent data on the plane.

Here, my data is quite simple and represents the points with weights. Just assume that this data is generated by some another process. The data is:

[ { "x" : 5, "y" : 20, "weight" : 0.48 }, { "x" : 34, "y" : 1565, "weight" : 0.9 }, { "x" : 153, "y" : 530, "weight" : 0.7 }, { "x" : 51, "y" : 247, "weight" : 0.5 }, { "x" : 447, "y" : 290, "weight" : 0.74 }, { "x" : 45, "y" : 120, "weight" : 0.63 }, { "x" : 351, "y" : 29, "weight" : 0.95 }, { "x" : 654, "y" : 160, "weight" : 0.31 }, { "x" : 315, "y" : 320, "weight" : 0.17 }, { "x" : 50, "y" : 632, "weight" : 0.57 }, { "x" : 50, "y" : 20, "weight" : 0.64 }, { "x" : 159, "y" : 200, "weight" : 0.83 }, { "x" : 512, "y" : 170, "weight" : 0.19 }, { "x" : 445, "y" : 512, "weight" : 0.77 }
]

Here the motive is to plot the dots on an x-y plane based on the x and y values and give the size and color to each dot based on the weight, respectively. For example, the first point's values are:

 {
"x" : 5,
"y" : 20,
"weight" : 0.48
}

It will plot a dot on (5, 20) with an intensity of 0.48. What I mean by intensity is that smaller the weight amount, the smaller the radius and the greater the transparency of the dot. This all is handled with D3 scales.

Once the .json() method loads the data and it’s saved in the dataset variable, we call the renderChart() function. This method is below:

function renderTheGraph() { setScales(); var circles = svg.selectAll('circle')
.data(dataset)
.enter()
.append('circle'); circles.attr('cx', function ( d ) {
console.log("The x - " + xScale(d.x));
return xScale(d.x);
})
.attr('cy', function ( d ) {
console.log("The y - " + d.y);
return yScale(d.y);
})
.attr('r', function ( d ) {
console.log("The radius - " + d.weight);
return radiusScale(d.weight);
})
.attr('fill', 'orange')
.attr('stroke', function (d) {
return 'rgba(' + (colorXScale(d.x) + colorYScale(d.y)) / 2+ "," + 0 + "," + 0 + ', '+ d.weight+')';
})
.attr('stroke-width' , function ( d ) {
console.log("The stroke-width - " + d.weight);
return radiusScale(d.weight) /2 ;
}); svg.selectAll('text')
.data(dataset)
.enter()
.append('text')
.text(function( d ) {
return "(" + d.x + "," + d.y + ")";
})
.attr('x', function ( d ) {
console.log("The x - " + xScale(d.x));
return xScale(d.x);
})
.attr('y', function ( d ) {
console.log("The y - " + d.y);
return yScale(d.y + (d.weight * 50));
})
.attr('text-anchor', 'middle')
.attr('font-size', 11); renderAxes();
}

This method, further, performs three sub-tasks:

  1. Setting the scales.
  2. Plotting the dots.
  3. Drawing the axes.

The second task could be deliberated to another function but laziness was all over me to do that. Before explaining the 2nd and 3rd sub-tasks we need to see how the scales are set according to the data coming from the server. The setScales function does that. Here is the code for that function:

function setScales ( ) { xScale = d3.scaleLinear()
.domain( [ 0, 50 + d3.max( dataset, function( d ) { return d.x }) ] )
.range( [ paddingH, svgWidth - paddingH ] ); yScale = d3.scaleLinear()
.domain( [ 0, 50 + d3.max( dataset, function( d ) { return d.y }) ] )
.range( [ svgHeight - (paddingV * 2), paddingV ] ); radiusScale = d3.scaleLinear()
.domain( [0, d3.max( dataset, function ( d ) { return d.weight })] )
.range( [ 5, 15] ); colorXScale = d3.scaleLinear()
.domain( [ 0, d3.max( dataset, function( d ) { return d.x }) ] )
.range( [ 100, 255 ] ); colorYScale = d3.scaleLinear()
.domain( [ 0, d3.max( dataset, function( d ) { return d.y }) ] )
.range( [ 100, 255 ] ); }

Note: The method scaleLinear() will work only with version four of D3, but not version 3. For version 3, use d3.scale.linear().

Before I explain what a scale actually is, we should familiarize ourselves with the concepts of domain and range. Domain and Range are concepts of set theory. If you’ve studied elementary mathematics, then you probably remember (or maybe you already are a mathematics genius) that domain and range are both sets of values. If you’re not too much into mathematics, then think of them as an array of values and these values can be either primitive or object data types. In the case of D3, the domain covers the data that comes from the server and we calculate the maximum and minimum from that data to create the domain. The range is the set of the values that we’ll map our incoming data to some other array of values. For example, if our data has values from 10 to 1000, and we want to map it to 0 to 500, then 10 will be mapped to 0, and 1000 to 500; similarly the middle value of the data (domain), 500, will be mapped to a little more than 250 (range).

But why do we actually need to scale our data to some range? In our case, for instance, if the value of x is 1200 and value of y is 750, then we’ll need a canvas of at least that much width and height (1200 × 750) so that point can be plotted at least on the very corner of the plane. And this big canvas is too rigid because it’ll cross the boundaries of some devices (like phones and tablets). So, we need a solution so that big numbers could be represented inside a small SVG canvas. And the solution is to scale down our big domain values to smaller range values. The same can be done for smaller values, too (think about data in which x and y coordinates are drawn from values in between 1 to 5!)

For calculating the domain and range of the scale, D3 provides the methods .domain() and .range(). Both of these methods take an array of length 2 (in the case of a linear scale). The first value of the array represents the lowest value while the second value represents the maximum value. So for both domain and range, we give the maximum and minimum values. For domain, we calculated the maximum values with d3.max(, a method which is self-explanatory. The minimum in the domain is taken as 0 (I assume no point in my data has -ve values). The range for the data is taken from the padding of the SVG canvas to the maximum (width and height) values of the canvas minus the padding. For the y range, I reversed the values so that my scatter plots will draw according to the charts we’ve been drawing since childhood (actually computer screens draw the things from upper left corner and that draws the y plots upside down, so in order to make them natural to us, the values are reversed in the range). You might be wondering why I’ve added 50 to the maximum of the data values in the domain() method. I did it so that my highest valued dot doesn’t plot on the edge of the plane. Just remove that 50 + and see for yourself.

Once our x and y scales are defined, we define our radius scale that is decided on the basis of the size of weight of a point. The more the weight, the more the radius. But I scaled my weights from 5px to 15px radii, because if some point weighs 0 or close to 0, then it will be invisible on the plane. Next, I defined the scale for the color values. You are smart, you can figure out those color scales, I know.

Wooooooo! If you’ve digested all of that, congratulations! Now we proceed to our second step of drawing the chart – plotting the dots on the plane (using scale).

Step 2

Okay! Now that we know what scales are and we can define our scales and use those scales to plot our points (circles in our scatter plot) on the x-y plane, we plot our circles on the coordinates based on the x-y values defined in the dataset. With the following code, we add some empty circles to the SVG canvas:

var circles = svg.selectAll('circle') .data(dataset) .enter() .append('circle');

The method selectAll() selects all the attributes defined in the SVG canvas. Wait! What? We didn’t define any circles in the canvas, so why did we select circle tags? Yes, you’re right there, is no circle tag defined in the canvas, that’s why this method, in our case, returns the empty array. With data(dataset) we defined the data source that we will be working on. enter() is the method used to tell D3 that we are going to add the circle elements to the SVG canvas for the first time (no update or delete, we’ll look at them too… in later blog posts). The append method adds the circles to the SVG canvas, though we don’t have any circles yet. We know it’s confusing. But, if there are no circles, then how did we add the circles to the canvas? Actually, D3 keeps it in mind that we’ll add the circles to the canvas as we read the data from a dataset and also set their attributes.

If we print the circles on the console we see that: 

Object { _groups: Array[1], _parents: Array[1] }

So, it’s actually an object with two fields which are arrays with lengths equal to 1.

Now, let’s define the attributes of the circles so that they start appearing on the SVG canvas. Here, we define the x-y coordinates with x and y scales, the radius of the circles based on the radius scale, fill them with an orange color, create the boundary of each circle, and set the width of each boundary. The attributes of the circle are defined with the .attr() method.

Setting the x and y attributes of the circles:

.attr('cx', function ( d ) { console.log("The x - " + xScale(d.x)); return xScale(d.x);
})
.attr('cy', function ( d ) { console.log("The y - " + d.y); return yScale(d.y);
})

Here, if we simply write the .attr('cx', 30 ) then all the circles will plot on the x = 30 vertical line, that is, all the circles will have the x value as 30. But, instead of giving a fixed value, we can define the x and y coordinates based on the data available in the dataset. To do that, the .attr() method takes an anonymous method as the second parameter. This anonymous method gets the values from the dataset one by one. The first parameter to the anonymous method is the data element, and the second parameter is the index of that element in the dataset. As we don’t need an index of the data element, we simply don’t mention it in the parameter list of the anonymous method. Here function ( d ) {.....}‘s d parameter represents an object containing the keys “x,” “y,” and “weight,” and their respective values. So we simply take the x values and pass them to xScale() and, similarly, y values are passed to yScale(). [Note: The scales we defined are functions which return the values according to limits set with domain() and range()] And eventually, we set the “cx” and “cy” attributes of the circles to be plotted on the SVG canvas. You can use the console.log(....) to print the meaningful values in case you have some confusion about what is the intermediate state of a variable and/or function.

Similarly, we define the various attributes using the .attr() method. Note, this is an SVG element and all the attributes we set are the attributes defined in this SVG element. You can explore more of the attributes of this SVG element and use them to enhance the representation of the circles plotted on the canvas. The circle SVG element is explained here. Setting the other attributes of the circles is left to the reader as an area of further study.

The circles that we plotted on the canvas are pretty visible by now, but we want to give meaning to the circles plotted on the canvas. So the values of each coordinate that each circle is plotted on, is written (actually drawn) around it. The circle SVG element doesn’t itself have a way to draw the text on it [though we have a “title” attribute which is different from what we want to achieve]. To write the text inside an SVG canvas, there is an SVG element. So we write the text in a scatter plot with following lines:

svg.selectAll('text')
.data(dataset)
.enter()
.append('text')
.text(function( d ) {
return "(" + d.x + "," + d.y + ")";
})
.attr('x', function ( d ) {
console.log("The x - " + xScale(d.x));
return xScale(d.x);
})
.attr('y', function ( d ) {
console.log("The y - " + d.y);
return yScale(d.y + (d.weight * 50));
})
.attr('text-anchor', 'middle')
.attr('font-size', 11);

They have attributes of “x” and “y” which are used to draw the text on particular coordinates on the x-y plane. “text-anchor” shifts the text to the position so that the middle of the text lies over the x and y coordinates of the text set with the attr() method. If you didn’t get the last line, just remove the “text-anchor” attribute and see the difference. There are more attributes that can be set on the element.

So, step two, plotting the dots on the x-y plane, is done! Yippie! Now move on to draw the x and y axes on our SVG canvas.

Step 3

We draw the axes on the SVG canvas with the function, renderAxes(). This function appends an SVG group for each of the x and y axes. A group is a g SVG element that contains the inner elements that draw the lines or curves on the SVG canvas. We group them together in order to keep them separate and collectively process them with one property. Our axes are groups that further contain the groups of SVG elements. Once we are finished with drawing the axes, just inspect them in your browser’s developer tools. I’m using Firefox and opened the Inspector to see what the DOM elements that draw the axes actually are. It’s pretty interesting to see the structure of the elements. Our function to render the axes is:

function renderAxes() {
svg.append('g')
.attr('transform', 'translate(' + 0 + ', '+ (svgHeight - paddingV * 2) + ')')
.call(d3.axisBottom(xScale)
.ticks(20)); svg.append("g")
.attr("transform", "translate( "+ paddingV+', '+ 0 + ')')
.call(d3.axisLeft(yScale)
.ticks(20)) }

First, we append a group to the SVG canvas for each of the axes. Then we transform that group to move the group to a particular location in order to position them correctly (with the translate() method of the SVG). Once the groups have been transformed, we draw our axes with the .call(d3.axisBottom(xScale)) and .call(d3.axisLeft(yScale)) methods. This is the least requirement to draw the axes on the SVG canvas. We can further set the properties of an axis, for example, here we’ve set the ticks to 20. The d3.axisBottom() takes the xAxis (d3 scale) as the parameter to draw the horizontal axis and d3.axisLeft(yScale) takes yScale as the parameter to draw the vertical axis.

One of the most crucial things in an SVG graph is defining the padding in a graph. As we can see, the padding is used everywhere, in scales and axes. Tweaking the padding so as to draw everything precisely is itself a little bit of a pain. But the more you play with D3, the more you learn.

Finally, our graph is ready and it looks like this:

scatterplot-for-blog

Happy learning!


Tag cloud