Parameters and interaction

Interaction lets a users to change and manipulate a visualization without rebuilding it from scratch. The genome-spy-pythonAPI follows closely the parameter ergonomics of Altair: create a handle, attach it to a chart, then reuse that handle in an encoding, expression, or filter.

In practice, GenomeSpy represents interactive state with named parameters. Scales, transforms, encodings, and expressions can read those names and update when their values change. The GenomeSpy documentation describes the complete parameter model in parameters.

Zoom and pan

The simplest interaction needs no parameter at all. This chart’s locus scale is navigable as it stands:

# Each datum is one genomic variant with a score, sequencing depth, and impact.
import genome_spy as gs

VARIANTS = [
    {
        "id": "v1",
        "chrom": "chr17",
        "pos": 43_044_295,
        "score": 0.42,
        "depth": 38,
        "impact": "moderate",
    },
    {
        "id": "v2",
        "chrom": "chr17",
        "pos": 43_057_481,
        "score": 0.91,
        "depth": 72,
        "impact": "high",
    },
    {
        "id": "v3",
        "chrom": "chr17",
        "pos": 43_070_977,
        "score": 0.27,
        "depth": 51,
        "impact": "low",
    },
    {
        "id": "v4",
        "chrom": "chr17",
        "pos": 43_082_144,
        "score": 0.73,
        "depth": 64,
        "impact": "high",
    },
]

REGION = [
    {"chrom": "chr17", "pos": 43_040_000},
    {"chrom": "chr17", "pos": 43_090_000},
]


# A plain locus scale already supports zooming and panning.
zoom_chart = (
    gs.Chart(VARIANTS)
    .mark_point(filled=True, size=110)
    .encode(
        x=gs.Locus("chrom", "pos").scale(domain=REGION).axis(title="Genomic position"),
        y=gs.Y("score:Q").scale(domain=[0, 1]).title("Score"),
        color=gs.Color("impact:N"),
        tooltip=["id:N", "score:Q", "impact:N"],
    )
    .properties(assembly="hg38", title="Zoomable locus scale")
)

The scale domain is the state being changed, and no explicit parameter holds it. In a linked-track browser, put the domain on the one shared scale so every track follows it.

Parameters handle interaction

Bind a parameter to an input

param() declares a value, binding_range() makes a slider, and add_params() attaches the parameters to the chart:

# A value parameter and its slider binding as a handle.
min_score = gs.param(
    "minScore",
    value=0.4,
    bind=gs.binding_range(
        min=0,
        max=1,
        step=0.05,
        name="Minimum score: ",
    ),
)
point_size = gs.param("pointSize", expr=60 + min_score * 100)

# Keep the categories stable when the slider filters rows out.
VARIANT_DOMAIN = ["v1", "v2", "v3", "v4"]

bound_chart = (
    gs.Chart(VARIANTS)
    .transform_filter(gs.datum.score >= min_score)
    .mark_point(
        filled=True,
        color="#4c78a8",
        size=point_size,
    )
    .encode(
        x=gs.X("id:N").scale(domain=VARIANT_DOMAIN).title("Variant"),
        y=gs.Y("score:Q").scale(domain=[0, 1]).title("Score"),
    )
    .properties(title="Filter with a bound parameter")
    .add_params(min_score, point_size)
)

Parameters can be used directly in Python expressions. Here, min_score controls the filter:

gs.datum.score >= min_score

datum means the current data row. Moving the slider makes GenomeSpy run the filter again in the browser.

Use transform_collect() before a parameter-dependent transform to replay cached rows instead of returning to the data source when a slider changes.

The second parameter demonstrates a reactive expression:

point_size = gs.param("pointSize", expr=60 + min_score * 100)

GenomeSpy recalculates point_size when min_score changes. Passing the handle to mark_point(size=...) uses its current value.

The x-axis is told to always show all variant names. Moving the filter slider may hide some points, but it does not make the remaining names shift position.

The GenomeSpy documentation covers the available input widgets in input bindings and reactive parameters in expressions.

More slider examples

Select marks and style them conditionally

A selection parameter stores what the user picks. Use selection_point() for discrete marks Use when() to make an encoding conditional: choose one visual value when a condition matches (.then(...)) and optionally another when it does not (.otherwise(...)). This reacts to a selection or a value parameter, for example, changing a mark’s color, opacity, size, or outline.

# Clicking a point updates this named selection.
selected_variant = gs.selection_point("selectedVariant", empty=False)

# The point opacity and stroke width is controlled with the
# .when().then().otherwise() notation.
selection_chart = (
    gs.Chart(VARIANTS)
    .mark_point(filled=True, size=140, stroke="black")
    .encode(
        x=gs.Locus("chrom", "pos").scale(domain=REGION),
        y=gs.Y("score:Q").scale(domain=[0, 1]),
        color=gs.Color("impact:N"),
        key=gs.Key("id"),
        opacity=gs.when(selected_variant).then(gs.value(1)).otherwise(gs.value(0.25)),
        strokeWidth=gs.when(selected_variant).then(gs.value(2)).otherwise(gs.value(0)),
        tooltip=["id:N", "impact:N", "score:Q"],
    )
    .properties(assembly="hg38", title="Click a variant to select it")
    .add_params(selected_variant)
)

Selected points are opaque and outlined; other points are faint.

In the selection definition, empty=False makes the chart start with no selected points:

selected_variant = gs.selection_point("selectedVariant", empty=False)

In the point encoding, key=gs.Key("id") uses each point’s id value to identify it. This helps the chart keep the right point selected if its data is updated or reordered.

See point for more configuration options.

Select intervals with brushing

Use selection_interval() or a brush for a dragged range selection. A brush is a translucent rectangle a user drags to choose an area. It is useful, for example, when an overview, such as a chromosome track, should control what other linked tracks show.

In code, create a named value to hold the selected brush range with param(). Then add an interval selection with the same name using selection_interval() to let the user drag a rectangle. Dragging updates brush with the chosen range. Other chart parts can reuse brush to zoom, filter data, or change mark styles.

In the following example, The top row is a map of the chromosomes. Drag across it to choose which part of the genome appears below. That dragged rectangle is the brush. It stores the selected genomic range under the name brush, and both detail tracks read that same range, so they move together.

# The parent owns the selected genomic interval. The overview below updates it;
# the detail tracks read it as their x-scale domain.
DETAIL_REGION = [
    {"chrom": "chr6", "pos": 20_000_000},
    {"chrom": "chr11", "pos": 40_000_000},
]
BRUSH_VARIANTS = [
    {"id": "g1", "chrom": "chr1", "pos": 45_000_000, "score": 0.42, "depth": 38},
    {"id": "g2", "chrom": "chr7", "pos": 35_000_000, "score": 0.91, "depth": 72},
    {"id": "g3", "chrom": "chr9", "pos": 60_000_000, "score": 0.27, "depth": 51},
    {"id": "g4", "chrom": "chr11", "pos": 20_000_000, "score": 0.73, "depth": 64},
    {"id": "g5", "chrom": "chr17", "pos": 43_000_000, "score": 0.58, "depth": 46},
    {"id": "g6", "chrom": "chr20", "pos": 30_000_000, "score": 0.36, "depth": 57},
]


def build_brush_chart() -> gs.TopLevelSpec:
    """Build the overview-and-detail brush example."""
    brush = gs.param("brush")
    brush_update = gs.selection_interval(
        "brush",
        encodings=["x"],
        mark=gs.BrushConfig(
            clip=False,
            fill="#4c78a8",
            fillOpacity=0.18,
            stroke="#4c78a8",
            measure="outside",
            zindex=11,
        ),
        push="outer",
        persist=False,
    )
    chromosome_rects = (
        gs.Chart()
        .mark_rect(tooltip=None)
        .encode(
            fill=gs.Fill("odd:N")
            .scale(domain=[True, False], range=["#e8e8e8", "white"])
            .legend(None)
        )
    )
    chromosome_labels = (
        gs.Chart()
        .mark_text(paddingX=3, paddingY=5, tooltip=None)
        .encode(text=gs.Text("name:N"))
    )
    overview_track = (
        gs.layer(chromosome_rects, chromosome_labels)
        .encode(
            x=gs.X("continuousStart:L").scale(zoom=False).axis(None),
            x2=gs.X2("continuousEnd"),
        )
        .properties(
            data=gs.Data(lazy=gs.AxisGenomeData(type="axisGenome", channel="x")),
            height=26,
        )
        .add_params(brush_update)
    )
    overview = gs.vconcat(overview_track).resolve_scale(x="excluded")
    brush_score_track = (
        gs.Chart()
        .mark_point(filled=True, size=100, color="#4c78a8")
        .encode(
            x=gs.Locus("chrom", "pos")
            .scale(
                domain=gs.SelectionDomainRef(param=brush.name, initial=DETAIL_REGION)
            )
            .axis(None),
            y=gs.Y("score:Q").scale(domain=[0, 1]).title(None),
            tooltip=["id:N", "score:Q"],
        )
        .properties(height=80, title=gs.title("Score", orient="left"))
    )
    brush_depth_track = (
        gs.Chart()
        .mark_point(filled=True, size=100, color="#f58518", shape="square")
        .encode(
            x=gs.Locus("chrom", "pos").scale(
                domain=gs.SelectionDomainRef(param=brush.name, initial=DETAIL_REGION)
            ),
            y=gs.Y("depth:Q").scale(domain=[0, 80]).title(None),
            tooltip=["id:N", "depth:Q"],
        )
        .properties(height=80, title=gs.title("Depth", orient="left"))
    )
    return (
        gs.vconcat(overview, brush_score_track, brush_depth_track)
        .properties(
            data=BRUSH_VARIANTS,
            assembly="hg38",
            padding=gs.Paddings(top=20),
            spacing=8,
        )
        .add_params(brush)
        .resolve_scale(x="independent", y="independent")
        .resolve_axis(x="independent", y="independent")
    )


