Pieter Robberechts

CS Engineer, PhD student in sports analytics, Data geek

xG map

This notebook illustrates how D3 soccer can be used to draw an expected goals (xG) map. This type of visualization shows the location and xG value of each shot during a specific game. This notebook uses the StatsBomb data of the 2018 Champions League final.

Appendix

import { fetchp } from ["@tomlarkworthy/fetchp"](/content/@tomlarkworthy/fetchp/index.html)

d3 = Object {format: ƒ(t), formatPrefix: ƒ(t, n), timeFormat: ƒ(t), timeParse: ƒ(t), utcFormat: ƒ(t), utcParse: ƒ(t), Adder: class, Delaunay: class, FormatSpecifier: ƒ(t), InternMap: class, InternSet: class, Node: ƒ(t), Path: class, Voronoi: class, ZoomTransform: ƒ(t, n, e), active: ƒ(t, n), arc: ƒ(), area: ƒ(t, n, e), areaRadial: ƒ(), ascending: ƒ(t, n), …}

const HEIGHT_HEADER = 90;
const HEIGHT_PITCH = 300;
const HEIGHT_FOOTER = 20;

// Create the pitch
const pitch = d3_soccer.pitch()
  .height(HEIGHT_PITCH);

// Create an SVG container
const chart = d3.create('div')
  .style('font-family','Helvetica')

const svg = chart.append('svg')
  .attr('width', pitch.width())
  .attr('height', HEIGHT_HEADER + HEIGHT_PITCH + HEIGHT_FOOTER);

// Create the header
const header = d3_soccer.matchHeader()
  .hed("2018 Champions League Final")
  .score([3, 1])
  .logoAway("https://upload.wikimedia.org/wikipedia/fr/thumb/5/54/Logo_FC_Liverpool.svg/langfr-130px-Logo_FC_Liverpool.svg.png")
  .logoHome("https://upload.wikimedia.org/wikipedia/fr/thumb/c/c7/Logo_Real_Madrid.svg/langfr-130px-Logo_Real_Madrid.svg.png");

// Draw the header
svg.append('g').attr("transform","translate(15, 20)").call(header);
svg.selectAll("image").attr("height", 35);

// Draw the pitch
svg.append('g').attr("transform", `translate(0, ${HEIGHT_HEADER})`).call(pitch);

// Draw the data notice
svg.append('text')
  .attr('x', pitch.width()-10)
  .attr('y', HEIGHT_HEADER + HEIGHT_PITCH + 10)
  .attr('text-anchor', 'end')
  .style("fill", "grey")
  .style("font-style", "italic")
  .style("font-size", "11px")
  .text("Data provided by StatsBomb");

// Draw xG circles
const shots = data.filter(d => d.type.name == "Shot")
var teams = {
  "Real Madrid": {"color": "gold", "side": "home"},
  "Liverpool": {"color": "crimson", "side": "away"}
}

chart.select(".above")
.selectAll(`circle`)
.data(shots).enter()
.append('circle')
  .attr('cx', d => {
    if (teams[d.team.name].side === 'home') {
      return 105 - d.location[0] * (105 / 120)
    } else {
      return d.location[0] * (105 / 120)
    }
  })
  .attr('cy', d => d.location[1] * (68 / 80))
  .attr('stroke', d => teams[d.team.name].color)
  .attr('stroke-width', .2)
  .attr('fill', d => d.shot.outcome.name === "Goal" ? teams[d.team.name].color : "none")
  .attr('fill-opacity', 0.5)
  .attr('r', d => d.shot.statsbomb_xg * 5);

// Draw a legend
const legend = svg.append('g').attr("transform", `translate(0, ${HEIGHT_HEADER + HEIGHT_PITCH + 10})`)
for (let i = 0; i < 3; i++) {
  legend
    .append('circle')
    .attr('cx', 30 + i * 10 - 2 * i)
    .attr('cy', 0)
    .attr('r', (3 - i) * 3)
    .attr('stroke', 'grey')
    .attr('stroke-width', .2)
    .attr('fill', 'none');
  legend.append('text')
    .attr('x', 60)
    .attr('y', 3)
    .attr('text-anchor', 'start')
    .style("fill", "grey")
    .style("font-style", "italic")
    .style("font-size", "11px")
    .text("xG value");
  legend
    .append('circle')
    .attr('cx', 130)
    .attr('cy', 0)
    .attr('r', 6)
    .attr('stroke', 'none')
    .attr('fill', 'grey');
  legend.append('text')
    .attr('x', 145)
    .attr('y', 3)
    .attr('text-anchor', 'start')
    .style("fill", "grey")
    .style("font-style", "italic")
    .style("font-size", "11px")
    .text("Goal");
}

return chart.node();
data = await fetchp('https://github.com/statsbomb/open-data/blob/master/data/events/18245.json?raw=true').then(res => res.json());
import { fetchp } from '@tomlarkworthy/fetchp'
d3 = require("d3@7")
d3_soccer = require("d3-soccer@0.2.0")

data
d3
d3_soccer