# Interface Design: Morphometric Visualization Functions

Status: Draft v5 (2026-03-10)

## Overview

Two new plot functions for `ktch.plot` that generalize shape visualization across all morphometric methods (EFA, SHA, GPA) and dimensionality reduction methods (PCA, KernelPCA, etc.):

1. `shape_variation_plot` -- Grid of reconstructed shapes along component axes (rows = components, columns = SD values)
2. `morphospace_plot` -- Scatter plot of scores with reconstructed shape insets at grid positions

Both functions are method-agnostic through a two-stage callback pipeline (`reducer` + `descriptor`) and a `shape_type` parameter.

Reference implementations:
- `spharm.vis.plot_3d_outline_shapes_along_pc_axes` (external, not in this repo; SPHARM-specific, function 1)
- `spharm.vis.plot_recon_morphs` (external, not in this repo; SPHARM-specific, function 2)
- Tutorial helpers in `doc/tutorials/harmonic/elliptic_Fourier_analysis.md` and `doc/tutorials/landmark/generalized_Procrustes_analysis.md` (primary design reference)

## Design Principles

- Naming convention: `<noun>_plot` suffix (e.g., `morphospace_plot`, `shape_variation_plot`), consistent with existing `explained_variance_ratio_plot`, `tps_grid_2d_plot` and close to seaborn's `<noun>plot` style
- Two API levels following seaborn convention:
  - Axes-level: `morphospace_plot` -- accept `ax`, return `Axes`; composable with seaborn `FacetGrid` and user-managed subplot layouts
  - Figure-level: `shape_variation_plot` -- accept `fig`, return `Figure`; manages its own subplot grid (components x SD values). Class wrapper unnecessary for current scope since the grid structure is fixed and no `.map()` or data-variable faceting is needed.
- Lazy imports via `require_dependencies()` from `ktch.plot._base`
- Method-agnostic via two-stage callback pipeline + `shape_type` parameter
- Reducer convenience: `reducer` parameter works with PCA-compatible estimators (those exposing `.inverse_transform`, `.explained_variance_`, `.n_components_`). Low-level override parameters support arbitrary reducers (KernelPCA, etc.).
- Built-in renderers for common cases; custom `render_fn` override
- 0-indexed component indices (consistent with sklearn `components_[i]`)
- NumPy-style docstrings

## Reconstruction Pipeline

The core abstraction separates two inverse transforms and rendering into a three-stage pipeline:

```
low-dim scores ──[reducer_inverse_transform]──> coefficients ──[descriptor_inverse_transform]──> shape coords ──[render]──> plot
                  (PCA, KernelPCA, etc.)         (EFA, SHA, identity for GPA)
```

Both stages follow the same convenience pattern:

| Stage | Convenience param | Override param | Auto-extracted attribute |
|---|---|---|---|
| Reducer | `reducer` | `reducer_inverse_transform` | `reducer.inverse_transform` |
| | | `explained_variance` | `reducer.explained_variance_` (PCA-compatible) |
| | | `n_components` | `reducer.n_components_` -> `reducer.n_components` (fallback) |
| Descriptor | `descriptor` | `descriptor_inverse_transform` | `descriptor.inverse_transform` |

The `reducer` convenience path targets PCA-compatible estimators. For non-PCA reducers (e.g., `KernelPCA`), use the explicit override parameters instead (see KernelPCA example below).

For GPA (landmarks), `descriptor` and `descriptor_inverse_transform` are both `None` (identity). The reducer output IS the shape data, reshaped using `n_dim`.

### Parameter resolution logic

```python
# --- Reducer ---
if reducer is not None:
    if reducer_inverse_transform is None:
        reducer_inverse_transform = reducer.inverse_transform
    if explained_variance is None:
        explained_variance = getattr(reducer, "explained_variance_", None)
    if n_components is None:
        # Fallback chain: n_components_ (PCA) -> n_components (KernelPCA, etc.)
        n_components = getattr(
            reducer, "n_components_",
            getattr(reducer, "n_components", None),
        )

# --- Descriptor ---
if descriptor is not None and descriptor_inverse_transform is None:
    descriptor_inverse_transform = descriptor.inverse_transform

# --- Validation ---
if reducer_inverse_transform is None:
    raise ValueError(
        "Either reducer or reducer_inverse_transform must be provided"
    )
# (explained_variance, n_components validation for shape_variation_plot)

if descriptor_inverse_transform is None:
    # Identity (GPA) case: n_dim required unless inferable from shape_type
    if n_dim is None:
        if shape_type in ("landmarks_2d",):
            n_dim = 2
        elif shape_type in ("landmarks_3d",):
            n_dim = 3
        else:
            raise ValueError(
                "n_dim is required when descriptor is not provided "
                "and shape_type is not an explicit landmarks type"
            )
```

### `descriptor_inverse_transform` contract

- Input: `np.ndarray` of shape `(n_samples, n_features)`
- Output: `np.ndarray` (not DataFrame). The function calls `np.asarray()` on the output as a safety measure.

### SHA surface resolution control

For SHA 3D surfaces, reconstruction resolution is controlled at the `descriptor_inverse_transform` call site via `theta_range` and `phi_range` parameters, not via extra plot function parameters. Users wrap the callable:

```python
import numpy as np

morphospace_plot(
    ...,
    descriptor_inverse_transform=lambda c: sha.inverse_transform(
        c,
        theta_range=np.linspace(0, np.pi, 30),
        phi_range=np.linspace(0, 2 * np.pi, 60),
    ),
)
```

SHA defaults to 90x180 resolution. For inset rendering, lower resolution (e.g., 30x60) is recommended for performance. This avoids proliferating domain-specific parameters. Document examples in docstrings.

## Shape Rendering

### Shape type taxonomy

`shape_type` controls two things: the geometric representation (how to interpret coords) and the display mode (2D or 3D axes). The naming follows `{geometry}_{display_dim}`.

Classification axes:

| Geometry | Description | Rendering | Morphometric examples |
|---|---|---|---|
| `curve` | 1D manifold (open or closed) | `ax.plot` | EFA outline (2D/3D), any parametric curve |
| `surface` | 2D manifold, structured grid | `ax.plot_surface` | SHA (sphere), torus, annulus, disk harmonics |
| `mesh` | 2D manifold, triangulated | `ax.plot_trisurf` | genus-N manifold (future) |
| `landmarks` | discrete point set + links | `ax.scatter` + line segments | GPA landmarks |

### Built-in shape types (initial implementation)

| `shape_type` | Input coords shape | Rendering | Axes |
|---|---|---|---|
| `"curve_2d"` | `(t, 2+)` | `ax.plot(x, y)` | 2D, equal aspect |
| `"curve_3d"` | `(t, 3)` | `ax.plot(x, y, z)` | 3D |
| `"surface_3d"` | `(m, n, 3)` | `ax.plot_surface(X, Y, Z)` | 3D |
| `"landmarks_2d"` | `(n_lm, 2+)` | `ax.scatter` + `LineCollection` | 2D, equal aspect |
| `"landmarks_3d"` | `(n_lm, 3)` | `ax.scatter` + line segments | 3D |

Note: `curve_2d` and `landmarks_2d` accept coords with 3+ columns and use only the first 2. This enables 2D projection of 3D data by simply specifying `shape_type="curve_2d"` or `shape_type="landmarks_2d"` with 3D data.

### Reserved for future implementation

| `shape_type` | Input | Rendering | Use case |
|---|---|---|---|
| `"mesh_3d"` | vertices `(v, 3)` + faces `(f, 3)` | `ax.plot_trisurf` | genus-N manifold |

### `n_dim` and `shape_type` independence

`n_dim` specifies the spatial dimensionality of the data (used for reshape in GPA identity case). `shape_type` specifies the display mode. They are independent:

| `n_dim` | `shape_type` | Behavior |
|---|---|---|
| 3 | `landmarks_3d` | 3D axes, full 3D rendering |
| 3 | `landmarks_2d` | 2D axes, XY projection (first 2 dims) |
| 3 | `curve_3d` | 3D axes, 3D curve |
| 3 | `curve_2d` | 2D axes, XY projection |
| 2 | `landmarks_2d` | 2D axes, standard rendering |
| 2 | `curve_2d` | 2D axes, standard rendering |

For projections other than XY (e.g., XZ, YZ), use a custom `render_fn`:
```python
def xz_renderer(coords, ax, **kw):
    ax.scatter(coords[:, 0], coords[:, 2], **kw)
    ax.set_aspect("equal")

shape_variation_plot(reducer, n_dim=3, render_fn=xz_renderer)
```

### Auto-detection rule (`shape_type="auto"`)

When `descriptor_inverse_transform` is provided:
- Output ndim == 4 -> `"surface_3d"`
- Output ndim == 3, shape[-1] == 2 -> `"curve_2d"`
- Output ndim == 3, shape[-1] == 3 -> `"curve_3d"`

When `descriptor_inverse_transform is None` (GPA / identity case):
- `n_dim == 2` -> `"landmarks_2d"`
- `n_dim == 3` -> `"landmarks_3d"`

Note: For ndim == 3 arrays with `shape[-1] == 3`, auto-detection chooses `"curve_3d"`. If the data represents landmarks rather than a curve, specify `shape_type="landmarks_3d"` explicitly.

Users can always override auto-detection by specifying `shape_type` explicitly.

### Renderer signature

```python
def render_fn(
    coords: np.ndarray,
    ax: matplotlib.axes.Axes,
    *,
    color: str = "gray",
    alpha: float = 1.0,
    links: Sequence[Sequence[int]] | None = None,
    **kwargs: Any,
) -> None:
```

Built-in renderers (`_renderers.py`) are internal but their signature is documented for custom `render_fn` authors.

### render_kw conflict resolution

Explicit style parameters (`color`, `alpha`) and `**render_kw` can potentially conflict. Resolution: explicit parameters take precedence with a warning.

```python
_EXPLICIT_RENDER_KEYS = frozenset({"color", "alpha", "links"})

def _resolve_render_kw(render_kw, *, color, alpha, links):
    """Merge explicit params with render_kw. Explicit params take precedence."""
    resolved = dict(render_kw)
    conflicts = _EXPLICIT_RENDER_KEYS & resolved.keys()
    if conflicts:
        warnings.warn(
            f"{conflicts} in render_kw ignored; "
            f"use the explicit parameter(s) instead",
            UserWarning,
            stacklevel=3,
        )
        for key in conflicts:
            del resolved[key]
    resolved["color"] = color
    resolved["alpha"] = alpha
    resolved["links"] = links
    return resolved
```

### Built-in renderer implementations

`_render_curve_2d`:
```python
def _render_curve_2d(coords, ax, *, color="gray", alpha=1.0, **kw):
    # coords: (t, 2) or (t, 3+) — use first 2 dims
    ax.plot(coords[:, 0], coords[:, 1], color=color, alpha=alpha, **kw)
    ax.set_aspect("equal")
```

`_render_curve_3d`:
```python
def _render_curve_3d(coords, ax, *, color="gray", alpha=1.0, **kw):
    # coords: (t, 3)
    ax.plot(coords[:, 0], coords[:, 1], coords[:, 2],
            color=color, alpha=alpha, **kw)
    ax.set_box_aspect([np.ptp(coords[:, i]) for i in range(3)])
```

`_render_surface_3d`:
```python
def _render_surface_3d(coords, ax, *, color="orange", alpha=1.0, **kw):
    # coords: (m, n, 3) — structured grid surface (SHA, torus, annulus, disk, etc.)
    X, Y, Z = coords[..., 0], coords[..., 1], coords[..., 2]
    ax.plot_surface(X, Y, Z, color=color, alpha=alpha, **kw)
    ax.set_box_aspect([np.ptp(X), np.ptp(Y), np.ptp(Z)])
```

`_render_landmarks_2d`:
```python
def _render_landmarks_2d(coords, ax, *, color="gray", alpha=1.0,
                         links=None, **kw):
    # coords: (n_lm, 2) or (n_lm, 3+) — use first 2 dims
    x, y = coords[:, 0], coords[:, 1]
    if links:
        pts = coords[:, :2]
        segments = [pts[list(link)] for link in links
                    if all(i < len(pts) for i in link)]
        lc = LineCollection(segments, colors=color, alpha=alpha)
        ax.add_collection(lc)
    ax.scatter(x, y, c=color, alpha=alpha, s=kw.pop("s", 10), **kw)
    ax.set_aspect("equal")
```

`_render_landmarks_3d`:
```python
def _render_landmarks_3d(coords, ax, *, color="gray", alpha=1.0,
                         links=None, **kw):
    # coords: (n_lm, 3)
    if links:
        for link in links:
            pts = coords[list(link)]
            ax.plot(pts[:, 0], pts[:, 1], pts[:, 2],
                    color=color, alpha=alpha)
    ax.scatter(coords[:, 0], coords[:, 1], coords[:, 2],
               c=color, alpha=alpha, s=kw.pop("s", 10), **kw)
    ax.set_box_aspect([np.ptp(coords[:, i]) for i in range(3)])
```

---

## API Reference

### Module layout

```
ktch/plot/
  __init__.py       # Export public functions
  _base.py          # require_dependencies, lazy imports (existing)
  _pca.py           # explained_variance_ratio_plot (existing), shape_variation_plot
  _morphospace.py   # morphospace_plot (new)
  _renderers.py     # Built-in shape renderers (new, internal)
  _kriging.py       # tps_grid_2d_plot (existing)
  tests/
```

`shape_variation_plot` is placed in `_pca.py` alongside `explained_variance_ratio_plot` since both operate on decomposition results. The module name is internal and does not affect the public API.

---

### `shape_variation_plot`

Location: `ktch/plot/_pca.py` (replaces existing `plot_shapes_along_pcs` stub)

```python
def shape_variation_plot(
    reducer: Any | None = None,
    *,
    # Descriptor (shape reconstruction)
    descriptor: Any | None = None,
    descriptor_inverse_transform: Callable[[np.ndarray], np.ndarray] | None = None,
    # Reducer overrides
    reducer_inverse_transform: Callable[[np.ndarray], np.ndarray] | None = None,
    explained_variance: np.ndarray | None = None,
    n_components: int | None = None,
    # Component selection
    components: Sequence[int] = (0, 1, 2),
    sd_values: Sequence[float] = (-2.0, -1.0, 0.0, 1.0, 2.0),
    # Shape rendering
    shape_type: str = "auto",
    render_fn: Callable[..., None] | None = None,
    n_dim: int | None = None,
    links: Sequence[Sequence[int]] | None = None,
    # Style
    color: str = "gray",
    alpha: float = 1.0,
    # Figure
    fig: matplotlib.figure.Figure | None = None,
    dpi: int = 150,
    figscale: float = 3.0,
    **render_kw: Any,
) -> matplotlib.figure.Figure:
```

Creates a grid of subplots showing shape variation along component axes.
Each row corresponds to a component, each column to a standard deviation multiplier.

Parameters:

| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| `reducer` | fitted estimator or `None` | No | `None` | Fitted dimensionality reduction object (e.g., `sklearn.decomposition.PCA`). Convenience parameter for PCA-compatible estimators: extracts `reducer_inverse_transform` via `.inverse_transform`, `explained_variance` via `.explained_variance_`, `n_components` via `.n_components_` (fallback to `.n_components`). For non-PCA reducers, use the explicit override parameters instead. |
| `descriptor` | fitted estimator or `None` | No | `None` | Fitted shape descriptor object (e.g., `EllipticFourierAnalysis`). Convenience parameter: extracts `descriptor_inverse_transform` via `.inverse_transform`. For GPA: `None` (identity). |
| `descriptor_inverse_transform` | `Callable` or `None` | No | `None` | Converts reducer-space coefficients to shape coordinates. Overrides `descriptor.inverse_transform`. For SHA resolution control, wrap with lambda: `lambda c: sha.inverse_transform(c, theta_range=..., phi_range=...)`. |
| `reducer_inverse_transform` | `Callable` or `None` | No | `None` | Converts low-dimensional scores to coefficient space. Overrides `reducer.inverse_transform`. |
| `explained_variance` | `np.ndarray` or `None` | No | `None` | Variance per component for SD calculation. Overrides `reducer.explained_variance_`. |
| `n_components` | `int` or `None` | No | `None` | Total number of components. Overrides `reducer.n_components_`. |
| `components` | `Sequence[int]` | No | `(0, 1, 2)` | 0-indexed component indices to display as rows |
| `sd_values` | `Sequence[float]` | No | `(-2, -1, 0, 1, 2)` | Standard deviation multipliers for columns |
| `shape_type` | `str` | No | `"auto"` | One of `"auto"`, `"curve_2d"`, `"curve_3d"`, `"surface_3d"`, `"landmarks_2d"`, `"landmarks_3d"` |
| `render_fn` | `Callable` or `None` | No | `None` | Custom renderer `(coords, ax, **kw) -> None`. Overrides `shape_type`. |
| `n_dim` | `int` or `None` | No | `None` | Spatial dimensionality (used for reshape in GPA identity case). Required when `descriptor` is not provided, unless `shape_type` is an explicit landmarks type (`"landmarks_2d"` -> 2, `"landmarks_3d"` -> 3). |
| `links` | `Sequence[Sequence[int]]` or `None` | No | `None` | Landmark link pairs (e.g., `[[0,1],[1,2]]`) |
| `color` | `str` | No | `"gray"` | Shape color. Takes precedence over `color` in `render_kw` if both are provided. |
| `alpha` | `float` | No | `1.0` | Shape transparency. Takes precedence over `alpha` in `render_kw` if both are provided. |
| `fig` | `Figure` or `None` | No | `None` | Existing figure. If `None`, creates one. |
| `dpi` | `int` | No | `150` | Figure resolution |
| `figscale` | `float` | No | `3.0` | Scale factor for figure size (width = `figscale * len(sd_values)`, height = `figscale * len(components)`) |
| `**render_kw` | `Any` | No | -- | Forwarded to the renderer (e.g., `linewidth`, `rstride`, `cstride` for `plot_surface`) |

