Source code for vampire.anno.pl._motif_msa

from __future__ import annotations
from typing import TYPE_CHECKING
from typing import Any, Literal, Sequence
import numpy as np
import polars as pl

if TYPE_CHECKING:
    import anndata as ad
    import numpy as np
    import polars as pl
    import plotly.graph_objects as go
    import plotly.subplots as sp

import logging
logger = logging.getLogger(__name__)

from . import _sizing
from ._setting import _save_figure
from ._setting import _COLORMAP_OPTIONS # dict[str, list[str] | dict[str, str]]

[docs] def motif_msa( adata: "ad.AnnData", store_key: str = "motif_msa", sample_order: list[str] | None = None, base_colors: dict[str, str] | None = None, block_size: int = 15, stripe_width: int = 10, phase: int = 0, show_ins_bases: bool = False, figsize: tuple[int | None, int | None] | None = (None, None), save: str | bool | None = None, **kwargs, ) -> "go.Figure": """ Plot motif alignment waterfall with variant pileup. Visualises sample sequences aligned against a reference motif. The reference row is fixed at the top; each sample occupies one row below it. Only variant positions (substitution, insertion, deletion) are drawn -- matching bases are left blank. Parameters ---------- adata : ad.AnnData Annotated data with alignment results stored by ``tl.motif_msa()``. store_key : str, default="motif_msa" Key in ``adata.uns`` that holds the alignment result. sample_order : list[str] | None, optional Explicit order for samples on the y-axis. If ``None``, samples are sorted alphabetically. base_colors : dict[str, str] | None, optional Mapping from nucleotide to hex colour. Defaults to a DNA palette. block_size : int, default=15 Pixel size of each nucleotide block. stripe_width : int, default=10 Width of alternating background stripes in bp. phase : int, default=0 Circular phase shift. When ``phase > 0`` the motif is rolled right by ``phase`` bases before plotting (e.g. ``phase=1`` moves the first base to the end). show_ins_bases : bool, default=False When ``True``, display the inserted nucleotide sequence in purple text to the right of each insertion symbol. figsize : tuple[int | None, int | None] | None, default=(None, None) Figure size ``(width, height)`` in pixels. ``(None, None)`` triggers auto-computation from sequence length and sample count. save : str | bool | None, default=None If ``True`` or a ``str``, save the figure. A string is appended to the default filename. Infer the filetype if ending on {``'.pdf'``, ``'.png'``, ``'.svg'``}. **kwargs Additional arguments passed to ``fig.update_layout()``. Returns ------- go.Figure Plotly figure with the alignment waterfall. Examples -------- >>> import vampire as vp >>> vp.anno.pl.set_default_plotstyle() >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.tl.motif_msa(adata) >>> vp.anno.pl.motif_msa(adata) """ import plotly.graph_objects as go # Retrieve alignment results result = adata.uns.get(store_key) if result is None: raise KeyError( f"Alignment data not found at uns['{store_key}']. " f"Run tl.motif_msa() first." ) mode: str = result.get("mode", "pairwise") ref_seq: str = result["reference"] variants_df: pl.DataFrame = result["variants"] logger.info( "You are using alignment data generated in %s mode.", mode ) # Apply circular phase shift for display (roll left) seq_len = len(ref_seq) if phase: phase = phase % seq_len ref_seq = ref_seq[phase:] + ref_seq[:phase] colors = {**_COLORMAP_OPTIONS["dna"], **(base_colors or {})} # Resolve sample order – numeric sort so "0".."23" is ordered correctly def _sort_key(x): try: return (0, int(x)) except (ValueError, TypeError): return (1, str(x)) all_samples = sorted( [k for k in result["alignment"].keys() if k != "reference"], key=_sort_key, ) if sample_order is not None: missing = set(sample_order) - set(all_samples) if missing: raise ValueError(f"sample_order contains unknown samples: {missing}") samples = [s for s in sample_order if s in all_samples] else: samples = all_samples n_samples = len(samples) sample_to_y = {s: n_samples - i for i, s in enumerate(samples)} # Auto-size figure font_size = kwargs.get("font", {}).get("size") if font_size is None: font_size = _sizing.get_active_font_size() # Prepare label strings so we can size the left margin dynamically if mode == "msa": ref_label = "consensus" else: ref_label = ( f"reference ({result.get('reference_id')})" if result.get("reference_id") is not None else "reference" ) _labels = [ref_label] + [str(s) for s in samples] _max_label_len = max(len(lbl) for lbl in _labels) _left_margin = int(_max_label_len * _sizing._scale(8, font_size) + _sizing._scale(40, font_size)) _right_margin = 40 _top_margin = 40 _bottom_margin = 80 # Natural size driven by block_size (scaleanchor="x" keeps blocks square) _nat_width = _left_margin + seq_len * block_size + _right_margin _nat_height = _top_margin + (n_samples + 1) * block_size + _bottom_margin # Apply user overrides from figsize _width = figsize[0] if figsize is not None and figsize[0] is not None else _nat_width _height = figsize[1] if figsize is not None and figsize[1] is not None else _nat_height fig = go.Figure() # ---- Layer 1: alternating background stripes (grey / transparent) ---- for i in range(0, seq_len, stripe_width): if (i // stripe_width) % 2 == 0: continue # leave white / transparent fig.add_vrect( x0=i, x1=min(i + stripe_width, seq_len), fillcolor="#e9e9e9", opacity=0.5, layer="below", line_width=0, ) # Helper to add a nucleotide block def _add_block( x: int, y: int, base: str, hover_text: str = "", opacity: float = 1.0, ) -> None: color = colors.get(base.upper(), "#9E9E9E") fig.add_shape( type="rect", x0=x, x1=x + 1, y0=y - 0.5, y1=y + 0.5, fillcolor=color, line=dict(width=_sizing.get_active_line_width(), color="white"), opacity=opacity, layer="above", ) _text_size = max(8, int(block_size * 0.55)) fig.add_annotation( x=x + 0.5, y=y, text=f"<b>{base}</b>", showarrow=False, font=dict(size=_text_size, color="white"), xanchor="center", yanchor="middle", ) if hover_text: fig.add_trace(go.Scatter( x=[x + 0.5], y=[y], mode="markers", marker=dict(size=block_size, color="rgba(0,0,0,0)"), hovertext=hover_text, hoverinfo="text", showlegend=False, )) # ---- Layer 2: reference row ---- ref_y = n_samples + 1 for pos, base in enumerate(ref_seq): _add_block( x=pos, y=ref_y, base=base, hover_text=f"Ref: {base}<br>Pos: {pos}", ) # ---- Layer 3: sample rows ---- if len(variants_df) > 0: variant_groups: dict[tuple[str, int], list[dict]] = {} for row in variants_df.iter_rows(named=True): key = (row["sample"], row["pos"]) rec = {k: v for k, v in row.items() if k not in ("sample", "pos")} variant_groups.setdefault(key, []).append(rec) for (sample, pos), records in variant_groups.items(): if sample not in sample_to_y: continue base_y = sample_to_y[sample] for stack_idx, rec in enumerate(records): y = base_y + stack_idx * 0.9 vtype = rec["type"] # Map original position to display position after phase shift display_pos = (pos - phase) % seq_len # Insertion at the very end (pos == seq_len) should stay at the # right edge, not wrap to 0. if vtype == "ins" and pos >= seq_len: display_pos = seq_len if vtype == "sub": alt = rec.get("alt", "N") _add_block( x=display_pos, y=y, base=alt, hover_text=( f"Sample: {sample}<br>" f"Pos: {display_pos}<br>" f"Type: substitution<br>" f"{rec.get('ref', '')}{alt}" ), ) elif vtype == "ins": ins_seq = rec.get("seq", "") # Place the tick on the boundary after display_pos ins_x = display_pos _INS_COLOR = "#8945dc" # Draw entirely inside the current row [y-0.5, y+0.5] tri_top = y + 0.5 # row top edge = triangle base stick_top = y + 0.1 # triangle tip / stick top stick_bottom = y - 0.5 # stick bottom = row bottom edge # Vertical stick fig.add_shape( type="line", x0=ins_x, x1=ins_x, y0=stick_bottom, y1=stick_top, line=dict(color=_INS_COLOR, width=2), layer="above", ) # Downward-pointing triangle (wide, right-angle tip) fig.add_shape( type="path", path=f"M {ins_x - 0.4} {tri_top} " f"L {ins_x + 0.4} {tri_top} " f"L {ins_x} {stick_top} Z", fillcolor=_INS_COLOR, line=dict(width=0), layer="above", ) # Invisible hover target fig.add_trace(go.Scatter( x=[ins_x], y=[y], mode="markers", marker=dict(size=22, color="rgba(0,0,0,0)"), hovertext=( f"Sample: {sample}<br>" f"Pos: {display_pos}<br>" f"Type: insertion<br>" f"Seq: {ins_seq}" ), hoverinfo="text", showlegend=False, )) # Optionally show inserted bases to the right of the symbol if show_ins_bases: for offset, ins_base in enumerate(ins_seq): fig.add_annotation( x=ins_x + 0.4 + offset * 0.5, y=y, text=f"<b>{ins_base}</b>", showarrow=False, font=dict(size=max(8, int(block_size * 0.55)), color=_INS_COLOR), xanchor="center", yanchor="middle", ) elif vtype == "del": length = rec.get("length", 1) for offset in range(length): _add_block( x=(display_pos + offset) % seq_len, y=y, base="-", hover_text=( f"Sample: {sample}<br>" f"Pos: {(display_pos + offset) % seq_len}<br>" f"Type: deletion<br>" f"Length: {length}" ), ) # ---- Axes and layout ---- # Choose tick step so that ~6–15 ticks are shown regardless of motif length def _tick_step(n: int) -> int: for step in (1, 5, 10): if n // step + 1 <= 15: return step return 10 _step = _tick_step(seq_len) tick_vals = list(range(0, seq_len + 1, _step)) tick_text = [str(i) for i in tick_vals] fig.update_layout( xaxis=dict( range=[0, seq_len], tickvals=tick_vals, ticktext=tick_text, showgrid=False, zeroline=False, showline=True, linecolor="black", linewidth=_sizing.get_active_line_width(), ticks="outside", tickfont=dict(size=font_size), title="Position (bp)", ), yaxis=dict( tickvals=[ref_y] + [sample_to_y[s] for s in samples], ticktext=[ref_label] + [str(s) for s in samples], range=[0.5, n_samples + 1.5], scaleanchor="x", scaleratio=1, showgrid=False, zeroline=False, showline=True, linecolor="black", linewidth=_sizing.get_active_line_width(), ticks="outside", tickfont=dict(size=font_size), ), width=_width, height=_height, plot_bgcolor="white", paper_bgcolor="white", margin=dict(l=_left_margin, r=_right_margin, t=_top_margin, b=_bottom_margin), **kwargs, ) if save is not None: _save_figure(fig, save, "motif_msa") return fig