Introduction

There are two things I love: football and AG Grid so I thought this could be a perfect opportunity to utilize AG Grid to display football statistics.

What is FootballStatsDirect?

FootballStatsDirect is a React app I created for fun, publicly available at footballstatsdirect.com. It is hosted on Firebase. The main component of the app is AG Grid to display various statistics from the top 5 European leagues which are retrieved from an external API. I've also included the ability to view the data using AG Grid's own charting tools and various filter buttons to view different data such as different team/league/top scorers/most red cards etc.

Why create FootballStatsDirect?

I wanted to showcase AG Grid using real data that made sense to me as opposed to dummy data that looks pretty but have no real interest. Moreover, there are many other, more official, places on the web where you can view all these statistics (and probably more) but none of them use AG Grid, therefore you cannot play around with the data as much as you'd like. For example, what if you wanted to know the correlation between a players height and the number of goals they score? That's where FootballStatsDirect comes in.

In this post, I'll go through the more technical details and the architecture of the project and why AG Grid was the perfect grid library to use.

Why AG Grid?

Simple: because it's the best grid in the world 😃

Here is a list of the features I was really interested in when using AG Grid:

  • Row Grouping: group by a field such as club to view all the relevant rows for that club. This is used using the row group panel where you can drag a field, instead of preset grouped rows. I've enabled row grouping for all columns by setting enableRowGroup to true in the defaultColDef. But for my custom cell renderers, I have had to create a keyCreator function on the column definition:
keyCreator: (params) => {
    return params.value.name;
}
  • Pivot: this is a vital piece of functionality required by FootballStatsDirect. Pivoting allows you to group by one or more field and aggregate some numeric fields. A good example here would be to view the players in the grid, group by the club and select the sum of the goals to view the total number of goals scored by each club. Here is an example of this in action:

chrome_3blJT6TQl7

This is a very simple example, much more complex pivoting can be performed on the data to analyse the data further.

To enable pivoting, I set enablePivot to true in the defaultColDef and for each of my numeric fields such as goals, assists, height, weight etc. I set the enableValue field to true on the column definition which tells AG Grid that this field can be aggregated on.

  • Ordering: ordering is simple with AG Grid, simply click on the column header to order. Some field do use the valueGetter function to order the fields as the raw value may not be able to provide the correct order. For example, the height field has a raw value which includes cm, e.g. "172cm" so the valueGetter function will return the numeric value only:
valueGetter: (params) => {
    if (!params.data || !params.data.player.height) { return; }
    return parseInt(params.data.player.height.replace(/\D/g, ''));
},
  • Filtering: filtering works out the box for most of the fields although I did have to specify the filter type for each field, e.g. agNumberColumnFilter for numeric fields, agTextColumnFilter for text fields such as Club Name etc.

Charting

I'm using the standalone AG Grid Charts library, and I have enabled integrated charting within the grid. There are 3 different graph types I'm using:

Line Chart

I use the for the league data, and it's the only chart type available for the league data. This works well for this type of data as you can compare how teams have progressed throughout the season.

chrome_TvEwbXUUDw

Bar Chart

When viewing player data, you have the option of viewing the data in either a scatter graph or as a bar chart. You can toggle between these 2 charts using the toggle:

chrome_J3D3KYA8eF

Scatter Graph

The scatter graph works great with football stats. It gives you the ability to easily analyse how well a player is performing by looking at their goal to minute ratio. The bigger the circle on the chart, the better the player is performing; the higher up in the chart the player, the more goals they have scored.

chrome_8SEXDjmE9v

Avoiding Scope Creep

One big issue I constantly had to fight against was scope creep. And it wasn't as if I had a client who was giving me more and more work, I'm the one who wanted to add in more and more features! I realised that if I kept creating more work, this project would never finish and eventually I'd get bored. Some features I wanted to integrate were:

  • Ability to view stats per season instead of the only the current season.
  • Ability to view stats in a selection of different graphs or even all the available charts.
  • Add stats, players and teams from leagues other than the top 5 from Europe.

Managing the Column Definitions

I have 2 main sets of column definitions: one for the teams and one for the players. If you select any filter on the second level:

chrome_oeWO9KkLsM

it will show you a list of players (and click the button again to deselect). If a filter button from the second row is not selected, the team stats will be displayed in the grid. To differentiate between these 2, I create a enum StatsType which specifies what the grid should be displaying: either League or Player. When the selected value for the stats type changes in the state, I update the state by setting the correct column definitions object for the grid to use.

I also have a "Show All Stats" toggle:

chrome_cHVl5wLtVS

Enabling this toggle will simply add more column definitions into the column definitions object variable.

