In [1]:
# Import necessary libraries for data manipulation, visualization, and analysis
import numpy as np
import pandas as pd
import matplotlib
from matplotlib.patches import FancyArrow
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
from matplotlib.colors import LinearSegmentedColormap
from PIL import Image
import urllib
import socceraction
import socceraction.spadl as spadl
In [2]:
# Load custom fonts for visualization
fe_regular = fm.FontEntry(
fname='/Users/davidegualano/Documents/Python FTBLData/SourceSansPro-Regular.ttf',
name='SourceSansPro-Regular'
)
fe_semibold = fm.FontEntry(
fname='/Users/davidegualano/Documents/Python FTBLData/SourceSansPro-SemiBold.ttf',
name='SourceSansPro-SemiBold'
)
# Insert both fonts into the font manager
fm.fontManager.ttflist.insert(0, fe_regular)
fm.fontManager.ttflist.insert(1, fe_semibold)
# Set the font family to the custom regular font
matplotlib.rcParams['font.family'] = fe_regular.name
In [3]:
#Selecting season to do it one time
season = '2425'
In [4]:
# Load datasets from CSV files
fb = pd.read_csv("teamsFOTMOB.csv", index_col=0)
players = pd.read_csv(f"players{season}.csv", index_col = 0)
actions = pd.read_csv(f"actions{season}.csv", index_col = 0)
In [5]:
# Adding infos to events
actions = spadl.add_names(actions)
In [6]:
# Merging the other dataframes to have all the infos we want on the events
dfb = (actions
.merge(players, how="left")
.merge(fb, how="left")
)
In [7]:
# Calculate movement distances and angles
dfb["beginning_distance"] = np.sqrt(np.square(105-dfb['start_x_a0']) + np.square(34-dfb['start_y_a0'])).round(2)
dfb["end_distance"] = np.sqrt(np.square(105-dfb['end_x_a0']) + np.square(34-dfb['end_y_a0'])).round(2)
dfb["length"] = dfb["end_distance"] - dfb["beginning_distance"]
dfb['length'] = dfb['length'].abs()
dfb["angle"] = np.arctan2(dfb["end_y_a0"] - dfb["start_y_a0"], dfb["end_x_a0"] - dfb["start_x_a0"])
dfb['angle_degrees'] = np.degrees(dfb['angle']) % 360
dfb['angle'] = dfb['angle'] % (2 * np.pi) # Ensure angles are between 0 and 2π
In [8]:
# Function to format season ID into a readable format
def format_season_id(season_id):
# Convert to integer if it's a float
season_id = int(season_id)
# Extract the last two digits of the year
start_year = str(season_id -1)[-2:]
# Calculate the end year
end_year = str(season_id)[-2:]
# Format as 20/21
formatted_season = f"{start_year}/{end_year}"
return formatted_season
In [9]:
# Selecting only passes
passes = dfb[dfb["type_name"] == 'pass']
In [10]:
#Get the whole list of players in the data
playerlist = passes['player_name'].unique().tolist()
cleaned_playerlist = [name for name in playerlist if pd.notna(name)]
cleaned_playerlist.sort()
In [11]:
from IPython.display import display, HTML
# Generate the HTML dropdown to easily search for players
options_html = ''.join([f'<option value="{name}">{name}</option>' for name in cleaned_playerlist])
dropdown_html = f"""
<input list="players" id="dropdown" oninput="handleInput()" placeholder="Choose Someone">
<datalist id="players">
{options_html}
</datalist>
<p id="output"></p>
<script>
function handleInput() {{
var input = document.getElementById("dropdown").value;
var output = document.getElementById("output");
output.innerHTML = "Selected: " + input;
}}
</script>
"""
# Display the dropdown
display(HTML(dropdown_html))
In [12]:
# Select a specific player
P0 = passes[passes["player_name"] == 'Angelo Stiller']
# Print unique team names and ids for the player
print(P0.team_name.unique())
print(P0.fotmob_id.unique())
['Stuttgart'] [10269]
In [13]:
# Define team information for that player
P1 = P0[P0["team_name"] == "Stuttgart"]
teamid = 10269
In [14]:
# Formatting the season info for visualization purposes
P1['formatted_season'] = P1['season_id'].apply(format_season_id)
P1.season_id.unique()
Out[14]:
array([2425.])
In [15]:
# Selecting season just to be sure
P = P1[P1["season_id"] == 2425.]
In [16]:
def calculate_segments_per_ring(rings, blocks_in_first_ring, radius_step):
"""
Calculate the appropriate number of angular segments for each ring in the sonar plot
to maintain a balanced visual appearance.
Parameters:
-----------
rings : int
Total number of concentric rings in the visualization.
blocks_in_first_ring : int
Number of angular segments to use in the innermost ring.
radius_step : float
The radial distance between consecutive rings.
Returns:
--------
segments_per_ring : numpy.ndarray
Array of integers specifying how many angular segments to use for each ring.
Notes:
------
This function scales the number of segments based on ring area to maintain
roughly consistent segment areas throughout the visualization.
"""
# Create an array of radii for each ring boundary (from center to outer edge)
radii = np.arange(0, radius_step * (rings + 1), radius_step)
# Calculate the area difference between consecutive rings
# Since area of a circle is π*r², the difference in r² is proportional to area
area_differences = np.diff(radii**2)
# Scale the number of segments proportionally to the ring area
# The first ring has blocks_in_first_ring segments
# Outer rings get proportionally more segments based on their area
segments_per_ring = np.round(area_differences / (area_differences[0] / blocks_in_first_ring)).astype(int)
return segments_per_ring
In [17]:
def plot_normalized_sonar(passes, rings=10, blocks_in_first_ring=4, max_pass_length=None,
all_passes_data=None, # Add this parameter
title=None, subtitle=None, endnote=None,
title_fontsize=25, subtitle_fontsize=15, endnote_fontsize=10,
title_pos=1.05, subtitle_pos=1.02, endnote_pos=-0.02,
title_fontfamily='SourceSansPro-SemiBold',
sonar_background_color='#D7D1CF',
edge_color='#777777',
edge_linewidth=0.5):
"""
Plot a pass sonar visualization with a normalized distance scale and customizable text elements.
Parameters:
passes: DataFrame containing pass information with 'length' and 'angle' columns
rings: Number of distance rings to display
blocks_in_first_ring: Number of angle segments in the innermost ring
max_pass_length: Maximum pass length to display (default: automatically determined)
sonar_background_color: Color for the sonar plot background only (default: #D7D1CF)
edge_color: Color for the edges of blocks (default: #777777)
edge_linewidth: Width of the edge lines (default: 0.5)
Text parameters:
title, subtitle, endnote: Text content for visualization
title_fontsize, subtitle_fontsize, endnote_fontsize: Font sizes for text elements
title_pos, subtitle_pos, endnote_pos: Vertical positions for text elements (in axes coordinates)
title_fontfamily: Font family to use for the title (default: SourceSansPro-SemiBold)
"""
if max_pass_length is None:
if all_passes_data is not None:
# Use ALL passes for standardized distance if provided
max_pass_length = np.percentile(all_passes_data['length'], 99)
else:
# Fallback to player-specific data if all_passes_data not provided
max_pass_length = np.percentile(passes['length'], 99)
# Calculate the radius step
radius_step = max_pass_length / rings
# Calculate the number of segments per ring
segments_per_ring = calculate_segments_per_ring(rings, blocks_in_first_ring, radius_step)
# Create figure and axis
fig, ax = plt.subplots(figsize=(15, 8), subplot_kw={'projection': 'polar'})
# Scaling factors for each ring
scaling_factors = np.linspace(3, 1.5, rings)
# Calculate total number of passes for normalization
total_passes = len(passes)
for i in range(rings):
segments = segments_per_ring[i]
# Calculate the lower and upper length bounds for this ring
min_length = i * radius_step
max_length = (i + 1) * radius_step
# Filter passes within this length range
ring_passes = passes[(passes['length'] >= min_length) &
(passes['length'] < max_length)]
# Check if there are any passes in this ring
if ring_passes.shape[0] == 0:
continue
# Divide the ring into angular segments
for j in range(segments):
angle_min = j * 2 * np.pi / segments
angle_max = (j + 1) * 2 * np.pi / segments
angle_width = angle_max - angle_min
# Filter passes within this angular segment
segment_passes = ring_passes[(ring_passes['angle'] >= angle_min) &
(ring_passes['angle'] < angle_max)]
if len(segment_passes) > 0:
# Adjust thickness based on the number of passes relative to ALL passes
# and scaling factor for the ring
thickness_adjustment = scaling_factors[i] * np.sqrt(len(segment_passes) / total_passes)
# Calculate inner radius with thickness adjustment
inner_radius = max(max_length - (radius_step * thickness_adjustment),
min_length + radius_step * 0.1)
# Calculate success rate for color
success_rate = (segment_passes['result_name'] == 'success').mean()
# Use the YlOrRd colormap (yellow to red)
color = plt.cm.YlOrRd(success_rate)
# Plot the segment with customized edge color and line width
ax.bar(x=angle_min, height=max_length - inner_radius,
width=angle_width, bottom=inner_radius,
color=color, edgecolor=edge_color, linewidth=edge_linewidth)
# Customize plot appearance
ax.set_theta_zero_location('N') # 0 degrees at the top (North)
ax.set_theta_direction(1) # Counterclockwise direction
ax.set_ylim(0, max_pass_length) # Set the radial limit
ax.grid(False) # Remove grid lines
ax.set_facecolor(sonar_background_color) # Set background color for sonar only
ax.set_yticklabels([]) # Remove radial ticks
ax.set_xticklabels([]) # Remove angular ticks
# Do NOT set the figure background color - leave it as default
# fig.patch.set_facecolor() line is removed
# Add direction arrow
arrow_ax = fig.add_axes([0.78, 0.25, 0.03, 0.5]) # Position [left, bottom, width, height]
arrow_ax.add_patch(FancyArrow(0.5, 0.1, 0, 0.8, width=0.1, head_width=0.3, head_length=0.1, color='#000000'))
arrow_ax.set_xlim(0, 1)
arrow_ax.set_ylim(0, 1)
arrow_ax.axis('off') # Hide the axis
arrow_ax.patch.set_alpha(0) # Make the arrow axis background transparent
# Add title with semibold font
if title:
plt.text(0.5, title_pos, title, ha='center', va='bottom', transform=ax.transAxes,
color='#000000', fontsize=title_fontsize,
fontfamily=title_fontfamily) # Use specified font family for title
# Add subtitle (using default font)
if subtitle:
plt.text(0.5, subtitle_pos, subtitle, ha='center', va='bottom', transform=ax.transAxes,
color='#000000', fontsize=subtitle_fontsize)
# Add endnote (using default font)
if endnote:
plt.text(0.5, endnote_pos, endnote, ha='center', va='top', transform=ax.transAxes,
color='#000000', fontsize=endnote_fontsize)
return fig, ax
In [18]:
# endnote creation function
def create_endnote_with_ring_explanation(all_passes_data, rings=10):
"""
Create an endnote that explains the ring distances in the visualization
based on ALL passes in the dataset (not player-specific).
Parameters:
all_passes_data: DataFrame containing ALL pass data with 'length' column
rings: Number of rings in the visualization (default: 10)
Returns:
Formatted endnote text explaining the visualization including ring distances
"""
# Calculate the 99th percentile of ALL pass lengths
max_pass_length = np.percentile(all_passes_data['length'], 99)
# Calculate radius step
radius_step = max_pass_length / rings
# Format values to one decimal place with f-strings
radius_step_formatted = f"{radius_step:.1f}"
max_length_formatted = f"{max_pass_length:.1f}"
endnote = (
"X: @gualanodavide | Bluesky: @gualanodavide.bsky.social | Linkedin: www.linkedin.com/in/davide-gualano-a2454b187 | "
"Newsletter: the-cutback.beehiiv.com | idea: @thecomeonman\n"
"Each block represents passes within a specific direction and distance range, with the color scaled from yellow to red,\n"
f"indicating the success rate from 0% (yellow) to 100% (red). The thickness corresponds to the number of passes.\n"
f"The visualization uses {rings} concentric rings, each representing ~{radius_step_formatted}m distance bands from "
f"0m (center) to {max_length_formatted}m (outer edge)."
)
return endnote, max_pass_length # Return both the text and the calculated max length
In [19]:
# Calculate pass success statistics
A = P.groupby(["result_name"], observed=True).size().reset_index(name='count')
A['total'] = P.result_name.count()
A['percent'] = ((A['count']*100) /A['total']).round(1)
In [20]:
# Let's see the resumed dataframe
A
Out[20]:
result_name | count | total | percent | |
---|---|---|---|---|
0 | fail | 257 | 2547 | 10.1 |
1 | offside | 6 | 2547 | 0.2 |
2 | success | 2284 | 2547 | 89.7 |
In [21]:
nrows = P.shape[0]
# Loop through each row in the dataframe
for y in range(nrows):
titley = P['player_name'].iloc[y]
titlex = P['season_id'].iloc[y]
# Calculate standardized endnote and max_pass_length ONCE for ALL passes
endnote, all_passes_max_length = create_endnote_with_ring_explanation(passes, rings=10)
# Generate the plot with custom font sizes, positions, and semibold title
fig, ax = plot_normalized_sonar(
P,
all_passes_data=passes,
# Content parameters
title=f"{titley}'s pass sonar for {P.team_name.iloc[y]}",
subtitle=f"Passes : {P['type_name'].count()} | Success rate : {(((P['result_name'] == 'success').mean())*100).round(1)}% | {', '.join(P['competition_id'].unique())} {P.formatted_season.iloc[y]}",
endnote=endnote,
# Font size parameters
title_fontsize=22,
subtitle_fontsize=12,
endnote_fontsize=7,
# Position parameters
title_pos=1.08,
subtitle_pos=1.04,
endnote_pos=-0.04,
# Use the semibold font for the title
title_fontfamily='SourceSansPro-SemiBold',
# Sonar Background color
sonar_background_color='#000000',
# Edge customization
edge_color='#000000', # Lighter gray edges
edge_linewidth=0.3
)
# Add team logo on right side near the arrow
# Define the position for the logo (in figure coordinates)
logo_position = [0.72, 0.85, 0.15, 0.15] # [left, bottom, width, height]
# Create a new axis for the logo
logo_ax = fig.add_axes(logo_position, frameon=False)
# Look up the team's fotmob_id from your teams dataframe
try:
# Get team ID - adjust this to your actual data structure
team_id = teamid
# Construct the URL for the team logo
fotmob_url = 'https://images.fotmob.com/image_resources/logo/teamlogo/'
logo_url = f"{fotmob_url}{team_id}.png"
# Load and display the logo
team_logo = Image.open(urllib.request.urlopen(logo_url)).convert('RGBA')
logo_ax.imshow(team_logo)
logo_ax.axis('off') # Turn off axis
except Exception as e:
print(f"Error loading logo for {team_name}: {e}")
# Continue with plot creation even if logo loading fails
# Save the plot immediately after generation
fig.savefig(f'{titley}-Passsonar-{titlex}.png', dpi=500, facecolor="#D7D1CF", bbox_inches="tight")
# Show the plot (you may want to comment this out if you're saving many plots)
plt.show()
In [ ]:
In [ ]:
In [ ]: