Source code for vampire.anno.pl._plot

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 _get_categorical_colormap
from ._setting import _COLORMAP_OPTIONS # dict[str, list[str] | dict[str, str]]
from ..pp._markdup import markdup


"""
#
# tracksplot function
#
"""
[docs] def tracksplot( tracks: list, region: str, title: str = "", x_title: str = "Position (bp)", vertical_spacing: float = 0.02, base_width: int = 600, track_name_dx: float = -0.07, figsize: tuple[int | None, int | None] | None = (None, None), save: str | bool | None = None, **kwargs ) -> go.Figure: """ Create a multi-track genomic plot with shared x-axis. Each track gets its own subplot with independent y-axis, but all tracks share the same x-axis (genomic position). Supported track types include bedgraph, bed, and heatmap. Parameters ---------- tracks : list[dict] list of track configuration dictionaries. Each dictionary should contain: Common fields for all track types: - **name** (`str`) - Track name displayed on the left side - **type** (`str`) - Track type, one of `"bedgraph"`, `"bed"`, or `"heatmap"` - **data** (`pl.DataFrame | pl.LazyFrame`) - Polars DataFrame with track data - **height** (`float`, optional) - Relative height of the track. Default is 1.0 - **showlegend** (`bool`, optional) - Whether to show the legend. Default is False Additional options for `"bedgraph"` tracks: - **plot_type** (`str`, optional) - `"line"`, `"bar"` or `"density"`. Default is `"line"` - **max_value** (`float`, optional) - Maximum value. Default is the maximum in the data - **min_value** (`float`, optional) - Minimum value. Default is the minimum in the data - **linewidth** (`float`, optional) - Line width. Default is 1 - **color** (`str`, optional) - Line or bar color. Default is `"#212529"` - **colorscale** (`list[str]` or `list[tuple[float, str]]`, optional) - Colorscale for density plot Additional options for `"bed"` tracks: - **stranded** (`bool`, optional) - Whether to show stranded arrows. Default is False - **arrowhead_length** (`float`, optional) - Arrowhead length compared with the region length for stranded arrows. Default is 0.03 - **color** (`str`, optional) - color. Default is `"#212529"` - **draw_baseline** (`bool`, optional) - When ``True``, draws a thin black horizontal line across the full region before the rectangles, so gaps appear as breaks. Default is ``False``. Additional options for `"heatmap"` tracks: - **max_value** (`float`, optional) - Maximum value. Default is the maximum in the data - **min_value** (`float`, optional) - Minimum value. Default is the minimum in the data - **colorscale** (`list[str]` or `list[tuple[float, str]]`, optional) - Colorscale for heatmap - **flip_y** (`bool`, optional) - Whether to flip the y-axis. Default is False region : str Genomic region in the format "chrom:start-end" (e.g., "chr1:1000-2000"). title : str, optional Title of the figure. Default is an empty string. x_title : str, optional Title of the x axis. Default is `"Position (bp)"`. vertical_spacing : float, optional Vertical spacing between subplots as a fraction of total height. Default is 0.02. base_width : int, default is 600 The plotting width of the whole figure. track_name_dx : float, optional Horizontal offset applied to track name position along the x-axis,expressed as a fraction of the total width. Default is -0.07. figsize : tuple[int | None, int | None], optional Figure size in pixels. Default is (None, None). save: str | bool | 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 keyword arguments passed to Plotly `update_layout`. Used to control figure-level styling (e.g. template, margin, background color, legend settings). Returns ------- go.Figure A Plotly figure object with all tracks plotted as subplots. Examples -------- >>> import vampire as vp >>> vp.anno.pl.set_default_plotstyle() >>> tracks = vp.datasets.chm13_cen1_tracks() >>> vp.anno.pl.tracksplot( ... tracks, ... region = "chm13_chr1:121119216-127324115", ... title = "chm13_chr1:121119216-127324115", ... vertical_spacing = 0.02, ... track_name_dx = -0.08, ... base_width = 400, # optional; adjust figure width to fit within the manual page width ... ) """ import numpy as np import polars as pl import plotly.graph_objects as go import plotly.subplots as sp # get coordinates region = region.split(":") CHROM = region[0] START = int(region[1].split("-")[0]) END = int(region[1].split("-")[1]) # create subplots: one row per track, shared x-axis n_tracks = len(tracks) if n_tracks == 0: return go.Figure() # get real figure size MIN_MARGIN: int = 40 MAX_NAME_LENGTH: int = max(len(track["name"]) for track in tracks) HAVE_HEATMAP: bool = any(track["type"] == "heatmap" for track in tracks) # Resolve figsize with auto-sizing font_size = kwargs.get("font", {}).get("size") if font_size is None: font_size = _sizing.get_active_font_size() _width, _height = _sizing.resolve_figsize( figsize[0] if figsize is not None else None, figsize[1] if figsize is not None else None, calc_width=lambda: _sizing.tracksplot_width( tracks, font_size, max_name_length=MAX_NAME_LENGTH, base_width=base_width ), calc_height=lambda: _sizing.tracksplot_height( tracks, font_size, vertical_spacing=vertical_spacing, max_name_length=MAX_NAME_LENGTH, base_width=base_width ), ) figsize = (_width, _height) left_margin: int = _sizing.tracksplot_left_margin( tracks, font_size, max_name_length=MAX_NAME_LENGTH ) real_height: float = figsize[1] - MIN_MARGIN * 2 real_width: float = figsize[0] - left_margin - MIN_MARGIN heights, _ = _sizing.tracksplot_subplot_heights( tracks, real_width=real_width, real_height=real_height, vertical_spacing=vertical_spacing, font_size=font_size ) # set subplot titles and heights fig = sp.make_subplots( rows = n_tracks, cols = 1, shared_xaxes = True, # share x-axis across all subplots vertical_spacing = vertical_spacing, # spacing between subplots row_heights = heights, ) track_idx = 0 for track in tracks: # get data name: str = track["name"] type: str = track["type"] data: pl.DataFrame|pl.LazyFrame = track["data"] match type: case "heatmap": data = data.filter((pl.col("chrom1") == CHROM) & (pl.col("chrom2") == CHROM) & (pl.col("end1") >= START) & (pl.col("start1") <= END) & (pl.col("end2") >= START) & (pl.col("start2") <= END)) case _: data = data.filter((pl.col("chrom") == CHROM) & (pl.col("end") >= START) & (pl.col("start") <= END)) if isinstance(data, pl.LazyFrame): data = data.collect() logger.debug( "Track %d/%d: '%s' (%s) — %d items after region filter", track_idx + 1, n_tracks, name, type, data.height, ) match type: case "bedgraph": plot_type = track.get("plot_type", "line") if plot_type == "line": _plot_bedgraph_track_line(fig, track, data, track_idx + 1) elif plot_type == "bar": _plot_bedgraph_track_bar(fig, track, data, track_idx + 1) elif plot_type == "density": _plot_bedgraph_track_density(fig, track, data, track_idx + 1) else: raise ValueError(f"Invalid plot type: {plot_type}") case "bed": _plot_bed_track(fig, track, data, track_idx + 1, (CHROM, START, END)) case "heatmap": _plot_heatmap_track(fig, track, data, track_idx + 1) case _: raise ValueError(f"Cannot identify the track type from the columns: {track.columns}") track_idx += 1 # update layout: only show x-axis ticks/labels on bottom subplot if n_tracks > 1: for row_idx in range(1, n_tracks): fig.update_xaxes( showticklabels = False, ticks = "", row = row_idx, col = 1, ) fig.update_xaxes( range = [START, END], title_text = x_title, title_standoff = 10, showline = True, # show x-axis line linecolor = "black", # axis line color linewidth = _sizing.get_active_line_width(), # axis line width tickwidth = _sizing.get_active_line_width(), row = n_tracks, col = 1, ) # set figure size fig.update_layout( width = figsize[0], height = figsize[1], margin = dict(l=left_margin, r=MIN_MARGIN, t=MIN_MARGIN, b=MIN_MARGIN + 10 + 5), autosize = False ) # set figure title left_margin = fig.layout.margin.l right_margin = fig.layout.margin.r width = fig.layout.width fig.update_layout( title=dict( text=title, font=dict(size=font_size + 2), x=(width + left_margin - right_margin) / (2 * width), xanchor="center", ) ) fig.update_layout( **kwargs ) # add annotations on the left side for idx, track in enumerate(tracks): y_domain = fig.layout[f"yaxis{idx+1}"].domain y_center = (y_domain[0] + y_domain[1]) / 2 fig.add_annotation( text = track["name"], xref = "paper", yref = "paper", x = track_name_dx, y = y_center, xanchor = "right", yanchor = "middle", showarrow = False ) if save: _save_figure(fig, save, "tracksplot") return fig
def _plot_bedgraph_track_line( fig: go.Figure, track: dict, data: pl.DataFrame, row: int ) -> None: """ Plot a bedgraph track as a line plot. Parameters ---------- fig : go.Figure The Plotly figure object to add the bedgraph track to. track : dict Track configuration dictionary containing plot settings (e.g., name, color, max_value, min_value). data : pl.DataFrame Polars DataFrame containing bedgraph data with columns: chrom, start, end, and value. row : int Subplot row number (1-indexed) where the bedgraph should be added. Returns ------- None The function modifies the figure in-place. """ import plotly.graph_objects as go ymax = track.get("max_value", data["value"].max()) ymin = track.get("min_value", data["value"].min()) # build line plot from bedgraph data x_coords = [] y_coords = [] # sort by start position data_sorted = data.sort("start") for row_data in data_sorted.iter_rows(named = True): x_coords.extend([row_data["start"], row_data["end"]]) y_coords.extend([row_data["value"], row_data["value"]]) # add trace with lines fig.add_trace( go.Scatter( x = x_coords, y = y_coords, mode = 'lines', name = track["name"], line = dict( width=track.get("linewidth", 1), color=track.get("color", "#212529"), shape='hv', ), # step plot (horizontal-vertical) showlegend = track.get("showlegend", False), legendgroup=track["name"], legendgrouptitle_text=track["name"] ), row = row, col = 1 ) fig.update_yaxes( range = [ymin, ymax], showline = True, # show x-axis line linecolor = "black", # axis line color linewidth = 1.4, tickwidth = 1.4, row = row, col = 1 ) fig.update_xaxes( showline = True, # show y-axis line linecolor = "black", # axis line color linewidth = 1.4, row = row, col = 1 ) def _plot_bedgraph_track_bar( fig: go.Figure, track: dict, data: pl.DataFrame, row: int ) -> None: """ Plot a bedgraph track as a bar plot. Parameters ---------- fig : go.Figure The Plotly figure object to add the bedgraph track to. track : dict Track configuration dictionary containing plot settings (e.g., name, color, max_value, min_value). data : pl.DataFrame Polars DataFrame containing bedgraph data with columns: chrom, start, end, and value. row : int Subplot row number (1-indexed) where the bedgraph should be added. Returns ------- None The function modifies the figure in-place. """ import plotly.graph_objects as go ymax = track.get("max_value", data["value"].max()) ymin = track.get("min_value", data["value"].min()) # sort by start position data_sorted = data.sort("start") # add trace with bars fig.add_trace( go.Bar( x = data_sorted["start"].to_list(), y = data_sorted["value"].to_list(), marker = dict( color=track.get("color", "#212529"), ), name = track["name"], showlegend = track.get("showlegend", False), legendgroup=track["name"], legendgrouptitle_text=track["name"] ), row = row, col = 1 ) fig.update_yaxes( range = [ymin, ymax], showline = True, # show x-axis line linecolor = "black", # axis line color linewidth = 1.4, tickwidth = 1.4, row = row, col = 1 ) fig.update_xaxes( showline = True, # show y-axis line linecolor = "black", # axis line color linewidth = 1.4, row = row, col = 1 ) def _plot_bedgraph_track_density( fig: go.Figure, track: dict, data: pl.DataFrame, row: int ) -> None: """ """ import plotly.graph_objects as go if data.height == 0: return # determine value range ymax = track.get("max_value", data["value"].max()) ymin = track.get("min_value", data["value"].min()) data = data.filter((data["value"] >= ymin) & (data["value"] <= ymax)) data_sorted = data.sort("start") if data_sorted.height == 0: return # x use the interval start x_vals = data_sorted["start"].to_list() # 1D heatmap z_vals = [data_sorted["value"].to_list()] all_columns = data_sorted.columns DEFAULT_COLORMAP: list[str] = ["#5E4FA2", "#3288BD", "#66C2A5", "#ABDDA4", "#E6F598", "#FFFFBF", "#FEE08B", "#FDAE61", "#F46D43", "#D53E4F", "#9E0142"] colorscale = track.get("colorscale", DEFAULT_COLORMAP) if all(isinstance(x, str) for x in colorscale): values = data_sorted["value"].to_numpy() colorscale = _get_colorscale(values, colorscale) elif all(_is_float_str_tuple(x) for x in colorscale): # nothing to do pass else: raise ValueError(f""" Invalid colorscale: {colorscale}, give a list of colors or a list of tuples with breaks and colors\n Example: ['#5E4FA2', '#3288BD', '#66C2A5']\n Example: [(0, '#5E4FA2'), (0.5, '#3288BD'), (1, '#66C2A5')] """) # customdata must be 2D customdata: list[list[list[Any]]] = [ [ [row_data[col] for col in all_columns] for row_data in data_sorted.iter_rows(named=True) ] ] fig.add_trace( go.Heatmap( x = x_vals, y = [0], # single row z = z_vals, colorscale = colorscale, zmin = ymin, zmax = ymax, showscale = track.get("showlegend", False), customdata = customdata, meta = all_columns, hovertemplate = ( "<br>".join( f"{col}: %{{customdata[{i}]}}" for i, col in enumerate(all_columns) ) + "<extra></extra>" ), colorbar = dict( title=track["name"], orientation="h", # horizontal x=0.5, xanchor="center", y=-0.1 - 0.1 * row, len=1 ), legendgroup=track["name"], legendgrouptitle_text=track["name"] ), row = row, col = 1 ) fig.update_yaxes( showticklabels = False, ticks = "", row = row, col = 1 ) def _plot_bed_track( fig: go.Figure, track: dict, data: pl.DataFrame, row: int, region: tuple[str, int, int] ) -> None: """ Plot a bed track as rectangles or arrows. Parameters ---------- fig : go.Figure The Plotly figure object to add the bed track to. track : dict Track configuration dictionary containing plot settings (e.g., name, stranded). If stranded is True, arrows are drawn; otherwise rectangles. - **draw_baseline** (`bool`, optional) — When ``True``, a thin black horizontal line is drawn across the full region *before* the rectangles, so gaps (positions with no data) appear as breaks in the line. Default is ``False``. data : pl.DataFrame Polars DataFrame containing bed data with columns: chrom, start, end, and optionally itemRgb (for colors) and strand (if stranded is True). row : int Subplot row number (1-indexed) where the bed track should be added. region : tuple[str, int, int] Genomic region in the format (chrom, start, end). Returns ------- None The function modifies the figure in-place. """ import numpy as np import plotly.graph_objects as go CHROM, START, END = region data_sorted = data.sort("start") # Background baseline — drawn as a shape with layer="below" so it is # guaranteed to sit underneath all traces regardless of trace type. if track.get("draw_baseline", False): fig.add_shape( type="line", x0=START, y0=0.5, x1=END, y1=0.5, line=dict(color="black", width=1), layer="below", row=row, col=1, ) # prepare data for batch plotting bases = [] # x starting positions widths = [] # widths (end - start) colors = [] # colors for each rectangle y_positions = [] # y positions (all same for one track) custom_data_list = [] # custom data for each rectangle # rectangle height rect_height = 1.0 y_center = 0.5 # center at 0.5 for each subplot # check if color column exists has_itemRgb = "itemRgb" in data.columns # get all column names all_columns = data.columns for row_data in data_sorted.iter_rows(named=True): bases.append(row_data["start"]) widths.append(row_data["end"] - row_data["start"]) y_positions.append(y_center) # determine color color = track.get("color", "#212529") if has_itemRgb and "color" not in track.keys(): # if color is specified in the track, use it item_rgb = row_data.get("itemRgb") if item_rgb and item_rgb != ".": # format: r,g,b if ',' in item_rgb: rgb = item_rgb.split(",") if len(rgb) == 3: color = f"rgb({rgb[0]},{rgb[1]},{rgb[2]})" elif item_rgb.startswith("#"): color = item_rgb colors.append(color) # prepare custom data custom_data_list.append([row_data.get(col) for col in all_columns]) # add single trace with all rectangles (batch plotting for efficiency) if bases: # only add if there are rectangles if not track.get("stranded", False): fig.add_trace( go.Bar( x = widths, # bar lengths (widths) y = y_positions, # y positions base = bases, # x starting positions orientation = 'h', # horizontal bars marker = dict( color=colors ), name = track["name"], width = rect_height, # height of bars in y direction showlegend = track.get("showlegend", False), customdata = custom_data_list, # add custom data for hover and interaction meta = all_columns, # store column names for reference hovertemplate = ( "<br>".join( f"{col}: %{{customdata[{i}]}}" for i, col in enumerate(all_columns) ) + "<extra></extra>" ), legendgroup=track["name"], legendgrouptitle_text=track["name"] ), row = row, col = 1 ) else: ARROWHEAD_RATIO: float = track.get("arrowhead_length", 0.03) BODY_WIDTH: float = 0.4 ARROWHEAD_LENGTH: float = ARROWHEAD_RATIO * (END - START) for idx, row_data in enumerate(data_sorted.iter_rows(named=True)): dx = row_data["end"] - row_data["start"] if row_data["strand"] == "+" else row_data["start"] - row_data["end"] pts = _make_solid_arrow(row_data["start"], y_center, dx, body_width=BODY_WIDTH, arrowhead_length=ARROWHEAD_LENGTH) cd = np.tile(custom_data_list[idx], (len(pts), 1)).tolist() fig.add_trace( go.Scatter( x = pts[:,0], y = pts[:,1], fill = 'toself', line = dict( width=1, color="white", ), mode='lines', fillcolor = colors[idx], hoverinfo="skip", showlegend = track.get("showlegend", False), legendgroup=track["name"], legendgrouptitle_text=track["name"] ), row = row, col = 1 ) fig.add_trace( go.Bar( x = [widths[idx]], y = [y_positions[idx]], base = [bases[idx]], marker = dict( color = colors[idx] ), orientation = 'h', opacity=0, # pseudo-trace to show hover showlegend = False, customdata = cd, meta = all_columns, hovertemplate = ( "<br>".join( f"{col}: %{{customdata[{i}]}}" for i, col in enumerate(all_columns) ) + "<extra></extra>" ) ), row = row, col = 1 ) # set y-axis fig.update_yaxes( range = [0, 1], showticklabels = False, ticks = "", row = row, col = 1 ) def _plot_heatmap_track( fig: go.Figure, track: dict, data: pl.DataFrame, row: int ) -> None: """ Plot a triangular heatmap track on the figure. Parameters ---------- fig : go.Figure The Plotly figure object to add the heatmap to. track : dict Track configuration dictionary containing plot settings (e.g., max_value, min_value, colorscale, flip_y). data : pl.DataFrame Polars DataFrame containing heatmap data with columns: chrom1, start1, end1, chrom2, start2, end2, and value. row : int Subplot row number (1-indexed) where the heatmap should be added. Returns ------- None The function modifies the figure in-place. """ import plotly.graph_objects as go # compute zmin/zmax zmax = track.get("max_value", data["value"].max()) zmin = track.get("min_value", data["value"].min()) data = data.filter((data["value"] >= zmin) & (data["value"] <= zmax)) if data.height == 0: return # prepare colorscale DEFAULT_COLORMAP: list[str] = ["#5E4FA2", "#3288BD", "#66C2A5", "#ABDDA4", "#E6F598", "#FFFFBF", "#FEE08B", "#FDAE61", "#F46D43", "#D53E4F", "#9E0142"] colorscale = track.get("colorscale", DEFAULT_COLORMAP) if all(isinstance(x, str) for x in colorscale): # assign breaks to colors values = data["value"].to_numpy() colorscale = _get_colorscale(values, colorscale) elif all(_is_float_str_tuple(x) for x in colorscale): # nothing to do pass else: raise ValueError(f""" Invalid colorscale: {colorscale}, give a list of colors or a list of tuples with breaks and colors\n Example: ['#5E4FA2', '#3288BD', '#66C2A5']\n Example: [(0, '#5E4FA2'), (0.5, '#3288BD'), (1, '#66C2A5')] """) # build bins bins = ( pl.concat([ data.select([pl.col("start1").alias("start"), pl.col("end1").alias("end")]), data.select([pl.col("start2").alias("start"), pl.col("end2").alias("end")]) ]) .unique() .sort("start") .with_row_index("idx") ) bin_starts = bins["start"].to_numpy() bin_ends = bins["end"].to_numpy() bin_centers = (bin_starts + bin_ends) / 2 # join i/j data = ( data .join(bins.rename({"start": "start1", "idx": "i"}), on="start1", how="left") .join(bins.rename({"start": "start2", "idx": "j"}), on="start2", how="left") ) i = data["i"].to_numpy() j = data["j"].to_numpy() v = data["value"].to_numpy() x1 = bin_centers[i] x2 = bin_centers[j] # compute rotated triangle coordinates xp = (x1 + x2) / 2 yp = (x2 - x1) / 2 mask = yp >= 0 xp = xp[mask] yp = yp[mask] v = v[mask] if len(xp) == 0: return None, None, None # compute resolution resolution = int(np.median(np.diff(np.sort(bin_centers)))) if resolution <= 0: resolution = 1 # build grid x_min, x_max = xp.min(), xp.max() y_min, y_max = 0, yp.max() x_bins = np.arange(x_min, x_max + resolution, resolution) y_bins = np.arange(y_min, y_max + resolution, resolution) z_vals = np.zeros((len(y_bins), len(x_bins)), dtype=np.float32) counts = np.zeros_like(z_vals, dtype=np.int32) # compute floor index x_idx = np.floor((xp - x_min) / resolution).astype(int) y_idx = np.floor((yp - y_min) / resolution).astype(int) valid = ( (x_idx >= 0) & (x_idx < len(x_bins)) & (y_idx >= 0) & (y_idx < len(y_bins)) ) # accumulate and count np.add.at(z_vals, (y_idx[valid], x_idx[valid]), v[valid]) np.add.at(counts, (y_idx[valid], x_idx[valid]), 1) # compute average value and NaN mask_nonzero = counts > 0 z_vals[mask_nonzero] /= counts[mask_nonzero] # output frequency of values with 2 or more counts (normal situation) if np.any(counts[mask_nonzero] > 1): frequency = np.sum(counts[mask_nonzero] > 1) / len(counts[mask_nonzero]) logger.debug(f"Some cells have 2 or more values: {frequency:.3%}") z_vals[~mask_nonzero] = np.nan # add trace fig.add_trace( go.Heatmap( x = x_bins, y = y_bins, z = z_vals, name = track["name"], colorscale = colorscale, zmin = zmin, zmax = zmax, showscale = track.get("showlegend", False), colorbar = dict( title=track["name"], orientation="h", # horizontal x=0.5, xanchor="center", y=-0.1 - 0.1 * row, len=1 ), legendgroup=track["name"], legendgrouptitle_text=track["name"] ), row = row, col = 1 ) fig.update_yaxes( range = [0, (x_max - x_min) / 2] if not track.get("flip_y", False) else [(x_max - x_min) / 2, 0], scaleanchor = f"x{row}", # bind to the x-axis of the current row constrain="domain", showticklabels = False, # hide tick labels (numbers) ticks = "", row = row, col = 1 ) def _make_solid_arrow( x0: float, y0: float, dx: float, body_width: float, arrowhead_length: float ) -> np.ndarray: """ Generate coordinates for a solid arrow shape. Parameters ---------- x0 : float Arrow start point x-coordinate. y0 : float Arrow start point y-coordinate. dx : float Arrow vector (positive for positive strand, negative for negative strand). body_width : float Arrow body relative width. arrowhead_length : float Arrow length. Returns ------- np.ndarray Array of shape (n_points, 2) containing the arrow point coordinates. """ # define 7 points of the arrow shape (relative coordinates) # assume the arrow is in the positive x-axis direction, and the tail is at (0,0) # # |\ y4 # |--------| \ y3 # |--------| / y2 # |/ y1 # x1(0) x2 x3(1) import numpy as np ARROW_WIDTH: float = 1.0 Y_CENTER: float = 0.0 BODY_LENGTH: float = (abs(dx) - arrowhead_length) / abs(dx) if BODY_LENGTH <= 0: y1, y4 = Y_CENTER - ARROW_WIDTH / 2, Y_CENTER + ARROW_WIDTH / 2 x1, x3 = 0, 1 pts = np.array([ [x1, y1], [x3, Y_CENTER], [x1, y4], [x1, y1] ]) else: y1, y2, y3, y4 = Y_CENTER - ARROW_WIDTH / 2, Y_CENTER - ARROW_WIDTH * body_width / 2, Y_CENTER + ARROW_WIDTH * body_width / 2, Y_CENTER + ARROW_WIDTH / 2 x1, x2, x3 = 0, BODY_LENGTH, 1 pts = np.array([ [x1, y2], [x2, y2], [x2, y1], [x3, Y_CENTER], [x2, y4], [x2, y3], [x1, y3], [x1, y2] ]) # adjust strand if dx < 0: pts[:,0] = 1 - pts[:,0] # scale x coordinates to match dx, dy vector pts[:,0] *= abs(dx) pts[:,0] += x0 pts[:,1] += y0 return pts def _is_float_str_tuple(x): """ Check if x is a float-string tuple. Parameters ---------- x : Any The value to check. Returns ------- bool True if x is a float-string tuple, False otherwise. """ return ( isinstance(x, tuple) and len(x) == 2 and isinstance(x[0], (float, int)) # sometimes you may give int and isinstance(x[1], str) ) def _get_colorscale(values: np.ndarray, color_list: list[str]) -> np.ndarray: """ Get numeric color break boundaries for given values. Parameters ---------- values : np.ndarray Array of values. color_list : list[str] list of colors. Returns ------- list[tuple[float, str]] list of color scale tuples with lower/upper boundaries and colors. """ import numpy as np values = np.asarray(values) ncolors = len(color_list) zmin = values.min() zmax = values.max() # corner case: empty if values.size == 0: return [] # corner case: all values identical if np.all(values == values[0]): return np.array([values[0], values[0]]) # equal-quantile breaks probs = np.linspace(0, 1, ncolors + 1) breaks = np.quantile(values, probs) # remove duplicate breakpoints breaks = np.unique(breaks) # still only one value after unique if len(breaks) == 1: breaks = np.array([breaks[0], breaks[0]]) breaks = (breaks - zmin) / (zmax - zmin) breaks[0] = 0.0 breaks[-1] = 1.0 colorscale = color_list[:len(breaks)-1] # construct colorscale with double points colorscale = [ (float(pos), color) for i, color in enumerate(colorscale) for pos in (breaks[i], breaks[i + 1]) ] return colorscale """ # # waterfall function # """
[docs] def waterfall( adata: ad.AnnData, feature: str = "motif", sample_order: list[str] | None = None, color: str = "id", colormap: dict[str, str] | list | str = "rainbow", deduplicate: bool = False, row_annotation: str | list[str] | dict[str, str] | dict[str, dict[str, str]] | None = None, row_annotation_colormap: ( dict[str, str] | str | dict[str, dict[str, str] | str] | None ) = None, figsize: tuple[int | None, int | None] = (None, None), track_name_dx: float = -0.01, save: str | bool | None = None, **kwargs ) -> go.Figure: """ Create a waterfall plot for motif composition across samples. The waterfall plot visualizes motif variation across samples in a stacked or ordered layout, where each sample is represented along the y-axis. Parameters ---------- adata : ad.AnnData Annotated data object generated from `pp.read_anno()`. feature : str, default="motif" Key prefix for the feature arrays stored in ``adata.uns``. The function looks up ``uns[f"{feature}_array"]`` and ``uns[f"{feature.replace('motif', 'orientation')}_array"]``. Common values: ``"motif"`` (raw arrays), ``"aligned_motif"`` (alignment output from ``vp.anno.tl.sample_msa()``). sample_order : list of str, optional Ordered list of sample identifiers defining the x-axis order. If None, samples are ordered based on the default order in `adata.obs`. color : str, default="id" Column name in `adata.var` used to assign motif coloring. colormap : dict | list | str Color mapping for features. Default is `rainbow`. - dict: explicit mapping {feature -> color} - list: sequential color assignment following input order - str: use preset colormap: `rainbow`, `glasbey`, `sequential` deduplicate : bool, default=False If True, collapse samples with identical motif arrays into a single track. The track label shows the first sample name followed by ``... (n=X)`` where X is the number of collapsed samples. The draw order follows the position of the first occurrence in ``sample_order``. row_annotation : str | list[str] | dict[str, str] | dict[str, dict[str, str]] | None, optional Sample-level categorical annotation displayed as colored block(s) between the track label and the main plot. - ``str`` — column name in ``adata.obs`` to read categories from. - ``list[str]`` — list of column names in ``adata.obs``; each column becomes an independent annotation dimension. - ``dict[str, str]`` — explicit ``{sample_name -> category}``. - ``dict[str, dict[str, str]]`` — multiple dimensions, e.g. ``{"haplotype": {sample: label, ...}, "batch": {...}}``. Each dimension is rendered as an independent annotation column. - ``None`` — no annotation drawn. row_annotation_colormap : dict[str, str] | str | dict[str, dict[str, str] | str] | None, optional Color mapping for ``row_annotation`` categories. - Non-nested values apply to **all** dimensions. - Nested ``dict[str, ...]`` keys must match dimension names. - ``str``: preset colormap name (``"rainbow"``, ``"glasbey"``, ``"sequential"``). - ``None``: auto-generate from preset. figsize : tuple[int | None, int | None], optional Figure size as (width, height) in pixels. Default is (None, None). - (None, None): auto-compute both dimensions from data. - (w, None): fixed width, auto-compute height from sample count. - (None, h): fixed height, auto-compute width from motif/kmer count. - (w, h): use user-specified size. width is proportional to the maximum sequence length (max_x) and font size to prevent horizontal crowding. height is proportional to the number of samples (n_tracks) and font size to keep track labels readable and avoid vertical overlap or excessive sparsity. track_name_dx: float, optional Horizontal offset applied to track name position along the x-axis,expressed as a fraction of the total width. Default is -0.01. save: str | bool | 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 keyword arguments passed to Plotly `update_layout`. Used to control figure-level styling (e.g. template, margin, background color, legend settings). Returns ------- fig : go.Figure Plotly figure object representing the waterfall visualization. Examples -------- >>> import vampire as vp >>> vp.anno.pl.set_default_plotstyle() >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.pl.waterfall( ... adata, ... colormap = "rainbow", ... ) """ import polars as pl import anndata as ad import plotly.graph_objects as go track_list: list[dict] = [] max_x: int = 0 # check sample_order if sample_order is None: sample_order: list[str] = list(adata.obs.index) all_sample_list: list[str] = list(adata.obs.index) missing = set(all_sample_list) - set(sample_order) if missing: raise KeyError(f"Missing samples in sample_order: {missing}") # resolve row_annotation into dict[str, dict[str, str]] row_annotations: dict[str, dict[str, str]] = {} if row_annotation is not None: if isinstance(row_annotation, str): col = row_annotation if col not in adata.obs.columns: raise ValueError( f"row_annotation='{col}' not found in adata.obs.columns" ) name = col row_annotations = {name: dict(adata.obs[col])} elif isinstance(row_annotation, list): if ( len(row_annotation) > 0 and all(isinstance(x, str) for x in row_annotation) and all(x in adata.obs.columns for x in row_annotation) ): for col in row_annotation: row_annotations[col] = dict(adata.obs[col]) else: raise ValueError( "row_annotation as list must be a list of column names present in adata.obs.columns" ) elif isinstance(row_annotation, dict): if not row_annotation: row_annotations = {} else: first_val = next(iter(row_annotation.values())) if isinstance(first_val, dict): # multi-dimension: {dim_name: {sample: label}} for dim_name, mapping in row_annotation.items(): missing = set(all_sample_list) - set(mapping.keys()) if missing: raise KeyError( f"row_annotation['{dim_name}'] is missing samples: {missing}" ) row_annotations = row_annotation else: # single-dimension: {sample: label} missing = set(all_sample_list) - set(row_annotation.keys()) if missing: raise KeyError( f"row_annotation dict is missing samples: {missing}" ) name = "annotation" row_annotations = {name: row_annotation} else: raise TypeError( f"row_annotation must be str, list of column names, dict or None, got {type(row_annotation)}" ) # build motif colormap using _get_categorical_colormap all_id_list = list(adata.var.index) if color == "id": id2element = {m: m for m in all_id_list} else: if color not in adata.var.columns: raise ValueError( f"color = '{color}' not found in adata.var.columns: {list(adata.var.columns)}" ) id2element = dict(zip(adata.var.index, adata.var[color])) all_element_list = list(dict.fromkeys(id2element.values())) _, mapped_colormap = _get_categorical_colormap(all_element_list, colormap) motif_array_dict: dict[str, list[str]] = adata.uns[f"{feature}_array"] orientation_name: str = feature.replace("motif", "orientation") orientation_array_dict: dict[str, list[str]] = adata.uns[f"{orientation_name}_array"] # deduplicate identical motif/orientation arrays if deduplicate: if "unique_group" not in adata.obs.columns: logger.warning( "unique_group not found in adata.obs. " "vp.anno.pp.markdup() has not been run. Running it automatically." ) adata = markdup(adata) # ensure ordering matches sample_order obs = adata.obs.loc[sample_order].copy() # deduplicate using unique_group seen: dict[tuple, dict] = {} for idx, sample in enumerate(sample_order): gid = obs.loc[sample, "unique_group"] if gid not in seen: seen[gid] = { "idx": idx, "first_sample": sample, "count": 0, "samples": [], } seen[gid]["samples"].append(sample) seen[gid]["count"] += 1 _ordered = sorted(seen.values(), key=lambda x: x["idx"]) draw_items = [ (x["first_sample"], x["first_sample"], x["count"], x["samples"]) for x in _ordered ] else: draw_items = [(s, s, 1, [s]) for s in sample_order] for sample, first_sample, count, samples in draw_items: # get data motif_array: list[str] = motif_array_dict[sample] orientation_array: list[str] = orientation_array_dict[sample] array_len: int = len(motif_array) # skip gaps ("-") but keep original positions so alignment is preserved start_array: list[float] = [] end_array: list[float] = [] motif_filtered: list[str] = [] ori_filtered: list[str] = [] color_filtered: list[str] = [] block_cn_list = adata.uns.get("block_copy_number", {}).get(sample, []) if not isinstance(block_cn_list, list): block_cn_list = list(block_cn_list) cn_idx: int = 0 for pos, (m, o) in enumerate(zip(motif_array, orientation_array)): if m == "-": continue start_array.append(float(pos)) if cn_idx < len(block_cn_list): end_array.append(float(pos + block_cn_list[cn_idx])) else: end_array.append(float(pos + 1)) motif_filtered.append(m) ori_filtered.append(o) color_filtered.append(mapped_colormap[m]) cn_idx += 1 # Old behavior: only the last block could be fractional. Kept as a # fallback when per-block copy numbers are unavailable. if end_array and not block_cn_list: total_cn: float = adata.obs.loc[adata.obs.index == sample, "copy_number"].iloc[0] end_array[-1] = start_array[-1] + total_cn - int(total_cn) max_x = max(max_x, array_len) track_data: pl.DataFrame = pl.DataFrame({ "chrom": ["seq"] * len(motif_filtered), "start": start_array, "end": end_array, "motif": motif_filtered, "strand": ori_filtered, "itemRgb": color_filtered, }, schema={ "chrom": pl.Utf8, "start": pl.Float64, "end": pl.Float64, "motif": pl.Utf8, "strand": pl.Utf8, "itemRgb": pl.Utf8, }) track_name = first_sample if count == 1 else f"{first_sample} ... (n={count})" track_dict = { "name": track_name, "type": "bed", "data": track_data, } track_list.append(track_dict) # auto-compute figsize to avoid crowding or excessive sparsity n_tracks = len(track_list) # get real font size: user override > active template > fallback font_size = kwargs.get("font", {}).get("size") if font_size is None: font_size = _sizing.get_active_font_size() # Reserve space for the longest sample name so tracksplot does not # squeeze the content area. max_name_length = max(len(td["name"]) for td in track_list) # ---- sizing: account for optional annotation column(s) ---- n_anno_dims = len(row_annotations) anno_width_px_for_sizing = 0 if n_anno_dims > 0: _, base_height = _sizing.waterfall_height(n_tracks, font_size) real_height_approx = base_height - _sizing.WATERFALL_TOP_MARGIN - _sizing.WATERFALL_BOTTOM_MARGIN track_height_approx = real_height_approx / n_tracks if n_tracks > 0 else 0 dim_width = int(track_height_approx) if not deduplicate else int(track_height_approx * 3) gap_between = 3 # px gap between dimension columns anno_width_px_for_sizing = dim_width * n_anno_dims + gap_between * max(0, n_anno_dims - 1) plot_width, total_width = _sizing.waterfall_width( max_x, font_size, max_name_length, annotation_width_px=anno_width_px_for_sizing ) plot_height, total_height = _sizing.waterfall_height(n_tracks, font_size) width, height = _sizing.resolve_figsize( figsize[0], figsize[1], calc_width=lambda: total_width, calc_height=lambda: total_height, ) actual_figsize = (width, height) # Detect whether any sample contains gaps ("-") — only draw baselines for # aligned data where gaps need to be visualised as breaks in the line. has_gap = any( any(m == "-" for m in motif_array_dict[s]) for s, _, _, _ in draw_items ) if has_gap: for td in track_list: td["draw_baseline"] = True fig: go.Figure = tracksplot( tracks=track_list, region=f"seq:0-{max_x}", title="", x_title="Copy index", figsize=actual_figsize, vertical_spacing=0.00, track_name_dx=track_name_dx, **kwargs ) def _estimate_legend_layout(names, title, avail_width, font_size): """ Estimate the number of wrapped legend rows and the total legend height in pixels. Parameters ---------- names : list[str] List of legend entry labels, in the same order they are displayed horizontally in Plotly. title : str | None Legend title text. avail_width : int Available width for the legend, in pixels. font_size : int Font size used for legend text. Returns ------- tuple[int, int] Estimated number of rows and total height of the legend in pixels, as ``(n_rows, total_height)``. """ if not names: return 0, 0 _marker_px = 20 # marker + left padding _gap_px = 10 # entry spacing distance _char_px = font_size * 0.7 entry_widths = [len(str(n)) * _char_px + _marker_px + _gap_px for n in names] # simulate the layout logic of Plotly orientation="h" lines = 0 current_width = 0 for w in entry_widths: if current_width + w > avail_width and current_width > 0: lines += 1 current_width = w else: current_width += w if current_width > 0: lines += 1 _title_h = font_size + 8 if title else 0 _line_h = font_size + 6 total_h = _title_h + lines * _line_h + 8 # bottom padding return lines, int(total_h) # ---- Re-place track names and optionally add annotation overlay ---- # Remove tracksplot's default annotations so we control exact placement. fig.layout.annotations = [] # ---- Annotation overlay ---- if row_annotations: from collections import Counter # Collect per-dimension, per-track annotation labels track_anno_by_dim: dict[str, list[list[str]]] = {} dim_palettes: dict[str, dict[str, str]] = {} for dim_name, mapping in row_annotations.items(): track_anno_by_dim[dim_name] = [ [mapping.get(s, "Unknown") for s in samples] for _, _, _, samples in draw_items ] all_cats = sorted(set( a for annos in track_anno_by_dim[dim_name] for a in annos )) if isinstance(row_annotation_colormap, dict): cur_colormap = row_annotation_colormap[dim_name] else: cur_colormap = row_annotation_colormap _, palette = _get_categorical_colormap(all_cats, cur_colormap) dim_palettes[dim_name] = palette # Compute exact track height from the rendered figure y_domain = fig.layout.yaxis1.domain track_height_paper = y_domain[1] - y_domain[0] track_height_px = track_height_paper * fig.layout.height # Fixed-pixel annotation width per dimension dim_width_px = track_height_px * 3 if deduplicate else track_height_px gap_px = 3 plot_width_px = fig.layout.width - fig.layout.margin.l - fig.layout.margin.r dim_width_paper = dim_width_px / plot_width_px if plot_width_px > 0 else 0 gap_paper = gap_px / plot_width_px if plot_width_px > 0 else 0 n_dims = len(row_annotations) total_anno_width_paper = dim_width_paper * n_dims + gap_paper * max(0, n_dims - 1) # Rightmost annotation edge just left of plot area rightmost_anno_x1 = -gap_paper leftmost_anno_x0 = rightmost_anno_x1 - total_anno_width_paper name_x = leftmost_anno_x0 - gap_paper # Ensure left margin accommodates shifted track names + all annotation columns px_per_char = _sizing._scale(_sizing.TRACKSPLOT_NAME_PX_PER_CHAR, font_size) text_width_px = max_name_length * px_per_char required_left_px = int(abs(name_x) * plot_width_px + text_width_px + gap_px) required_left_px = max(required_left_px, int(abs(leftmost_anno_x0) * plot_width_px + gap_px)) current_left_px = fig.layout.margin.l if required_left_px > current_left_px: extra_px = required_left_px - current_left_px fig.update_layout( margin=dict(l=required_left_px), width=fig.layout.width + extra_px, ) else: name_x = track_name_dx # Re-add track-name annotations at computed positions for idx, track in enumerate(track_list): y_domain = fig.layout[f"yaxis{idx+1}"].domain y_center = (y_domain[0] + y_domain[1]) / 2 fig.add_annotation( text=track["name"], xref="paper", yref="paper", x=name_x, y=y_center, xanchor="right", yanchor="middle", showarrow=False, ) # ---- Draw annotation shapes per dimension (horizontal side-by-side) ---- plot_area_height = fig.layout.height - fig.layout.margin.t - fig.layout.margin.b if row_annotations: min_idx = 0 _legend_infos: list[tuple[str, str, list[str]]] = [] # (legend_id, title, names) for dim_idx, (dim_name, track_annos) in enumerate(track_anno_by_dim.items()): palette = dim_palettes[dim_name] # Compute x range for this dimension column dim_anno_x1 = rightmost_anno_x1 - dim_idx * (dim_width_paper + gap_paper) dim_anno_x0 = dim_anno_x1 - dim_width_paper # Build ordered category list for this dimension dim_cats_ordered: list[str] = [] for annos in track_annos: for a in annos: if a not in dim_cats_ordered: dim_cats_ordered.append(a) # Draw shapes for each track for t_idx, annos in enumerate(track_annos): y_domain = fig.layout[f"yaxis{t_idx+1}"].domain y0 = y_domain[0] y1 = y_domain[1] if len(annos) == 1: fig.add_shape( type="rect", xref="paper", yref="paper", x0=dim_anno_x0, x1=dim_anno_x1, y0=y0, y1=y1, fillcolor=palette[annos[0]], line=dict(width=0), layer="above", ) else: cat_counts = Counter(annos) x_start = dim_anno_x0 total = len(annos) for cat in dim_cats_ordered: if cat not in cat_counts: continue x_end = x_start + (dim_anno_x1 - dim_anno_x0) * cat_counts[cat] / total fig.add_shape( type="rect", xref="paper", yref="paper", x0=x_start, x1=x_end, y0=y0, y1=y1, fillcolor=palette[cat], line=dict(width=0), layer="above", ) x_start = x_end if deduplicate and t_idx == min_idx: fig.add_annotation( x=(dim_anno_x0 + dim_anno_x1) / 2, y=y1 + gap_paper, xref="paper", yref="paper", text="100%", showarrow=False, xanchor="center", yanchor="bottom", font=dict(size=font_size), ) # Dimension name label (vertical, below annotation column) last_y_domain = fig.layout[f"yaxis{len(track_list)}"].domain bottom_y = last_y_domain[0] fig.add_annotation( xref="paper", yref="paper", x=(dim_anno_x0 + dim_anno_x1) / 2, y=bottom_y - 3.0 / plot_area_height, text=dim_name, showarrow=False, textangle=-90, xanchor="center", yanchor="top", font=dict(size=font_size), ) # Legend entries for this dimension legend_id = f"legend{dim_idx + 2}" for cat in dim_cats_ordered: fig.add_trace( go.Scatter( x=[None], y=[None], mode="markers", marker=dict(size=10, color=palette[cat], symbol="square"), name=str(cat), showlegend=True, legend=legend_id, ), ) _legend_infos.append((legend_id, dim_name, dim_cats_ordered)) # ---- Layout per-dimension legends stacked vertically ---- avail_width = fig.layout.width - fig.layout.margin.l - fig.layout.margin.r plot_area_height = fig.layout.height - fig.layout.margin.t - fig.layout.margin.b _legend_gap_px = 30 current_y_offset_px = 50 # first legend top distance from plot bottom total_legend_height = 0 for legend_id, title, names in _legend_infos: lines, h = _estimate_legend_layout(names, title, avail_width, font_size) fig.update_layout(**{ legend_id: dict( orientation="h", yanchor="top", y=-current_y_offset_px / plot_area_height, x=0.5, xanchor="center", bgcolor="rgba(0,0,0,0)", title=dict(text=title, side="top"), ) }) current_y_offset_px += h + _legend_gap_px total_legend_height += h + _legend_gap_px # Increase figure height & bottom margin to fit all legends if total_legend_height > 0: current_bottom = fig.layout.margin.b required_bottom = current_bottom + total_legend_height + 10 extra_height = required_bottom - current_bottom fig.update_layout( height=fig.layout.height + extra_height, margin=dict(b=required_bottom), ) fig.update_layout(**kwargs) if save: _save_figure(fig, save, "waterfall") return fig
[docs] def waterfall_legend( adata: ad.AnnData, feature: str = "motif", sample_order: list[str] | None = None, color: str = "id", colormap: dict | list | str = "rainbow", figsize: tuple[int | None, int | None] = (None, None), track_name_dx: float = -0.01, save: str | bool | None = None, **kwargs ) -> go.Figure: """ Create a legend figure for the waterfall plot. Displays colored squares alongside their corresponding motif sequences (or color-column values) in a separate figure. The order and coloring are consistent with `vp.anno.pl.waterfall()`. Parameters ---------- adata : ad.AnnData Annotated data object generated from `pp.read_anno()`. feature : str, default="motif" Key prefix for the feature arrays stored in ``adata.uns``. The function looks up ``uns[f"{feature}_array"]`` and ``uns[f"{feature.replace('motif', 'orientation')}_array"]``. Common values: ``"motif"`` (raw arrays), ``"aligned_motif"`` (alignment output from ``vp.anno.tl.sample_msa()``). sample_order : list of str, optional Unused in legend, kept for API consistency with `waterfall()`. color : str, default="id" Column name in `adata.var` used to assign coloring. When ``color="id"``, legend labels show motif ids; otherwise labels show values from the specified column. colormap : dict | list | str Color mapping specification. Must match the colormap used in the corresponding `waterfall()` call for consistent coloring. figsize : tuple[int | None, int | None], optional Figure size as (width, height) in pixels. Default is (None, None). - (None, None): auto-compute both dimensions from data. - (w, None): fixed width, auto-compute height from element count. - (None, h): fixed height, auto-compute width from label length. - (w, h): use user-specified size. track_name_dx: float, optional Unused in legend, kept for API consistency with `waterfall()`. 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 keyword arguments passed to Plotly `update_layout`. Returns ------- fig : go.Figure Plotly figure object with colored squares and their labels. Examples -------- >>> import vampire as vp >>> vp.anno.pl.set_default_plotstyle() >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.pl.waterfall_legend( ... adata, ... color = "motif", ... colormap = "rainbow", ... ) """ import plotly.graph_objects as go # build motif colormap using _get_categorical_colormap all_id_list = list(adata.var.index) if color == "id": id2element = {m: m for m in all_id_list} else: if color not in adata.var.columns: raise ValueError( f"color = '{color}' not found in adata.var.columns: {list(adata.var.columns)}" ) id2element = dict(zip(adata.var.index, adata.var[color])) all_element_list = list(dict.fromkeys(id2element.values())) _, mapped_colormap = _get_categorical_colormap(all_element_list, colormap) fig = go.Figure() n_items = len(mapped_colormap) if n_items == 0: return fig font_size = kwargs.get("font", {}).get("size") if font_size is None: font_size = _sizing.get_active_font_size() max_label_len = max(len(str(k)) for k in mapped_colormap.keys()) gap_length: float = 0.1 y_pos_list: list[float] = [] for i, (element, color_val) in enumerate(mapped_colormap.items()): y_pos = n_items - 1 - i - i * gap_length # top-to-bottom order y_pos_list.append(y_pos) # colored square (no border), width=1 height=1 for 1:1 aspect fig.add_trace(go.Scatter( x=[0, 1, 1, 0, 0], y=[y_pos, y_pos, y_pos + 1, y_pos + 1, y_pos], fill="toself", fillcolor=color_val, line=dict(width=0), showlegend=False, hoverinfo="skip", mode="lines", )) # label text, left-aligned fig.add_annotation( x=1.5, y=y_pos + 0.5, text=str(element), showarrow=False, xanchor="left", yanchor="middle", font=dict(size=font_size), ) ymax = max(y_pos_list) + 1 ymin = min(y_pos_list) # auto-compute figsize and margins so long legend labels stay visible width, height = _sizing.resolve_figsize( figsize[0], figsize[1], calc_width=lambda: _sizing.waterfall_legend_width(max_label_len, font_size), calc_height=lambda: _sizing.waterfall_legend_height(n_items, font_size), ) fig.update_layout( xaxis=dict( range=[0, (ymax - ymin) / float(height) * float(width)], zeroline=False, showticklabels=False, showline=False, ticks="", ), yaxis=dict( range=[ymin, ymax], scaleanchor="x", scaleratio=1, showgrid=False, zeroline=False, showticklabels=False, showline=False, ticks="", ), width=width, height=height, margin=_sizing.waterfall_legend_margin(max_label_len, font_size), ) fig.update_layout(**kwargs) if save: _save_figure(fig, save, "waterfall_legend") return fig
""" # # haplotype clustering evaluation plot # """
[docs] def haplotype_leiden_res_scan( adata: ad.AnnData, *, store_key: str = "haplotype", title: str | None = None, figsize: tuple[int | None, int | None] = (None, None), save: str | bool | None = None, ) -> go.Figure: """ Plot cluster evaluation curve based on resolution-based evaluation (from ``vp.anno.tl.haplotype_leiden_res_scan()``). Parameters ---------- adata : ad.AnnData Annotated data with evaluation results. store_key : str, default="haplotype" Key prefix matching the ``store_key`` used in the tool function. figsize : tuple[int | None, int | None], default=(None, None) Figure size in pixels. title : str | None Plot title. 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'``}. Returns ------- go.Figure Plotly figure with the evaluation curve (and cluster-count bars for resolution-based data). Examples -------- >>> import vampire as vp >>> vp.anno.pl.set_default_plotstyle() >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.tl.haplotype_neighbor(adata, metrics=["structural", "composition"]) >>> best_res = vp.anno.tl.haplotype_leiden_res_scan(adata) >>> vp.anno.pl.haplotype_leiden_res_scan(adata) """ import plotly.graph_objects as go from plotly.subplots import make_subplots import numpy as np eval_data = adata.uns.get(f"{store_key}_evaluation") if eval_data is None: raise KeyError( f"Evaluation data not found at uns['{store_key}_evaluation']. " f"Run a haplotype evaluation function first." ) # Detect data format: legacy k-based or new resolution-based is_resolution_based = "resolution_range" in eval_data line_width = _sizing.get_active_line_width() resolutions = eval_data["resolution_range"] k_values = eval_data["n_clusters"] metric_data = eval_data.get("metric", {}) scores = metric_data.get("scores", []) best_res = metric_data.get("best_resolution") best_score = metric_data.get("best_score") fig = make_subplots(specs=[[{"secondary_y": True}]]) # Modularity line (left Y) fig.add_trace( go.Scatter( x=resolutions, y=scores, mode="lines+markers", name="Modularity", line=dict(color="#212529", width=line_width), marker=dict(size=8), ), secondary_y=False, ) # Mark best point if best_res is not None and best_res in resolutions: best_idx = int(np.where(np.array(resolutions) == best_res)[0][0]) fig.add_trace( go.Scatter( x=[best_res], y=[scores[best_idx]], mode="markers", name=f"Best res={best_res:.2f}", marker=dict(color="#f94144", size=14, symbol="star"), ), secondary_y=False, ) # k bar chart (right Y) fig.add_trace( go.Bar( x=resolutions, y=k_values, name="k (clusters)", opacity=0.25, marker_color="#6c757d", showlegend=True, ), secondary_y=True, ) # X-axis: resolution with k on second line ticktext = [ ###f"{r}<br>(k={k})" for r, k in zip(resolutions, k_values) r for r in resolutions ] fig.update_xaxes( title_text="Resolution", ticktext=ticktext, tickvals=resolutions, showline=True, linecolor="black", linewidth=line_width, ticks="outside", tickwidth=line_width, ) fig.update_yaxes( title_text="Modularity", showline=True, linecolor="black", linewidth=line_width, ticks="outside", tickwidth=line_width, secondary_y=False, ) fig.update_yaxes( title_text="Number of clusters (k)", showline=True, linecolor="black", linewidth=line_width, ticks="outside", tickwidth=line_width, secondary_y=True, ) fig.update_layout( title=title, showlegend=True, legend=dict( orientation="h", yanchor="top", y=-0.25, xanchor="center", x=0.5, bgcolor="rgba(0,0,0,0)", ), ) fig.update_layout( width=700 if figsize[0] is None else figsize[0], height=600 if figsize[1] is None else figsize[1], ) if save: _save_figure(fig, save, "haplotype_evaluation") return fig