Skip to content

Styling Helpers

Module-level helpers that apply styling using the options system.

apply_base_styling(ax, grid_axis='both', hide_spines=False)

Apply base plot styling (spines, grid, background) using options.

Resets any pre-existing (pandas) grid before applying the styled one.

Parameters:

Name Type Description Default
ax Axes

The axes to style.

required
grid_axis GridAxis

"x" draws only vertical lines, "y" only horizontal, "both" both, "none" off.

'both'
hide_spines bool

Hide all four spines, for plots whose cells define the boundaries (heatmap, cohort).

False
Source code in openretailscience/plots/styles/styling_helpers.py
def apply_base_styling(ax: Axes, grid_axis: GridAxis = "both", hide_spines: bool = False) -> None:
    """Apply base plot styling (spines, grid, background) using options.

    Resets any pre-existing (pandas) grid before applying the styled one.

    Args:
        ax: The axes to style.
        grid_axis: ``"x"`` draws only vertical lines, ``"y"`` only horizontal,
            ``"both"`` both, ``"none"`` off.
        hide_spines: Hide all four spines, for plots whose cells define the boundaries
            (heatmap, cohort).
    """
    style = PlotStyleHelper()
    ax.set_facecolor(style.background_color)
    ax.set_axisbelow(True)

    ax.spines["top"].set_visible(False if hide_spines else style.show_top_spine)
    ax.spines["right"].set_visible(False if hide_spines else style.show_right_spine)
    ax.spines["bottom"].set_visible(False if hide_spines else style.show_bottom_spine)
    ax.spines["left"].set_visible(False if hide_spines else style.show_left_spine)

    # Reset any pre-existing grid (pandas can leave one behind) before applying our own.
    ax.grid(False, which="both")
    if grid_axis != "none":
        ax.grid(
            which="major",
            axis=grid_axis,
            color=style.grid_color,
            alpha=style.grid_alpha,
            zorder=1,
        )

apply_chart_chrome(ax, *, eyebrow=None, title=None, subtitle=None, source_text=None, warn_stacklevel=4)

Add the header (tab, eyebrow, title, subtitle) above the axes and the source below it.

The axes shrink to make room; an omitted element takes no space. The layout survives a later figure resize, and calling this again on the same axes replaces its chrome. The tab mark follows the plot.style.show_tab option (default True).

Parameters:

Name Type Description Default
ax Axes

The plot axes; the chrome is drawn on its figure.

required
eyebrow str | None

Small uppercase label above the title.

None
title str | None

Main headline; wraps if it would exceed the figure width.

None
subtitle str | None

Supporting copy below the title; wraps.

None
source_text str | None

Footer text (rendered italic, muted).

None
warn_stacklevel int

Stacklevel for the multi-axes chrome warning; 4 points at user code via standard_graph_styles, direct public callers (e.g. venn.plot) pass 3.

4
Source code in openretailscience/plots/styles/styling_helpers.py
def apply_chart_chrome(
    ax: Axes,
    *,
    eyebrow: str | None = None,
    title: str | None = None,
    subtitle: str | None = None,
    source_text: str | None = None,
    warn_stacklevel: int = 4,
) -> None:
    """Add the header (tab, eyebrow, title, subtitle) above the axes and the source below it.

    The axes shrink to make room; an omitted element takes no space. The layout survives a
    later figure resize, and calling this again on the same axes replaces its chrome. The tab
    mark follows the ``plot.style.show_tab`` option (default True).

    Args:
        ax: The plot axes; the chrome is drawn on its figure.
        eyebrow: Small uppercase label above the title.
        title: Main headline; wraps if it would exceed the figure width.
        subtitle: Supporting copy below the title; wraps.
        source_text: Footer text (rendered italic, muted).
        warn_stacklevel: Stacklevel for the multi-axes chrome warning; 4 points at user
            code via ``standard_graph_styles``, direct public callers (e.g. ``venn.plot``)
            pass 3.
    """
    style = PlotStyleHelper()
    fig = ax.figure
    show_tab = style.show_tab
    has_header = eyebrow is not None or title is not None or subtitle is not None
    any_chrome = has_header or source_text is not None
    chrome_gid = f"_ors_chrome:{id(ax)}"

    _clear_prior_chrome(fig, chrome_gid)
    # Drop any prior layout for this axes too: _clear_prior_chrome just detached its chrome texts
    # (their figure is now None), so the reflow below and the engine must not try to re-wrap them.
    # The fresh layout replaces this entry at the end of the call.
    prior_layouts = getattr(fig, "_ors_chrome_layouts", None)
    if prior_layouts is not None:
        prior_layouts.pop(id(ax), None)

    if any_chrome:
        _track_chrome_axes(fig, ax, warn_stacklevel=warn_stacklevel)

    chrome_x = _resolve_chrome_left(fig, ax) if any_chrome else _CHROME_FALLBACK_LEFT_MARGIN

    fig_h = fig.get_figheight()
    fig_w = fig.get_figwidth()
    dpi = fig.dpi
    renderer = _active_renderer(fig)
    cur_y = 1.0 - _CHROME_TOP_MARGIN_IN / fig_h

    # Always reserve the tab's vertical slot when a header is present, even when
    # the tab is hidden. This keeps the title's distance from the figure edge
    # consistent whether the tab is drawn or hidden, so suppressing the tab
    # doesn't push the title against the top margin.
    tab_spec: tuple[Rectangle, float, float, float] | None = None
    header_top_offset_in = _CHROME_TOP_MARGIN_IN
    if has_header:
        tab_height_fig = _CHROME_TAB_HEIGHT_IN / fig_h
        tab_to_eyebrow_fig = _CHROME_TAB_TO_EYEBROW_GAP_IN / fig_h
        if show_tab:
            tab_width_fig = _CHROME_TAB_WIDTH_IN / fig_w
            tab = Rectangle(
                (chrome_x, cur_y - tab_height_fig),
                tab_width_fig,
                tab_height_fig,
                transform=fig.transFigure,
                facecolor=style.tab_color,
                edgecolor="none",
                clip_on=False,
            )
            tab.set_gid(chrome_gid)
            fig.patches.append(tab)
            tab_spec = (tab, _CHROME_TOP_MARGIN_IN, _CHROME_TAB_HEIGHT_IN, _CHROME_TAB_WIDTH_IN)
        cur_y -= tab_height_fig + tab_to_eyebrow_fig
        header_top_offset_in = (1.0 - cur_y) * fig_h

    # Eyebrow's gap is always applied; the title→subtitle gap only when both
    # are present. Subtitle has no trailing gap — _CHROME_GAP_HEADER_TO_AXES_IN
    # below covers the whitespace down to the plot. Eyebrows stay single-line; the
    # title and subtitle re-wrap to the figure's current width on every draw.
    header_specs = (
        (
            eyebrow.upper() if eyebrow is not None else None,
            style.eyebrow_font,
            style.eyebrow_size,
            style.eyebrow_color,
            False,
            _CHROME_GAP_EYEBROW_TO_TITLE_IN,
        ),
        (
            title,
            style.title_font,
            style.title_size,
            style.title_color,
            True,
            _CHROME_GAP_TITLE_TO_SUBTITLE_IN if subtitle is not None else 0.0,
        ),
        (
            subtitle,
            style.subtitle_font,
            style.subtitle_size,
            style.subtitle_color,
            True,
            0.0,
        ),
    )
    header: list[_ChromeTextSpec] = []
    for text_str, font, size, color, wrap, gap_after_in in header_specs:
        if text_str is None:
            continue
        # y is a placeholder; _layout_chrome_header stacks the elements into their real positions below.
        artist = fig.text(
            chrome_x,
            1.0 - header_top_offset_in / fig_h,
            text_str,
            ha="left",
            va="top",
            fontproperties=get_font_properties(font),
            fontsize=size,
            color=color,
            wrap=wrap,
        )
        artist.set_gid(chrome_gid)
        header.append(_ChromeTextSpec(artist, text_str, gap_after_in, wrap=wrap))

    header_bottom_in = _layout_chrome_header(header, header_top_offset_in, fig_h, dpi, renderer)

    # tight_layout reserves most top tick-label height inside [bottom, header_bottom],
    # but its default label padding leaves a little less visible subtitle-to-content
    # whitespace than the bottom-label case. Add only the residual headroom needed
    # to keep that visible gap consistent.
    has_top_labels = any(t.label2.get_visible() for t in [*ax.xaxis.get_major_ticks(), *ax.yaxis.get_major_ticks()])
    extra_gap_in = style.tick_size * _CHROME_TOP_LABEL_HEADROOM_FACTOR / 72.0 if has_top_labels else 0.0
    rect_top_in = header_bottom_in + _CHROME_GAP_HEADER_TO_AXES_IN + extra_gap_in if has_header else header_bottom_in
    header_bottom = 1.0 - rect_top_in / fig_h

    source_spec: _ChromeTextSpec | None = None
    axes_bottom_in = _CHROME_BOTTOM_MARGIN_IN
    if source_text is not None:
        # y is a placeholder; _layout_chrome_source sets the real position below.
        source_artist = fig.text(
            chrome_x,
            _CHROME_BOTTOM_MARGIN_IN / fig_h,
            source_text,
            ha="left",
            va="bottom",
            fontproperties=get_font_properties(style.source_font),
            fontsize=style.source_size,
            color=style.source_color,
            wrap=True,
        )
        source_artist.set_gid(chrome_gid)
        source_spec = _ChromeTextSpec(source_artist, source_text, 0.0)
        source_top_in = _layout_chrome_source(source_spec, fig_h, dpi, renderer)
        axes_bottom_in = source_top_in + _CHROME_GAP_SOURCE_TO_AXES_IN
    axes_bottom = axes_bottom_in / fig_h

    # Cache the chrome rect so callers (e.g. _auto_rotate_categorical_x_ticks)
    # can re-reflow after they change tick label heights. Without this, a
    # post-chrome rotation/wrap leaves the axes box sized for the original
    # pandas-90° labels and you get a tall empty gap below the data area.
    fig._ors_chrome_rect = (header_bottom, axes_bottom)

    if not _reflow_axes(fig, top=header_bottom, bottom=axes_bottom):
        fig.subplots_adjust(top=header_bottom, bottom=axes_bottom)

    # Store the placement as a resize-invariant recipe and install the engine so the chrome
    # re-wraps and re-flows to the figure's current size on later draws.
    layouts = getattr(fig, "_ors_chrome_layouts", None)
    if layouts is None:
        layouts = {}
        fig._ors_chrome_layouts = layouts
    layout = _ChromeLayout(
        ax=ax,
        header_top_offset_in=header_top_offset_in,
        header=header,
        header_to_axes_gap_in=0.0,
        source=source_spec,
        source_to_axes_gap_in=0.0,
        axes_bottom_offset_in=_CHROME_BOTTOM_MARGIN_IN,
        tab=tab_spec,
    )
    _recompute_chrome_axes_gaps(layout, fig, renderer)
    layouts[id(ax)] = layout
    _install_chrome_layout_engine(fig)

