Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 157 additions & 24 deletions app/web/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
builders.
"""

from datetime import date
from functools import lru_cache

import plotly.graph_objects as go
Expand All @@ -21,7 +22,7 @@
TeamHitsLeagueComparison,
TeamStrikeoutsAnalysis,
)
from app.web.formatting import format_long_date, format_matchup
from app.web.formatting import format_long_date, format_matchup, format_short_date

CHART_DIV_ID = "team-hits-chart"
RAW_HITS_TRACE_NAME = "Game Hits"
Expand All @@ -48,7 +49,14 @@
_AMBER = "#b26a00"
_RAW_LINE = "#b7c7d8"
_RAW_MARKER = "#7c93ab"
_GRID = "#e6ebf1"
_GRID = "#dbe2ea"
_AXIS_LINE = "#c9d3de"
_AXIS_INK = "#5b6b7c"
# The right gutter holds the reference-line label; the rest is sized by the
# axes themselves.
_MARGIN = {"l": 8, "r": 78, "t": 8, "b": 8}
_AXIS_TITLE_FONT = {"size": 12, "color": _AXIS_INK}
_TICK_FONT = {"size": 11, "color": _AXIS_INK}

CHART_CONFIG = {
"responsive": True,
Expand All @@ -63,6 +71,62 @@ def rolling_average_trace_name(rolling_window: int) -> str:
return f"{rolling_window}-Game Average"


def _season_game_ticks(
game_numbers: list[int],
game_dates: list[date],
) -> tuple[list[int], list[str]]:
"""Label about ten games across the season with their number and date.

A season game number alone does not tell a reader when in the season a
stretch happened, so each labelled tick carries the date of that game. The
last game is always labelled, because where the stored data ends is the
thing a reader most often wants to place in time.
"""
count = len(game_numbers)
step = max(1, round(count / 10))
indexes = list(range(0, count, step))
# Replace the final stepped tick when it would crowd the last game rather
# than adding a label on top of it.
if count - 1 - indexes[-1] < step / 2:
indexes[-1] = count - 1
else:
indexes.append(count - 1)

return (
[game_numbers[index] for index in indexes],
[
f"{game_numbers[index]}<br>{format_short_date(game_dates[index])}"
for index in indexes
],
)


def _label_reference_line(
figure: go.Figure,
*,
x: int,
y: float,
name: str,
) -> None:
"""Name a horizontal reference line at the right edge of the plot.

The legend says which line is which, but the value itself is what a reader
compares against, and reading it off the axis is guesswork when two
reference lines sit close together.
"""
figure.add_annotation(
x=x,
y=y,
text=f"{name}<br>{y:.2f}",
showarrow=False,
xanchor="left",
yanchor="middle",
xshift=8,
align="left",
font={"size": 11, "color": _AXIS_INK},
)


def build_team_hits_figure(
analysis: TeamHitsAnalysis,
league_comparison: TeamHitsLeagueComparison | None = None,
Expand All @@ -74,6 +138,7 @@ def build_team_hits_figure(
average to draw, and the team's own chart must still render.
"""
game_numbers = [point.season_game_number for point in analysis.points]
game_dates = [point.game_date for point in analysis.points]
hits = [point.hits for point in analysis.points]
rolling = [point.rolling_average for point in analysis.points]
# Each hover box shows date, matchup, hits, and rolling average regardless
Expand Down Expand Up @@ -105,7 +170,14 @@ def build_team_hits_figure(
name=RAW_HITS_TRACE_NAME,
mode="lines+markers",
line={"color": _RAW_LINE, "width": 1.2},
marker={"color": _RAW_MARKER, "size": 5},
# Open circles: the game markers sit on top of each other in a
# 162-game season, and an outline stays readable where filled
# dots merge into a blob.
marker={
"size": 5,
"color": "rgba(0,0,0,0)",
"line": {"color": _RAW_MARKER, "width": 1.2},
},
hovertemplate=hover_template,
)
)
Expand Down Expand Up @@ -146,34 +218,66 @@ def build_team_hits_figure(
)
)

