Wednesday 22 July 2020

Web Tutorial: D3 Pie Chart (Part 2/4)

So now we have a placeholder to put the pie chart in, and let's waste no time getting into it.

Add properties scale, dataWidth and dataSpacing to the config object. Also add the setData() method.

scale is used to enlarge or shrink your chart as you see fit. It beats having to make changes to the whole of your setup when you can just change one value.

dataWidth specifies the diameter of your pie and width of your legend.

dataSpacing is all the spaces between your elements and data points.

And, of course, setData() renders your pie chart.
var config =
{
    "scale": 12,
    "dataWidth": 20,
    "dataSpacing": 2,
    "setData": function ()
    {

    }
}


In here, get the values of the drop-down lists, assigning the value to the variables year and stat. Those will be used to generate the pie.
"setData": function ()
{
    var year = d3.select("#ddlYear").node().value;
    var stat = d3.select("#ddlStat").node().value;
}


Then create the dataSet object with the arrays labels and stats.
"setData": function ()
{
    var year = d3.select("#ddlYear").node().value;
    var stat = d3.select("#ddlStat").node().value;

    var dataSet =
    {
      "labels": [],
      "stats": []
    };
}


Use a For loop to go through the cols array of the graphData object.
var dataSet =
{
  "labels": [],
  "stats": []
};

for (var i = 0; i < graphData.cols.length; i++)
{

}


Declare the array filtered and use the filter() method on the stats array of each element, returning those that correspond with year. If filtered is not empty...
for (var i = 0; i < graphData.cols.length; i++)
{
    var filtered = graphData.cols[i].stats.filter(function(x) { return x.year == year;})

    if (filtered.length > 0)
    {

    }
}


... and if the value of the required stat is not 0, push in data such as color and values into the appropriate arrays. Why do we have this condition? Well, it's a pie, and zero values aren't supposed to show up, that's why.
for (var i = 0; i < graphData.cols.length; i++)
{
    var filtered = graphData.cols[i].stats.filter(function(x) { return x.year == year;})

    if (filtered.length > 0)
    {
        if (filtered[0][stat] > 0)
        {
            dataSet.labels.push({title: graphData.cols[i].title, color: graphData.cols[i].color});
            dataSet.stats.push({value: filtered[0][stat], color: graphData.cols[i].color});        
        }
    }
}


Now, use the d3 object's select() method to grab the container, wrapper, chart and legend.
for (var i = 0; i < graphData.cols.length; i++)
{
    var filtered = graphData.cols[i].stats.filter(function(x) { return x.year == year;})

    if (filtered.length > 0)
    {
        if (filtered[0][stat] > 0)
        {
            dataSet.labels.push({title: graphData.cols[i].title, color: graphData.cols[i].color});
            dataSet.stats.push({value: filtered[0][stat], color: graphData.cols[i].color});        
        }
    }
}

var container = d3.select(".pieChartContainer");
var wrapper = d3.select(".pieChart");
var chart = d3.select(".pieChartSvg");
var legend = d3.select(".pieLegendSvg");


This next piece of code sets the width of the container. Since the dataWidth property of the config object is supposed to define the diameter of the pie and the width of the legend, and we want to have some spacing on both sides of the chart, the width of the chart should be double the sum of dataSpacing and dataWidth.
var container = d3.select(".pieChartContainer");
var wrapper = d3.select(".pieChart");
var chart = d3.select(".pieChartSvg");
var legend = d3.select(".pieLegendSvg");

container
.style("width", function(d)
{
    return ((config.dataSpacing + config.dataWidth) * 2) + "em";
});


wrapper measures the height of the chart, which is the diameter of the pie plus spacing at the bottom and top. The height of the legend is measured by how many players are being represented in the pie (which is the length property of the labels array of the dataSet object), plus a bit of spacing at the top. Whichever is greater is the returned value for wrapper.
container
.style("width", function(d)
{
    return ((config.dataSpacing + config.dataWidth) * 2) + "em";
});

