It's world cup time again and I've been looking for a good overview of how it's all progressing in the group stages. Being a
computer programmer I of course spent a small amount of time looking for one done by someone else, and then decided to just
do it myself. With the trusty jsPlumb Toolkit at my disposal I figure it'll be a doddle.
Let's start with the result (this is from the morning of June 25th):
A visualisation like this gives us a good overview of how each group is progressing. We can see at a glance which matches are yet to be played, and the result of each match that has already been played (at the time of writing there are edges missing from each of these: the group stages are not yet finished). You can also see how the group rankings are coming along: the team with the green border is in first place, the amber border is second, and the teams with the red borders are coming in 3rd and 4th.
This graphic is much easier to parse than the tabular format this information is usually presented in, and satisfies my urge to respect the concept of the "data-ink ratio", as discussed in one of my favourite books of all time, The Visual Display of Quantitative Information. If you haven't read this book I highly recommend it. When I was searching for that link to include, though, I noticed one of the suggestions was "visual display of quantitative information pdf". Please don't steal it. That wouldn't be very cool.
Teams, groups and matches are stored in a JSON object that is loaded as a script. A rough outline:
worldCupData = {
"teams":{
"ru":{"code":"ru", "name":"Russia"},
"uy":{"code":"uy","name":"Uruguay"},
"eg":{"code":"eg","name":"Egypt"},
...
},
"groups":[
{ "id":"A", "teams":[ "ru", "uy", "eg", "sa" ], "rank":[]},
{ "id":"B", "teams":[ "es", "pt", "ir", "ma"], "rank":[]},
...
],
"matches":{
"group":[
{"id":"1","teams":["ru","sa"],"date":0, "score":[5,0]},
{"id":"2","teams":["eg","uy"],"date":0, "score":[0,1]},
{"id":"3","teams":["ir","ma"],"date":0, "score":[1,0]},
...
},
"roundof16":[
{ "id":"33", "participants":["C1", "D2"], "score":[], "date":0 },
{ "id":"34", "participants":["A1", "B2"], "score":[], "date":0 },
{ "id":"35", "participants":["B1", "A2"], "score":[], "date":0 },
...
],
"quarters":[
{ "id":"41", "matches":["33", "34"], "score":[], "date":0 },
{ "id":"42", "matches":["35", "36"], "score":[], "date":0 },
...
],
"semis":[
{ "id":"45", "matches":["41", "42"], "score":[], "date":0 },
{ "id":"46", "matches":["43", "44"], "score":[], "date":0 }
],
"thirdplace":{
"score":[], "date":0, "id":"47"
},
"final":{
"score":[], "date":0, "id":"48"
}
}
}
There are of course a million ways to model a dataset. I chose this. Teams are modelled first; each team has a name and
a country code. We then list out the groups, with their ID and list of teams, plus a rank
array which the code populates
and is therefore not strictly necessary in the json.
We split the matches into the various phases of the World Cup - first the group matches, then the knockout round of 16,
followed by the quarter finals, the semi finals, and then the third place playoff and the final. Each match has this form:
{ "id":"33", "participants":["C1", "D2"], "score":[], "date":0 }
This example is from the knockout round. participants
identifies which teams from the group stage will play in this
match - in this case, the 1st placed team from group C and the 2nd placed team from group D.
For the quarters onwards we slightly change the syntax:
{ "id":"41", "matches":["33", "34"], "score":[], "date":0 },
ie. we use matches
instead of participants
to identify who will be playing - the winners of the matches with the given
IDs.
Note that for the later stages of the tournament we have not recorded the teams in each match; we populate these dynamically.
Note also the date
field, which always has a value of 0 at the moment. This is a placeholder; a future post in this
series may populate this field and then do some tricks with it.
We render each group via this loop:
worldCupData.groups.forEach(renderGroup);
The renderGroup
function consists of three parts - first we call processGroup
to populate its current state. processGroup
looks like this:
function processGroup(group) {
var nodes = [], edges = [];
for (var i = 0; i < group.teams.length; i++) {
var teamA = getTeam(group.teams[i]);
nodes.push(teamA);
for (var j = 0; j < group.teams.length; j++) {
var teamB = getTeam(group.teams[j]);
if (teamA.code < teamB.code) {
var score = getGroupMatchScore(teamA, teamB);
if (score != null) {
edges.push({
source: teamA.code,
target: teamB.code,
data: {scoreA: "" + score[0], scoreB: "" + score[1]}
});
// increment goals
teamA.goals += score[0];
teamB.goals += score[1];
// calculate winner/loser
if (score[0] === score[1]) {
teamA.draws++;
teamB.draws++;
} else {
teamA[score[0] > score[1] ? "wins" : "losses"]++;
teamB[score[0] > score[1] ? "losses" : "wins"]++;
}
}
}
}
}
nodes.sort(function (a, b) {
if (a.wins > b.wins) {
return -1;
} else if (b.wins > a.wins) {
return 1;
} else {
if (a.goals > b.goals) {
return -1;
} else {
return 1;
}
}
});
nodes.forEach(function (n, i) {
n.rank = i;
group.rank[i] = n;
});
return { nodes:nodes, edges:edges };
}
getTeam
looks up a team by its country code and returns an object containing the team's name, code, and count of wins,
draws, losses and goals. Here we add each team from the group as a node, and then for each match that exists
for the given team, we add that match as an edge, populating and adjusting the count of wins, losses, draws and goals
for each team in the match. Note the data
member of the edge contains the score; we will use that in the
renderer.
Lastly, we sort the nodes, which is our list of teams, by their number of wins, or if they have the same number of wins
as some other team, by the number of goals they have scored (football experts: is this accurate?). We then inject the calculated
rank into each team object.
Now we've got a suitable dataset we instantiate a Toolkit instance and render:
var tk = jsPlumbToolkit.newInstance({ idFunction:function(d) { return d.code; } });
tk.render({
container:document.getElementById(group.id),
layout:{
type:"Circular"
},
view:{
nodes:{
default:{
template:"team"
}
},
edges:{
default:{
overlays:[
[ "Label", { label:"${scoreA}", location:0.3 }],
[ "Label", { label:"${scoreB}", location:0.7 }]
]
}
}
},
jsPlumb:{
Connector:"Straight",
Endpoint:"Blank",
Anchor:"Center"
},
templates:{
"team":"<div class=\"group-node\" title=\"${name}\" rank=\"${rank}\"><img class=\"flag\" src=\"/path/to/flags/4x3/${code}.svg\" alt=\"${name} Flag\"/></div>"
},
enableWheelZoom:false,
enablePan:false,
zoomToFit:true
});
Points to note:
Circular
layout to place the teams. For a demonstration of this, see this page.Label
and their label
value extracts data from the edge's backing node.enableWheelZoom:false
and enablePan:false
.zoomToFit:true
, we instruct the renderer to pan and zoom after data load so that the entire dataset is visible and centered in the viewporttemplates
parameter. We could also provision them as elements in the DOM, but these posts are passed through a markdown renderer and it was causing me grief when compiling the page so I switched to inline template provision.rank
as an attribute with the name rank
. We use CSS to colour code our nodes according to the value of the rank
attribute.Lastly, we load our dataset:
tk.load({
data:groupData
});
These are the styles used in this page:
/* the container for all the groups visualisations */
#groups {
display:flex;
flex-wrap:wrap;
}
/* a single group visualisation */
.group-vis {
border:2px solid #CCC;
flex-basis:300px;
width:300px;
height:300px;
overflow:hidden !important;
margin:10px;
position:relative;
}
/* the group name element in each group */
.group-vis span {
position:absolute;
top:0;
bottom:0;
left:0;
right:0;
display:flex;
justify-content:center;
align-items:center;
font-size:170px;
color:#ededf1;
}
/* a team node in a group. by default has a red border */
.group-node {
border-radius: 50%;
border:3px solid red;
}
/* the team ranked 1st in the group has a green border. `rank` was extracted from the team data by the template. */
.group-node[rank="0"] {
border-color:green;
}
/* the team ranked 2nd has an orange border */
.group-node[rank="1"] {
border-color:orange;
}
/* flag image for each team */
.group-node img {
width:40px;
height:40px;
border-radius: 50%;
border:2px solid #CCC;
}
/* assigned to all toolkit nodes. we set z-index so they appear above the edges representing each match */
.jtk-node {
z-index:1;
}
/* assigned to all toolkit overlays. we use overlays for the score of each match */
.jtk-overlay {
width:15px;
height:15px;
background-color:white;
display:flex;
justify-content:center;
align-items:center;
font-size:12px;
}
/* these are "nudge" buttons for panning, visible by default. we hide them in this visualisation.
.jtk-surface-pan {
display:none;
}
It's all pretty standard CSS. One thing to note is the way we use the rank
member of each team's data to render a custom
attribute, which we then style via css.
This is part one of what we intend to be a few posts, in which we'll progressively build up our visualisation to give us
different views of what's going on at the World Cup. Stay tuned!