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.

Parameters:

Name Type Description Default
ax Axes

The axes to style.

required
grid_axis GridAxis

Which axis grid lines to draw. "both" draws horizontal and vertical lines, "x" draws only vertical (helpful when reading off the x-axis, e.g. horizontal bars), "y" draws only horizontal (the common case for line, area, and vertical-bar charts), and "none" suppresses both.

'both'
hide_spines bool

If True, hide all four axis spines regardless of the per-spine style options. Use for plots where cell colours or shapes already define the boundaries (heatmap, cohort) and spines would only repeat that information.

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.

    Args:
        ax: The axes to style.
        grid_axis: Which axis grid lines to draw. ``"both"`` draws horizontal and vertical
            lines, ``"x"`` draws only vertical (helpful when reading off the x-axis, e.g.
            horizontal bars), ``"y"`` draws only horizontal (the common case for line, area,
            and vertical-bar charts), and ``"none"`` suppresses both.
        hide_spines: If True, hide all four axis spines regardless of the per-spine style
            options. Use for plots where cell colours or shapes already define the
            boundaries (heatmap, cohort) and spines would only repeat that information.
    """
    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)

Place the figure-level chrome (eyebrow, tab, title, subtitle, source) and reflow the axes.

Sequential layout: each header element is placed and drawn in turn so the next element starts directly below the previous one's measured bbox. Wrapped titles therefore push subsequent elements down by their actual rendered height, never their estimated height. After the header and source are placed, tight_layout reflows the axes (with their tick and axis labels) into the remaining vertical band.

Each absent element collapses its slot, so a chart with only a title takes only the vertical space the title needs.

Parameters:

Name Type Description Default
ax Axes

The plot axes (used to get the figure handle).

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. Defaults to 4 so the warning points at user code when reached via standard_graph_styles from a public *.plot function. Callers that invoke apply_chart_chrome directly from a public entry point (e.g. venn.plot) should pass 3.

4

The small green tab mark above the title block is controlled by the plot.style.show_tab option (default True). Set the option to False to suppress it project-wide; use option_context to scope the change.

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:
    """Place the figure-level chrome (eyebrow, tab, title, subtitle, source) and reflow the axes.

    Sequential layout: each header element is placed and drawn in turn so
    the next element starts directly below the previous one's measured
    bbox. Wrapped titles therefore push subsequent elements down by their
    actual rendered height, never their estimated height. After the header
    and source are placed, ``tight_layout`` reflows the axes (with their
    tick and axis labels) into the remaining vertical band.

    Each absent element collapses its slot, so a chart with only a title
    takes only the vertical space the title needs.

    Args:
        ax: The plot axes (used to get the figure handle).
        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. Defaults to 4
            so the warning points at user code when reached via
            ``standard_graph_styles`` from a public ``*.plot`` function. Callers
            that invoke ``apply_chart_chrome`` directly from a public entry point
            (e.g. ``venn.plot``) should pass ``3``.

    The small green tab mark above the title block is controlled by the
    ``plot.style.show_tab`` option (default True). Set the option to False to
    suppress it project-wide; use ``option_context`` to scope the change.
    """
    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)

    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()
    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.
    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.get_figwidth()
            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)
        cur_y -= tab_height_fig + tab_to_eyebrow_fig

    # 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.
    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 / fig_h,
        ),
        (
            title,
            style.title_font,
            style.title_size,
            style.title_color,
            True,
            _CHROME_GAP_TITLE_TO_SUBTITLE_IN / fig_h if subtitle is not None else 0.0,
        ),
        (
            subtitle,
            style.subtitle_font,
            style.subtitle_size,
            style.subtitle_color,
            True,
            0.0,
        ),
    )
    for text, font, size, color, wrap, gap_after in header_specs:
        if text is None:
            continue
        cur_y = _place_header_text(ax, chrome_x, cur_y, text, font, size, color, wrap=wrap, gid=chrome_gid)
        cur_y -= gap_after

    # 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
    header_bottom = cur_y - (_CHROME_GAP_HEADER_TO_AXES_IN + extra_gap_in) / fig_h if has_header else cur_y

    bottom_margin_fig = _CHROME_BOTTOM_MARGIN_IN / fig_h
    axes_bottom = bottom_margin_fig
    if source_text is not None:
        source_t = fig.text(
            chrome_x,
            bottom_margin_fig,
            source_text,
            ha="left",
            va="bottom",
            fontproperties=get_font_properties(style.source_font),
            fontsize=style.source_size,
            color=style.source_color,
            wrap=True,
        )
        source_t.set_gid(chrome_gid)
        fig.canvas.draw()
        source_top_px = source_t.get_window_extent(renderer=fig.canvas.get_renderer()).y1
        axes_bottom = source_top_px / fig.bbox.height + _CHROME_GAP_SOURCE_TO_AXES_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)

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.

Handles are read from ax via get_legend_handles_labels() so the legend can be rebuilt with reversed order (stacked-area / stacked-bar visual stack) or substituted labels (e.g. column ids → human-readable names) in a single build. Calling this once from standard_graph_styles rather than after it ensures chrome's tight_layout reserves the slot matching the final 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 when True.

False
reverse bool

Reverse the handle and label order before rebuilding.

False
custom_labels list[str] | None

Override the labels read off ax. Applied after reverse, so the list is the final on-screen order.

None

Raises:

Type Description
ValueError

If custom_labels is provided and its 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.

    Handles are read from ``ax`` via ``get_legend_handles_labels()`` so the legend
    can be rebuilt with reversed order (stacked-area / stacked-bar visual stack)
    or substituted labels (e.g. column ids → human-readable names) in a single
    build. Calling this once from ``standard_graph_styles`` rather than after it
    ensures chrome's ``tight_layout`` reserves the slot matching the final legend.

    Args:
        ax (Axes): The axes whose labelled artists drive the legend.
        title (str | None): Legend title; ``None`` leaves it unset.
        outside (bool): Anchor the legend outside the axes when ``True``.
        reverse (bool): Reverse the handle and label order before rebuilding.
        custom_labels (list[str] | None): Override the labels read off ``ax``.
            Applied after ``reverse``, so the list is the final on-screen order.

    Raises:
        ValueError: If ``custom_labels`` is provided and its length does not
            match the number of legend handles on ``ax``.
    """
    style = PlotStyleHelper()
    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)

    if outside:
        legend = ax.legend(
            handles,
            labels,
            frameon=False,
            bbox_to_anchor=style.legend_bbox_to_anchor,
            loc=style.legend_loc,
        )
    else:
        legend = ax.legend(handles, labels, frameon=False)

    legend_font_props = get_font_properties(style.legend_font)
    if title is not None:
        legend.set_title(title)
        legend.get_title().set_fontproperties(legend_font_props)
        legend.get_title().set_fontsize(style.legend_size)

    for text in legend.get_texts():
        text.set_fontproperties(legend_font_props)
        text.set_fontsize(style.legend_size)

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 to a Matplotlib graph using styling helpers.

