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()
No description has been provided for this image
In [ ]:
 
In [ ]:
 
In [ ]: