Facet planning and rendering#

The v0.6 facet foundation separates data partitioning and layout decisions from figure creation. ggstyle.facet_plan() validates the complete request and returns an immutable ggstyle.FacetPlan without creating Matplotlib axes or retaining the source frame.

Render facets#

ggstyle.facets() turns a validated wrap or grid policy into an owned Matplotlib figure and row-major tuple of ordinary axes. A one-variable wrap looks like:

grid = gs.facets(
    frame,
    col="series",
    wrap=3,
    scales="free_y",
    theme="minimal",
    max_panels=12,
)
grid.map(lambda panel, ax: gs.line(panel, x="date", y="value", ax=ax))
grid.map(lambda panel, ax: gs.points(panel, x="date", y="value", ax=ax))

grid.figure is a native matplotlib.figure.Figure; grid.axes contains one native matplotlib.axes.Axes per planned panel. Rectangular cells beyond the panel count are removed. Plain axes titles identify facet values, including NA for a kept missing level. Styled strips and shared figure labels arrive in the later presentation workstream.

Omit wrap and provide row, col, or both to render a grid. Two-variable grids materialize the complete row-by-column Cartesian product already recorded in the plan:

grid = gs.facets(
    frame,
    row="region",
    col="metric",
    row_order=("North", "South"),
    col_order=("Revenue", "Margin"),
    include_unobserved=True,
)
grid.map(lambda panel, ax: gs.line(panel, x="date", y="value", ax=ax))

Axes and callbacks follow row-major plan order. Empty combinations remain real panels and receive empty subsets of the same pandas or Polars dataframe type. Titles list resolved values in row-then-column order; native axes labels retain the corresponding variable names for inspection and accessibility.

Callbacks run in plan order as callback(panel_data, ax). Pandas and Polars inputs retain their dataframe type, mapping inputs become dictionaries of selected columns, and every call receives a fresh defensive subset. Callback return values are ignored, making repeated map calls a simple way to add layers. If a callback raises, ggstyle.FacetCallbackError identifies its panel and chains the original error. Earlier arbitrary callback mutations cannot be rolled back safely and remain visible.

scales wires native Matplotlib sharing at subplot construction: fixed shares both axes, free_x shares only y, free_y shares only x, and free shares neither.

Fixed, free, and collapsed date scales#

After mapping the first date layer, configure facet-wide date behavior explicitly:

grid = gs.facets(frame, col="series", wrap=3, scales="fixed")
grid.map(lambda panel, ax: gs.line(panel, x="date", y="value", ax=ax))
grid.dates(mode="collapse", limits="union")

fixed and free_y keep x fixed, so every populated date panel joins one live, revisioned observation registry. Their observation union gives the same date the same collapsed coordinate everywhere. free_x and free train one registry and visible range per populated panel instead. date_handles is aligned with grid.axes and contains None for panels without date observations.

Empty panels in a fixed-x layout inherit the shared Matplotlib transform and limits but do not receive a synthetic handle or observations. Calling dates() when no panel has date data raises rather than guessing that numeric coordinates represent dates.

Once configured, later successful map() passes refresh the same policy automatically. Semantic helpers defer their per-layer refreshes during that pass, then the fixed group publishes one registry revision. Shared synchronization reuses the transactional sync_dates boundary; a failed scale application restores prior modes, observations, limits, and registries and disposes handles created only for the failed request.

Wrap plans#

A wrap plan uses one column and a maximum number of layout columns:

plan = gs.facet_plan(
    frame,
    col="series",
    wrap=3,
    scales="free_y",
    max_panels=12,
)

plan.shape
plan.panels[0].values
plan.panels[0].indices

Levels use stable first-seen order for ordinary columns. Pandas categorical and Polars categorical/enum columns preserve their declared order. Pass col_order= to make that order independent of frame metadata. Observed levels are retained by default; include_unobserved=True creates empty panels for declared levels with no rows.

Grid plans#

Supplying row and column variables creates their row-major Cartesian product:

plan = gs.facet_plan(
    frame,
    row="region",
    col="metric",
    row_order=("North", "South"),
    scales="fixed",
)

Grid combinations remain present when no source rows select them. This makes empty-panel behavior part of the plan instead of an accident of rendering. A row-only plan has one layout column; a column-only plan has one layout row.

Missing values and safeguards#

missing="drop" excludes a source row when any facet value is missing and reports the count in diagnostics. "keep" creates a final None level, while "raise" rejects the first missing row. The default max_panels=64 limit is checked before panel descriptions are allocated; raising it is always an explicit caller decision.

scales accepts "fixed", "free_x", "free_y", or "free". Pure plans record that policy without rendering; ggstyle.facets() applies native sharing for wrap and grid layouts. ggstyle.FacetGrid.dates() applies the corresponding date registry policy.

Inspection and rendering boundary#

plan.as_dict() returns bounded strict-JSON data with panel row counts but not every source index. plan.describe() formats the same payload deterministically. Concrete panels retain immutable positional indices used by the callback renderer.

Pure planning still creates no figure, axes, artists, callbacks, or global state. PR23 adds wrap rendering and PR24 extends the same callback boundary to grids. Shared date registries and fixed/free coordinate semantics land in PR25; styled strips, shared labels, and guide collection follow in PR26.