brush_chart = build_brush_chart()

Dragging the overview updates brush with the selected range. Both detail tracks use that range as their x-axis domain, so they move together. The overview always shows the full chromosome layout; drag its brush or zoom a detail track to change the shared view.

The brush example uses a few extra configuration objects because an overview controls two linked detail tracks.

  • BrushConfig controls how the dragged selection rectangle looks, including its fill and outline. Use it only when the default brush style is not enough.

  • AxisGenomeData provides the built-in chromosome overview data for the top row.

  • SelectionDomainRef connects each detail track’s x-axis to brush. Its initial value chooses the range shown when the chart first opens.

See GenomeSpy’s documentation on interval selections and domains from selection parameters for the underlying grammar. The linked brush gallery example applies the same pattern to three genome-wide association tracks.

Add one ruler across linked tracks

A ruler parameter follows a coordinate and draws a guide across tracks. ruler() is GenomeSpy-specific, but it uses the same create, attach, and reuse pattern as value and selection parameters.

Define one ruler at the common parent of the tracks it should span:

# A ruler is a shared pointer-following guide rather than a data selection.
cursor = gs.ruler(
    "cursor",
    persist=False,
    encodings=["x"],
    extent="container",
    display="line",
    mark=gs.RulerMarkConfig(stroke="#d62728", strokeWidth=1),
)

score_track = (
    gs.Chart()
    .mark_point(filled=True, size=90, color="#4c78a8")
    .encode(y=gs.Y("score:Q").scale(domain=[0, 1]).title("Score"))
    .properties(height=90, title=gs.title("Score", orient="left"))
)

depth_track = (
    gs.Chart()
    .mark_point(filled=True, size=90, color="#f58518")
    .encode(y=gs.Y("depth:Q").scale(domain=[0, 80]).title("Depth"))
    .properties(height=90, title=gs.title("Read depth", orient="left"))
)

ruler_chart = (
    (score_track & depth_track)
    .properties(
        data=VARIANTS,
        assembly="hg38",
        scales=gs.scales(x=gs.Scale(domain=REGION)),
        axes=gs.axes(x=gs.GenomeAxis(title="Genomic position")),
        spacing=8,
    )
    .add_params(cursor)
    .encode(x=gs.Locus("chrom", "pos"))
    .resolve_scale(x="shared", y="independent")
    .resolve_axis(x="shared", y="independent")
)

In the ruler definition, encodings=["x"] makes the ruler follow the horizontal position, and extent="container" makes one line span both tracks. persist=False clears the ruler when the pointer leaves the chart.

RulerMarkConfig controls how that line looks, including its color and width. Use it only when the default ruler style is not enough.

Pointer rulers follow mouse movement by default. Set source="viewport" when the ruler should instead follow the centre of the visible range.

The GenomeSpy documentation covers snapping, clearing, and guide styling in ruler parameters.

Share parameters with the charts that need them

Attach a parameter to the smallest chart group that needs it.

If a slider, selection, or ruler affects only one track, add it to that track. If it affects several tracks, add it to the chart that contains those tracks. The tracks inside that group can then use the same parameter.

Define each shared parameter once. This keeps every affected track in sync and avoids giving the chart several competing values with the same name.

The ASCAT fitting example uses sliders that control several linked tracks.