Files
nprasad2077 11a5cc0f18
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 1m11s
shot chart and navbar
2026-10-28 02:37:56 -05:00

229 lines
7.1 KiB
Python

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=(
"<b>Missed FG</b><br>"
"Zone: %{customdata[0]}<br>"
"Distance: %{customdata[1]} ft<br>"
"Period: Q%{customdata[2]}<extra></extra>"
),
)
)
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=(
"<b>Made FG</b><br>"
"Zone: %{customdata[0]}<br>"
"Distance: %{customdata[1]} ft<br>"
"Period: Q%{customdata[2]}<extra></extra>"
),
)
)
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