apply_label(ax, label, axis, pad=None)

Apply axis label styling using options.

Source code in openretailscience/plots/styles/styling_helpers.py
def apply_label(ax: Axes, label: str, axis: Literal["x", "y"], pad: int | None = None) -> None:
    """Apply axis label styling using options."""
    style = PlotStyleHelper()
    if pad is None:
        pad = style.x_label_pad if axis == "x" else style.y_label_pad

    font_props = get_font_properties(style.label_font)

    axis_fn = ax.set_xlabel if axis == "x" else ax.set_ylabel
    axis_fn(label, fontproperties=font_props, fontsize=style.label_size, labelpad=pad)

apply_legend(ax, title=None, outside=False, *, reverse=False, custom_labels=None)

Apply legend styling using options.

Call it before apply_chart_chrome (standard_graph_styles does) so the chrome leaves room for the legend.

Parameters:

Name Type Description Default
ax Axes

The axes whose labelled artists drive the legend.

required
title str | None

Legend title; None leaves it unset.

None
outside bool

Anchor the legend outside the axes.

False
reverse bool

Reverse the legend entry order.

False
custom_labels list[str] | None

Override the labels read off ax; the list is the final on-screen order.

None

Raises:

Type Description
ValueError

If custom_labels length does not match the number of legend handles on ax.

Source code in openretailscience/plots/styles/styling_helpers.py
def apply_legend(
    ax: Axes,
    title: str | None = None,
    outside: bool = False,
    *,
    reverse: bool = False,
    custom_labels: list[str] | None = None,
) -> None:
    """Apply legend styling using options.

    Call it before ``apply_chart_chrome`` (``standard_graph_styles`` does) so the chrome
    leaves room for the legend.

    Args:
        ax: The axes whose labelled artists drive the legend.
        title: Legend title; ``None`` leaves it unset.
        outside: Anchor the legend outside the axes.
        reverse: Reverse the legend entry order.
        custom_labels: Override the labels read off ``ax``; the list is the final on-screen order.

    Raises:
        ValueError: If ``custom_labels`` length does not match the number of legend handles on ``ax``.
    """
    handles, labels = ax.get_legend_handles_labels()
    if reverse:
        handles = list(reversed(handles))
        labels = list(reversed(labels))
    if custom_labels is not None:
        if len(custom_labels) != len(handles):
            msg = f"legend_labels length {len(custom_labels)} != number of legend handles {len(handles)}"
            raise ValueError(msg)
        labels = list(custom_labels)

    legend = (
        _build_outside_legend(ax, handles, labels, ncols=1) if outside else ax.legend(handles, labels, frameon=False)
    )
    _style_legend(legend, title)

