Manhattan Plot for GWAS — Pygal

A Manhattan plot visualizes genome-wide association study (GWAS) results by displaying -log10 transformed p-values across chromosomal positions. Points are arranged by genomic position along the x-axis with alternating colors for each chromosome, making it easy to identify significant associations. A horizontal threshold line indicates genome-wide significance (typically p < 5×10⁻⁸). This plot is essential for identifying genetic variants associated with traits or diseases.

Manhattan Plot for GWAS rendered with Pygal

Python source (Pygal)

""" anyplot.ai
manhattan-gwas: Manhattan Plot for GWAS
Library: pygal 3.1.0 | Python 3.13.13
Quality: 89/100 | Updated: 2026-05-15
"""

import os

import numpy as np
import pygal
from pygal.style import Style


np.random.seed(42)

THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477")

# Chromosome lengths (simplified, in Mb)
chrom_lengths = {
    "1": 249,
    "2": 243,
    "3": 198,
    "4": 191,
    "5": 182,
    "6": 171,
    "7": 159,
    "8": 146,
    "9": 141,
    "10": 136,
    "11": 135,
    "12": 134,
    "13": 115,
    "14": 107,
    "15": 102,
    "16": 90,
    "17": 83,
    "18": 80,
    "19": 59,
    "20": 64,
    "21": 47,
    "22": 51,
}

chromosomes = list(chrom_lengths.keys())
n_snps_per_chrom = 500

# Storage for data
all_chroms = []
all_cumulative_pos = []
all_pvalues = []

# Track chromosome boundaries for x-axis labels
chrom_midpoints = []
cumulative_offset = 0

for chrom in chromosomes:
    length = chrom_lengths[chrom]
    positions = np.sort(np.random.uniform(0, length, n_snps_per_chrom))

    # Store chromosome midpoint for x-axis label
    chrom_midpoints.append(cumulative_offset + length / 2)

    # Generate p-values: mostly uniform with some significant hits
    pvalues = np.random.uniform(0, 1, n_snps_per_chrom)

    # Add significant peaks on selected chromosomes
    if chrom in ["6", "11", "16"]:
        n_sig = np.random.randint(3, 8)
        sig_indices = np.random.choice(n_snps_per_chrom, n_sig, replace=False)
        pvalues[sig_indices] = 10 ** (-np.random.uniform(8, 15, n_sig))

    # Add suggestive signals
    n_sugg = np.random.randint(5, 15)
    sugg_indices = np.random.choice(n_snps_per_chrom, n_sugg, replace=False)
    pvalues[sugg_indices] = 10 ** (-np.random.uniform(5, 8, n_sugg))

    cumulative_positions = positions + cumulative_offset
    all_chroms.extend([chrom] * n_snps_per_chrom)
    all_cumulative_pos.extend(cumulative_positions)
    all_pvalues.extend(pvalues)

    cumulative_offset += length

# Convert to -log10 p-values
neg_log_pvalues = -np.log10(np.array(all_pvalues))

# Thresholds
genome_wide_sig = -np.log10(5e-8)  # ~7.3
suggestive_sig = 5.0  # -log10(1e-5)

# Custom style with theme-adaptive colors
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=IMPRINT,
    title_font_size=28,
    label_font_size=22,
    major_label_font_size=18,
    legend_font_size=16,
    value_font_size=14,
)

# Create XY scatter chart
chart = pygal.XY(
    width=4800,
    height=2700,
    style=custom_style,
    title="manhattan-gwas · pygal · anyplot.ai",
    x_title="Chromosome",
    y_title="-log₁₀(p-value)",
    show_legend=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=5,
    legend_box_size=16,
    stroke=False,
    dots_size=4,
    show_x_guides=False,
    show_y_guides=True,
    truncate_label=-1,
    print_values=False,
    x_label_rotation=0,
    range=(0, 16),
    xrange=(0, cumulative_offset),
    tooltip_border_radius=5,
    explicit_size=True,
    spacing=20,
    margin=50,
    margin_bottom=150,
    margin_top=80,
)

# Set x-axis labels at chromosome midpoints
chart.x_labels = chrom_midpoints


def format_x_label(x_val):
    """Find closest chromosome midpoint and return chromosome number."""
    if not chrom_midpoints:
        return ""
    min_dist = float("inf")
    closest_idx = 0
    for i, pos in enumerate(chrom_midpoints):
        dist = abs(x_val - pos)
        if dist < min_dist:
            min_dist = dist
            closest_idx = i
    # Only return label if close to midpoint
    if min_dist < 50:
        return str(closest_idx + 1)
    return ""


chart.x_value_formatter = lambda x: format_x_label(x)

# Prepare data by chromosome with alternating colors
odd_chrom_points = []
even_chrom_points = []
significant_points = []

for idx, chrom in enumerate(chromosomes):
    chrom_mask = [c == chrom for c in all_chroms]
    chrom_x = [all_cumulative_pos[j] for j in range(len(all_chroms)) if chrom_mask[j]]
    chrom_y = [neg_log_pvalues[j] for j in range(len(all_chroms)) if chrom_mask[j]]

    for x, y in zip(chrom_x, chrom_y, strict=True):
        point = {"value": (x, y), "label": f"Chr {chrom}: {x:.1f} Mb, -log₁₀(p)={y:.2f}"}
        if y >= genome_wide_sig:
            significant_points.append(point)
        elif idx % 2 == 0:
            odd_chrom_points.append(point)
        else:
            even_chrom_points.append(point)

# Add data series
chart.add("Odd chromosomes", odd_chrom_points, stroke=False, show_dots=True)
chart.add("Even chromosomes", even_chrom_points, stroke=False, show_dots=True)
chart.add("Significant (p<5×10⁻⁸)", significant_points, stroke=False, show_dots=True)

# Add threshold lines
n_line_points = 200
threshold_x = np.linspace(10, cumulative_offset - 10, n_line_points)

gw_line_points = [
    {"value": (x, genome_wide_sig), "label": f"Genome-wide threshold: -log₁₀(5×10⁻⁸) = {genome_wide_sig:.1f}"}
    for x in threshold_x
]
chart.add("p = 5×10⁻⁸ threshold", gw_line_points, stroke=True, show_dots=True, dots_size=2)

sugg_line_points = [
    {"value": (x, suggestive_sig), "label": f"Suggestive threshold: -log₁₀(1×10⁻⁵) = {suggestive_sig:.1f}"}
    for x in threshold_x
]
chart.add("p = 1×10⁻⁵ threshold", sugg_line_points, stroke=True, show_dots=True, dots_size=2)

# Save outputs
chart.render_to_file(f"plot-{THEME}.html")
chart.render_to_png(f"plot-{THEME}.png")

Part of Manhattan Plot for GWAS on anyplot.ai.

Other implementations