Tuesday 3 March 2020

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

Time to render some data!

We're going to start off by creating a new object, config. This will hold values that will help scale the chart, and make adjustments according to the data presented.
var graphData =
{
    "cols":
    [
        {
            "title": "Adam Lallana",
            "stats":
            [
                {"year": 2015, "goals": 7, "appearances": 49},
                {"year": 2016, "goals": 8, "appearances": 35},
                {"year": 2017, "goals": 0, "appearances": 15},
                {"year": 2018, "goals": 0, "appearances": 16}
            ]
        },
        {
            "title": "Sadio ManĂ©",
            "stats":
            [
                {"year": 2016, "goals": 13, "appearances": 29},
                {"year": 2017, "goals": 20, "appearances": 44},
                {"year": 2018, "goals": 26, "appearances": 50}
            ]
        },
        {
            "title": "Roberto Firminho",
            "stats":
            [
                {"year": 2015, "goals": 11, "appearances": 49},
                {"year": 2016, "goals": 12, "appearances": 41},
                {"year": 2017, "goals": 27, "appearances": 54},
                {"year": 2018, "goals": 16, "appearances": 48}
            ]
        },
        {
            "title": "Divock Origi",
            "stats":
            [
                {"year": 2015, "goals": 10, "appearances": 33},
                {"year": 2016, "goals": 11, "appearances": 43},
                {"year": 2017, "goals": 0, "appearances": 1},
                {"year": 2018, "goals": 7, "appearances": 21}
            ]
        },
        {
            "title": "Daniel Sturridge",
            "stats":
            [
                {"year": 2015, "goals": 13, "appearances": 25},
                {"year": 2016, "goals": 7, "appearances": 27},
                {"year": 2017, "goals": 3, "appearances": 14},
                {"year": 2018, "goals": 4, "appearances": 27}
            ]
        },
        {
            "title": "James Milner",
            "stats":
            [
                {"year": 2015, "goals": 7, "appearances": 45},
                {"year": 2016, "goals": 7, "appearances": 40},
                {"year": 2017, "goals": 1, "appearances": 47},
                {"year": 2018, "goals": 7, "appearances": 45}
            ]
        },
    ],
    "rows": [2015, 2016, 2017, 2018],
    "stats": ["goals", "appearances"]
};

var config =
{

};


These are the properties of the config object. All these numeric values are in units of em.

scale - defines how much bigger the chart will be than the actual values. So this value defines how much we want to scale up or down by. Any number less than 1 is a scaling down, while any number greater than 1 is a scaling up.
dataWidth - defines the width of the bar columns and legend labels.
dataSpacing - defines how much spacing there is between bars.
scaleWidth - that's the width of the scale, and the filler. It's already defined in the CSS, but that was just for illustration. We ideally want something more maintainable. Use, say, 6 as the value.
legendHeight - like scaleWidth, this defines the height of the legend, and the filler. Use 4 as the value.
max and mean - these are aggregated values of the displayed dataset and may change depending on the data. Both are initialized to 0.
var config =
{
    "scale": 0.5,
    "dataWidth": 10,
    "dataSpacing": 1,
    "scaleWidth": 6,
    "legendHeight": 4,
    "max": 0,
    "mean": 0
};


Now for the methods! As mentioned, the height and width of the chart depends on the data being displayed. Thus, we need a getChartHeight() method and a getChartWidth() method. The getChartWidth() method requires a parameter - the number of bars, to be precise. setData() is the method that renders all this. It will be the largest method.
var config =
{
    "scale": 0.5,
    "dataWidth": 10,
    "dataSpacing": 1,
    "scaleWidth": 6,
    "legendHeight": 4,
    "max": 0,
    "mean": 0,
    "getChartHeight": function()
    {

    },
    "getChartWidth": function(datalength)
    {

    },
    "setData": function ()
    {

    }
};


To get the chart height, first you get the maximum value of the dataset, which will be stored in the config object's max property, multiplied by the scale property. We then multiply the result by 1.5 just to add a little buffer at the top.
"getChartHeight": function()
{
    return (this.max * this.scale * 1.5);
},


For the getChartWidth() method, we use datalength (number of bars, remember?) multiplied by the sum of dataWidth property (how wide each bar is) and the dataSpacing property (how much pace there is between bars).
"getChartWidth": function(datalength)
{
    return (datalength * (this.dataWidth + this.dataSpacing));
},


And then we add a little spacing to the end, like, maybe half of datalength.
"getChartWidth": function(datalength)
{
    return (datalength * (this.dataWidth + this.dataSpacing)) + (datalength * 0.5);
},


