Creating Shot Charts

Beginner 10 min read 1 views Nov 27, 2025
# Shot Charts Basics ## Introduction Shot charts are fundamental visualizations in basketball analytics that display the spatial distribution of shot attempts and their outcomes on a representation of the basketball court. They provide insights into shooting patterns, player tendencies, offensive strategies, and defensive schemes. ## What Are Shot Charts? Shot charts are visual representations that map where shots are taken on a basketball court and whether they were made or missed. They serve multiple purposes: - **Player Evaluation**: Identify shooting strengths and weaknesses - **Scouting**: Understand opponent tendencies and preferences - **Strategy Development**: Design offensive plays and defensive schemes - **Performance Tracking**: Monitor improvement over time - **Fan Engagement**: Create compelling visual narratives ### Key Components 1. **Court Representation**: A scaled diagram of the basketball court 2. **Shot Locations**: X,Y coordinates marking where shots were taken 3. **Shot Outcomes**: Visual indicators (color, size, shape) showing makes/misses 4. **Shot Types**: Different markers for 2-pointers, 3-pointers, free throws 5. **Contextual Data**: Shot distance, time remaining, defender distance ## Court Coordinate Systems Understanding coordinate systems is crucial for creating accurate shot charts. ### NBA Coordinate System The NBA uses a coordinate system where: - **Origin (0,0)**: Center of the basket - **X-axis**: Horizontal (left-right), ranges from -250 to 250 (in inches/10) - **Y-axis**: Vertical (baseline-baseline), ranges from -47.5 to 892.5 (in inches/10) - **Units**: Tenths of feet (1 unit = 1.2 inches) ``` Court Dimensions (in coordinate units): - Court length: 940 units (94 feet) - Court width: 500 units (50 feet) - 3-point line: 237.5 units (23.75 feet) from basket at top - Paint width: 160 units (16 feet) - Free throw line: 142.5 units (14.25 feet) from baseline ``` ### Converting Coordinates **From raw coordinates to visualization:** ``` visual_x = (x + 250) / 500 * court_width visual_y = y / 940 * court_length ``` **Distance from basket:** ``` distance = sqrt(x^2 + y^2) ``` **Shot angle:** ``` angle = atan2(y, x) ``` ## Visualization Techniques ### 1. Scatter Plots The simplest form of shot chart plotting individual shots as points. **Advantages:** - Shows exact shot locations - Easy to distinguish makes vs. misses - Good for small sample sizes - Preserves individual shot data **Disadvantages:** - Cluttered with large datasets - Difficult to identify patterns with many shots - Overlapping points obscure density **Best Use Cases:** - Single game analysis - Small sample sizes (< 100 shots) - When exact locations matter ### 2. Hexbin (Hexagonal Binning) Divides the court into hexagonal bins and aggregates shots within each bin. **Advantages:** - Handles large datasets effectively - Shows shot density clearly - Reduces visual clutter - Hexagons tile perfectly without gaps **Disadvantages:** - Loses individual shot details - Bin size affects interpretation - Less intuitive than scatter plots **Best Use Cases:** - Season-long analysis - Large sample sizes (> 500 shots) - Identifying shooting zones **Key Parameters:** - **Gridsize**: Number of hexagons (10-30 typical) - **Color mapping**: Frequency or shooting percentage - **Bin calculation**: Count, percentage, or efficiency ### 3. Heat Maps Continuous color gradients representing shot frequency or efficiency. **Advantages:** - Smooth, visually appealing - Shows gradual transitions in shooting - Good for presentations - Easy to interpret at a glance **Disadvantages:** - Can be misleading with small samples - Smoothing may hide important details - Computationally intensive **Best Use Cases:** - Public-facing visualizations - Comparing multiple players - Marketing and media **Implementation Techniques:** - **Kernel Density Estimation (KDE)**: Smooth probability density - **Gaussian blur**: Apply smoothing to binned data - **Interpolation**: Fill gaps between data points ### 4. Zone-Based Analysis Divides the court into predefined zones based on basketball strategy. **Common Zone Definitions:** 1. **Simple Zones**: - Paint (restricted area) - Mid-range (2-point outside paint) - 3-point (beyond arc) 2. **Detailed Zones** (6-8 zones): - Restricted area - Paint (non-restricted) - Short mid-range (< 16 feet) - Long mid-range (16-23 feet) - Corner 3 (both sides) - Above-the-break 3 3. **Strategic Zones** (14+ zones): - Separate left/right sides - Include wing, top, elbow positions - Distinguish baseline vs. mid-range **Zone Analysis Metrics:** - Attempts per zone - Field goal percentage by zone - Points per shot by zone - Shot distribution (% of total attempts) - Efficiency metrics (eFG%, True Shooting %) ## Interpreting Shot Charts ### Reading Basic Shot Charts **What to Look For:** 1. **Hot Zones**: Areas with high frequency and/or high efficiency 2. **Cold Zones**: Areas with low efficiency or avoidance 3. **Shot Distribution**: Spread vs. concentration of attempts 4. **Range**: Maximum distance from basket 5. **Asymmetry**: Left/right or top/bottom preferences ### Advanced Interpretation **Shot Selection Quality:** - Compare actual FG% to expected FG% by zone - Evaluate shot distribution relative to league averages - Assess balance between volume and efficiency **Matchup Analysis:** - Compare player tendencies to defensive coverage - Identify exploitable mismatches - Design defensive game plans **Temporal Patterns:** - Early vs. late game shot selection - Shot location changes when leading/trailing - Clutch shooting zones ### Common Pitfalls 1. **Sample Size**: Small samples lead to unreliable patterns 2. **Context Ignorance**: Not accounting for defense, game situation 3. **Overcomplexity**: Too many colors/bins obscures insights 4. **Scale Issues**: Improper color scaling misleads interpretation 5. **Selection Bias**: Filtered data may not represent true tendencies ## Python Implementation ### Using matplotlib and nba_api ```python import numpy as np import pandas as pd import matplotlib.pyplot as plt from matplotlib.patches import Circle, Rectangle, Arc, Polygon from nba_api.stats.endpoints import shotchartdetail from nba_api.stats.static import players, teams # Function to draw NBA court def draw_court(ax=None, color='black', lw=2, outer_lines=False): """ Draw an NBA basketball court. Parameters: ----------- ax : matplotlib axes object color : court line color lw : line width outer_lines : include outer court boundaries """ if ax is None: ax = plt.gca() # Basketball hoop hoop = Circle((0, 0), radius=7.5, linewidth=lw, color=color, fill=False) # Backboard backboard = Rectangle((-30, -7.5), 60, -1, linewidth=lw, color=color) # Paint/Lane outer_box = Rectangle((-80, -47.5), 160, 190, linewidth=lw, color=color, fill=False) inner_box = Rectangle((-60, -47.5), 120, 190, linewidth=lw, color=color, fill=False) # Free throw circle top_free_throw = Arc((0, 142.5), 120, 120, theta1=0, theta2=180, linewidth=lw, color=color, fill=False) bottom_free_throw = Arc((0, 142.5), 120, 120, theta1=180, theta2=0, linewidth=lw, color=color, linestyle='dashed') # Restricted area restricted = Arc((0, 0), 80, 80, theta1=0, theta2=180, linewidth=lw, color=color) # Three point line corner_three_a = Rectangle((-220, -47.5), 0, 140, linewidth=lw, color=color) corner_three_b = Rectangle((220, -47.5), 0, 140, linewidth=lw, color=color) three_arc = Arc((0, 0), 475, 475, theta1=22, theta2=158, linewidth=lw, color=color) # Center court center_outer = Arc((0, 422.5), 120, 120, theta1=180, theta2=0, linewidth=lw, color=color) center_inner = Arc((0, 422.5), 40, 40, theta1=180, theta2=0, linewidth=lw, color=color) # Court elements list court_elements = [hoop, backboard, outer_box, inner_box, top_free_throw, bottom_free_throw, restricted, corner_three_a, corner_three_b, three_arc, center_outer, center_inner] if outer_lines: outer = Rectangle((-250, -47.5), 500, 470, linewidth=lw, color=color, fill=False) court_elements.append(outer) for element in court_elements: ax.add_patch(element) return ax # Fetch shot chart data def get_player_shot_data(player_name, season='2023-24'): """ Fetch shot chart data for a player. Parameters: ----------- player_name : str Player's full name season : str NBA season (format: 'YYYY-YY') Returns: -------- DataFrame with shot data """ # Find player ID player_dict = players.get_players() player = [p for p in player_dict if p['full_name'] == player_name][0] player_id = player['id'] # Fetch shot data shot_data = shotchartdetail.ShotChartDetail( team_id=0, player_id=player_id, season_nullable=season, season_type_all_star='Regular Season', context_measure_simple='FGA' ) shots_df = shot_data.get_data_frames()[0] return shots_df # 1. Basic Scatter Plot Shot Chart def create_scatter_shot_chart(shots_df, player_name, save_path=None): """ Create a basic scatter plot shot chart. """ fig, ax = plt.subplots(figsize=(12, 11)) # Draw court draw_court(ax, outer_lines=True) # Plot shots made_shots = shots_df[shots_df['SHOT_MADE_FLAG'] == 1] missed_shots = shots_df[shots_df['SHOT_MADE_FLAG'] == 0] ax.scatter(missed_shots['LOC_X'], missed_shots['LOC_Y'], c='red', marker='x', s=50, alpha=0.5, label='Miss') ax.scatter(made_shots['LOC_X'], made_shots['LOC_Y'], c='green', marker='o', s=50, alpha=0.5, label='Make') # Calculate shooting percentage fg_pct = (len(made_shots) / len(shots_df)) * 100 # Styling ax.set_xlim(-250, 250) ax.set_ylim(-47.5, 422.5) ax.set_aspect('equal') ax.axis('off') ax.legend(loc='upper right') ax.set_title(f'{player_name} Shot Chart\n' f'FG: {len(made_shots)}/{len(shots_df)} ({fg_pct:.1f}%)', fontsize=16, fontweight='bold') plt.tight_layout() if save_path: plt.savefig(save_path, dpi=300, bbox_inches='tight') return fig, ax # 2. Hexbin Shot Chart def create_hexbin_shot_chart(shots_df, player_name, save_path=None): """ Create a hexbin shot chart showing shot frequency. """ fig, ax = plt.subplots(figsize=(12, 11)) # Draw court draw_court(ax, color='white', lw=2, outer_lines=True) # Create hexbin hexbin = ax.hexbin(shots_df['LOC_X'], shots_df['LOC_Y'], gridsize=25, cmap='YlOrRd', alpha=0.8, edgecolors='black', linewidths=0.5) # Add colorbar cbar = plt.colorbar(hexbin, ax=ax, orientation='horizontal', pad=0.05, aspect=30) cbar.set_label('Shot Attempts', fontsize=12, fontweight='bold') # Styling ax.set_xlim(-250, 250) ax.set_ylim(-47.5, 422.5) ax.set_aspect('equal') ax.set_facecolor('#f0f0f0') ax.axis('off') ax.set_title(f'{player_name} Shot Frequency Hexbin\n' f'Total Shots: {len(shots_df)}', fontsize=16, fontweight='bold') plt.tight_layout() if save_path: plt.savefig(save_path, dpi=300, bbox_inches='tight') return fig, ax # 3. Shooting Percentage Hexbin def create_accuracy_hexbin(shots_df, player_name, min_shots=5, save_path=None): """ Create a hexbin showing shooting percentage by location. """ fig, ax = plt.subplots(figsize=(12, 11)) # Draw court draw_court(ax, color='black', lw=2, outer_lines=True) # Calculate shooting percentage for hexbins x = shots_df['LOC_X'].values y = shots_df['LOC_Y'].values made = shots_df['SHOT_MADE_FLAG'].values # Create custom hexbin with FG% hexbin = ax.hexbin(x, y, C=made, gridsize=20, reduce_C_function=np.mean, cmap='RdYlGn', alpha=0.8, edgecolors='black', linewidths=0.5, vmin=0, vmax=1, mincnt=min_shots) # Add colorbar cbar = plt.colorbar(hexbin, ax=ax, orientation='horizontal', pad=0.05, aspect=30) cbar.set_label('Field Goal %', fontsize=12, fontweight='bold') cbar.set_ticks([0, 0.25, 0.5, 0.75, 1.0]) cbar.set_ticklabels(['0%', '25%', '50%', '75%', '100%']) # Styling ax.set_xlim(-250, 250) ax.set_ylim(-47.5, 422.5) ax.set_aspect('equal') ax.set_facecolor('#cccccc') ax.axis('off') ax.set_title(f'{player_name} Shooting Accuracy by Location\n' f'(Minimum {min_shots} attempts per hexagon)', fontsize=16, fontweight='bold') plt.tight_layout() if save_path: plt.savefig(save_path, dpi=300, bbox_inches='tight') return fig, ax # 4. Heat Map with KDE from scipy.stats import gaussian_kde def create_heatmap_shot_chart(shots_df, player_name, save_path=None): """ Create a smooth heat map using Kernel Density Estimation. """ fig, ax = plt.subplots(figsize=(12, 11)) # Prepare data x = shots_df['LOC_X'].values y = shots_df['LOC_Y'].values # Create grid xi = np.linspace(-250, 250, 200) yi = np.linspace(-47.5, 422.5, 200) Xi, Yi = np.meshgrid(xi, yi) # Calculate KDE positions = np.vstack([Xi.ravel(), Yi.ravel()]) values = np.vstack([x, y]) kernel = gaussian_kde(values) Zi = np.reshape(kernel(positions).T, Xi.shape) # Draw heat map im = ax.contourf(Xi, Yi, Zi, levels=20, cmap='YlOrRd', alpha=0.7) # Draw court on top draw_court(ax, color='black', lw=2, outer_lines=True) # Add colorbar cbar = plt.colorbar(im, ax=ax, orientation='horizontal', pad=0.05, aspect=30) cbar.set_label('Shot Density', fontsize=12, fontweight='bold') # Styling ax.set_xlim(-250, 250) ax.set_ylim(-47.5, 422.5) ax.set_aspect('equal') ax.axis('off') ax.set_title(f'{player_name} Shot Heat Map\n' f'Total Shots: {len(shots_df)}', fontsize=16, fontweight='bold') plt.tight_layout() if save_path: plt.savefig(save_path, dpi=300, bbox_inches='tight') return fig, ax # 5. Zone-Based Analysis def create_zone_analysis(shots_df, player_name): """ Analyze shooting by predefined court zones. """ def assign_zone(row): x = row['LOC_X'] y = row['LOC_Y'] dist = np.sqrt(x**2 + y**2) # Restricted area if dist <= 40: return 'Restricted Area' # Paint (non-restricted) elif abs(x) <= 80 and y <= 142.5 and dist > 40: return 'Paint' # Short mid-range elif dist <= 160: return 'Short Mid-Range' # Long mid-range elif dist <= 237.5: return 'Long Mid-Range' # Corner 3 elif abs(x) >= 220 and y <= 92.5: return 'Corner 3' # Above-the-break 3 else: return 'Above-Break 3' # Assign zones shots_df['ZONE'] = shots_df.apply(assign_zone, axis=1) # Calculate statistics by zone zone_stats = shots_df.groupby('ZONE').agg({ 'SHOT_MADE_FLAG': ['sum', 'count', 'mean'] }).round(3) zone_stats.columns = ['Makes', 'Attempts', 'FG%'] zone_stats['Points_Per_Shot'] = zone_stats.apply( lambda row: row['FG%'] * (3 if '3' in row.name else 2), axis=1 ).round(3) zone_stats['Frequency'] = (zone_stats['Attempts'] / zone_stats['Attempts'].sum() * 100).round(1) # Sort by frequency zone_stats = zone_stats.sort_values('Attempts', ascending=False) print(f"\n{player_name} - Zone Analysis") print("=" * 80) print(zone_stats.to_string()) return zone_stats # Example usage if __name__ == '__main__': # Fetch data player_name = 'Stephen Curry' shots_df = get_player_shot_data(player_name, season='2023-24') # Create visualizations create_scatter_shot_chart(shots_df, player_name, 'curry_scatter.png') create_hexbin_shot_chart(shots_df, player_name, 'curry_hexbin.png') create_accuracy_hexbin(shots_df, player_name, 'curry_accuracy.png') create_heatmap_shot_chart(shots_df, player_name, 'curry_heatmap.png') # Zone analysis zone_stats = create_zone_analysis(shots_df, player_name) ``` ### Advanced: Comparative Shot Charts ```python def create_comparison_shot_chart(shots_df1, name1, shots_df2, name2): """ Compare two players' shot distributions side by side. """ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 11)) # Player 1 draw_court(ax1, color='black', lw=2, outer_lines=True) hexbin1 = ax1.hexbin(shots_df1['LOC_X'], shots_df1['LOC_Y'], C=shots_df1['SHOT_MADE_FLAG'], gridsize=20, reduce_C_function=np.mean, cmap='RdYlGn', alpha=0.8, vmin=0, vmax=1, edgecolors='black', linewidths=0.5) ax1.set_xlim(-250, 250) ax1.set_ylim(-47.5, 422.5) ax1.set_aspect('equal') ax1.axis('off') ax1.set_title(f'{name1}\nFG%: {shots_df1["SHOT_MADE_FLAG"].mean():.1%}', fontsize=14, fontweight='bold') # Player 2 draw_court(ax2, color='black', lw=2, outer_lines=True) hexbin2 = ax2.hexbin(shots_df2['LOC_X'], shots_df2['LOC_Y'], C=shots_df2['SHOT_MADE_FLAG'], gridsize=20, reduce_C_function=np.mean, cmap='RdYlGn', alpha=0.8, vmin=0, vmax=1, edgecolors='black', linewidths=0.5) ax2.set_xlim(-250, 250) ax2.set_ylim(-47.5, 422.5) ax2.set_aspect('equal') ax2.axis('off') ax2.set_title(f'{name2}\nFG%: {shots_df2["SHOT_MADE_FLAG"].mean():.1%}', fontsize=14, fontweight='bold') # Shared colorbar fig.subplots_adjust(right=0.9) cbar_ax = fig.add_axes([0.92, 0.15, 0.02, 0.7]) cbar = fig.colorbar(hexbin2, cax=cbar_ax) cbar.set_label('Field Goal %', fontsize=12, fontweight='bold') plt.suptitle('Shot Chart Comparison', fontsize=18, fontweight='bold') return fig ``` ## R Implementation ### Using ggplot2 and hoopR ```r # Load required libraries library(hoopR) library(ggplot2) library(dplyr) library(tidyr) library(hexbin) # Function to draw NBA court draw_nba_court <- function() { # Court dimensions court_theme <- theme_minimal() + theme( panel.grid = element_blank(), axis.text = element_blank(), axis.title = element_blank(), axis.ticks = element_blank(), panel.background = element_rect(fill = '#f0f0f0'), plot.background = element_rect(fill = 'white'), legend.position = 'bottom' ) # Create court lines court_lines <- list( # Hoop geom_circle(aes(x0 = 0, y0 = 0, r = 7.5), color = 'black', linewidth = 1, inherit.aes = FALSE), # Backboard geom_segment(aes(x = -30, y = -7.5, xend = 30, yend = -7.5), linewidth = 1, color = 'black'), # Paint (outer) geom_rect(aes(xmin = -80, xmax = 80, ymin = -47.5, ymax = 142.5), color = 'black', fill = NA, linewidth = 1), # Paint (inner) geom_rect(aes(xmin = -60, xmax = 60, ymin = -47.5, ymax = 142.5), color = 'black', fill = NA, linewidth = 1), # Free throw circle (top) ggforce::geom_arc(aes(x0 = 0, y0 = 142.5, r = 60, start = 0, end = pi), color = 'black', linewidth = 1), # Free throw circle (bottom, dashed) ggforce::geom_arc(aes(x0 = 0, y0 = 142.5, r = 60, start = pi, end = 2*pi), color = 'black', linewidth = 1, linetype = 'dashed'), # Restricted area ggforce::geom_arc(aes(x0 = 0, y0 = 0, r = 40, start = 0, end = pi), color = 'black', linewidth = 1), # Three-point line (arc) ggforce::geom_arc(aes(x0 = 0, y0 = 0, r = 237.5, start = 0.386, end = 2.756), color = 'black', linewidth = 1), # Three-point line (corners) geom_segment(aes(x = -220, y = -47.5, xend = -220, yend = 92.5), linewidth = 1, color = 'black'), geom_segment(aes(x = 220, y = -47.5, xend = 220, yend = 92.5), linewidth = 1, color = 'black'), # Outer boundaries geom_rect(aes(xmin = -250, xmax = 250, ymin = -47.5, ymax = 422.5), color = 'black', fill = NA, linewidth = 1.5), # Set limits coord_fixed(ratio = 1, xlim = c(-250, 250), ylim = c(-47.5, 422.5)) ) return(list(court_theme, court_lines)) } # Helper function for circle geom_circle <- function(mapping = NULL, data = NULL, ...) { ggforce::geom_circle(mapping = mapping, data = data, ...) } # Fetch shot chart data get_nba_shot_data <- function(player_name, season = 2024) { # Get player info players <- hoopR::nba_commonallplayers(season = season) player_info <- players %>% filter(display_first_last == player_name) if(nrow(player_info) == 0) { stop("Player not found") } player_id <- player_info$person_id[1] # Get shot chart data shots <- hoopR::nba_shotchartdetail( player_id = player_id, season = paste0(season - 1, "-", substr(season, 3, 4)) ) return(shots$Shot_Chart_Detail) } # 1. Basic Scatter Plot Shot Chart create_scatter_shot_chart <- function(shots_data, player_name) { # Prepare data shots <- shots_data %>% mutate( shot_result = ifelse(shot_made_flag == 1, 'Made', 'Missed'), x = loc_x, y = loc_y ) # Calculate statistics fg_pct <- mean(shots$shot_made_flag) * 100 total_shots <- nrow(shots) # Create plot p <- ggplot() + draw_nba_court() + geom_point(data = shots, aes(x = x, y = y, color = shot_result, shape = shot_result), size = 3, alpha = 0.6) + scale_color_manual(values = c('Made' = '#00AA00', 'Missed' = '#FF0000')) + scale_shape_manual(values = c('Made' = 16, 'Missed' = 4)) + labs( title = paste(player_name, 'Shot Chart'), subtitle = sprintf('FG: %d/%d (%.1f%%)', sum(shots$shot_made_flag), total_shots, fg_pct), color = 'Result', shape = 'Result' ) + theme( plot.title = element_text(size = 18, face = 'bold', hjust = 0.5), plot.subtitle = element_text(size = 14, hjust = 0.5), legend.title = element_text(size = 12, face = 'bold'), legend.text = element_text(size = 10) ) return(p) } # 2. Hexbin Frequency Shot Chart create_hexbin_frequency <- function(shots_data, player_name) { shots <- shots_data %>% mutate(x = loc_x, y = loc_y) p <- ggplot(shots, aes(x = x, y = y)) + draw_nba_court() + geom_hex(bins = 25, color = 'black', linewidth = 0.2) + scale_fill_gradient( low = '#FFFF99', high = '#CC0000', name = 'Shot\nAttempts', guide = guide_colorbar( barwidth = 15, barheight = 1, title.position = 'top', title.hjust = 0.5 ) ) + labs( title = paste(player_name, 'Shot Frequency Hexbin'), subtitle = paste('Total Shots:', nrow(shots)) ) + theme( plot.title = element_text(size = 18, face = 'bold', hjust = 0.5), plot.subtitle = element_text(size = 14, hjust = 0.5), legend.position = 'bottom' ) return(p) } # 3. Shooting Accuracy Hexbin create_hexbin_accuracy <- function(shots_data, player_name, min_shots = 5) { shots <- shots_data %>% mutate(x = loc_x, y = loc_y) # Create hexbins with custom aggregation p <- ggplot(shots, aes(x = x, y = y, z = shot_made_flag)) + draw_nba_court() + stat_summary_hex( bins = 20, fun = function(z) { if(length(z) >= min_shots) mean(z) else NA }, color = 'black', linewidth = 0.2 ) + scale_fill_gradient2( low = '#FF0000', mid = '#FFFF00', high = '#00AA00', midpoint = 0.5, limits = c(0, 1), na.value = '#CCCCCC', name = 'FG%', labels = scales::percent_format(), guide = guide_colorbar( barwidth = 15, barheight = 1, title.position = 'top', title.hjust = 0.5 ) ) + labs( title = paste(player_name, 'Shooting Accuracy by Location'), subtitle = sprintf('(Minimum %d attempts per hexagon)', min_shots) ) + theme( plot.title = element_text(size = 18, face = 'bold', hjust = 0.5), plot.subtitle = element_text(size = 14, hjust = 0.5), legend.position = 'bottom' ) return(p) } # 4. Density Heat Map create_density_heatmap <- function(shots_data, player_name) { shots <- shots_data %>% mutate(x = loc_x, y = loc_y) p <- ggplot(shots, aes(x = x, y = y)) + draw_nba_court() + stat_density_2d( aes(fill = after_stat(level)), geom = 'polygon', alpha = 0.7, bins = 20 ) + scale_fill_gradient( low = '#FFFF99', high = '#CC0000', name = 'Shot\nDensity', guide = guide_colorbar( barwidth = 15, barheight = 1, title.position = 'top', title.hjust = 0.5 ) ) + labs( title = paste(player_name, 'Shot Density Heat Map'), subtitle = paste('Total Shots:', nrow(shots)) ) + theme( plot.title = element_text(size = 18, face = 'bold', hjust = 0.5), plot.subtitle = element_text(size = 14, hjust = 0.5), legend.position = 'bottom' ) return(p) } # 5. Zone-Based Analysis create_zone_analysis <- function(shots_data, player_name) { # Assign zones shots <- shots_data %>% mutate( x = loc_x, y = loc_y, distance = sqrt(x^2 + y^2), zone = case_when( distance <= 40 ~ 'Restricted Area', abs(x) <= 80 & y <= 142.5 & distance > 40 ~ 'Paint', distance <= 160 ~ 'Short Mid-Range', distance <= 237.5 ~ 'Long Mid-Range', abs(x) >= 220 & y <= 92.5 ~ 'Corner 3', TRUE ~ 'Above-Break 3' ) ) # Calculate zone statistics zone_stats <- shots %>% group_by(zone) %>% summarise( attempts = n(), makes = sum(shot_made_flag), fg_pct = mean(shot_made_flag), .groups = 'drop' ) %>% mutate( points_per_shot = fg_pct * ifelse(grepl('3', zone), 3, 2), frequency = attempts / sum(attempts) * 100 ) %>% arrange(desc(attempts)) # Print table cat(sprintf("\n%s - Zone Analysis\n", player_name)) cat(strrep("=", 80), "\n") print(zone_stats, n = Inf) # Create bar plot p <- ggplot(zone_stats, aes(x = reorder(zone, -attempts), y = attempts)) + geom_bar(stat = 'identity', aes(fill = fg_pct), color = 'black') + geom_text(aes(label = sprintf('%.1f%%', fg_pct * 100)), vjust = -0.5, size = 4, fontface = 'bold') + scale_fill_gradient2( low = '#FF0000', mid = '#FFFF00', high = '#00AA00', midpoint = 0.45, name = 'FG%', labels = scales::percent_format() ) + labs( title = paste(player_name, 'Zone Analysis'), subtitle = 'Shot Attempts by Court Zone', x = 'Zone', y = 'Attempts' ) + theme_minimal() + theme( plot.title = element_text(size = 18, face = 'bold', hjust = 0.5), plot.subtitle = element_text(size = 14, hjust = 0.5), axis.text.x = element_text(angle = 45, hjust = 1, size = 10), axis.title = element_text(size = 12, face = 'bold'), legend.position = 'right' ) return(list(stats = zone_stats, plot = p)) } # Example usage if (interactive()) { # Fetch data player_name <- 'Stephen Curry' shots <- get_nba_shot_data(player_name, season = 2024) # Create visualizations scatter_plot <- create_scatter_shot_chart(shots, player_name) hexbin_freq <- create_hexbin_frequency(shots, player_name) hexbin_acc <- create_hexbin_accuracy(shots, player_name) density_map <- create_density_heatmap(shots, player_name) zone_results <- create_zone_analysis(shots, player_name) # Display plots print(scatter_plot) print(hexbin_freq) print(hexbin_acc) print(density_map) print(zone_results$plot) # Save plots ggsave('curry_scatter_r.png', scatter_plot, width = 10, height = 11, dpi = 300) ggsave('curry_hexbin_r.png', hexbin_freq, width = 10, height = 11, dpi = 300) ggsave('curry_accuracy_r.png', hexbin_acc, width = 10, height = 11, dpi = 300) ggsave('curry_density_r.png', density_map, width = 10, height = 11, dpi = 300) ggsave('curry_zones_r.png', zone_results$plot, width = 12, height = 8, dpi = 300) } ``` ## Best Practices ### Data Quality 1. **Minimum Sample Size**: Use at least 100 shots for reliable patterns 2. **Data Validation**: Check for outliers and coordinate errors 3. **Context Inclusion**: Include game situation, defense, time data 4. **Consistent Coordinate System**: Verify court dimensions match NBA standards ### Visualization Design 1. **Color Selection**: - Use colorblind-friendly palettes - Red/green for make/miss is standard but not accessible - Consider blue/orange or other contrasting pairs 2. **Resolution and Binning**: - Too few bins: Lose spatial detail - Too many bins: Noisy, unreliable estimates - Start with 20-25 hexagons for full-season data 3. **Court Orientation**: - Standard: Basket at bottom, half court at top - Consider flipping for defensive perspectives 4. **Annotations**: - Include sample size - Show league average for comparison - Mark significant zones or outliers ### Analysis Workflow 1. **Exploration**: Start with scatter plots to see raw data 2. **Aggregation**: Use hexbins to identify patterns 3. **Communication**: Create heat maps for presentations 4. **Insights**: Perform zone analysis for actionable findings 5. **Validation**: Compare with video, league norms, opponent data ## Conclusion Shot charts are essential tools in modern basketball analytics, providing visual and quantitative insights into shooting performance. Understanding coordinate systems, choosing appropriate visualization techniques, and interpreting patterns correctly are crucial skills for analysts, coaches, and fans. The choice between scatter plots, hexbins, heat maps, and zone analysis depends on your data size, audience, and analytical goals. Combining multiple approaches often yields the most comprehensive understanding of shooting patterns and player tendencies.

Discussion

Have questions or feedback? Join our community discussion on Discord or GitHub Discussions.