Skip to content

Interactive Plotting

WebGL-accelerated Plotly scatter plot with hover metadata (pipeline step 4b).

Note

Requires the plotly extra: pip install 'multiscoresplot[interactive]'

plot_embedding_interactive

plot_embedding_interactive(
    adata_or_coords: object,
    rgb: RGBResult | NDArray,
    *,
    basis: str | None = None,
    components: tuple[int, int] = (0, 1),
    scores: DataFrame | None = None,
    method: str | None = None,
    gene_set_names: list[str] | None = None,
    colors: list[tuple[float, float, float]] | None = None,
    prefix: str | None = None,
    suffix: str | None = None,
    legend: bool = True,
    legend_loc: str = "lower right",
    legend_size: float = 0.3,
    legend_resolution: int = 128,
    legend_kwargs: dict | None = None,
    hover_columns: list[str] | None = None,
    point_size: float = 2,
    alpha: float = 1.0,
    figsize: tuple[float, float] = (6.5, 6.0),
    dpi: int = 100,
    title: str = "",
    show: bool = True,
) -> object | None

Interactive Plotly scatter plot of embedding coordinates coloured by RGB.

Parameters:

Name Type Description Default
adata_or_coords object

An AnnData object (with basis in .obsm) or a raw (n_cells, 2) coordinate array.

required
rgb RGBResult | NDArray

:class:RGBResult from blend_to_rgb/reduce_to_rgb, or a plain (n_cells, 3) RGB array. When an RGBResult is passed the method, gene_set_names and colors metadata are used automatically (explicit parameters still override).

required
basis str | None

Full obsm key name (e.g. "X_umap", "umap_consensus"). Required when adata_or_coords is AnnData.

None
components tuple[int, int]

Which two components to plot (0-indexed).

(0, 1)
scores DataFrame | None

DataFrame with score-* columns. If None and adata_or_coords is AnnData, scores are auto-extracted from adata.obs.

None
method str | None

Reduction method ("pca", "nmf", etc.) used to derive RGB. Inferred from rgb when it is an RGBResult. Controls the channel labels in hover info and the legend type. If None or "direct", channels are labeled R/G/B.

None
gene_set_names list[str] | None

Human-readable labels for the legend and hover score labels. Inferred from rgb when it is an RGBResult.

None
colors list[tuple[float, float, float]] | None

Base colours for direct-mode legends.

None
prefix str | None

Column name prefix for score columns. None (default) inherits from RGBResult.prefix or falls back to "score-".

None
suffix str | None

Column name suffix for score columns. None (default) inherits from RGBResult.suffix or falls back to "".

None
legend bool

Whether to add a colour-space legend overlay.

True
legend_loc str

Position for the legend ("lower right", "lower left", "upper right", "upper left").

'lower right'
legend_size float

Size of the legend as a fraction of the plot (0-1).

0.3
legend_resolution int

Pixel resolution of the legend image.

128
legend_kwargs dict | None

Extra keyword arguments forwarded to render_legend (excluding resolution, which is controlled by legend_resolution).

None
hover_columns list[str] | None

Extra columns to include in hover info. Looked up first in adata.obs; if not found there, looked up in adata.var_names to display individual gene expression values.

None
point_size float

Scatter marker size.

2
alpha float

Marker opacity.

1.0
figsize tuple[float, float]

Figure size as (width_inches, height_inches). Multiplied by dpi to obtain pixel dimensions.

(6.5, 6.0)
dpi int

Resolution (dots per inch). figsize * dpi gives the pixel dimensions of the Plotly figure.

100
title str

Plot title.

''
show bool

If True, call fig.show() and return None. If False, return the plotly.graph_objects.Figure.

True

Returns:

Type Description
Figure or None

The figure when show=False; None when show=True.