Now, for the setData() method! We start with defining the data to be displayed. First, declare two variables, year and stat. Using the d3 object's select() method on the ddlYear and ddlStat drop-down lists will yield objects. Using the node() method on each will in turn yield an object whose value property is the drop-down list's selected value. In this case, it's "2015" and "goals" respectively.
"setData": function ()
{
    var year = d3.select("#ddlYear").node().value;
    var stat = d3.select("#ddlStat").node().value;
},


Next, create a dataSet object which holds the properties labels and stats. Both of these are empty arrays.
var year = d3.select("#ddlYear").node().value;
var stat = d3.select("#ddlStat").node().value;

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


Let's start grabbing the data from the graphData object. First, iterate through the cols array of the graphData object.
var dataSet =
{
  "labels": [],
  "stats": []
};

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

}


Next, declare the variable filtered. Every element in cols has a stats array. This stats array should be run through the filter() function to return only the elements whose year property corresponds to year. The final result should be a single-element array, or an empty array, stored in filtered.
for (var i = 0; i < graphData.cols.length; i++)
{
    var filtered = graphData.cols[i].stats.filter(function(x) { return x.year == year;});
}


If filtered is not an empty array, use the push() function to add the title property into the labels array of the dataSet object. The value to push into the stats array of the dataSet object, should be the first (and only) element of the filtered object, either "goals" or "appearances". In this case, stat is "goals".
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)
    {
      dataSet.labels.push(graphData.cols[i].title);
      dataSet.stats.push(filtered[0][stat]);
    }
}


So now that we have our data, let's set the mean and max properties of the config object. For this, we can leverage on d3's aggregate methods, mean() and max(). Just pass in the stats array of the dataSet object for each, and for the second argument, provide an anonymous function. So convenient!
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)
    {
      dataSet.labels.push(graphData.cols[i].title);
      dataSet.stats.push(filtered[0][stat]);
    }
}

config.mean = d3.mean(dataSet.stats, function(d) { return d; });
config.max = d3.max(dataSet.stats, function(d) { return d; });


Now, use d3's select() method to get the div styled using the barChartContainer CSS class, and the barChart class, and assign those to the variables container and wrapper.

Declare variables height and width. For height, use the getChartHeight() method we created earlier. For width, use the getChartWidth() method. Pass in the size of the cols array of the graphData object, as an argument.
config.mean = d3.mean(dataSet.stats, function(d) { return d; });
config.max = d3.max(dataSet.stats, function(d) { return d; });

var container = d3.select(".barChartContainer");
var wrapper = d3.select(".barChart");

var height = config.getChartHeight();
var width = config.getChartWidth(graphData.cols.length);


Then set the width of container the D3 way, using the style() method. The total chart width should be the width of the chart area plus the width of the scale.
var container = d3.select(".barChartContainer");
var wrapper = d3.select(".barChart");

var height = config.getChartHeight();
var width = config.getChartWidth(graphData.cols.length);

container
.style("width", function(d)
{
    return (width + config.scaleWidth) + "em";
});


For wrapper, the height should be the chart height plus the height of the legend. Again, remember that units are all in em.
var container = d3.select(".barChartContainer");
var wrapper = d3.select(".barChart");

var height = config.getChartHeight();
var width = config.getChartWidth(graphData.cols.length);

container
.style("width", function(d)
{
    return (width + config.scaleWidth) + "em";
});

wrapper
.style("height", function(d)
{
    return (height + config.legendHeight) + "em";
});


You won't see any difference until you call the setData() method. Do that after populating the drop-down lists.
var ddlYear = d3.select("#ddlYear");

ddlYear.selectAll("option")
.data(graphData.rows)
.enter()
.append("option")
.property("selected", function(d, i)
{
    return i == 0;
})
.attr("value", function(d)
{
    return d;
})
.text(function(d)
{
    return d;
});

var ddlStat = d3.select("#ddlStat");

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();


You can see that the chart area has changed!


Now, add these lines. These are event handlers that will fire off whenever the ddlYear or ddlStat drop-down lists' value is changed.
config.setData();

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


Change the year to, say, "2018". You'll see the chart area change again in accordance to the data! That's because there were a lot more goals scored in 2018. Or rather, in 2015 the leading goalscorer was Daniel Sturridge with 13 goals, but in 2018, the leading goalscorer was Sadio Mané with 26! So the max property of the config object was much higher for 2018, leading to the change in height.


Now, declare the scale, chart, legend and filler the same way.
var container = d3.select(".barChartContainer");
var wrapper = d3.select(".barChart");
var scale = d3.select(".barScaleSvg");
var chart = d3.select(".barChartSvg");
var legend = d3.select(".barLegendSvg");
var filler = d3.select(".barFillerSvg");

