Animate NBA shot events with Paper.js

All the shots and FT attempts in one animation made with NBA spatio-temporal data (maintained by neilmj) and paper.js.The data is from Golden State Warriors vs Denver Nuggets on January 13th 2016.

I took the following simple two steps.

  • Data cooking with Python

  • Animation with Paper.js

Data cooking with Python

The final goal of this step is to generate a JSON file for Paper.js.Concretely I like to have Let’s first get the data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import json
import pandas as pd
import os
import numpy as np
from collections import defaultdict
from itertools import compress
import urllib
os.chdir('PATH/TO/YOUR/WORKINGDIRECTORY')

tid = '1610612744' # Team ID for GSW
gid = '0021500583' # Game ID of this game

# Let's get the data
datalink = ("https://raw.githubusercontent.com/neilmj/BasketballData/"
 "master/2016.NBA.Raw.SportVU.Game.Logs/01.13.2016.GSW.at.DEN.7z")

# You can either download the data and unzip the above URL manually or do the
# following

os.system("curl " + datalink + " -o " + os.getcwd() + "/zipped_data") 
os.system("7za x " + os.getcwd() + "/zipped_data") 
# This should output 0021500583.json in your working directory

with open('{gid}.json'.format(gid=gid)) as data_file:
 data = json.load(data_file) # Load this json

# You can explore the data here if you like
# Following code and we get player lists for both home
# and visitors
home = [data["events"][0]["home"]["players"][i]["playerid"] for 
i in xrange(len(data["events"][0]["home"]["players"]))]

visitors = [data["events"][0]["visitor"]["players"][i]["playerid"] for 
i in xrange(len(data["events"][0]["visitor"]["players"]))]

pids = home + visitors # This is going to be a list of all the player IDs

In the next snippet, we are going to call a play by play API from stats.nba.com/.

Play by play data contains how each event ends up like 2PT attemps, rebound, turnover or substitution.We are going to use this information to get only field goal and free throw attempts.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
os.system('curl "http://stats.nba.com/stats/playbyplayv2?'
 'EndPeriod=0&'
 'EndRange=0&'
 'GameID={gid}&'
 'RangeType=0&'
 'Season=2015-16&'
 'SeasonType=Season&'
 'StartPeriod=0&'
 'StartRange=0" > {cwd}/pbp_{gid}.json'.format(cwd=os.getcwd(), gid=gid))
# Download json from the API

pbp = pd.DataFrame()
with open("pbp_{gid}.json".format(gid=gid)) as json_file:
 parsed = json.load(json_file)['resultSets'][0]
 pbp = pbp.append(
 pd.DataFrame(parsed['rowSet'], columns=parsed['headers']))
# Conver only the necessary part into DataFrame

shot_events = pbp[pbp["EVENTMSGTYPE"].isin([1,2,3])]["EVENTNUM"].values
# EVENTMSGTYPE is how an event ends up
# 1: FG made
# 2: FG missed
# 3: FT

raw_json=list()
for event in xrange(len(data['events'])): #for each event
 if event in shot_events: # if event was any attempt at a rim
 for num in xrange(0,len(data['events'][event]['moments']),1):
 # for each record of data. apparently every 0.1-0.2 second
 lstlsts = data['events'][event]['moments'][num][5]
 # this block contains spatio-temporal data for all the players on
 # the court
 tmplst=list()
 for pid in [-1]+pids: # for each player and ball (pids -1)
 if any(pid in lst for lst in lstlsts)==True: # for each player
 indx = [pid in lst for lst in lstlsts]
 # get an array index of a player
 rw = list(compress(lstlsts,indx))[0]
 # and get the row itself
 tmplst.append(rw[2]) # X coordinate
 tmplst.append(rw[3]) # Y coordinate
 else:
 tmplst.append(None)
 tmplst.append(None)
 # Add None if the player is on bench
 raw_json.append(tmplst)

json_df = pd.DataFrame(raw_json)

json_df.fillna(0,inplace=True) # get all tthe resting players to (0, 0)
json_df.drop_duplicates(inplace=True)