Source code in src/multiscoresplot/_interactive.py
def plot_embedding_interactive(
    adata_or_coords: object,
    rgb: RGBResult | NDArray,
    *,
    basis: str | None = None,
    components: tuple[int, int] = (0, 1),
    scores: DataFrame | None = None,
    # legend metadata (overrides RGBResult when provided)
    method: str | None = None,
    gene_set_names: list[str] | None = None,
    colors: list[tuple[float, float, float]] | None = None,
    # prefix/suffix for score column detection
    prefix: str | None = None,
    suffix: str | None = None,
    # legend
    legend: bool = True,
    legend_loc: str = "lower right",
    legend_size: float = 0.30,
    legend_resolution: int = 128,
    legend_kwargs: dict | None = None,
    # hover / scatter
    hover_columns: list[str] | None = None,
    point_size: float = 2,
    alpha: float = 1.0,
    # figure
    figsize: tuple[float, float] = (6.5, 6.0),
    dpi: int = 100,
    title: str = "",
    show: bool = True,
) -> object | None:
    """Interactive Plotly scatter plot of embedding coordinates coloured by RGB.

    Parameters
    ----------
    adata_or_coords
        An ``AnnData`` object (with *basis* in ``.obsm``) or a raw
        ``(n_cells, 2)`` coordinate array.
    rgb
        :class:`RGBResult` from ``blend_to_rgb``/``reduce_to_rgb``, or a
        plain ``(n_cells, 3)`` RGB array.  When an ``RGBResult`` is passed
        the ``method``, ``gene_set_names`` and ``colors`` metadata are used
        automatically (explicit parameters still override).
    basis
        Full obsm key name (e.g. ``"X_umap"``, ``"umap_consensus"``).
        Required when *adata_or_coords* is AnnData.
    components
        Which two components to plot (0-indexed).
    scores
        DataFrame with ``score-*`` columns.  If *None* and *adata_or_coords*
        is AnnData, scores are auto-extracted from ``adata.obs``.
    method
        Reduction method (``"pca"``, ``"nmf"``, etc.) used to derive RGB.
        Inferred from *rgb* when it is an ``RGBResult``.  Controls the
        channel labels in hover info and the legend type.  If *None* or
        ``"direct"``, channels are labeled R/G/B.
    gene_set_names
        Human-readable labels for the legend and hover score labels.
        Inferred from *rgb* when it is an ``RGBResult``.
    colors
        Base colours for direct-mode legends.
    prefix
        Column name prefix for score columns.  *None* (default) inherits
        from ``RGBResult.prefix`` or falls back to ``"score-"``.
    suffix
        Column name suffix for score columns.  *None* (default) inherits
        from ``RGBResult.suffix`` or falls back to ``""``.
    legend
        Whether to add a colour-space legend overlay.
    legend_loc
        Position for the legend (``"lower right"``, ``"lower left"``,
        ``"upper right"``, ``"upper left"``).
    legend_size
        Size of the legend as a fraction of the plot (0-1).
    legend_resolution
        Pixel resolution of the legend image.
    legend_kwargs
        Extra keyword arguments forwarded to ``render_legend``
        (excluding *resolution*, which is controlled by *legend_resolution*).
    hover_columns
        Extra columns to include in hover info.  Looked up first in
        ``adata.obs``; if not found there, looked up in ``adata.var_names``
        to display individual gene expression values.
    point_size
        Scatter marker size.
    alpha
        Marker opacity.
    figsize
        Figure size as ``(width_inches, height_inches)``.  Multiplied by
        *dpi* to obtain pixel dimensions.
    dpi
        Resolution (dots per inch).  ``figsize * dpi`` gives the pixel
        dimensions of the Plotly figure.
    title
        Plot title.
    show
        If *True*, call ``fig.show()`` and return *None*.  If *False*,
        return the ``plotly.graph_objects.Figure``.

    Returns
    -------
    Figure or None
        The figure when ``show=False``; *None* when ``show=True``.
    """
    go = _ensure_plotly()

    # Unpack RGBResult metadata
    rgb_arr, meta_method, meta_names, meta_colors = _unpack_rgb(rgb)
    eff_method = method if method is not None else meta_method
    eff_names = gene_set_names if gene_set_names is not None else meta_names
    eff_colors = colors if colors is not None else meta_colors

    # Resolve effective prefix/suffix
    if isinstance(rgb, RGBResult):
        eff_prefix = prefix if prefix is not None else rgb.prefix
        eff_suffix = suffix if suffix is not None else rgb.suffix
    else:
        eff_prefix = prefix if prefix is not None else SCORE_PREFIX
        eff_suffix = suffix if suffix is not None else ""

    coords, basis_label = _extract_coords(adata_or_coords, basis, components)
    n_cells = coords.shape[0]
    rgb_arr = _validate_rgb(rgb_arr, n_cells)

    # Determine if we have an AnnData object
    has_obs = hasattr(adata_or_coords, "obs")

    # --- Build hover text ---
    hover_parts: list[list[str]] = [[] for _ in range(n_cells)]

    # 1. Gene set scores
    score_df: DataFrame | None = scores
    if score_df is None and has_obs:
        obs = adata_or_coords.obs  # type: ignore[attr-defined]
        score_cols = [
            c for c in obs.columns if c.startswith(eff_prefix) and c.endswith(eff_suffix)
        ]
        # Filter to only the gene sets in the current RGB result so we
        # don't show scores from unrelated prior score_gene_sets() calls.
        if eff_names is not None:
            expected = {f"{eff_prefix}{name}{eff_suffix}" for name in eff_names}
            score_cols = [c for c in score_cols if c in expected]
        if score_cols:
            score_df = obs[score_cols]

    if score_df is not None:
        score_cols = [
            c for c in score_df.columns if c.startswith(eff_prefix) and c.endswith(eff_suffix)
        ]
        labels = (
            eff_names
            if eff_names is not None and len(eff_names) == len(score_cols)
            else _extract_gene_set_names(score_cols, eff_prefix, eff_suffix)
        )
        score_vals = score_df[score_cols].to_numpy(dtype=np.float64)
        for i in range(n_cells):
            for j, label in enumerate(labels):
                hover_parts[i].append(f"{label}: {score_vals[i, j]:.3f}")

    # 2. RGB channel values
    if eff_method is not None and eff_method != "direct":
        channel_labels = get_component_labels(eff_method)
    else:
        channel_labels = ["R", "G", "B"]

    for i in range(n_cells):
        for j, ch_label in enumerate(channel_labels):
            hover_parts[i].append(f"{ch_label}: {rgb_arr[i, j]:.2f}")

    # 3. Extra columns (adata.obs first, then adata.var_names for gene expr)
    if hover_columns is not None:
        if not has_obs:
            raise ValueError("hover_columns requires an AnnData object, not raw coordinates.")

        for col_name in hover_columns:
            values, is_numeric = _resolve_hover_column(adata_or_coords, col_name, n_cells)
            for i in range(n_cells):
                val = values.iloc[i] if hasattr(values, "iloc") else values[i]  # type: ignore[union-attr,index]
                if is_numeric:
                    hover_parts[i].append(f"{col_name}: {val:.3f}")
                else:
                    hover_parts[i].append(f"{col_name}: {val}")

    hover_text = ["<br>".join(parts) for parts in hover_parts]

    # --- Build color strings ---
    marker_colors = [
        f"rgba({int(r * 255)},{int(g * 255)},{int(b * 255)},{alpha})" for r, g, b in rgb_arr
    ]

    # --- Axis labels ---
    if basis_label is not None:
        xaxis_title = f"{basis_label}{components[0] + 1}"
        yaxis_title = f"{basis_label}{components[1] + 1}"
    else:
        xaxis_title = ""
        yaxis_title = ""

    # --- Pixel dimensions from figsize + dpi ---
    width_px = int(figsize[0] * dpi)
    height_px = int(figsize[1] * dpi)

    # --- Create figure ---
    fig = go.Figure(
        data=go.Scattergl(
            x=coords[:, 0],
            y=coords[:, 1],
            mode="markers",
            marker=dict(
                size=point_size,
                color=marker_colors,
            ),
            hovertext=hover_text,
            hoverinfo="text",
        ),
    )

    _axis_style = dict(
        showticklabels=False,
        ticks="",
        showline=True,
        linecolor="black",
        linewidth=1,
        mirror=True,
    )
    fig.update_layout(
        width=width_px,
        height=height_px,
        title=title,
        xaxis=dict(title=xaxis_title, scaleanchor="y", **_axis_style),
        yaxis=dict(title=yaxis_title, **_axis_style),
        plot_bgcolor="white",
    )

    # Legend — consistent with plot_embedding()
    if legend:
        if eff_method is None:
            raise ValueError(
                "Cannot draw legend: 'method' is unknown. Pass method= explicitly "
                "or use an RGBResult from blend_to_rgb()/reduce_to_rgb()."
            )
        if eff_method == "direct" and eff_names is None:
            raise ValueError(
                "Cannot draw direct-mode legend without gene_set_names. "
                "Pass gene_set_names= or use an RGBResult from blend_to_rgb()."
            )
        _add_plotly_legend(
            fig,
            method=eff_method,
            gene_set_names=eff_names,
            colors=eff_colors,
            legend_loc=legend_loc,
            legend_size=legend_size,
            legend_resolution=legend_resolution,
            legend_kwargs=legend_kwargs,
        )

    if show:
        fig.show()
        return None

    return fig  # type: ignore[no-any-return]