var height = config.getChartHeight();
var width = config.getChartWidth(graphData.cols.length);


For scale, set the width. This one is simple - it will be exactly how many em the scaleWidth property of the config object dictates.
container
.style("width", function(d)
{
    return (width + config.scaleWidth) + "em";
});

wrapper
.style("height", function(d)
{
    return (height + config.legendHeight) + "em";
});

scale
.style("width", function(d)
{
    return config.scaleWidth + "em";
});


Then set the height. It will be, in em, the height we earlier defined using the getChartHeight() method.
scale
.style("width", function(d)
{
    return config.scaleWidth + "em";
})
.style("height", function(d)
{
    return height + "em";
});


And then clear the contents of the object, making way for any SVG elements we'll insert later.
scale
.style("width", function(d)
{
    return config.scaleWidth + "em";
})
.style("height", function(d)
{
    return height + "em";
})
.html("");


You see what we're doing here?


Do the same for chart. Height and width are determined by what we got earlier using the getChartHeight() and getChartWidth() methods.
scale
.style("width", function(d)
{
    return config.scaleWidth + "em";
})
.style("height", function(d)
{
    return height + "em";
})
.html("");

chart
.style("height", function(d)
{
    return height + "em";
})
.style("width", function(d)
{
    return width + "em";
})
.html("");


Now that the scale and chart are aligned, you can see how they stack up.


Next is legend. We use the legendHeight property of the config object for the height, and use width for the width.
chart
.style("height", function(d)
{
    return height + "em";
})
.style("width", function(d)
{
    return width + "em";
})
.html("");

legend
.style("height", function(d)
{
    return config.legendHeight + "em";
})
.style("width", function(d)
{
    return width + "em";
})
.html("");


Now the legend is in place, and the only thing left is the filler.


Finally, filler. We use both scaleWidth and legendHeight properties for width and height respectively. No need to clear the HTML; we're never going to insert any SVG elements in it anyway. It's just filler to help align stuff.
legend
.style("height", function(d)
{
    return config.legendHeight + "em";
})
.style("width", function(d)
{
    return width + "em";
})
.html("");

filler
.style("width", function(d)
{
    return config.scaleWidth + "em";
})
.style("height", function(d)
{
    return config.legendHeight + "em";
});


Yes! Now the content fits the outline! And changing the values of the drop-down lists will adjust things accordingly!


Putting in data

Remember what we did with the drop-down lists? We'll be doing pretty much the same thing to populate data.

Use chart here as the element to fill, and use the selectAll() method with the argument "rect" because we are going to insert rect tags. Use the data() method and pass in the stats array of the dataSet object as an argument. Then, as in the code for the drop-down lists, use the enter() method and the append() method, passing in "rect" to the append method.
filler
.style("width", function(d)
{
    return config.scaleWidth + "em";
})
.style("height", function(d)
{
    return config.legendHeight + "em";
});

chart.selectAll("rect")
.data(dataSet.stats)
.enter()
.append("rect");


Now that we're appending rect tags as the bars, we need to define the width and height of those. For the width, we just use the dataWidth property of the config object. For height, we get a value that has been passed into the anonymous function, because that is the actual data value, and multiply it by the scale property of the config object. Remember that values are in em, so add that to your strings accordingly!
chart.selectAll("rect")
.data(dataSet.stats)
.enter()
.append("rect")
.attr("width", function(d)
{
    return (config.dataWidth) + "em";
})
.attr("height", function(d)
{
    return (d * config.scale) + "em";
});


We'll also want to set the x property, For that, we'll need to use the parameter i which determines which bar it is in the array. The x property defines how far away that bar is from the left side of the chart where the data starts. We'll add the dataWidth and dataSpacing properties of the config object, then multiply the result by i. Then we'll add 2 for some initial spacing.
chart.selectAll("rect")
.data(dataSet.stats)
.enter()
.append("rect")
.attr("x", function(d, i)
{
    return ((i * (config.dataWidth + config.dataSpacing)) + 2) + "em";
})
.attr("width", function(d)
{
    return (config.dataWidth) + "em";
})
.attr("height", function(d)
{
    return (d * config.scale) + "em";
});


Uh-oh! It's a bar chart all right and the bars look legit, but it's all upside down!


We need to set the y property as well. We know the maximum height of the chart and how to derive the height of each bar. So what we do is deduct the height of the bar from the height of the chart for the y value!
chart.selectAll("rect")
.data(dataSet.stats)
.enter()
.append("rect")
.attr("x", function(d, i)
{
    return ((i * (config.dataWidth + config.dataSpacing)) + 2) + "em";
})
.attr("y", function(d)
{
    return (height - (d * config.scale)) + "em";
})
.attr("width", function(d)
{
    return (config.dataWidth) + "em";
})
.attr("height", function(d)
{
    return (d * config.scale) + "em";
});