Returns: `matplotlib.figure.Figure`

Internal logic:
```python
n_rows = len(components)
n_cols = len(sd_values)

if fig is None:
    fig = plt.figure(figsize=(figscale * n_cols, figscale * n_rows), dpi=dpi)

# Use local axes list for robust labeling (never rely on fig.axes order)
axes_grid = []

for i, comp_idx in enumerate(components):
    sd = sqrt(explained_variance[comp_idx])
    row_axes = []
    for j, sd_val in enumerate(sd_values):
        score = zeros(n_components)
        score[comp_idx] = sd_val * sd

        coeffs = reducer_inverse_transform(score.reshape(1, -1))

        if descriptor_inverse_transform is not None:
            coords = np.asarray(descriptor_inverse_transform(coeffs))
            single = coords[0]
        else:
            single = coeffs[0].reshape(-1, n_dim)  # GPA case

        # proj and renderer are resolved from shape_type / render_fn
        # (see _resolve_descriptor_params and shape_type -> renderer mapping)
        ax = fig.add_subplot(n_rows, n_cols, n_cols * i + j + 1,
                             projection=proj)
        row_axes.append(ax)
        resolved = _resolve_render_kw(render_kw, color=color,
                                      alpha=alpha, links=links)
        renderer(single, ax, **resolved)
        ax.axis("off")

    axes_grid.append(row_axes)

# Row labels on leftmost subplots
for i, comp_idx in enumerate(components):
    axes_grid[i][0].set_ylabel(f"PC{comp_idx + 1}")

# Column labels on topmost subplots
for j, sd_val in enumerate(sd_values):
    axes_grid[0][j].set_title(f"{sd_val:+g} SD")
```

Row/column labels default to "PC{i+1}" / "{sd_val} SD". Users can modify via standard matplotlib methods after calling the function.

Usage examples:

```python
from sklearn.decomposition import PCA
from ktch.harmonic import EllipticFourierAnalysis
from ktch.plot import shape_variation_plot

# EFA 2D -- convenience parameters
efa = EllipticFourierAnalysis(n_harmonics=20)
coeffs = efa.fit_transform(outlines_2d)
pca = PCA(n_components=10).fit(coeffs)
fig = shape_variation_plot(pca, descriptor=efa)

# GPA 2D landmarks with links
gpa = GeneralizedProcrustesAnalysis()
aligned = gpa.fit_transform(landmarks)
pca = PCA(n_components=10).fit(aligned)
fig = shape_variation_plot(
    pca,
    n_dim=2,
    links=[[0, 1], [1, 2], [2, 3]],
)

# GPA 3D landmarks, displayed in 2D (XY projection)
fig = shape_variation_plot(
    pca,
    n_dim=3,
    shape_type="landmarks_2d",  # project to XY
    links=links,
)

# SHA 3D surface with reduced resolution
sha = SphericalHarmonicAnalysis(n_harmonics=15)
coeffs = sha.fit_transform(surfaces, theta_phi=theta_phi)
pca = PCA(n_components=10).fit(coeffs)
fig = shape_variation_plot(
    pca,
    descriptor_inverse_transform=lambda c: sha.inverse_transform(
        c,
        theta_range=np.linspace(0, np.pi, 30),
        phi_range=np.linspace(0, 2 * np.pi, 60),
    ),
    shape_type="surface_3d",
    color="orange",
)

# KernelPCA -- explicit overrides
from sklearn.decomposition import KernelPCA
kpca = KernelPCA(n_components=10, kernel="rbf", fit_inverse_transform=True)
kpca.fit(coeffs)
fig = shape_variation_plot(
    reducer_inverse_transform=kpca.inverse_transform,
    explained_variance=kpca.eigenvalues_,
    n_components=kpca.n_components,
    descriptor=efa,
)

# Custom renderer
def my_renderer(coords, ax, **kw):
    kw.pop("links", None)
    ax.fill(coords[:, 0], coords[:, 1], **kw)
    ax.set_aspect("equal")

fig = shape_variation_plot(
    pca,
    descriptor=efa,
    render_fn=my_renderer,
    edgecolor="black",  # forwarded via **render_kw
)
```

---

### `morphospace_plot`

Location: `ktch/plot/_morphospace.py` (new file)