Technical

The technology choices were made based on whatever allowed me to focus on what I wanted to create rather than the overheads. Choosing RapidAPI and Firebase for caching made the development simple, easy to host and kept the costs low.

RapidAPI

To get the data required, I'm using RapidAPI which hosts many API's for all sorts of purposes. Using RapidAPI, I can retrieve data about all the players, teams, fixtures as well as all the stats.

Caching in Firebase

My app is not directly connected to RapidAPI. That is, when a user refreshes my app, it will not go to RapidAPI to retrieve the data. Instead this data will be retrieved from a Firebase database. I have a scheduled function which runs daily to retrieve all the data I require from RapidAPI and stores it in Firebase. This makes sense as the data is only really updated once a day. And if I were to connect my app directly to RapidAPI, the dta retrieval could be slightly slower and it would cost me a lot more money as RapidAPI charges per request.

Architecture Flow Diagram

flow-diagram.drawio

Custom Cell Renderers

I'm using 3 custom cell renderers: for the team name, the player name and the team form.

Team/Player Cell Renderer

The team and player name cell renderers are simple; they both use a div to display the team or player image using a url:

export default function TeamNameCellRenderer(props) {

    if (!props.value)
    {
        return null;
    }

    let value;

    if (IsJsonString(props.value))
    {
        value = JSON.parse(props.value);
    }
    else
    {
        value = props.value;
    }

    if (props.node.allLeafChildren)
    {
        value = props.node.allLeafChildren[0].data.team;
    }

    return (
        <div>
            <img
                height='20px'
                style={{paddingRight: '10px'}}
                alt={value.name}
                src={value.logo}
            />
            <span>{value.name}</span>
        </div>
    )
}   

FormCellRenderer

The form cell renderer is a bit more interesting. The form cell shows the teams results in their previous 5 games and the format of the data is, for example: WWDWL. The custom cell renderer takes each result (i.e. each letter) and displays a circle with result letter and the appropirate colour using CSS to produce the following result:

chrome_qDk3UGv8YW

Here is the complete code for the FormCellRenderer:

const styles = createUseStyles({
    resultIndicator: {
        height: '32px',
        width: '32px',
        borderRadius: '100%',
        display: 'inline-flex',
        flexDirection: 'column',
        textAlign: 'center',
        lineHeight: '32px',
        fontWeight: 'bold'
    },
    win: {
        backgroundColor: '#13cf00'
    },
    draw: {
        backgroundColor: '#76766f'
    },
    loss: {
        backgroundColor: '#d81920'
    }
});


export default function FormCellRenderer(props) {
    const classes = styles()

    if (!props.value)
    {
        return null;
    }

    return (
        <div>
            {props.value.split('').map(x => {
                const resultClassName = x === 'W' ? classes.win : x === 'L' ? classes.loss : classes.draw;
                return <span className={classes.resultIndicator + ' ' + resultClassName}>{x}</span>
            })}
        </div>
    );
}

Button Filters

chrome_YO1qfSWhfw

There are 3 level of button filters, but not all of them filter the data within the table itself.

Selecting one of the leagues (one of the buttons in the first row) will go to the Firebase database to retrieve the selected league data, and set that to rowData which will be used by the grid.

Selecting a button the second row (filter by player stat) will similarly go to the Firebase database to retrieve the relevant data, and set this to rowData. Once one of the second row filter button has been selected, I have a function availableTeamsToFilterBy that goes through each of the players and extract a list of distinct teams, which are then displayed as a third filter. This third filter is a bit more interesting AG Grid wise as it will filter the data within the grid using the AG Grid's api:

const filter = {
  'statistics.team': {
    filterType: 'text',
    type: 'contains',
    filter: [this.state.selectedTeamFilterName]
  }
}
this.gridApi.setFilterModel(filter);

Clicking on the team again will deselect the filter, at which point I destroy the filter in my code:

this.gridApi.destroyFilter('statistics.team');

Going Live

My app has a few bugs in it. Small and minor bugs which I hope nobody will notice. My project was 90% complete and I didn't want to spend more time on it, I was ready for it to go live and showcase it to the world.

Future

One thing I really wanted to include within my stats was to use xG, that is expected goals. xG is relatively new in football and it uses many different metrics to measure the expected goals for a team to score. A team could win 2-0, but the xG could be 0.3-0, the winning team could've just gotten very lucky.

Unfortunately, xG data is not available from RapidAPI and I've contacted the owners/authors of xG many times but they've never responded ☚ī¸ maybe one day in the future.

Code

All the code for is available on GitHub here: https://github.com/viqashussain/football-stats-direct