Parameters:

Name Type Description Default
ax Axes

The graph to apply the styles to.

required
title str

The title of the graph. Defaults to None.

None
x_label str

The x-axis label. Defaults to None.

None
y_label str

The y-axis label. Defaults to None.

None
x_label_pad int

The padding below the x-axis label. Defaults to styling context default.

None
y_label_pad int

The padding to the left of the y-axis label. Defaults to styling context default.

None
legend_title str

The title of the legend. If None, no legend title is applied. Defaults to None.

None
move_legend_outside bool

Whether to move the legend outside the plot. Defaults to False.

False
show_legend bool

Whether to display the legend or not.

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

When "end_of_line", suppress the box legend and draw inline labels at each line's right endpoint after chrome reflow. "box" and None leave the legend behaviour unchanged. legend_title and move_legend_outside are ignored under "end_of_line" and emit a UserWarning if set. Defaults to None.

None
legend_reverse bool

Reverse handle and label order before building the legend. Used by stacked area/bar plots where the column-order legend doesn't match the visual stack (bottom-up). Defaults to False.

False
legend_labels list[str] | None

Override the labels read from labelled artists (e.g. swap column ids for human-readable names). Applied after legend_reverse. Length must match the number of legend handles or ValueError is raised. Defaults to None.

None
eyebrow str

