import numpy as np import pandas as pd import plotly.graph_objects as go from utils.charts import ( AMBER, BORDER, COURT_FLOOR, COURT_LINE, MADE_EMERALD, MISS_CRIMSON, SURFACE, TEXT_WHITE, ) def add_nba_half_court(fig: go.Figure) -> go.Figure: """Add empirically calibrated NBA half-court markings to a Plotly figure. Coordinate system (Basketball-Reference half-court pixel units, 1 ft ≈ 10 units): - Rim center: (240.0, 52.5) - Court width: 50 ft = 500 units (-10 to 490, centered at x=240) - Half-court length: 47 ft = 470 units (y=0 baseline to y=470 half-court line) - Paint / key: 16 ft wide (x=160..320), 19 ft deep (y=0..190) - Restricted area: 4 ft radius (r=40) around rim - Corner 3-point lines: 22 ft from basket center (x=20 and x=460), y=0..142.0 - Above-the-break 3-point arc: 23.75 ft radius (r=237.5) around rim """ # Outer half-court boundary fig.add_shape( type="rect", x0=-10, x1=490, y0=0, y1=470, line=dict(color=BORDER, width=2), ) # Paint / Key (16ft wide, 19ft deep) with Hardwood Amber subtle tint fig.add_shape( type="rect", x0=160, x1=320, y0=0, y1=190, line=dict(color=COURT_LINE, width=1.8), fillcolor="rgba(224, 159, 62, 0.07)", ) # Free throw circle (top half solid, bottom half dashed) theta_ft = np.linspace(0, np.pi, 50) fig.add_trace( go.Scatter( x=240 + 60 * np.cos(theta_ft), y=190 + 60 * np.sin(theta_ft), mode="lines", line=dict(color=COURT_LINE, width=1.8), showlegend=False, hoverinfo="skip", ) ) fig.add_trace( go.Scatter( x=240 + 60 * np.cos(-theta_ft), y=190 + 60 * np.sin(-theta_ft), mode="lines", line=dict(color=COURT_LINE, width=1.2, dash="dash"), showlegend=False, hoverinfo="skip", ) ) # Restricted area arc (4ft radius = 40 units) rim_x, rim_y = 240.0, 52.5 theta_ra = np.linspace(0, np.pi, 30) fig.add_trace( go.Scatter( x=rim_x + 40 * np.cos(theta_ra), y=rim_y + 40 * np.sin(theta_ra), mode="lines", line=dict(color=COURT_LINE, width=1.8), showlegend=False, hoverinfo="skip", ) ) # Calibrated 3-point line: # Straight corner lines at x=20 and x=460 (22.0 ft from basket center) # intersecting the 23.75 ft (r=237.5) arc at y = 52.5 + sqrt(237.5^2 - 220^2) ≈ 142.0 r_3pt = 237.5 dx_corner = 220.0 theta_3_start = np.arccos(dx_corner / r_3pt) corner_x_left = rim_x - dx_corner corner_x_right = rim_x + dx_corner corner_y = rim_y + r_3pt * np.sin(theta_3_start) fig.add_shape( type="line", x0=corner_x_left, x1=corner_x_left, y0=0, y1=corner_y, line=dict(color=COURT_LINE, width=2.2), ) fig.add_shape( type="line", x0=corner_x_right, x1=corner_x_right, y0=0, y1=corner_y, line=dict(color=COURT_LINE, width=2.2), ) theta_3 = np.linspace(theta_3_start, np.pi - theta_3_start, 80) fig.add_trace( go.Scatter( x=rim_x + r_3pt * np.cos(theta_3), y=rim_y + r_3pt * np.sin(theta_3), mode="lines", line=dict(color=COURT_LINE, width=2.2), showlegend=False, hoverinfo="skip", ) ) # Center court circle arc at half-court line (y=470) theta_cc = np.linspace(np.pi, 2 * np.pi, 40) fig.add_trace( go.Scatter( x=rim_x + 60 * np.cos(theta_cc), y=470 + 60 * np.sin(theta_cc), mode="lines", line=dict(color=BORDER, width=1.8), showlegend=False, hoverinfo="skip", ) ) # Backboard & Rim (Hardwood Amber landmarks rendered on top) fig.add_shape( type="line", x0=210, x1=270, y0=40, y1=40, line=dict(color=AMBER, width=3), ) fig.add_shape( type="circle", x0=232, x1=248, y0=44.5, y1=60.5, line=dict(color=AMBER, width=2.5), fillcolor="rgba(11, 15, 23, 0.85)", ) return fig def build_shot_chart_figure(shot_data: pd.DataFrame, player_name: str) -> go.Figure: """Create the Arena Nocturne half-court spatial telemetry figure for a player's shots.""" fig = go.Figure() missed = shot_data[~shot_data["is_made"]] made = shot_data[shot_data["is_made"]] if not missed.empty: fig.add_trace( go.Scatter( x=missed["x"], y=missed["y"], mode="markers", name=f"Missed ({len(missed):,})", marker=dict( size=6.5, color="rgba(244, 63, 94, 0.20)", line=dict(width=1.2, color=MISS_CRIMSON), ), customdata=np.stack( [missed["shot_distance_zone"], missed["distance_ft"], missed["quarter_number"]], axis=-1, ), hovertemplate=( "Missed FG
" "Zone: %{customdata[0]}
" "Distance: %{customdata[1]} ft
" "Period: Q%{customdata[2]}" ), ) ) if not made.empty: fig.add_trace( go.Scatter( x=made["x"], y=made["y"], mode="markers", name=f"Made ({len(made):,})", marker=dict( size=7, color=MADE_EMERALD, opacity=0.88, line=dict(width=0.7, color="#064E3B"), ), customdata=np.stack( [made["shot_distance_zone"], made["distance_ft"], made["quarter_number"]], axis=-1, ), hovertemplate=( "Made FG
" "Zone: %{customdata[0]}
" "Distance: %{customdata[1]} ft
" "Period: Q%{customdata[2]}" ), ) ) add_nba_half_court(fig) fig.update_layout( title=dict( text=f"{player_name} — Half-Court Spatial Telemetry ({len(shot_data):,} FGA)", font=dict(size=15, color=TEXT_WHITE), ), height=580, xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-20, 500]), yaxis=dict( showgrid=False, zeroline=False, showticklabels=False, range=[-15, 480], scaleanchor="x", ), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor=COURT_FLOOR, font=dict(color=TEXT_WHITE), legend=dict( orientation="h", yanchor="bottom", y=1.01, xanchor="right", x=1, font=dict(size=12, color=TEXT_WHITE), ), hoverlabel=dict( bgcolor=SURFACE, bordercolor=AMBER, font=dict(color=TEXT_WHITE), ), margin=dict(l=10, r=10, t=45, b=10), ) return fig