wrapper
.style("height", function(d)
{
    var legendHeight = (config.dataSpacing * dataSet.labels.length) + 2;
    var chartHeight = config.dataSpacing + (config.dataWidth * 2);

    return (chartHeight > legendHeight ? chartHeight : legendHeight) + "em";
});


Now this next bit may not make sense at first. so bear with me. In chart, append a g tag. g is a group, in which we will append pie slices. And since they will all originate from here, g will be a one pixel element located right in the middle of chart. And that's why we use the transform property to translate the g tag. The x and y values for the translation are the same - the sum of half of dataSpacing (resulting in the radius of the pie) and dataWidth, multiplied by the scale property.

While we're at it, let's also clear the legend and set its height.
wrapper
.style("height", function(d)
{
    var legendHeight = (config.dataSpacing * dataSet.labels.length) + 2;
    var chartHeight = config.dataSpacing + (config.dataWidth * 2);

    return (chartHeight > legendHeight ? chartHeight : legendHeight) + "em";
});

chart
.html("")
.append("g")
.attr("transform", "translate(" + ((config.dataSpacing + (config.dataWidth / 2)) * (config.scale)) + "," + ((config.dataSpacing + (config.dataWidth / 2)) * (config.scale)) + ")");

legend
.html("")
.style("height", function(d) {
    return ((config.dataSpacing * dataSet.labels.length) + 2) + "em";
});


And then we set chart to the freshly created g tag instead. What this means is that every element inserted into this group will automatically be translated.
chart
.html("")
.append("g")
.attr("transform", "translate(" + ((config.dataSpacing + (config.dataWidth / 2)) * (config.scale)) + "," + ((config.dataSpacing + (config.dataWidth / 2)) * (config.scale)) + ")");

chart = d3.select(".pieChartSvg g");

legend
.html("")
.style("height", function(d) {
    return ((config.dataSpacing * dataSet.labels.length) + 2) + "em";
});


Now we run the pie() method of the d3 object! This should work in Version 3 but somehow doesn't, so I'm not gonna sweat it and just use Version 4 instead. At the time of this writing, Version 5 of D3 is out already and as such I see no value in persisting with Version 3.

What does pie() do? This method creates a function which will be used to calculate the angle values for any array of values you pass in later. Do let's set the function to a variable called pie.
legend
.html("")
.style("height", function(d) {
    return ((config.dataSpacing * dataSet.labels.length) + 2) + "em";
});

var pie =
d3.pie()
.value(function(d)
{
    return d.value;
});


Declare data, and use our newly minted pie() function, passing in the stats array of the dataSet object as an argument. This will convert data into an array of all the start and end angles of each pie slice, based on the stats array!
var pie =
d3.pie()
.value(function(d)
{
    return d.value;
});

var data = pie(dataSet.stats);


Now we declare the variable arc. Set arc to the function returnd by the arc() method of the d3 object. In here. we are going to declare two things by running the innerRadius() and outerRadius() methods. We want a pie, so the inner radius needs to be 0. If we wanted a donut, of course, then we'd have a larger number.

For the outer radius, we use the dataWidth property, halved, which is the intended radius of the pie, multipled by scale.
var data = pie(dataSet.stats);

var arc =
d3.arc()
.innerRadius(0)
.outerRadius((this.dataWidth / 2) * config.scale);


Are you ready? Because the next part is pure D3 magic. We grab chart (which is now the grouping element in the SVG) and append a path tag to it. For data, we'll pass in data which is actually the array of start and end angles generated by the pie() function! Isn't it nice you didn't have to calculate it yourself?
var arc =
d3.arc()
.innerRadius(0)
.outerRadius((this.dataWidth / 2) * config.scale);

chart
.selectAll("path")
.data(data)
.enter()
.append("path");


