Lollipop plot

Mutation counts aggregated by amino-acid position and drawn above a protein domain map. Color encodes the dominant mutation class at each site, making hotspots easy to spot.

Code

"""Lollipop plot.

Mutation counts aggregated by amino-acid position and drawn above a protein
domain map. Color encodes the dominant mutation class at each site, making
hotspots easy to spot.
"""

from __future__ import annotations

import genome_spy as gs
from genome_spy.datasets._mutation import dnmt3a_lollipop_data
from genome_spy.schema import Legend, Scale


CLASS_ORDER = [
    "Frame_Shift_Del",
    "Nonsense_Mutation",
    "Missense_Mutation",
    "Splice_Site",
]

CLASS_COLORS = (
    Scale().domain(CLASS_ORDER).range(["#5f9ed1", "#e15759", "#66b55f", "#f28e2b"])
)


# Load mutation counts and the protein's named regions.
data = dnmt3a_lollipop_data()
features = data["features"].copy()
domains = data["domains"].copy()
backbone = data["backbone"].copy()

# Place the protein blocks below zero and leave room for labels above the points.
domains["y0"] = -5.3
domains["y1"] = -1.7
domains["mid"] = (domains["y0"] + domains["y1"]) / 2
domains["center"] = (domains["start"] + domains["end"]) / 2
backbone["y0"] = -5.0
backbone["y1"] = -2.2
backbone["mid"] = (backbone["y0"] + backbone["y1"]) / 2
features["base"] = float(domains["y1"].iloc[0])
features["label_y"] = features["count"] + 1.2
features["label_text"] = features["position"].map(
    lambda pos: f"R{pos}" if pos == 882 else ""
)
protein_length = data["protein_length"]
max_count = int(features["count"].max())
x_domain = [0, protein_length]
y_domain = [-6.2, max_count + 4]

# Arrange the mutation-class legend in two columns below the chart.
mutation_legend = (
    Legend()
    .title("Mutation class")
    .orient("bottom")
    .direction("horizontal")
    .columns(2)
    .symbolSize(72)
)

# Draw a grey band spanning the protein.
backbone_band = (
    gs.Chart(backbone)
    .mark_rect(color="#a8b5b6", stroke="#111111", strokeWidth=1)
    .encode(
        x=gs.X("start:Q")
        .scale(domain=x_domain, zoom=True)
        .title("Amino-acid position"),
        x2=gs.X2("end"),
        y=gs.Y("y0:Q")
        .scale(reverse=False, domain=y_domain)
        .axis(values=[1, max_count], grid=False)
        .title(None),
        y2=gs.Y2("y1"),
    )
)

# Add a colored block for each named protein region.
domain_layers = []
for domain in domains.to_dict(orient="records"):
    domain_layers.append(
        gs.Chart([domain])
        .mark_rect(
            color=domain["color"], cornerRadius=2, stroke="#111111", strokeWidth=1
        )
        .encode(
            x=gs.X("start:Q").scale(domain=x_domain, zoom=True),
            x2=gs.X2("end"),
            y=gs.Y("y0:Q").scale(reverse=False, domain=y_domain),
            y2=gs.Y2("y1"),
        )
    )

domain_blocks = domain_layers[0]
for layer in domain_layers[1:]:
    domain_blocks = domain_blocks + layer

# Write each region's name inside its block.
domain_labels = (
    gs.Chart(domains)
    .mark_text(size=8, color="#111111")
    .encode(
        x=gs.X("center:Q").scale(domain=x_domain, zoom=True),
        y=gs.Y("mid:Q").scale(reverse=False, domain=y_domain),
        text=gs.Text("name:N"),
    )
)

# Draw a stem from the protein to the mutation count at each position.
stems = (
    gs.Chart(features)
    .mark_rule(color="#c0c0c0", size=1)
    .encode(
        x=gs.X("position:Q").scale(domain=x_domain, zoom=True),
        y=gs.Y("base:Q").scale(reverse=False, domain=y_domain),
        y2=gs.Y2("count"),
    )
)

# Top each stem with a point colored by the most common mutation class there.
heads = (
    gs.Chart(features)
    .mark_point(size=88, filled=True, opacity=0.9)
    .encode(
        x=gs.X("position:Q").scale(domain=x_domain, zoom=True),
        y=gs.Y("count:Q").scale(reverse=False, domain=y_domain),
        color=gs.Color("class:N").scale(CLASS_COLORS).legend(mutation_legend),
    )
)

# Label the frequently mutated R882 position.
hotspot_labels = (
    gs.Chart(features[features["is_hotspot"]].copy())
    .mark_text(dy=-12, size=11, color="#111111")
    .encode(
        x=gs.X("position:Q").scale(domain=x_domain, zoom=True),
        y=gs.Y("label_y:Q").scale(reverse=False, domain=y_domain),
        text=gs.Text("label_text:N"),
    )
)

# Combine the protein blocks, lollipops, and labels in one chart.
chart = (
    backbone_band + domain_blocks + domain_labels + stems + heads + hotspot_labels
).properties(
    title=(f"{data['gene']} : [Somatic Mutation Rate: {data['mutation_rate']:.2f}%]"),
    description=(
        "A DNMT3A lollipop plot derived from the maftools TCGA LAML example, "
        "with per-position mutation counts, protein domains, and the labeled "
        "R882 hotspot."
    ),
)