json_df has X, Y coordinates of all the players per observation.Each row is a time (recorded every 0.1 - 0.2 sec).Column zero is a X coordinate of player 1 (pids[0]), column one is a Y coordinate of the same player, column two is a X-coordinate of player 2 (pids [1]) and so on.

The last trick needed is to convert Python DataFrame to a right for Paper.js.

There are more than one way to do this but we are going to make a nba_data.js and define JavaScript variables in there.

1
2
3
4
5
6
7
json_str = json_df.drop_duplicates().values.tolist()
json_str = "var DATA = " + str(json_str) + "\n"
json_str += "var count1 = " + str(len(home)) + "\n"
json_str += "var count2 = " + str(len(pids)+1) + "\n"
json_str += "var pids = " + str(pids)
with open('nba_data.js', 'w') as f:
 f.write(json_str)

Animation with Paper.js

Now we got the data and now we are going to make animation in JavaScript.The below is a simple html skelton.Put this in the project folder so this can find nba_data.js.I downloaded Paper.js by bower install paper so the location might be different from bower_components/paper/dist/paper-full.js depending on how you download the library.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
<!-- Load the saved JavaScript variables -->
<script src="nba_data.js" ></script>
<!-- Load paper.js -->
<script type="text/javascript"
src="bower_components/paper/dist/paper-full.js"></script>
<!-- Make animation -->
<script type="text/paperscript" canvas="myCanvas">
<!-- WE ARE GOING TO FILL IN HERE -->
</script>

</head>
<body>
 <canvas id="myCanvas" style="background:black" resize=True
 ></canvas>

</body>
</html>

As you can see in the script tag above, Paper.js needs a special type=”text/paperscript” and canvas=ID.The content of a canvas is shown in html <canvas id = ID> part.

The last part is to put the following in the above WE ARE GOING TO FILL IN HERE.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// We start with making 11 circle objects with Path.Circle, which are
// essentially going to be wrapped up into Symbol objects

// Ball comes first
var path0 = new Path.Circle({
 center: [0, 0],
 radius: 3,
 fillColor: 'brown',
 strokeColor: 'brown'
}); 

var symbol0 = new Symbol(path0);

// White and blue color home court jersey for Denver Nuggets
var path = new Path.Circle({
 center: [0, 0],
 radius: 3,
 fillColor: 'white',
 strokeColor: 'skyblue'
});

var symbol = new Symbol(path);

// Blue and yellow away jersey for Golden State Warriors
var path2 = new Path.Circle({
 center: [0, 0],
 radius: 3,
 fillColor: 'blue',
 strokeColor: 'yellow'
});

var symbol2 = new Symbol(path2);

// Assign center and scale for the 11 objects
var center = Point.random();
var placedSymbol0 = symbol0.place(center);
placedSymbol0.scale(1);

for (var i = 1; i <= count1; i++) {
 var center = Point.random();
 var placedSymbol = symbol.place(center);
 placedSymbol.scale(1.5);
}

for (var i = count1+1; i <= count2; i++) {
 var center = Point.random();
 var placedSymbol2 = symbol2.place(center);
 placedSymbol2.scale(1.5);
}

// Everything in onFrame will be in animation
// When this function is defined, it is called up to 60 times a second by Paper.js
// The number of count is obtained by event.count so using this to assign
// new position to each object

function onFrame(event) {
 project.activeLayer.children[0].position = [DATA[event.count][0]*3,
 DATA[event.count][1]*2.5];

 for (var i = 1; i <= count1; i++) {
 var item = project.activeLayer.children[i];
 item.position = [DATA[event.count][(i*2)]*3,
 DATA[event.count][(i*2)+1]*2.5];
 }

 for (var i = count1+1; i <= count2; i++) {
 var item = project.activeLayer.children[i];
 item.position = [DATA[event.count][(i*2)]*3,
 DATA[event.count][(i*2)+1]*2.5];
 }
}

The final output in html is a smooth one game shot highlight animatioin too big to be fit in this post. so here are some GIF extractions from the original JavaScript animation.

Though I prefer a black background with circle objects moving around, you can draw a basketball court or replace circiles with player photos (you know, there’s also API for photos).

The full code is available here