This horizontal bar chart—built with D3—shows the total points scored by each English Premier League team in the 2022/2023 season.
How I built the chart
It all begins with the data.
Manchester City won the league with 89 points while Southampton’s 25 points meant they finished the season as bottom of the rung.
Team | Points | |
---|---|---|
1 | Man City | 89 |
2 | Arsenal | 84 |
3 | Man United | 75 |
4 | Newcastle | 71 |
5 | Liverpool | 67 |
6 | Brighton | 62 |
7 | Aston Villa | 61 |
8 | Tottenham | 60 |
9 | Brentford | 59 |
10 | Fulham | 52 |
11 | Crystal Palace | 45 |
12 | Chelsea | 44 |
13 | Wolves | 41 |
14 | West Ham | 40 |
15 | Bournemouth | 39 |
16 | Nottingham Forest | 38 |
17 | Everton | 36 |
18 | Leicester City | 34 |
19 | Leeds United | 31 |
20 | Southampton | 25 |
Data
The data is structured as an array of each team’s name and total points. I didn’t have to do any cleanup here because I have total control over the data’s structure.
const eplStandings2022_23 = [
{ team: "Man City", pts: 89 },
{ team: "Arsenal", pts: 84 },
{ team: "Man United", pts: 75 },
{ team: "Newcastle", pts: 71 },
{ team: "Liverpool", pts: 67 },
{ team: "Brighton", pts: 62 },
{ team: "Aston Villa", pts: 61 },
{ team: "Tottenham", pts: 60 },
{ team: "Brentford", pts: 59 },
{ team: "Fulham", pts: 52 },
{ team: "Crystal Palace", pts: 45 },
{ team: "Chelsea", pts: 44 },
{ team: "Wolves", pts: 41 },
{ team: "West Ham", pts: 40 },
{ team: "Bournemouth", pts: 39 },
{ team: "Nottm Forest", pts: 38 },
{ team: "Everton", pts: 36 },
{ team: "Leicester City", pts: 34 },
{ team: "Leeds United", pts: 31 },
{ team: "Southampton", pts: 25 },
];
Dimensions
The width
of the canvas/svg will be the same as the width of its parent container, in this case, chartContainer
.
The other dimensions are arbitrary. For example, marginBottom
and marginRight
are set to 0 since no spacing is needed on those axes, that is, axisBottom
and axisRight
.
The height
of the chart is calculated based on the barHeight
, the vertical margins, and the total number of teams.
const chartContainer = document.getElementById("chart-container");
const marginTop = 25;
const marginRight = 0;
const marginBottom = 0;
const marginLeft = 90;
const width = chartContainer.getBoundingClientRect().width;
const barHeight = 40;
const height =
Math.ceil((eplStandings2022_23.length + 0.1) * barHeight) +
marginTop +
marginBottom;
Drawing the canvas
const canvas = drawCanvas();
The canvas is the svg. It is created (with its viewBox
) and appended to the chartContainer
function drawCanvas() {
const canvas = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height]);
// Always be rendering
chartContainer.append(canvas.node());
return canvas;
}
With the canvas drawn, I created the other parts of the charts and placed them on the canvas.
Horizontal Axis: left to right, axisTop
/ axisBottom
Before drawing the horizontal axis with:
horizontalAxis.draw({ svg: canvas });
is the logic to do so. The draw
function relies on the getScale
function for its subsistence.
A linear scale, d3.scaleLinear
, is used since the data, that is, each team’s points are
quantitative (can be counted) and continuous (can be measured) data.
const horizontalAxis = {
getScale(data) {
const xDomain = [0, d3.max(d3.map(data, (d) => d.pts))];
const xRange = [marginLeft, width - marginRight];
const xScale = d3.scaleLinear(xDomain, xRange);
return { xDomain, xRange, xScale };
},
draw({ data = eplStandings2022_23, svg }) {
const { xScale } = this.getScale(data);
const xAxis = d3.axisTop(xScale);
svg
.append("g")
.classed("h-axis", true)
.append("g")
.classed("top-axis", true)
.attr("transform", `translate(0,${marginTop})`)
.call(xAxis)
.call((g) => g.select(".domain").remove());
},
};
Vertical Axis: top to bottom, axisLeft
/ axisRight
The idea here is similar to that of the horizontal axis.
verticalAxis.draw({ svg: canvas });
The draw
and getScale
functions also go hand in hand. A band scale, d3.scaleBand
, is used since the
teams’ names are categorical.
const verticalAxis = {
getScale(data) {
const yDomain = d3.groupSort(
data,
([d]) => d.pts,
(d) => d.team
);
const yRange = [height - marginBottom, marginTop];
const yScale = d3.scaleBand(yDomain, yRange).padding(0.1);
return { yDomain, yRange, yScale };
},
draw({ data = eplStandings2022_23, svg }) {
const { yScale } = this.getScale(data);
const yAxis = d3
.axisLeft(yScale)
.tickPadding(10)
.tickSizeInner(0)
.tickSizeOuter(0);
svg
.append("g")
.attr("transform", `translate(${marginLeft}, 0)`)
.call(yAxis)
.call((g) => g.select(".domain").attr("stroke", "var(--green-accent)"));
},
};
Content
The content of the chart are the bars—and the labels.
With the canvas and axes set, the bars can be drawn. The xScale
and yScale
are also needed here.
function drawContent({ data = eplStandings2022_23, svg }) {
const { xScale } = horizontalAxis.getScale(data);
const { yScale } = verticalAxis.getScale(data);
// Draw the bars
svg
.append("g")
.attr("fill", "var(--green-fade)")
.classed("bars", true)
.selectAll()
.data(data)
.join("rect")
.attr("height", yScale.bandwidth())
.attr("width", (d) => xScale(d.pts) - xScale(0))
.attr("x", marginLeft)
.attr("y", (d) => yScale(d.team));
// Draw the labels
svg
.append("g")
.classed("labels", true)
.attr("fill", "currentColor")
.attr("text-anchor", "end")
.selectAll()
.data(eplStandings2022_23)
.join("text")
.attr("x", (d) => xScale(d.pts))
.attr("y", (d) => yScale(d.team) + yScale.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("dx", -4)
.text((d) => `${d.pts} pts`);
}
drawContent({ svg: canvas });