apply_ticks(ax)

Apply tick styling using options.

Source code in openretailscience/plots/styles/styling_helpers.py
def apply_ticks(ax: Axes) -> None:
    """Apply tick styling using options."""
    style = PlotStyleHelper()
    # length=0 drops the tick marks; pad compensates for the lost label-to-spine gap.
    ax.tick_params(axis="both", which="both", labelsize=style.tick_size, length=0, pad=6)

    # Only AutoLocator-defaulted axes get the cap; FixedLocator (categorical) and
    # AutoDateLocator (time series) keep their callers' choices.
    for axis in (ax.xaxis, ax.yaxis):
        if isinstance(axis.get_major_locator(), AutoLocator):
            axis.set_major_locator(MaxNLocator(nbins=_MAX_NUMERIC_TICKS, prune=None))

    tick_font_props = get_font_properties(style.tick_font)
    for tick in [
        *ax.xaxis.get_major_ticks(),
        *ax.xaxis.get_minor_ticks(),
        *ax.yaxis.get_major_ticks(),
        *ax.yaxis.get_minor_ticks(),
    ]:
        tick.label1.set_fontproperties(tick_font_props)
        tick.label2.set_fontproperties(tick_font_props)

    _hide_zero_value_ticks(ax)

standard_graph_styles(ax, title=None, x_label=None, y_label=None, x_label_pad=None, y_label_pad=None, legend_title=None, move_legend_outside=False, show_legend=True, legend_style=None, legend_reverse=False, legend_labels=None, eyebrow=None, subtitle=None, source_text=None, grid_axis='both', x_margin=None, hide_spines=False)

Apply standard styles (spines, grid, ticks, legend, chrome) to a Matplotlib graph.

With legend_style="end_of_line" the box legend is suppressed and inline labels are drawn at each line's right endpoint; legend_title and move_legend_outside are ignored under it (UserWarning if set).

Parameters:

Name Type Description Default
ax Axes

The graph to apply the styles to.

required
title str | None

Chart title.

None
x_label str | None