```python
def morphospace_plot(
    data: pd.DataFrame | None = None,
    *,
    # Data mapping
    x: str | npt.ArrayLike | None = None,
    y: str | npt.ArrayLike | None = None,
    hue: str | npt.ArrayLike | None = None,
    # Reducer (dimensionality reduction)
    reducer: Any | None = None,
    reducer_inverse_transform: Callable[[np.ndarray], np.ndarray] | None = None,
    n_components: int | None = None,
    # Descriptor (shape reconstruction)
    descriptor: Any | None = None,
    descriptor_inverse_transform: Callable[[np.ndarray], np.ndarray] | None = None,
    components: tuple[int, int] = (0, 1),
    # Shape rendering
    shape_type: str = "auto",
    render_fn: Callable[..., None] | None = None,
    n_dim: int | None = None,
    links: Sequence[Sequence[int]] | None = None,
    n_shapes: int = 5,
    shape_scale: float = 1.0,
    shape_color: str = "lightgray",
    shape_alpha: float = 0.7,
    # Scatter style (forwarded to sns.scatterplot)
    palette: str | Sequence | None = None,
    hue_order: Sequence | None = None,
    scatter_kw: dict[str, Any] | None = None,
    # Figure / Axes
    ax: matplotlib.axes.Axes | None = None,
    **render_kw: Any,
) -> matplotlib.axes.Axes:
```

Scatter plot of specimens in morphospace with reconstructed shapes overlaid as inset axes at a grid of positions.

When `ax` is provided, the scatter plot and shape overlays are drawn on the existing axes. This enables composing with user-created scatter plots or seaborn's `FacetGrid.map_dataframe()`.

Parameters:

| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| `data` | `DataFrame` or `None` | No | `None` | DataFrame containing scores and metadata. If provided, `x`, `y`, `hue` refer to column names. |
| `x` | `str` or array-like | No | `None` | Horizontal axis values (column name or array) |
| `y` | `str` or array-like | No | `None` | Vertical axis values (column name or array) |
| `hue` | `str` or array-like | No | `None` | Grouping variable for scatter coloring |
| `reducer` | fitted estimator or `None` | No | `None` | Convenience parameter. Extracts `reducer_inverse_transform` via `.inverse_transform` and `n_components` via `.n_components_` (fallback to `.n_components`). Works with any reducer exposing these attributes (broader than `shape_variation_plot`, which also requires `explained_variance_`). |
| `reducer_inverse_transform` | `Callable` or `None` | No | `None` | Overrides `reducer.inverse_transform`. |
| `n_components` | `int` or `None` | No | `None` | Overrides `reducer.n_components_` (or `.n_components` fallback). |
| `descriptor` | fitted estimator or `None` | No | `None` | Convenience parameter. Extracts `descriptor_inverse_transform`. |
| `descriptor_inverse_transform` | `Callable` or `None` | No | `None` | Overrides `descriptor.inverse_transform`. |
| `components` | `tuple[int, int]` | No | `(0, 1)` | 0-indexed component indices for (horizontal, vertical) axes |
| `shape_type` | `str` | No | `"auto"` | Shape rendering type |
| `render_fn` | `Callable` or `None` | No | `None` | Custom renderer override |
| `n_dim` | `int` or `None` | No | `None` | Spatial dimensionality. Required when `descriptor` is not provided, unless `shape_type` is an explicit landmarks type. |
| `links` | `Sequence[Sequence[int]]` or `None` | No | `None` | Landmark link pairs |
| `n_shapes` | `int` | No | `5` | Number of shapes along each axis in the grid (total: `n_shapes * n_shapes`) |
| `shape_scale` | `float` | No | `1.0` | Scale factor for inset shape size |
| `shape_color` | `str` | No | `"lightgray"` | Color for reconstructed shapes. Takes precedence over `color` in `render_kw`. |
| `shape_alpha` | `float` | No | `0.7` | Transparency for reconstructed shapes. Takes precedence over `alpha` in `render_kw`. |
| `palette` | `str` or `Sequence` | No | `None` | Forwarded to `sns.scatterplot` |
| `hue_order` | `Sequence` or `None` | No | `None` | Forwarded to `sns.scatterplot` |
| `scatter_kw` | `dict` or `None` | No | `None` | Additional kwargs forwarded to `sns.scatterplot` |
| `ax` | `Axes` or `None` | No | `None` | Pre-existing axes. If `None`, creates new figure and axes. |
| `**render_kw` | `Any` | No | -- | Forwarded to the shape renderer (e.g., `linewidth`, `rstride`, `cstride`) |

Returns: `matplotlib.axes.Axes` -- The main scatter plot axes. The figure can be obtained via `ax.figure`.

Inset placement note:
This function calls `fig.canvas.draw()` internally to compute accurate pixel positions for inset axes. Inset positions are fixed at draw time and will not automatically update if the figure is later resized or saved at a different DPI. For best results, set the final figure size before calling this function. This is a known matplotlib limitation shared by the reference implementations.