Small uppercase label rendered above the title. Defaults to None.

None
subtitle str

Supporting copy rendered below the title. Defaults to None.

None
source_text str

Footer text rendered italic and muted at the bottom-left of the figure. The chrome layout engine reserves room for it.

None
grid_axis Literal['both', 'x', 'y', 'none']

Which axis to draw gridlines on. Defaults to "both".

'both'
x_margin float

If set, override matplotlib's default x-margin. Editorial line/area/time charts pass 0 so the first data point sits on the spine and the last reaches the right edge.

None
hide_spines bool

If True, hide all four axis spines. Use for plots whose cell colours or shapes already define their boundaries (heatmap, cohort). Defaults to False.

False

Returns:

Name Type Description
Axes Axes

The graph with the styles applied.

Source code in openretailscience/plots/styles/styling_helpers.py
def standard_graph_styles(  # noqa: PLR0913
    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 to a Matplotlib graph using styling helpers.

    Args:
        ax (Axes): The graph to apply the styles to.
        title (str, optional): The title of the graph. Defaults to None.
        x_label (str, optional): The x-axis label. Defaults to None.
        y_label (str, optional): The y-axis label. Defaults to None.
        x_label_pad (int, optional): The padding below the x-axis label. Defaults to styling context default.
        y_label_pad (int, optional): The padding to the left of the y-axis label. Defaults to styling context default.
        legend_title (str, optional): The title of the legend. If None, no legend title is applied. Defaults to None.
        move_legend_outside (bool, optional): Whether to move the legend outside the plot. Defaults to False.
        show_legend (bool): Whether to display the legend or not.
        legend_style (Literal["box", "end_of_line"], optional): When ``"end_of_line"``, suppress the box
            legend and draw inline labels at each line's right endpoint after chrome reflow. ``"box"`` and
            ``None`` leave the legend behaviour unchanged. ``legend_title`` and ``move_legend_outside`` are
            ignored under ``"end_of_line"`` and emit a UserWarning if set. Defaults to None.
        legend_reverse (bool, optional): Reverse handle and label order before building the legend.
            Used by stacked area/bar plots where the column-order legend doesn't match the visual
            stack (bottom-up). Defaults to False.
        legend_labels (list[str] | None, optional): Override the labels read from labelled artists
            (e.g. swap column ids for human-readable names). Applied after ``legend_reverse``.
            Length must match the number of legend handles or ``ValueError`` is raised. Defaults to None.
        eyebrow (str, optional): Small uppercase label rendered above the title. Defaults to None.
        subtitle (str, optional): Supporting copy rendered below the title. Defaults to None.
        source_text (str, optional): Footer text rendered italic and muted at the bottom-left of the figure.
            The chrome layout engine reserves room for it.
        grid_axis (Literal["both", "x", "y", "none"], optional): Which axis to draw gridlines on.
            Defaults to ``"both"``.
        x_margin (float, optional): If set, override matplotlib's default x-margin. Editorial line/area/time
            charts pass ``0`` so the first data point sits on the spine and the last reaches the right edge.
        hide_spines (bool, optional): If True, hide all four axis spines. Use for plots whose cell
            colours or shapes already define their boundaries (heatmap, cohort). Defaults to False.

    Returns:
        Axes: The graph with the styles applied.
    """
    # 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,
    )

    # 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