X-axis label; when omitted, any existing label (e.g. pandas' column name) is cleared.

None
y_label str | None

Y-axis label; when omitted, any existing label (e.g. pandas' column name) is cleared.

None
x_label_pad int | None

Padding below the x-axis label.

None
y_label_pad int | None

Padding to the left of the y-axis label.

None
legend_title str | None

Legend title.

None
move_legend_outside bool

Anchor the legend outside the plot.

False
show_legend bool

Whether to display the legend.

True
legend_style Literal['box', 'end_of_line'] | None

"end_of_line" suppresses the box legend for inline end labels.

None
legend_reverse bool

Reverse the legend entry order; used by stacked area/bar plots where column order does not match the visual stack.

False
legend_labels list[str] | None

Override the legend labels, given in final on-screen order (after legend_reverse).

None
eyebrow str | None

Small uppercase label rendered above the title.

None
subtitle str | None

Supporting copy rendered below the title.

None
source_text str | None

Footer text rendered italic and muted at the bottom-left of the figure.

None
grid_axis GridAxis

Which axis to draw gridlines on.

'both'
x_margin float | None

Override matplotlib's default x-margin; line/area/time/histogram/ period_on_period pass 0 so first/last points touch the spines.

None
hide_spines bool

Hide all four spines, for plots whose cells define the boundaries (heatmap, cohort).

False

Returns:

Name Type Description
Axes Axes

The graph with the styles applied.

Raises:

Type Description
ValueError

If legend_labels length does not match the number of legend handles.

Source code in openretailscience/plots/styles/styling_helpers.py
def standard_graph_styles(  # noqa: PLR0913, PLR0917
    ax: Axes,
    title: str | None = None,
    x_label: str | None = None,
    y_label: str | None = None,
    x_label_pad: int | None = None,
    y_label_pad: int | None = None,
    legend_title: str | None = None,
    move_legend_outside: bool = False,
    show_legend: bool = True,
    legend_style: Literal["box", "end_of_line"] | None = None,
    legend_reverse: bool = False,
    legend_labels: list[str] | None = None,
    eyebrow: str | None = None,
    subtitle: str | None = None,
    source_text: str | None = None,
    grid_axis: GridAxis = "both",
    x_margin: float | None = None,
    hide_spines: bool = False,
) -> Axes:
    """Apply standard styles (spines, grid, ticks, legend, chrome) to a Matplotlib graph.

    With ``legend_style="end_of_line"`` the box legend is suppressed and inline labels are
    drawn at each line's right endpoint; ``legend_title`` and
    ``move_legend_outside`` are ignored under it (``UserWarning`` if set).

    Args:
        ax: The graph to apply the styles to.
        title: Chart title.
        x_label: X-axis label; when omitted, any existing label (e.g. pandas' column name) is cleared.
        y_label: Y-axis label; when omitted, any existing label (e.g. pandas' column name) is cleared.
        x_label_pad: Padding below the x-axis label.
        y_label_pad: Padding to the left of the y-axis label.
        legend_title: Legend title.
        move_legend_outside: Anchor the legend outside the plot.
        show_legend: Whether to display the legend.
        legend_style: ``"end_of_line"`` suppresses the box legend for inline end labels.
        legend_reverse: Reverse the legend entry order; used by
            stacked area/bar plots where column order does not match the visual stack.
        legend_labels: Override the legend labels, given in final on-screen order (after
            ``legend_reverse``).
        eyebrow: Small uppercase label rendered above the title.
        subtitle: Supporting copy rendered below the title.
        source_text: Footer text rendered italic and muted at the bottom-left of the figure.
        grid_axis: Which axis to draw gridlines on.
        x_margin: Override matplotlib's default x-margin; line/area/time/histogram/
            period_on_period pass ``0`` so first/last points touch the spines.
        hide_spines: Hide all four spines, for plots whose cells define the boundaries
            (heatmap, cohort).

    Returns:
        Axes: The graph with the styles applied.

    Raises:
        ValueError: If ``legend_labels`` length does not match the number of legend handles.
    """
    # Suppress box-legend args before apply_legend runs; keep the pre-resolution
    # show_legend for the end-of-line draw call so single-series charts skip it.
    legend_show, legend_title, move_legend_outside = _resolve_end_of_line_legend_args(
        legend_style=legend_style,
        show_legend=show_legend,
        legend_title=legend_title,
        move_legend_outside=move_legend_outside,
    )

    apply_base_styling(ax, grid_axis=grid_axis, hide_spines=hide_spines)

    if x_margin is not None:
        ax.margins(x=x_margin)

    # Explicitly clear the pandas-auto labels when the caller didn't pass one in.
    if x_label is not None:
        apply_label(ax, x_label, "x", x_label_pad)
    else:
        ax.set_xlabel("")

    if y_label is not None:
        apply_label(ax, y_label, "y", y_label_pad)
    else:
        ax.set_ylabel("")

    apply_ticks(ax)

    # pandas auto-creates legends on multi-series plots, so show_legend=False
    # has to actively remove the existing one.
    legend_present_or_requested = (
        ax.get_legend() is not None
        or legend_title is not None
        or move_legend_outside
        or legend_reverse
        or legend_labels is not None
    )
    if legend_show and legend_present_or_requested:
        apply_legend(
            ax,
            legend_title,
            move_legend_outside,
            reverse=legend_reverse,
            custom_labels=legend_labels,
        )
    elif not legend_show and ax.get_legend() is not None:
        ax.get_legend().remove()

    apply_chart_chrome(
        ax,
        eyebrow=eyebrow,
        title=title,
        subtitle=subtitle,
        source_text=source_text,
    )

    # Before auto-rotation: widening the legend changes the axes width the rotation test reads.
    if legend_show and move_legend_outside:
        _fit_outside_legend(ax, legend_title)

    # Auto-rotate AFTER chrome so the axes width reflects the final layout
    # (legend placement and chrome's tight_layout both shrink the axes).
    # Measuring at the default size missed overlaps that only appeared once
    # the legend was moved outside and tight_layout reserved its slot.
    _auto_rotate_categorical_x_ticks(ax)

    # End-of-line labels run last so the bump algorithm sees the final axes
    # geometry (chrome's tight_layout + any rotation-induced reflow). Uses the
    # pre-resolution show_legend (not legend_show) so single-series charts skip.
    if legend_style == "end_of_line" and show_legend:
        draw_end_of_line_labels(ax)

    return ax