Concatenation (facet-like with independent axes)

Altair's built-in facet shares all axes across every sub-chart. This is often undesirable when comparing across splits or conditions that have very different value ranges. vchart and hchart produce concatenated layouts where each sub-chart computes its own axis domain independently, while still appearing as a faceted grid.

Both functions accept two dimensions:

This gives a grid where one direction has independent axes and the other has shared axes: exactly what you need when, say, comparing models across train/test splits that live in different value ranges.

Example: models across splits and groups

import polars as pl
from plotutils.concat import hchart, vchart
from plotutils.uncertainty import plot_predictions_errors

df = pl.DataFrame({
    "true":  [1.0, 2.0, 3.0,  4.0, 5.0, 6.0,   1.0, 2.0, 3.0,  4.0, 5.0, 6.0,
              50.0, 60.0, 70.0, 75.0, 80.0, 90.0, 50.0, 60.0, 70.0, 75.0, 80.0, 90.0],
    "pred":  [1.1, 2.3, 2.8,  4.2, 5.1, 5.8,   1.4, 1.7, 3.5,  3.6, 5.3, 5.5,
              52.0, 58.0, 72.0, 73.0, 82.0, 91.0, 55.0, 55.0, 75.0, 70.0, 84.0, 93.0],
    "split": ["train"] * 12 + ["test"] * 12,
    "group": (["A"] * 6 + ["B"] * 6) * 2,
    "model": (["linear"] * 3 + ["tree"] * 3) * 4,
})

Horizontal concat + vertical facet

Columns = splits (independent axes), rows = groups (shared axes within each split), color/shape = models:

chart = hchart(
    column="split",
    row="group",
    df=df,
    func=plot_predictions_errors,
    color_col="model",
    shape_col="model",
)

Vertical concat + horizontal facet

The inverse layout — rows = splits (independent axes), columns = groups (shared axes):

chart = vchart(
    row="split",
    column="group",
    df=df,
    func=plot_predictions_errors,
    color_col="model",
    shape_col="model",
)

Three splits side by side

Without a facet dimension, each panel simply gets its own axes:

df_split = pl.DataFrame({
    "true":  [1.0, 2.0, 3.0, 4.0,  10.0, 20.0, 30.0, 40.0,  50.0, 60.0, 70.0, 80.0],
    "pred":  [1.2, 1.8, 3.2, 3.9,  11.0, 19.0, 31.0, 39.0,  52.0, 58.0, 72.0, 78.0],
    "split": ["train"] * 4 + ["val"] * 4 + ["test"] * 4,
})

chart = hchart(
    column="split",
    df=df_split,
    func=plot_predictions_errors,
    width=300,
    height=300,
)

Reference

plotutils.concat

Facet-like concatenation with independent axes.

Altair's built-in facet shares all axes across every sub-chart. :func:vchart and :func:hchart instead produce concatenated layouts where each sub-chart computes its own axis domain independently, while still appearing as a faceted grid. Use vchart when sub-charts should share the y-axis direction (stacked vertically, one per row) and hchart when they should share the x-axis direction (laid out horizontally, one per column).

Both functions accept an optional second dimension (column for vchart, row for hchart) which is handled by Altair's native facet — sharing axes within each concat panel. This gives a grid where one direction has independent axes and the other has shared axes.

hchart(*args, column, row=None, df, func, **kwargs)

Lay out charts horizontally with independent axes per column.

Each column gets its own axis domain. When row is provided, each column is additionally faceted vertically using Altair's native facet, so rows within the same column share axes.

Source code in src/plotutils/concat.py
def hchart(
    *args,
    column: str,
    row: str | None = None,
    df: pl.DataFrame,
    func: ChartFunc,
    **kwargs,
) -> alt.HConcatChart:
    """Lay out charts horizontally with independent axes per column.

    Each column gets its own axis domain.  When *row* is provided, each
    column is additionally faceted vertically using Altair's native facet,
    so rows within the same column share axes.
    """
    groups = list(df.group_by(column, maintain_order=True))
    sub_charts = [func(_df, *args, title=_name, **kwargs) for _name, _df in groups]
    stripped, config = _strip_config(sub_charts)
    if row is not None:
        stripped = [
            c.facet(row=f"{row}:N", data=_df)
            for c, (_name, _df) in zip(stripped, groups)
        ]
    result = alt.hconcat(*stripped)
    if config is not alt.Undefined:
        result.config = config
    return result

vchart(*args, row, column=None, df, func, **kwargs)

Stack charts vertically with independent axes per row.

Each row gets its own axis domain. When column is provided, each row is additionally faceted horizontally using Altair's native facet, so columns within the same row share axes.

Source code in src/plotutils/concat.py
def vchart(
    *args,
    row: str,
    column: str | None = None,
    df: pl.DataFrame,
    func: ChartFunc,
    **kwargs,
) -> alt.VConcatChart:
    """Stack charts vertically with independent axes per row.

    Each row gets its own axis domain.  When *column* is provided, each
    row is additionally faceted horizontally using Altair's native facet,
    so columns within the same row share axes.
    """
    groups = list(df.group_by(row, maintain_order=True))
    sub_charts = [func(_df, *args, title=_name, **kwargs) for _name, _df in groups]
    stripped, config = _strip_config(sub_charts)
    if column is not None:
        stripped = [
            c.facet(column=f"{column}:N", data=_df)
            for c, (_name, _df) in zip(stripped, groups)
        ]
    result = alt.vconcat(*stripped)
    if config is not alt.Undefined:
        result.config = config
    return result