Nice.


But the bars on their own aren't enough to show us the value. So let's add some text! Again, we use the stats array from the dataSet object. Instead of appending rect tags, we will append text tags.
chart.selectAll("rect")
.data(dataSet.stats)
.enter()
.append("rect")
.attr("x", function(d, i)
{
    return ((i * (config.dataWidth + config.dataSpacing)) + 2) + "em";
})
.attr("y", function(d)
{
    return (height - (d * config.scale)) + "em";
})
.attr("width", function(d)
{
    return (config.dataWidth) + "em";
})
.attr("height", function(d)
{
    return (d * config.scale) + "em";
});

chart.selectAll("text")
.data(dataSet.stats)
.enter()
.append("text");


Use the text() method to input the value.
chart.selectAll("text")
.data(dataSet.stats)
.enter()
.append("text")
.text(function(d)
{
    return d;
});


For the x and y attribute, use the same thing we used for the rect tags.
chart.selectAll("text")
.data(dataSet.stats)
.enter()
.append("text")
.attr("x", function(d, i)
{
    return ((i * (config.dataWidth + config.dataSpacing)) + 2 + "em";
})
.attr("y", function(d)
{
    return (height - (d * config.scale)) + "em";
})
.text(function(d)
{
    return d;
});


It's all good. We just need a little adjustment.


For the y attribute, just deduct 1em from the final value.
chart.selectAll("text")
.data(dataSet.stats)
.enter()
.append("text")
.attr("x", function(d, i)
{
    return ((i * (config.dataWidth + config.dataSpacing)) + 2 + "em";
})
.attr("y", function(d)
{
    return (height - (d * config.scale) - 1) + "em";
})
.text(function(d)
{
    return d;
});


What a difference!


For the x attribute, you want to move the value to the middle of each corresponding bar. We know the width of each bar, so just divide that by 2 and add the resulting value to the final value!
chart.selectAll("text")
.data(dataSet.stats)
.enter()
.append("text")
.attr("x", function(d, i)
{
    return ((i * (config.dataWidth + config.dataSpacing)) + 2 + (config.dataWidth / 2)) + "em";
})
.attr("y", function(d)
{
    return (height - (d * config.scale) - 1) + "em";
})
.text(function(d)
{
    return d;
});


Good!


Let's add the labels!

Basically, we take legend and use the same method to append text tags, only this time we use the labels array of the dataSet object as an argument for the data() method.
chart.selectAll("text")
.data(dataSet.stats)
.enter()
.append("text")
.attr("x", function(d, i)
{
    return ((i * (config.dataWidth + config.dataSpacing)) + 2 + (config.dataWidth / 2)) + "em";
})
.attr("y", function(d)
{
    return (height - (d * config.scale) - 1) + "em";
})
.text(function(d)
{
    return d;
});

legend.selectAll("text")
.data(dataSet.labels)
.enter()
.append("text")
.text(function(d)
{
    return d;
});


For the x attribute, do what we did for the bar chart numbers. For the y attribute, maybe 1 or 2 em will do. Alternatively, just use the dataSpacing property of the config object.
legend.selectAll("text")
.data(dataSet.labels)
.enter()
.append("text")
.attr("x", function(d, i)
{
    return ((i * (config.dataWidth + config.dataSpacing)) + 2 + (config.dataWidth / 2)) + "em";
})
.attr("y", function(d)
{
    return config.dataSpacing + "em";
})
.text(function(d)
{
    return d;
});


You'll notice that the left edge of the legend text is just in the middle of each bar. It's the same for the bar chart numbers, only not quite so obvious because they're two-digit numbers.


Let's clean this up a tad. Style the text tags of the CSS classes barChartSvg and barLegendSvg. In both cases, they're set to black, bolded and most importantly, the text-anchor property is now "middle".
.barChartSvg
{
    width: 20em;
    height: 20em;
    float: left;
    background-color: rgba(0, 0, 255, 0.1);
}

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


.barFillerSvg
{
    width: 5em;
    height: 3em;
    float: left;
    background-color: rgba(0, 0, 0, 0.1);
}

.barLegendSvg
{
    width: 20em;
    height: 3em;
    float: left;
    background-color: rgba(255, 0, 0, 0.1);
}

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


Looking reeeeal nice.


Try changing the values of the drop-down lists. The chart should change accordingly.


Next

There's only the scale left! Let's get on it...

No comments:

Post a Comment