Obviously, once you append a path tag, you will need its d attribute. d is supplied by arc itself! Now, how did that happen? The thing is, the newly generated arc() function uses the inner and outer radii specified, plus the start and end angle given by data, and generates the d attribute of the pie slice from there.
chart
.selectAll("path")
.data(data)
.enter()
.append("path")
.attr("d", arc);


Next, supply the fill attribute by referencing the color property of the data object inside d.
chart
.selectAll("path")
.data(data)
.enter()
.append("path")
.attr("d", arc)
.attr("fill", function(d) {
    return d.data.color;
});


How did we get that data object inside d anyway?

Remember this? This basically ensured that when we ran the pie() function, it would embed the current value of d (which was the object containing the value and color) into data, along with the generated start and end angles!
var pie =
d3.pie()
.value(function(d)
{
    return d.value;
});

var data = pie(dataSet.stats);


Now near the bottom of your code, don't forget to call the setData() method after populating the drop-down lists... and also set the method to run every time the values of the drop-down lists change.
ddlStat.selectAll("option")
.data(graphData.stats)
.enter()
.append("option")
.property("selected", function(d, i)
{
    return i == 0;
})
.attr("value", function(d)
{
    return d;
})
.text(function(d)
{
    return d;
});

config.setData();

d3.select("#ddlYear").on("change", function() { config.setData(); });
d3.select("#ddlStat").on("change", function() { config.setData(); });


Anyway. This is your pie! You should see how it fits in with the area you've defined.


We're not quite done yet...

Remember we still have values to use? Let's use 'em!

Using the D3 method, insert text tags into chart. Remember chart is, at this point, referring to the grouping tag within the SVG. For data(), you pass in data again. The text() method is straightforward, and here you're just supplying the value property.
chart
.selectAll("path")
.data(data)
.enter()
.append("path")
.attr("d", arc)
.attr("fill", function(d) {
    return d.data.color;
});

chart
.selectAll("text")
.data(data)
.enter()
.append("text")
.text(function(d)
{
    return d.value;
});



For the x and y positioning of the text, we need to run the pointRadial() method of the d3 object. What does this do? Well, it takes an angle and a distance as parameters, and returns an array with x and y coordinates. Read more about it here!

To find the appropriate angle, we'll use the startAngle and endAngle properties of d (which were generated earlier using pie(), remember?) and determine the midpoint with some arithmetic. pointRadial() doesn't count the distance in em, so we'll need to pad dataWidth a bit.

Then after running the pointRadial() method, get the first element in the array returned as the x attribute.
chart
.selectAll("text")
.data(data)
.enter()
.append("text")
.attr("x", function(d)
{
    var midpoint = d.startAngle + ((d.endAngle - d.startAngle) / 2);
    return d3.pointRadial(midpoint, config.dataWidth * 3)[0];
})
.text(function(d)
{
    return d.value;
});


For y, do the same but get the second element.
chart
.selectAll("text")
.data(data)
.enter()
.append("text")
.attr("x", function(d)
{
    var midpoint = d.startAngle + ((d.endAngle - d.startAngle) / 2);
    return d3.pointRadial(midpoint, config.dataWidth * 3)[0];
})
.attr("y", function(d)
{
    var midpoint = d.startAngle + ((d.endAngle - d.startAngle) / 2);
    return d3.pointRadial(midpoint, config.dataWidth * 3)[1];
})
.text(function(d)
{
    return d.value;
});


We have numbers nicely positioned! But they need to be styled.


So let's set the color to a nice yellow. Add any other formatting you'd like.
.pieChartSvg
{
    width: 50%;
    height: 100%;
    float: left;
    background-color: rgba(200, 100, 100, 0.5);
}

.pieChartSvg text
{
    fill: rgba(255, 255, 0, 1);
    text-anchor: middle;
    font-weight: bold;
}

.pieLegendSvg
{
    width: 40%;
    height: 100%;
    float: right;
    background-color: rgba(100, 200, 100, 0.5);
}


Nice!


Next

A pie's not complete without a legend. We'll be working on that next. Don't go away!

No comments:

Post a Comment