# Only one of the two horizontal lines is labelled. They can sit within a
# tenth of a hit of each other, and two labels there would overlap.
if league_comparison is None:
_label_reference_line(
figure,
x=game_numbers[-1],
y=season_average,
name=TEAM_SEASON_AVERAGE_TRACE_NAME,
)
else:
_label_reference_line(
figure,
x=game_numbers[-1],
y=league_comparison.league.hits_per_game,
name=MLB_AVERAGE_TRACE_NAME,
)

tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates)
figure.update_layout(
template="plotly_white",
# Axis automargin sizes the gutters, which keeps the plot area as wide
# as possible on a narrow phone screen.
margin={"l": 8, "r": 8, "t": 8, "b": 8},
height=440,
# Axis automargin sizes the left and bottom gutters, which keeps the
# plot area as wide as possible on a narrow phone screen.
margin=_MARGIN,
height=470,
hovermode="closest",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font={"family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "size": 13},
legend={
"orientation": "h",
"yanchor": "bottom",
"y": 1.02,
"xanchor": "left",
"x": 0,
"font": {"size": 12},
"y": 1.04,
"xanchor": "center",
"x": 0.5,
"font": {"size": 12, "color": _AXIS_INK},
},
xaxis={
"title": {"text": X_AXIS_TITLE, "standoff": 8},
"gridcolor": _GRID,
"title": {"text": X_AXIS_TITLE, "standoff": 10, "font": _AXIS_TITLE_FONT},
"tickfont": _TICK_FONT,
"tickmode": "array",
"tickvals": tick_values,
"ticktext": tick_labels,
# Only the horizontal gridlines are drawn: they are what a reader
# measures a value against, and vertical lines only add noise.
"showgrid": False,
"showline": True,
"linecolor": _AXIS_LINE,
"zeroline": False,
"rangemode": "tozero",
"automargin": True,
},
yaxis={
"title": {"text": Y_AXIS_TITLE, "standoff": 8},
"title": {
"text": Y_AXIS_TITLE,
"standoff": 10,
"font": _AXIS_TITLE_FONT,
},
"tickfont": _TICK_FONT,
"gridcolor": _GRID,
"griddash": "dot",
"zeroline": False,
"rangemode": "tozero",
# Whole numbers of hits; the range still grows with the data.
Expand All @@ -188,6 +292,7 @@ def build_team_hits_figure(
def build_team_strikeouts_figure(analysis: TeamStrikeoutsAnalysis) -> go.Figure:
"""Build the batting-strikeouts-per-game figure for one team-season."""
game_numbers = [point.season_game_number for point in analysis.points]
game_dates = [point.game_date for point in analysis.points]
strikeouts = [point.strikeouts for point in analysis.points]
rolling = [point.rolling_average for point in analysis.points]
# Each hover box shows date, matchup, batting strikeouts, and rolling
Expand Down Expand Up @@ -219,7 +324,14 @@ def build_team_strikeouts_figure(analysis: TeamStrikeoutsAnalysis) -> go.Figure:
name=RAW_STRIKEOUTS_TRACE_NAME,
mode="lines+markers",
line={"color": _RAW_LINE, "width": 1.2},
marker={"color": _RAW_MARKER, "size": 5},
# Open circles: the game markers sit on top of each other in a
# 162-game season, and an outline stays readable where filled
# dots merge into a blob.
marker={
"size": 5,
"color": "rgba(0,0,0,0)",
"line": {"color": _RAW_MARKER, "width": 1.2},
},
hovertemplate=hover_template,
)
)
Expand Down Expand Up @@ -247,33 +359,54 @@ def build_team_strikeouts_figure(analysis: TeamStrikeoutsAnalysis) -> go.Figure:
hoverinfo="skip",
)
)
_label_reference_line(
figure,
x=game_numbers[-1],
y=season_average,
name=SEASON_AVERAGE_TRACE_NAME,
)

tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates)
figure.update_layout(
template="plotly_white",
margin={"l": 8, "r": 8, "t": 8, "b": 8},
height=440,
margin=_MARGIN,
height=470,
hovermode="closest",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font={"family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "size": 13},
legend={
"orientation": "h",
"yanchor": "bottom",
"y": 1.02,
"xanchor": "left",
"x": 0,
"font": {"size": 12},
"y": 1.04,
"xanchor": "center",
"x": 0.5,
"font": {"size": 12, "color": _AXIS_INK},
},
xaxis={
"title": {"text": X_AXIS_TITLE, "standoff": 8},
"gridcolor": _GRID,
"title": {"text": X_AXIS_TITLE, "standoff": 10, "font": _AXIS_TITLE_FONT},
"tickfont": _TICK_FONT,
"tickmode": "array",
"tickvals": tick_values,
"ticktext": tick_labels,
# Only the horizontal gridlines are drawn: they are what a reader
# measures a value against, and vertical lines only add noise.
"showgrid": False,
"showline": True,
"linecolor": _AXIS_LINE,
"zeroline": False,
"rangemode": "tozero",
"automargin": True,
},
yaxis={
"title": {"text": STRIKEOUTS_Y_AXIS_TITLE, "standoff": 8},
"title": {
"text": STRIKEOUTS_Y_AXIS_TITLE,
"standoff": 10,
"font": _AXIS_TITLE_FONT,
},
"tickfont": _TICK_FONT,
"gridcolor": _GRID,
"griddash": "dot",
"zeroline": False,
# Starts at zero like the hits chart, and grows with the data. No
# fixed maximum: a team that strikes out 20 times must still fit.
Expand Down
5 changes: 5 additions & 0 deletions app/web/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ def format_long_date(value: date) -> str:
return f"{_MONTHS[value.month - 1]} {value.day}, {value.year}"


def format_short_date(value: date) -> str:
"""Format a date as ``May 8``, for axis ticks that already carry the year."""
return f"{_MONTHS[value.month - 1][:3]} {value.day}"


def format_matchup(opponent_name: str, home_away: HomeAway) -> str:
"""Describe an opponent the way a box score does: ``vs`` home, ``at`` away."""
prefix = "vs" if home_away == "home" else "at"
Expand Down
11 changes: 11 additions & 0 deletions app/web/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ def _coerce_window(value: object) -> object:
RollingWindowParam = Annotated[RollingWindow, BeforeValidator(_coerce_window)]

PLOTLY_BUNDLE_PATH = "/vendor/plotly.min.js"
# Club and league marks are fetched by the browser from MLB's public logo
# host, keyed by the same team ids the application already stores. They are
# decorative: every page states the team in text as well, and the layout holds
# when the images do not load, which is what happens with no internet access.
MLB_LOGO_URL = "https://www.mlbstatic.com/team-logos/league-on-dark/1.svg"
TEAM_LOGO_URL_PREFIX = "https://www.mlbstatic.com/team-logos/"
IMPORT_COMMAND = (
"poetry run python scripts/import_team_season.py --team-id 136 --season 2025"
)
Expand Down Expand Up @@ -127,6 +133,8 @@ def index(
"selected_season": None,
"import_command": IMPORT_COMMAND,
"plotly_bundle_path": PLOTLY_BUNDLE_PATH,
"mlb_logo_url": MLB_LOGO_URL,
"team_logo_url_prefix": TEAM_LOGO_URL_PREFIX,
"form_action": HITS_PATH,
"nav_links": build_nav_links(
current_path=HITS_PATH,
Expand Down Expand Up @@ -232,6 +240,8 @@ def strikeouts(
"selected_season": None,
"import_command": IMPORT_COMMAND,
"plotly_bundle_path": PLOTLY_BUNDLE_PATH,
"mlb_logo_url": MLB_LOGO_URL,
"team_logo_url_prefix": TEAM_LOGO_URL_PREFIX,
"form_action": STRIKEOUTS_PATH,
"nav_links": build_nav_links(
current_path=STRIKEOUTS_PATH,
Expand Down Expand Up @@ -383,6 +393,7 @@ def _render_schema_error(
name="error.html",
context={
"app_name": settings.app_name,
"mlb_logo_url": MLB_LOGO_URL,
"heading": "The database schema is not ready",
"message": str(error),
"commands": [MIGRATION_HINT],
Expand Down
Loading
Loading