diff --git a/app/web/charts.py b/app/web/charts.py
index 5abc9a4..98bbb9c 100644
--- a/app/web/charts.py
+++ b/app/web/charts.py
@@ -1,15 +1,12 @@
-"""Plotly figure construction for team hitting visualizations.
+"""Plotly figure construction for team analytics visualizations.
Kept out of the route so the figure contract can be tested without HTTP and so
the route stays about request handling.
-The hits, batting strikeout, runs, baserunners, run differential, and
-normalized comparison figures are built by separate functions that share only
-the rendering helpers below. They look alike, but a single parameterized
-builder would have to encode which labels, colours, and axis semantics belong
-to which statistic, which is harder to read than six explicit builders. The run
-differential figure is the clearest case for keeping them apart: it is the only
-signed metric, so it is the only one that must not anchor its y axis at zero.
+Public builders keep each metric's baseball meaning explicit. Standard
+per-game count charts share small helpers for repeated Plotly mechanics, while
+pitching, run differential, and normalized comparison keep their distinct
+chart structures explicit.
"""
from datetime import date
@@ -179,52 +176,25 @@ def _label_reference_line(
)
-def build_team_hits_figure(
- analysis: TeamHitsAnalysis,
- league_comparison: TeamHitsLeagueComparison | None = None,
-) -> go.Figure:
- """Build the hits-per-game figure for one team-season analysis.
-
- ``league_comparison`` adds a fourth trace, a horizontal MLB reference line.
- It is optional: a season without complete league coverage has no MLB
- 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
- # of which trace the pointer is over.
- hover_data = [
- (
- format_long_date(point.game_date),
- format_matchup(point.opponent_name, point.home_away),
- point.hits,
- point.rolling_average,
- )
- for point in analysis.points
- ]
- rolling_name = rolling_average_trace_name(analysis.rolling_window)
- hover_template = (
- "%{customdata[0]}
"
- "%{customdata[1]}
"
- "Hits: %{customdata[2]}
"
- f"{analysis.rolling_window}-Game Avg: "
- "%{customdata[3]:.2f}"
- )
-
- figure = go.Figure()
+def _add_standard_raw_trace(
+ figure: go.Figure,
+ *,
+ game_numbers: list[int],
+ values: list[int],
+ hover_data: list[tuple[object, ...]],
+ trace_name: str,
+ hover_template: str,
+) -> None:
+ """Add the raw per-game observations used by standard count charts."""
figure.add_trace(
go.Scatter(
x=game_numbers,
- y=hits,
+ y=values,
customdata=hover_data,
- name=RAW_HITS_TRACE_NAME,
+ name=trace_name,
mode="lines+markers",
line={"color": _RAW_LINE, "width": 1.2},
- # 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.
+ # Open circles stay readable when 162 game markers overlap.
marker={
"size": 5,
"color": "rgba(0,0,0,0)",
@@ -233,23 +203,44 @@ def build_team_hits_figure(
hovertemplate=hover_template,
)
)
+
+
+def _add_standard_rolling_trace(
+ figure: go.Figure,
+ *,
+ game_numbers: list[int],
+ rolling_values: list[float],
+ hover_data: list[tuple[object, ...]],
+ trace_name: str,
+ hover_template: str,
+) -> None:
+ """Add the calculated rolling-average line for a standard count chart."""
figure.add_trace(
go.Scatter(
x=game_numbers,
- y=rolling,
+ y=rolling_values,
customdata=hover_data,
- name=rolling_name,
+ name=trace_name,
mode="lines",
- # Straight segments between calculated points. A spline would
- # overshoot between games and imply averages nobody calculated.
+ # Straight segments do not imply uncalculated values between games.
line={"color": _TEAL, "width": 3.5, "shape": "linear"},
hovertemplate=hover_template,
)
)
- season_average = analysis.summary.season_average
+
+
+def _add_standard_reference_lines(
+ figure: go.Figure,
+ *,
+ game_numbers: list[int],
+ season_average: float,
+ mlb_average: float | None,
+) -> None:
+ """Add and label the standard team and optional MLB reference lines."""
+ reference_x = [game_numbers[0], game_numbers[-1]]
figure.add_trace(
go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
+ x=reference_x,
y=[season_average, season_average],
name=TEAM_SEASON_AVERAGE_TRACE_NAME,
mode="lines",
@@ -257,11 +248,10 @@ def build_team_hits_figure(
hoverinfo="skip",
)
)
- if league_comparison is not None:
- mlb_average = league_comparison.league.hits_per_game
+ if mlb_average is not None:
figure.add_trace(
go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
+ x=reference_x,
y=[mlb_average, mlb_average],
name=MLB_AVERAGE_TRACE_NAME,
mode="lines",
@@ -270,28 +260,33 @@ 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,
- )
+ # The two lines can sit close enough for right-edge labels to overlap.
+ label_value = season_average if mlb_average is None else mlb_average
+ label_name = (
+ TEAM_SEASON_AVERAGE_TRACE_NAME
+ if mlb_average is None
+ else MLB_AVERAGE_TRACE_NAME
+ )
+ _label_reference_line(
+ figure,
+ x=game_numbers[-1],
+ y=label_value,
+ name=label_name,
+ )
+
+def _apply_standard_count_chart_layout(
+ figure: go.Figure,
+ *,
+ game_numbers: list[int],
+ game_dates: list[date],
+ y_axis_title: str,
+) -> None:
+ """Apply the shared layout for zero-anchored per-game count charts."""
tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates)
figure.update_layout(
template="plotly_white",
- # Axis automargin sizes the left and bottom gutters, which keeps the
- # plot area as wide as possible on a narrow phone screen.
+ # Axis automargin keeps the plot as wide as possible on narrow screens.
margin=_MARGIN,
height=470,
hovermode="closest",
@@ -312,8 +307,6 @@ def build_team_hits_figure(
"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,
@@ -323,7 +316,7 @@ def build_team_hits_figure(
},
yaxis={
"title": {
- "text": Y_AXIS_TITLE,
+ "text": y_axis_title,
"standoff": 10,
"font": _AXIS_TITLE_FONT,
},
@@ -332,12 +325,80 @@ def build_team_hits_figure(
"griddash": "dot",
"zeroline": False,
"rangemode": "tozero",
- # Whole numbers of hits; the range still grows with the data.
"tickformat": "d",
"dtick": 2,
"automargin": True,
},
)
+
+
+def build_team_hits_figure(
+ analysis: TeamHitsAnalysis,
+ league_comparison: TeamHitsLeagueComparison | None = None,
+) -> go.Figure:
+ """Build the hits-per-game figure for one team-season analysis.
+
+ ``league_comparison`` adds a fourth trace, a horizontal MLB reference line.
+ It is optional: a season without complete league coverage has no MLB
+ 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
+ # of which trace the pointer is over.
+ hover_data = [
+ (
+ format_long_date(point.game_date),
+ format_matchup(point.opponent_name, point.home_away),
+ point.hits,
+ point.rolling_average,
+ )
+ for point in analysis.points
+ ]
+ rolling_name = rolling_average_trace_name(analysis.rolling_window)
+ hover_template = (
+ "%{customdata[0]}
"
+ "%{customdata[1]}
"
+ "Hits: %{customdata[2]}
"
+ f"{analysis.rolling_window}-Game Avg: "
+ "%{customdata[3]:.2f}"
+ )
+
+ figure = go.Figure()
+ _add_standard_raw_trace(
+ figure,
+ game_numbers=game_numbers,
+ values=hits,
+ hover_data=hover_data,
+ trace_name=RAW_HITS_TRACE_NAME,
+ hover_template=hover_template,
+ )
+ _add_standard_rolling_trace(
+ figure,
+ game_numbers=game_numbers,
+ rolling_values=rolling,
+ hover_data=hover_data,
+ trace_name=rolling_name,
+ hover_template=hover_template,
+ )
+ season_average = analysis.summary.season_average
+ mlb_average = (
+ None if league_comparison is None else league_comparison.league.hits_per_game
+ )
+ _add_standard_reference_lines(
+ figure,
+ game_numbers=game_numbers,
+ season_average=season_average,
+ mlb_average=mlb_average,
+ )
+ _apply_standard_count_chart_layout(
+ figure,
+ game_numbers=game_numbers,
+ game_dates=game_dates,
+ y_axis_title=Y_AXIS_TITLE,
+ )
return figure
@@ -378,128 +439,39 @@ def build_team_strikeouts_figure(
)
figure = go.Figure()
- figure.add_trace(
- go.Scatter(
- x=game_numbers,
- y=strikeouts,
- customdata=hover_data,
- name=RAW_STRIKEOUTS_TRACE_NAME,
- mode="lines+markers",
- line={"color": _RAW_LINE, "width": 1.2},
- # 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,
- )
+ _add_standard_raw_trace(
+ figure,
+ game_numbers=game_numbers,
+ values=strikeouts,
+ hover_data=hover_data,
+ trace_name=RAW_STRIKEOUTS_TRACE_NAME,
+ hover_template=hover_template,
)
- figure.add_trace(
- go.Scatter(
- x=game_numbers,
- y=rolling,
- customdata=hover_data,
- name=rolling_name,
- mode="lines",
- # Straight segments between calculated points. A spline would
- # overshoot between games and imply averages nobody calculated.
- line={"color": _TEAL, "width": 3.5, "shape": "linear"},
- hovertemplate=hover_template,
- )
+ _add_standard_rolling_trace(
+ figure,
+ game_numbers=game_numbers,
+ rolling_values=rolling,
+ hover_data=hover_data,
+ trace_name=rolling_name,
+ hover_template=hover_template,
)
season_average = analysis.summary.season_average
- figure.add_trace(
- go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
- y=[season_average, season_average],
- name=TEAM_SEASON_AVERAGE_TRACE_NAME,
- mode="lines",
- line={"color": _NAVY, "width": 2, "dash": "dash"},
- hoverinfo="skip",
- )
+ mlb_average = (
+ None
+ if league_comparison is None
+ else league_comparison.league.strikeouts_per_game
)
- if league_comparison is not None:
- mlb_average = league_comparison.league.strikeouts_per_game
- figure.add_trace(
- go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
- y=[mlb_average, mlb_average],
- name=MLB_AVERAGE_TRACE_NAME,
- mode="lines",
- line={"color": _AMBER, "width": 2, "dash": "dot"},
- hoverinfo="skip",
- )
- )
-
- # Only one of the two horizontal lines is labelled. They can sit within a
- # tenth of a strikeout 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.strikeouts_per_game,
- name=MLB_AVERAGE_TRACE_NAME,
- )
-
- tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates)
- figure.update_layout(
- template="plotly_white",
- 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.04,
- "xanchor": "center",
- "x": 0.5,
- "font": {"size": 12, "color": _AXIS_INK},
- },
- xaxis={
- "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": 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.
- "rangemode": "tozero",
- "tickformat": "d",
- "dtick": 2,
- "automargin": True,
- },
+ _add_standard_reference_lines(
+ figure,
+ game_numbers=game_numbers,
+ season_average=season_average,
+ mlb_average=mlb_average,
+ )
+ _apply_standard_count_chart_layout(
+ figure,
+ game_numbers=game_numbers,
+ game_dates=game_dates,
+ y_axis_title=STRIKEOUTS_Y_AXIS_TITLE,
)
return figure
@@ -541,128 +513,37 @@ def build_team_runs_figure(
)
figure = go.Figure()
- figure.add_trace(
- go.Scatter(
- x=game_numbers,
- y=runs,
- customdata=hover_data,
- name=RAW_RUNS_TRACE_NAME,
- mode="lines+markers",
- line={"color": _RAW_LINE, "width": 1.2},
- # 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,
- )
+ _add_standard_raw_trace(
+ figure,
+ game_numbers=game_numbers,
+ values=runs,
+ hover_data=hover_data,
+ trace_name=RAW_RUNS_TRACE_NAME,
+ hover_template=hover_template,
)
- figure.add_trace(
- go.Scatter(
- x=game_numbers,
- y=rolling,
- customdata=hover_data,
- name=rolling_name,
- mode="lines",
- # Straight segments between calculated points. A spline would
- # overshoot between games and imply averages nobody calculated.
- line={"color": _TEAL, "width": 3.5, "shape": "linear"},
- hovertemplate=hover_template,
- )
+ _add_standard_rolling_trace(
+ figure,
+ game_numbers=game_numbers,
+ rolling_values=rolling,
+ hover_data=hover_data,
+ trace_name=rolling_name,
+ hover_template=hover_template,
)
season_average = analysis.summary.season_average
- figure.add_trace(
- go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
- y=[season_average, season_average],
- name=TEAM_SEASON_AVERAGE_TRACE_NAME,
- mode="lines",
- line={"color": _NAVY, "width": 2, "dash": "dash"},
- hoverinfo="skip",
- )
+ mlb_average = (
+ None if league_comparison is None else league_comparison.league.runs_per_game
)
- if league_comparison is not None:
- mlb_average = league_comparison.league.runs_per_game
- figure.add_trace(
- go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
- y=[mlb_average, mlb_average],
- name=MLB_AVERAGE_TRACE_NAME,
- mode="lines",
- line={"color": _AMBER, "width": 2, "dash": "dot"},
- hoverinfo="skip",
- )
- )
-
- # Only one of the two horizontal lines is labelled. They can sit within a
- # tenth of a run 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.runs_per_game,
- name=MLB_AVERAGE_TRACE_NAME,
- )
-
- tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates)
- figure.update_layout(
- template="plotly_white",
- 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.04,
- "xanchor": "center",
- "x": 0.5,
- "font": {"size": 12, "color": _AXIS_INK},
- },
- xaxis={
- "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": RUNS_Y_AXIS_TITLE,
- "standoff": 10,
- "font": _AXIS_TITLE_FONT,
- },
- "tickfont": _TICK_FONT,
- "gridcolor": _GRID,
- "griddash": "dot",
- "zeroline": False,
- # Starts at zero like the other charts, and grows with the data. No
- # fixed maximum: a 20-run blowout must still fit.
- "rangemode": "tozero",
- "tickformat": "d",
- "dtick": 2,
- "automargin": True,
- },
+ _add_standard_reference_lines(
+ figure,
+ game_numbers=game_numbers,
+ season_average=season_average,
+ mlb_average=mlb_average,
+ )
+ _apply_standard_count_chart_layout(
+ figure,
+ game_numbers=game_numbers,
+ game_dates=game_dates,
+ y_axis_title=RUNS_Y_AXIS_TITLE,
)
return figure
@@ -704,128 +585,39 @@ def build_team_baserunners_figure(
)
figure = go.Figure()
- figure.add_trace(
- go.Scatter(
- x=game_numbers,
- y=baserunners,
- customdata=hover_data,
- name=RAW_BASERUNNERS_TRACE_NAME,
- mode="lines+markers",
- line={"color": _RAW_LINE, "width": 1.2},
- # 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,
- )
+ _add_standard_raw_trace(
+ figure,
+ game_numbers=game_numbers,
+ values=baserunners,
+ hover_data=hover_data,
+ trace_name=RAW_BASERUNNERS_TRACE_NAME,
+ hover_template=hover_template,
)
- figure.add_trace(
- go.Scatter(
- x=game_numbers,
- y=rolling,
- customdata=hover_data,
- name=rolling_name,
- mode="lines",
- # Straight segments between calculated points. A spline would
- # overshoot between games and imply averages nobody calculated.
- line={"color": _TEAL, "width": 3.5, "shape": "linear"},
- hovertemplate=hover_template,
- )
+ _add_standard_rolling_trace(
+ figure,
+ game_numbers=game_numbers,
+ rolling_values=rolling,
+ hover_data=hover_data,
+ trace_name=rolling_name,
+ hover_template=hover_template,
)
season_average = analysis.summary.season_average
- figure.add_trace(
- go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
- y=[season_average, season_average],
- name=TEAM_SEASON_AVERAGE_TRACE_NAME,
- mode="lines",
- line={"color": _NAVY, "width": 2, "dash": "dash"},
- hoverinfo="skip",
- )
+ mlb_average = (
+ None
+ if league_comparison is None
+ else league_comparison.league.baserunners_per_game
)
- if league_comparison is not None:
- mlb_average = league_comparison.league.baserunners_per_game
- figure.add_trace(
- go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
- y=[mlb_average, mlb_average],
- name=MLB_AVERAGE_TRACE_NAME,
- mode="lines",
- line={"color": _AMBER, "width": 2, "dash": "dot"},
- hoverinfo="skip",
- )
- )
-
- # Only one of the two horizontal lines is labelled. They can sit within a
- # tenth of a baserunner 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.baserunners_per_game,
- name=MLB_AVERAGE_TRACE_NAME,
- )
-
- tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates)
- figure.update_layout(
- template="plotly_white",
- 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.04,
- "xanchor": "center",
- "x": 0.5,
- "font": {"size": 12, "color": _AXIS_INK},
- },
- xaxis={
- "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": BASERUNNERS_Y_AXIS_TITLE,
- "standoff": 10,
- "font": _AXIS_TITLE_FONT,
- },
- "tickfont": _TICK_FONT,
- "gridcolor": _GRID,
- "griddash": "dot",
- "zeroline": False,
- # Starts at zero like the other charts, and grows with the data. No
- # fixed maximum: a high-traffic offensive game must still fit.
- "rangemode": "tozero",
- "tickformat": "d",
- "dtick": 2,
- "automargin": True,
- },
+ _add_standard_reference_lines(
+ figure,
+ game_numbers=game_numbers,
+ season_average=season_average,
+ mlb_average=mlb_average,
+ )
+ _apply_standard_count_chart_layout(
+ figure,
+ game_numbers=game_numbers,
+ game_dates=game_dates,
+ y_axis_title=BASERUNNERS_Y_AXIS_TITLE,
)
return figure
@@ -872,126 +664,37 @@ def build_team_hits_allowed_figure(
)
figure = go.Figure()
- figure.add_trace(
- go.Scatter(
- x=game_numbers,
- y=hits_allowed,
- customdata=hover_data,
- name=RAW_HITS_ALLOWED_TRACE_NAME,
- mode="lines+markers",
- line={"color": _RAW_LINE, "width": 1.2},
- # Open circles: the game markers sit on top of each other across 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,
- )
+ _add_standard_raw_trace(
+ figure,
+ game_numbers=game_numbers,
+ values=hits_allowed,
+ hover_data=hover_data,
+ trace_name=RAW_HITS_ALLOWED_TRACE_NAME,
+ hover_template=hover_template,
)
- figure.add_trace(
- go.Scatter(
- x=game_numbers,
- y=rolling,
- customdata=hover_data,
- name=rolling_name,
- mode="lines",
- # Straight segments between calculated points. A spline would
- # overshoot between games and imply averages nobody calculated.
- line={"color": _TEAL, "width": 3.5, "shape": "linear"},
- hovertemplate=hover_template,
- )
+ _add_standard_rolling_trace(
+ figure,
+ game_numbers=game_numbers,
+ rolling_values=rolling,
+ hover_data=hover_data,
+ trace_name=rolling_name,
+ hover_template=hover_template,
)
season_average = analysis.summary.season_average
- figure.add_trace(
- go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
- y=[season_average, season_average],
- name=TEAM_SEASON_AVERAGE_TRACE_NAME,
- mode="lines",
- line={"color": _NAVY, "width": 2, "dash": "dash"},
- hoverinfo="skip",
- )
+ mlb_average = (
+ None if league_comparison is None else league_comparison.league.hits_per_game
)
- if league_comparison is not None:
- mlb_average = league_comparison.league.hits_per_game
- figure.add_trace(
- go.Scatter(
- x=[game_numbers[0], game_numbers[-1]],
- y=[mlb_average, mlb_average],
- name=MLB_AVERAGE_TRACE_NAME,
- mode="lines",
- line={"color": _AMBER, "width": 2, "dash": "dot"},
- hoverinfo="skip",
- )
- )
-
- # 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",
- 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.04,
- "xanchor": "center",
- "x": 0.5,
- "font": {"size": 12, "color": _AXIS_INK},
- },
- xaxis={
- "title": {"text": X_AXIS_TITLE, "standoff": 10, "font": _AXIS_TITLE_FONT},
- "tickfont": _TICK_FONT,
- "tickmode": "array",
- "tickvals": tick_values,
- "ticktext": tick_labels,
- "showgrid": False,
- "showline": True,
- "linecolor": _AXIS_LINE,
- "zeroline": False,
- "rangemode": "tozero",
- "automargin": True,
- },
- yaxis={
- "title": {
- "text": HITS_ALLOWED_Y_AXIS_TITLE,
- "standoff": 10,
- "font": _AXIS_TITLE_FONT,
- },
- "tickfont": _TICK_FONT,
- "gridcolor": _GRID,
- "griddash": "dot",
- "zeroline": False,
- # A no-hitter is a real 0, so the axis starts at zero and grows
- # with the data, exactly as the hits chart does.
- "rangemode": "tozero",
- "tickformat": "d",
- "dtick": 2,
- "automargin": True,
- },
+ _add_standard_reference_lines(
+ figure,
+ game_numbers=game_numbers,
+ season_average=season_average,
+ mlb_average=mlb_average,
+ )
+ _apply_standard_count_chart_layout(
+ figure,
+ game_numbers=game_numbers,
+ game_dates=game_dates,
+ y_axis_title=HITS_ALLOWED_Y_AXIS_TITLE,
)
return figure
diff --git a/docs/architecture-deep-dive.md b/docs/architecture-deep-dive.md
index f028a6c..2cdaa19 100644
--- a/docs/architecture-deep-dive.md
+++ b/docs/architecture-deep-dive.md
@@ -199,19 +199,19 @@ whether the "MLB average" line is trustworthy enough to draw at all.
## 4. Charts — `app/web/charts.py`
-One explicit builder function per chart (`build_team_hits_figure`,
-`build_team_strikeouts_figure`, `build_team_runs_figure`,
-`build_team_hitting_comparison_figure`) rather than one parameterized
-builder — the module docstring explains why: a single generic builder
-would have to encode which labels/colors/axis semantics belong to which
-statistic, which wound up harder to read than four builders that look
-almost identical. This is a real, considered tradeoff, not unconsidered
-duplication — worth knowing before you reflexively refactor it into one.
+Each chart keeps an explicit public builder so its baseball meaning remains
+visible in code. The standard per-game count charts share small private helpers
+for mechanical Plotly construction: raw and rolling traces, team/MLB reference
+lines, and their common layout. Metric values, hover wording, trace names, axis
+titles, and MLB-average extraction stay in the public builders rather than in a
+generic chart configuration. Pitching, run differential, and normalized
+comparison retain their independent structures because their axis and series
+semantics differ from the standard count charts.
Details that show up as small functions/constants rather than being
scattered inline:
-- `_trailing_averages`-driven rolling line, a raw-value scatter with
+- A rolling line, a raw-value scatter with
**open-circle markers** (deliberately, so 162 overlapping season points
don't merge into a solid blob), and up to two horizontal dashed
reference lines (team season average in navy, MLB average in amber
diff --git a/tests/test_hits_allowed.py b/tests/test_hits_allowed.py
index 958dbe9..575d310 100644
--- a/tests/test_hits_allowed.py
+++ b/tests/test_hits_allowed.py
@@ -246,11 +246,35 @@ def test_a_league_comparison_adds_the_mlb_line(self) -> None:
analysis, compare_team_hits_allowed_to_league(analysis, league)
)
- assert MLB_AVERAGE_TRACE_NAME in [trace.name for trace in figure.data]
+ assert [trace.name for trace in figure.data] == [
+ RAW_HITS_ALLOWED_TRACE_NAME,
+ rolling_average_trace_name(5),
+ TEAM_SEASON_AVERAGE_TRACE_NAME,
+ MLB_AVERAGE_TRACE_NAME,
+ ]
+ assert list(figure.data[3].y) == pytest.approx([8.0, 8.0])
+ assert figure.data[3].hoverinfo == "skip"
+ assert len(figure.layout.annotations) == 1
+ assert figure.layout.annotations[0].text == "MLB Average
8.00"
def test_the_raw_series_plots_hits_allowed(self, figure) -> None:
assert list(figure.data[0].y) == [6, 10, 8, 4, 7]
+ def test_hover_keeps_hits_allowed_and_innings_pitched_context(self, figure) -> None:
+ assert (
+ "%{customdata[2]} hits allowed over %{customdata[3]} IP"
+ in figure.data[0].hovertemplate
+ )
+ assert "5-Game Avg: %{customdata[4]:.2f}" in figure.data[0].hovertemplate
+ assert list(figure.data[0].customdata[0][2:]) == [6, "9.0", 6.0]
+
+ def test_the_team_reference_is_labelled_without_league_context(
+ self, figure
+ ) -> None:
+ assert list(figure.data[2].y) == pytest.approx([7.0, 7.0])
+ assert figure.data[2].line.dash == "dash"
+ assert figure.layout.annotations[0].text == "Team Season Average
7.00"
+
def test_the_axis_is_titled_and_starts_at_zero(self, figure) -> None:
assert figure.layout.yaxis.title.text == HITS_ALLOWED_Y_AXIS_TITLE
# A no-hitter is a real zero, so the axis anchors there.