Internal logic:
```python
# 1. Create or reuse axes
if ax is None:
    fig, ax = plt.subplots()
else:
    fig = ax.figure

# 2. Draw scatter plot (if data provided)
if x is not None and y is not None:
    sns.scatterplot(data=data, x=x, y=y, hue=hue, palette=palette,
                    hue_order=hue_order, ax=ax, **(scatter_kw or {}))

# 3. Resolve reducer/descriptor parameters
# (same resolution logic as shape_variation_plot)

# 4. Overlay shapes if reducer is available
if reducer_inverse_transform is not None:
    fig.canvas.draw()  # force layout for accurate positions

    comp_h, comp_v = components
    x_range = np.linspace(*ax.get_xlim(), n_shapes)
    y_range = np.linspace(*ax.get_ylim(), n_shapes)

    # Batch reconstruction for performance
    grid = np.array([(h, v) for h in x_range for v in y_range])
    all_scores = np.zeros((len(grid), n_components))
    all_scores[:, comp_h] = grid[:, 0]
    all_scores[:, comp_v] = grid[:, 1]

    all_coeffs = reducer_inverse_transform(all_scores)
    if descriptor_inverse_transform is not None:
        all_coords = np.asarray(descriptor_inverse_transform(all_coeffs))
    else:
        all_coords = all_coeffs.reshape(len(grid), -1, n_dim)

    # Compute inset sizing
    ax_width = ax.get_window_extent().width
    fig_width = fig.get_window_extent().width
    fig_height = fig.get_window_extent().height
    inset_size = shape_scale * ax_width / (fig_width * n_shapes)

    # proj and renderer are resolved from shape_type / render_fn
    # (see _resolve_descriptor_params and shape_type -> renderer mapping)
    proj = "3d" if shape_type.endswith("_3d") else None

    resolved = _resolve_render_kw(render_kw, color=shape_color,
                                  alpha=shape_alpha, links=links)

    for idx, (score_h, score_v) in enumerate(grid):
        single = all_coords[idx]

        loc = ax.transData.transform((score_h, score_v))
        axins = fig.add_axes(
            [loc[0] / fig_width - inset_size / 2,
             loc[1] / fig_height - inset_size / 2,
             inset_size, inset_size],
            anchor="C",
            projection=proj,
        )

        renderer(single, axins, **resolved)
        axins.axis("off")

    ax.patch.set_alpha(0)  # transparent background behind insets
```

Usage examples:

```python
from ktch.plot import morphospace_plot

# EFA 2D -- convenience parameters
ax = morphospace_plot(
    data=df_pca,
    x="PC1", y="PC2", hue="genus",
    reducer=pca,
    descriptor=efa,
    palette="Paired",
    n_shapes=5,
    shape_scale=0.8,
)

# GPA landmarks with links
ax = morphospace_plot(
    data=df_pca,
    x="pca0", y="pca1", hue="genus",
    reducer=pca,
    n_dim=2,
    links=links,
    palette="Paired",
)

# Scatter only (no shape overlay)
ax = morphospace_plot(
    data=df_pca,
    x="PC1", y="PC2", hue="genus",
    palette="Paired",
)

# Compose: user-created scatter + shape overlay on existing axes
fig, ax = plt.subplots(figsize=(10, 10))
sns.scatterplot(data=df_pca, x="PC1", y="PC2", hue="genus",
                palette="Paired", ax=ax, s=80)
morphospace_plot(
    reducer=pca,
    descriptor=efa,
    components=(0, 1),
    ax=ax,
)

# Compose with seaborn FacetGrid (faceting by data variable)
# Note: FacetGrid.map_dataframe injects extra kwargs (color, label, etc.).
# Use explicit keyword args in the lambda to capture and discard them.
g = sns.FacetGrid(df_pca, col="treatment", height=5)
g.map_dataframe(
    lambda data, *, color=None, label=None, **kw: morphospace_plot(
        data, x="PC1", y="PC2", hue="genus",
        reducer=pca, descriptor=efa,
        palette="Paired",
    ),
)

# Multi-panel component pairs (common pattern)
fig, axes = plt.subplots(2, 2, figsize=(16, 16), dpi=200)
for ax, (i, j) in zip(axes.flat[:3], [(0, 1), (1, 2), (2, 0)]):
    morphospace_plot(
        data=df_pca,
        x=f"PC{i+1}", y=f"PC{j+1}", hue="genus",
        reducer=pca,
        descriptor=efa,
        components=(i, j),
        palette="Paired",
        n_shapes=5,
        shape_scale=0.8,
        ax=ax,
    )
explained_variance_ratio_plot(pca, ax=axes[1, 1])
```

---

## Public API Changes

### `ktch/plot/__init__.py`

```python
from ._kriging import tps_grid_2d_plot
from ._morphospace import morphospace_plot
from ._pca import explained_variance_ratio_plot, shape_variation_plot

__all__ = [
    "explained_variance_ratio_plot",
    "morphospace_plot",
    "shape_variation_plot",
    "tps_grid_2d_plot",
]

# Deprecation redirects
_RENAMED_FUNCTIONS = {
    "plot_explained_variance_ratio": "explained_variance_ratio_plot",
    "plot_shapes_along_pcs": "shape_variation_plot",
}
```

---

## Error Handling

| Condition | Error |
|---|---|
| Neither `reducer` nor `reducer_inverse_transform` provided (`shape_variation_plot`) | `ValueError`: "Either reducer or reducer_inverse_transform must be provided" |
| Neither `reducer` nor `explained_variance` provided (`shape_variation_plot`) | `ValueError`: "Either reducer or explained_variance must be provided" |
| `descriptor_inverse_transform is None` and `n_dim is None` and `shape_type` is not explicit landmarks type | `ValueError`: "n_dim is required when descriptor is not provided and shape_type is not an explicit landmarks type" |
| `components` contains index >= `n_components` | `ValueError`: "Component index {i} exceeds number of components ({n})" |
| Invalid `shape_type` string | `ValueError`: list valid options |
| `descriptor_inverse_transform is None` and `shape_type` is `"curve_*"` or `"surface_*"` | `ValueError`: "descriptor or descriptor_inverse_transform is required for shape_type='{t}'" |
| matplotlib not installed | `ImportError` via `require_dependencies("matplotlib")` |
| seaborn not installed (`morphospace_plot`) | `ImportError` via `require_dependencies("matplotlib", "seaborn")` |
| `render_kw` contains keys that conflict with explicit params | `UserWarning`: "{keys} in render_kw ignored; use the explicit parameter(s) instead" |

