If you have been looking for animation support in Plotly, it’s the high time you explore it.
In this post, we will recreate a slice of the NBA game between Toronto Raptors and Charlotte Hornets using the Plotly animations.
Data Collection
NBA’s official site had a section for ‘player tracking movement’ data in the past. Currently, it’s offline due to some technical difficulties (according to the site).
We will be using a publicly available dump of that dataset from GitHub (source repository: linouk23/NBA-Player-Movements).
Let’s fetch the data for a game event.
import requests as req res = req.get('https://gist.githubusercontent.com/pravj/ea6b8ac5c14d41b81d87c7863b01ee3a/raw/a6d92935ae90a61524266c2c8640190abb2aa935/NBA-CHA-TOR-event.json') event = res.json()[0]
Basketball court using Plotly shapes
We will start with drawing the game court using Plotly shapes. You can read our post (NBA shots analysis using Plotly shapes) for more details on it. It’s based on the details provided in a similar blog by Savvas Tjortjoglou.
import plotly.graph_objs as go from plotly.offline import download_plotlyjs, init_notebook_mode, iplot, iplot_mpl, plot init_notebook_mode()
As the NBA court is 94 by 50 feet in dimension, we will use it for our court.
# point on the center of the court (dummy data) midpoint_trace = go.Scatter( x = [47], y = [25] ) # outer boundary outer_shape = { 'type': 'rect', 'x0': 0, 'y0': 0, 'x1': 94, 'y1': 50, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # left backboard left_backboard_shape = { 'type': 'line', 'x0': 4, 'y0': 22, 'x1': 4, 'y1': 28, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # right backboard right_backboard_shape = { 'type': 'line', 'x0': 90, 'y0': 22, 'x1': 90, 'y1': 28, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # left outer box left_outerbox_shape = { 'type': 'rect', 'x0': 0, 'y0': 17, 'x1': 19, 'y1': 33, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # left inner box left_innerbox_shape = { 'type': 'rect', 'x0': 0, 'y0': 19, 'x1': 19, 'y1': 31, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # right outer box right_outerbox_shape = { 'type': 'rect', 'x0': 75, 'y0': 17, 'x1': 94, 'y1': 33, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # right inner box right_innerbox_shape = { 'type': 'rect', 'x0': 75, 'y0': 19, 'x1': 94, 'y1': 31, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # left corner a leftcorner_topline_shape = { 'type': 'rect', 'x0': 0, 'y0': 47, 'x1': 14, 'y1': 47, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # left corner b leftcorner_bottomline_shape = { 'type': 'rect', 'x0': 0, 'y0': 3, 'x1': 14, 'y1': 3, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # right corner a rightcorner_topline_shape = { 'type': 'rect', 'x0': 80, 'y0': 47, 'x1': 94, 'y1': 47, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # right corner b rightcorner_bottomline_shape = { 'type': 'rect', 'x0': 80, 'y0': 3, 'x1': 94, 'y1': 3, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # half court half_court_shape = { 'type': 'rect', 'x0': 47, 'y0': 0, 'x1': 47, 'y1': 50, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # left hoop left_hoop_shape = { 'type': 'circle', 'x0': 6.1, 'y0': 25.75, 'x1': 4.6, 'y1': 24.25, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # right hoop right_hoop_shape = { 'type': 'circle', 'x0': 89.4, 'y0': 25.75, 'x1': 87.9, 'y1': 24.25, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # left free throw circle left_freethrow_shape = { 'type': 'circle', 'x0': 25, 'y0': 31, 'x1': 13, 'y1': 19, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # right free throw circle right_freethrow_shape = { 'type': 'circle', 'x0': 81, 'y0': 31, 'x1': 69, 'y1': 19, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # center big circle center_big_shape = { 'type': 'circle', 'x0': 53, 'y0': 31, 'x1': 41, 'y1': 19, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # center small circle center_small_shape = { 'type': 'circle', 'x0': 49, 'y0': 27, 'x1': 45, 'y1': 23, 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # left arc shape left_arc_shape = { 'type': 'path', 'path': 'M 14,47 Q 45,25 14,3', 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # right arc shape right_arc_shape = { 'type': 'path', 'path': 'M 80,47 Q 49,25 80,3', 'line': { 'color': 'rgba(0,0,0,1)', 'width': 1 }, } # list containing all the shapes _shapes = [ outer_shape, left_backboard_shape, right_backboard_shape, left_outerbox_shape, left_innerbox_shape, right_outerbox_shape, right_innerbox_shape, leftcorner_topline_shape, leftcorner_bottomline_shape, rightcorner_topline_shape, rightcorner_bottomline_shape, half_court_shape, left_hoop_shape, right_hoop_shape, left_freethrow_shape, right_freethrow_shape, center_big_shape, center_small_shape, left_arc_shape, right_arc_shape ] layout = go.Layout( title = 'Basketball Court', shapes = _shapes ) fig = go.Figure(data = [midpoint_trace], layout=layout) iplot(fig)
It will result in the following chart, we will be using it as the background of our animation chart.
Data Processing
We will create a dictionary titled team_details from the event data, it’ll contain the basic information like team name, team id, player jersey and name.
team_details = {} visitor = event['visitor'] home = event['home'] team_details[visitor['teamid']] = {'name': visitor['name'].encode('utf-8'), 'players': {}} team_details[home['teamid']] = {'name': home['name'].encode('utf-8'), 'players': {}} for player in visitor['players']: team_details[visitor['teamid']]['players'][player['playerid']] = (player['jersey'].encode('utf-8'), '{0} {1}'.format(player['firstname'], player['lastname'])) for player in home['players']: team_details[home['teamid']]['players'][player['playerid']] = (player['jersey'].encode('utf-8'), '{0} {1}'.format(player['firstname'], player['lastname'])) team_details[-1] = {'name': 'Ball', 'players': {-1: ('', 'Ball')}}
For this particular event, there are 600 total moments.
moments = event['moments'] print len(moments) // 600
A single grid can’t have all the 600 frames, so we will be using multiple grids. We will use 40 grids for all the 600 frames, each having data for 15 frames.
import time import plotly.plotly as py from plotly.grid_objs import Grid, Column grids = [] for j in range(40): text_attrs = [] columns = [] for i in range(j*15, (j+1)*15): moment = moments[i] text_attrs = [] attr_dict = { '{0}x'.format(visitor['name']): [], '{0}y'.format(visitor['name']): [], '{0}x'.format(home['name']): [], '{0}y'.format(home['name']): [], '{0}x'.format('Ball'): [], '{0}y'.format('Ball'): [], } for obj in moment[5]: attr_dict['{0}x'.format(team_details[obj[0]]['name'])].append(obj[2]) attr_dict['{0}y'.format(team_details[obj[0]]['name'])].append(obj[3]) text_attrs.append(team_details[obj[0]]['players'][obj[1]][0]) columns.append(Column(attr_dict['{0}x'.format(visitor['name'])], '{0}x{1}'.format(visitor['name'], i))) columns.append(Column(attr_dict['{0}y'.format(visitor['name'])], '{0}y{1}'.format(visitor['name'], i))) columns.append(Column(attr_dict['{0}x'.format(home['name'])], '{0}x{1}'.format(home['name'], i))) columns.append(Column(attr_dict['{0}y'.format(home['name'])], '{0}y{1}'.format(home['name'], i))) columns.append(Column(attr_dict['Ballx'], '{0}x{1}'.format('Ball', i))) columns.append(Column(attr_dict['Bally'], '{0}y{1}'.format('Ball', i))) columns.append(Column(text_attrs[:1], '{0}text'.format('Ball'))) columns.append(Column(text_attrs[1:6], '{0}text'.format(home['name']))) columns.append(Column(text_attrs[6:], '{0}text'.format(visitor['name']))) _grid = Grid(columns) grids.append(_grid) py.grid_ops.upload(grids[j], 'nba_grid'+str(time.time()), auto_open=False)
The animation will have 3 moving traces (groups); for home team, visitor team, and ball. We will use different colors and size for them.
groups = [visitor['name'], home['name'], 'Ball'] colormap = {visitor['name']: 'yellow', home['name']: 'orange', 'Ball': 'red'} sizemap = {visitor['name']: 20, home['name']: 20, 'Ball': 10}
Now we will create the frames iterating over all the grids and groups.
grid_frames = [] for j in range(40): for i in range(j*15, (j+1)*15): frame_data = [] for g in groups: frame_data.append({ 'xsrc': grids[j].get_column_reference('{0}x{1}'.format(g, i)), 'ysrc': grids[j].get_column_reference('{0}y{1}'.format(g, i)), 'textsrc': grids[j].get_column_reference('{0}text'.format(g)), 'mode': 'markers+text', 'name': g, 'marker': {'size': sizemap[g], 'color': colormap[g]} }) grid_frames.append(frame_data)
Finally, we can create the animation using the previously created shapes as a background.
sliders_dict = { 'active': 0, 'yanchor': 'top', 'xanchor': 'left', 'currentvalue': { 'font': {'size': 15}, 'prefix': '<b>', 'suffix': '</b>', 'visible': True, 'xanchor': 'left' }, 'transition': {'duration': 0, 'easing': 'cubic-in-out'}, 'pad': {'b': 10, 't': 50}, 'len': 1.0, 'x': 0, 'y': 0, 'steps': [] } # create figure figure = { 'data': grid_frames[0], 'layout': {'title': '<b>Charlotte Hornets</b> vs <b>Toronto Raptors</b>', 'shapes': _shapes, 'xaxis': {'range': [0, 94], 'autorange': False, 'showgrid': False, 'showticklabels': False}, 'yaxis': {'range': [0, 50], 'autorange': False, 'showgrid': False, 'showticklabels': False}, 'updatemenus': [{ 'buttons': [ {'args': [None, {'frame': {'redraw': True, 'duration': 75}, 'fromcurrent': True}], 'label': 'Resume', 'method': 'animate'}, { 'args': [[None], {'frame': {'duration': 0, 'redraw': False}, 'mode': 'immediate', 'transition': {'duration': 0}}], 'label': 'Pause', 'method': 'animate' } ], 'pad': {'r': 10, 't': 87}, 'hovermode': 'closest', 'showactive': True, 'type': 'buttons', }] }, 'frames': [] } # slider marks marks = [0, 145, 175, 355, 599] # label on slider marks mark_labels = { 0: 'Start', 145: 'Lowry (7) misses shot', 175: 'Defensive rebound', 355: 'Batum (5) makes shot', 599: 'End'} # function to generate slider labels based on index def frame_name(index): if index in marks: return mark_labels[index] else: return index + 1 left_grids = grid_frames for i in range(len(left_grids)): figure['frames'].append({'data': left_grids[i], 'name': str(i+1)}) if i in marks: slider_step = {'args': [ [i+1], {'frame': {'duration': 0, 'redraw': False}, 'mode': 'immediate', 'fromcurrent': True, 'transition': {'duration': 0}} ], 'label': frame_name(i), 'method': 'animate'} sliders_dict['steps'].append(slider_step) figure['layout']['slider'] = { 'args': [ 'slider.value', { 'duration': 0, 'ease': 'cubic-in-out' } ], 'initialValue': '1', 'plotlycommand': 'animate', 'values': ['1', '146', '176', '356', '600'], 'visible': True } figure['layout']['sliders'] = [sliders_dict] py.icreate_animations(figure)
The source code for this animation is available as IPython Notebook on Plotly.