Brush-linked genome tracks

A whole-genome overview brush controls three synchronized association tracks. The overview keeps its own scale while the detail views share a selection-driven locus domain.

Data use and provenance

The packaged table is a subset of HapMap variants from the MIT-licensed manhattanly and Plotly datasets projects. Genomic coordinates and annotations are real, but the association statistics are simulated and are not biological findings. During data loading, the package removes invalid p-values and calculates -log10(p). GenomeSpy then renders the prepared table.

What to notice

Drag across the whole-genome overview to choose the region shown by all three detail tracks. Association strength, effect size, and Z-score share one locus scale but keep independent y scales. Zooming or panning a detail track also updates the overview brush.

Why use this layout?

This is a real genome-browser pattern. A genome-wide plot helps find an interesting region, while the aligned detail tracks make that region readable. The overview also shows where the current window is, so users do not lose their place while zooming.

The three metrics here intentionally reuse one small dataset to keep the brush example focused. Association strength and Z-score contain related information, so this exact combination is illustrative rather than a recommended GWAS dashboard. A research browser could replace one of them with genes, linkage disequilibrium, regulatory annotations, or sequencing coverage while keeping the same linked-brush structure.

Python implementation

The outer composition declares an empty brush parameter. The overview writes gestures to it with push="outer", and each detail scale links its domain to the same parameter. The scales remain synchronized through the brush while the overview stays on the full genome.

Python loads the prepared packaged table and authors the selection and scale definitions. GenomeSpy executes the interaction and updates the linked scales in the browser. See the user guide’s overview-brush walkthrough and GenomeSpy’s documentation on interval selections and selection-driven scale domains.

Code

"""Brush-linked genome tracks.

A whole-genome overview brush controls three synchronized association tracks.
The overview keeps its own scale while the detail views share a selection-driven
locus domain.
"""

from __future__ import annotations

import genome_spy as gs
from genome_spy.datasets._hapmap import hapmap_manhattan_data
from genome_spy.schema import BrushConfig, SelectionDomainRef


INITIAL_REGION = [
    {"chrom": "chr5", "pos": 0},
    {"chrom": "chr5", "pos": 180_857_866},
]

# Load association results and the axis limits for the example.
data, _, domains = hapmap_manhattan_data()
# Store the selected range so all three detail tracks can use it.
brush = gs.param("brush")
# Let the reader drag a rectangle across the overview to choose that range.
brush_update = gs.selection_interval(
    "brush",
    encodings=["x"],
    mark=BrushConfig(
        clip=False,
        fill="#4c78a8",
        fillOpacity=0.2,
        stroke="#315f8c",
        strokeWidth=1.2,
        measure="outside",
    ),
    push="outer",
    persist=False,
)


# Keep the whole genome visible above the zoomed-in tracks.
overview_track = (
    gs.Chart()
    .mark_point(filled=True, size=13, opacity=0.68, color="#7f8c8d")
    .encode(
        x=gs.Locus("chrom", "BP")
        .scale(assembly="hg18", zoom=False)
        .axis(title=None, chromTicks=True, chromLabels=True),
        y=gs.Y("neglog:Q")
        .scale(domain=domains["y_domain"])
        .axis(title="Overview", grid=False, labels=False, ticks=False),
        tooltip=["SNP:N", "chrom:N", "BP:Q", "P:Q"],
    )
    .properties(height=105)
    .add_params(brush_update)
)
# Leave room above the overview for the selected range's length label.
overview = (
    gs.vconcat(overview_track)
    .properties(padding=gs.Paddings(top=24))
    .resolve_scale(x="excluded")
)


# Show association strength within the selected range, starting on chromosome 5.
association_track = (
    gs.Chart()
    .mark_point(filled=True, size=24, opacity=0.78, color="#4c78a8")
    .encode(
        x=gs.Locus("chrom", "BP")
        .scale(domain=SelectionDomainRef(param=brush.name, initial=INITIAL_REGION))
        .axis(None),
        y=gs.Y("neglog:Q").scale(domain=domains["y_domain"]).title("−log10 p"),
        tooltip=["SNP:N", "GENE:N", "P:Q"],
    )
    .properties(
        name="association-strength",
        height=95,
    )
)

# Show effect sizes for the same range.
effect_points = (
    gs.Chart()
    .mark_point(filled=True, size=24, opacity=0.78, color="#f58518")
    .encode(
        x=gs.Locus("chrom", "BP")
        .scale(domain=SelectionDomainRef(param=brush.name, initial=INITIAL_REGION))
        .axis(None),
        y=gs.Y("EFFECTSIZE:Q").scale(domain=[-3, 3]).title("Effect size"),
        tooltip=["SNP:N", "GENE:N", "EFFECTSIZE:Q"],
    )
)

# Put a zero line behind the points to separate positive and negative effects.
effect_baseline = (
    gs.Chart([{"EFFECTSIZE": 0}])
    .mark_rule(color="#888888", size=1, tooltip=None)
    .encode(y=gs.Y("EFFECTSIZE:Q").scale(domain=[-3, 3]).title("Effect size"))
)
effect_track = (effect_baseline + effect_points).properties(
    name="effect-size", height=95
)

# Add Z-scores as the third view of the selected range.
zscore_track = (
    gs.Chart()
    .mark_point(filled=True, size=24, opacity=0.78, color="#54a24b")
    .encode(
        x=gs.Locus("chrom", "BP").scale(
            domain=SelectionDomainRef(param=brush.name, initial=INITIAL_REGION)
        ),
        y=gs.Y("ZSCORE:Q").scale(domain=[0, 7]).title("Z-score"),
        tooltip=["SNP:N", "GENE:N", "ZSCORE:Q"],
    )
    .properties(
        name="z-score",
        height=95,
    )
)


# Stack the overview and detail tracks, and give them the same selection.
chart = (
    gs.vconcat(overview, association_track, effect_track, zscore_track)
    .properties(
        data=data,
        assembly="hg18",
        title="Brush-linked HapMap association tracks",
        description=(
            "A whole-genome interval selection controls three synchronized "
            "association detail tracks."
        ),
        spacing=8,
    )
    .add_params(brush)
    .resolve_scale(x="independent", y="independent")
    .resolve_axis(x="independent", y="independent")
)