---

## Test Strategy

- Smoke tests (required): each function completes without exceptions for each shape_type, with both convenience (`reducer`/`descriptor`) and explicit overrides
- Return type validation: `shape_variation_plot` returns `Figure`, `morphospace_plot` returns `Axes`
- Structural assertions: correct number of subplot axes, correct projection types (2D vs 3D)
- Error path tests: all `ValueError` / `ImportError` cases in the table above
- Warning tests: `render_kw` conflict produces `UserWarning`
- No image comparison tests in CI (environment-dependent). Keep optional `@pytest.mark.mpl_image_compare` for local regression checks.

## Performance Considerations

- Batch reconstruction: `morphospace_plot` builds all score vectors at once and calls `reducer_inverse_transform` and `descriptor_inverse_transform` in a single batch call (N = `n_shapes * n_shapes`). This is significantly faster than N individual calls, especially for SHA.
- 3D inset axes: matplotlib 3D projection in insets is slow. For SHA surfaces, recommend `n_shapes <= 3` and reduced resolution via lambda wrapper. Document this in docstring.

---

## Comparison: Tutorial Code vs Proposed API

### EFA morphospace (before)

```python
# ~80 lines: two helper functions + per-subplot boilerplate
def get_pc_scores_for_morphospace(ax, num=5): ...
def plot_recon_morphs(pca, efa, fig, ax, n_PCs_xy=[1,2], ...): ...

fig = plt.figure(figsize=(16, 16), dpi=200)
ax = fig.add_subplot(2, 2, 1)
sns.scatterplot(data=df_pca, x="PC1", y="PC2", hue="genus", ...)
plot_recon_morphs(pca, efa, morph_num=5, morph_scale=0.8, fig=fig, ax=ax)
# ... repeat 3 more times with different PC pairs
```

### EFA morphospace (after)

```python
from ktch.plot import morphospace_plot, explained_variance_ratio_plot

fig, axes = plt.subplots(2, 2, figsize=(16, 16), dpi=200)
for ax, (i, j) in zip(axes.flat[:3], [(0, 1), (1, 2), (2, 0)]):
    morphospace_plot(
        data=df_pca,
        x=f"PC{i+1}", y=f"PC{j+1}", hue="genus",
        reducer=pca, descriptor=efa,
        components=(i, j),
        palette="Paired",
        n_shapes=5, shape_scale=0.8,
        ax=ax,
    )
explained_variance_ratio_plot(pca, ax=axes[1, 1])
```

### GPA shape variation (before)

```python
# ~60 lines of custom code per tutorial
```

### GPA shape variation (after)

```python
from ktch.plot import shape_variation_plot

fig = shape_variation_plot(
    pca,
    n_dim=2,
    links=links,
    components=(0, 1, 2),
    sd_values=(-2, -1, 0, 1, 2),
)
```

---

## Design Decisions

Resolved through design discussion (2026-03-09):

1. Naming convention: `<noun>_plot` suffix. Consistent with existing exports (`explained_variance_ratio_plot`, `tps_grid_2d_plot`), close to seaborn style.

2. Function name: `shape_variation_plot` (not `shapes_along_pcs_plot`). Reducer-agnostic; the concept of "PC" is not baked into the function name. Deprecation redirect from old stub name `plot_shapes_along_pcs`.

3. Convenience parameter: `reducer` (not `pca`). Agent noun ("-er") makes it clear that the argument is a model object, not data. Avoids PCA-specific naming while remaining short and intuitive. Chosen over `embedding` (ambiguous: result vs model in sklearn) and `encoder` (neural network connotations).

4. `descriptor` convenience parameter mirrors `reducer` pattern. `descriptor=efa` extracts `.inverse_transform` automatically. Reduces typing for the common case. For advanced control (e.g., SHA resolution), use `descriptor_inverse_transform=lambda ...` explicitly.

5. Component parameter: `components` (not `pc_axes` or `n_pcs`). Matches sklearn's `components_` attribute naming. Used in both functions: `Sequence[int]` for `shape_variation_plot`, `tuple[int, int]` for `morphospace_plot`.

6. Reconstruction pipeline: two-stage callbacks (`reducer_inverse_transform` + `descriptor_inverse_transform`) with `reducer`/`descriptor` convenience parameters. Enables non-PCA reducers and custom descriptor control without over-engineering.

7. Non-PCA reducer support: low-level override parameters (`reducer_inverse_transform`, `explained_variance`, `n_components`) alongside `reducer` convenience. The `reducer` convenience path uses duck-typing with `getattr` fallbacks (e.g., `n_components_` -> `n_components`), but is designed primarily for PCA-compatible estimators. For `KernelPCA` and other non-standard reducers, the explicit override path is the documented standard pattern.

8. Shape type taxonomy: `{geometry}_{display_dim}` naming. `curve_2d`/`curve_3d` (replaces `outline_*`) for generality; `surface_3d` for structured grid surfaces (SHA, torus, annulus, disk); `landmarks_2d`/`landmarks_3d` for point sets. `mesh_3d` reserved for future triangulated surfaces (genus-N manifolds).

9. `n_dim` and `shape_type` independence: `n_dim` is data dimensionality (reshape), `shape_type` is display mode (axes type). Enables 3D data rendered in 2D (XY projection) by combining `n_dim=3` with `shape_type="*_2d"`.

10. `n_dim` default `None`: avoids silent mis-reshape when user forgets to specify dimensionality for GPA 3D case. Required when `descriptor` is not provided, with one exception: when `shape_type` is an explicit landmarks type (`"landmarks_2d"` or `"landmarks_3d"`), `n_dim` is inferred from the suffix (2 or 3). This rule is applied consistently in parameter descriptions, validation pseudo-code, and error table.

11. render_kw conflict resolution: explicit style parameters (`color`, `alpha`) take precedence over duplicates in `**render_kw`, with `UserWarning` issued. Follows seaborn precedent.

12. Plotly backend: not implemented now. The `render_fn` abstraction is the main insurance for future backend support. matplotlib-only for initial release.

13. Links type: `Sequence[Sequence[int]]` (pair list, e.g., `[[0,1],[1,2]]`). Matches tutorial convention.

14. Inset placement: `fig.canvas.draw()` approach with documented limitation.

15. SHA resolution control: users wrap `descriptor_inverse_transform` with lambda. Avoids domain-specific parameter proliferation.

16. API levels: `morphospace_plot` is axes-level (accept `ax`, return `Axes`); `shape_variation_plot` is figure-level (accept `fig`, return `Figure`). This follows seaborn's distinction where axes-level functions operate on a single axes and figure-level constructs manage multi-axes layouts. `shape_variation_plot` is a figure-level function (not a class) because its grid structure is fixed (components x SD values) and no `.map()` or data-variable faceting is needed. Figure-level `MorphospaceGrid` class planned as future work for multi-panel morphospace layouts.

---

## Internal Helpers

Both functions share parameter resolution logic. To avoid duplication, extract internal helpers:

- `_resolve_reducer_params(reducer, reducer_inverse_transform, explained_variance, n_components)` -- returns resolved `(reducer_inverse_transform, explained_variance, n_components)` with duck-typing fallback chain
- `_resolve_descriptor_params(descriptor, descriptor_inverse_transform, n_dim, shape_type)` -- returns resolved `(descriptor_inverse_transform, n_dim)`

These are private to `ktch.plot` and not part of the public API.

---

## Migration Checklist

- [ ] Add `shape_variation_plot`, `morphospace_plot` to `ktch/plot/__init__.py` exports and `__all__`
- [ ] Add deprecation redirect: `plot_shapes_along_pcs` -> `shape_variation_plot`
- [ ] Remove `plot_shapes_along_pcs` stub from `_pca.py`
- [ ] Update `doc/api/plotting.md` autosummary to include both new functions
- [ ] Add smoke/error/warning tests for `shape_variation_plot`
- [ ] Add smoke/error/warning tests for `morphospace_plot`
- [ ] Add deprecation redirect test for `plot_shapes_along_pcs`

---

## Future Directions

### MorphospaceGrid (Figure-level API)

A figure-level class that manages multiple component-pair panels, analogous to seaborn's `FacetGrid` but faceting by component pair instead of data variable.

```python
class MorphospaceGrid:
    """Multi-panel morphospace visualization.

    Manages a figure with one morphospace per component pair,
    optionally including a scree plot.

    Parameters
    ----------
    component_pairs : sequence of (int, int)
        Component index pairs for each panel.
    include_scree : bool
        Whether to add a scree plot panel.
    figsize : tuple or None
        Figure size. Auto-computed if None.
    dpi : int
        Figure resolution.
    """

    def __init__(
        self,
        component_pairs=((0, 1), (1, 2), (2, 0)),
        *,
        include_scree=True,
        figsize=None,
        dpi=150,
    ): ...

    @property
    def figure(self) -> Figure: ...

    @property
    def axes(self) -> dict[tuple[int, int], Axes]: ...

    def map_scatter(
        self, data, *, hue=None, palette=None, **kw
    ) -> MorphospaceGrid:
        """Draw scatter plots on each panel."""
        ...
        return self

    def map_shapes(
        self,
        reducer=None,
        *,
        descriptor=None,
        descriptor_inverse_transform=None,
        shape_type="auto",
        n_shapes=5,
        **render_kw,
    ) -> MorphospaceGrid:
        """Overlay reconstructed shapes on each panel.

        Calls morphospace_plot (axes-level) internally.
        """
        ...
        return self

    def add_scree(self, reducer, **kw) -> MorphospaceGrid:
        """Add explained variance ratio plot to the designated panel."""
        ...
        return self
```

Usage:
```python
grid = MorphospaceGrid(component_pairs=[(0, 1), (1, 2), (2, 0)])
grid.map_scatter(df_pca, hue="genus", palette="Paired")
grid.map_shapes(pca, descriptor=efa, n_shapes=5, shape_scale=0.8)
grid.add_scree(pca)
```

The axes-level API in this spec is intentionally composable to support this. The Grid class does not prevent direct use of seaborn's `FacetGrid` for data-variable faceting:
```python
g = sns.FacetGrid(df_pca, col="treatment")
g.map_dataframe(lambda data, **kw: morphospace_plot(data, x="PC1", ...))
```

### mesh_3d shape type

When genus-N manifold or other triangulated surface descriptors are added:

- New renderer `_render_mesh_3d` using `ax.plot_trisurf(x, y, z, triangles=faces)`
- `descriptor_inverse_transform` output convention: either a structured array or a tuple `(vertices, faces)`. Exact contract to be defined when implementing the first mesh-based descriptor.
- `render_fn` signature may need extension to accept non-ndarray coords. The custom `render_fn` escape hatch ensures backward compatibility.

### Plotly Backend

When needed, plotly support can be added via:
- Plotly-specific renderer functions (parallel to matplotlib renderers)
- Separate `morphospace_plotly` function or `backend="plotly"` parameter
- `shape_variation_plot` maps well to `plotly.subplots.make_subplots`
- `morphospace_plot` insets would require plotly images/annotations overlay (higher effort)
