Composition

Composition combines complete views into a larger visualization. GenomeSpy uses three concise operators for the most common layouts:

Operator

Composition

Result

a + b

Layer

Draw b over a in the same plot area

a & b

Vertical concatenation

Place a above b

a | b

Horizontal concatenation

Place a to the left of b

Use concat() for a grid, facet() to repeat a plot by category, and surround() to place plots around a main chart.

Layer complementary marks

Layer marks when they describe the same coordinate system. The first layer is drawn first; later layers appear above it:

import genome_spy as gs

measurements = [
    {"position": 1, "signal": 2.1, "quality": 0.72, "group": "control", "label": "C1"},
    {"position": 2, "signal": 2.8, "quality": 0.84, "group": "control", "label": "C2"},
    {"position": 3, "signal": 3.2, "quality": 0.66, "group": "control", "label": "C3"},
    {"position": 1, "signal": 2.4, "quality": 0.78, "group": "treated", "label": "T1"},
    {"position": 2, "signal": 3.5, "quality": 0.91, "group": "treated", "label": "T2"},
    {"position": 3, "signal": 4.3, "quality": 0.88, "group": "treated", "label": "T3"},
]

points = gs.Chart().mark_point(filled=True, size=100)
labels = gs.Chart().mark_text(dy=-12).encode(text="label:N")

layered_chart = (
    (points + labels)
    .encode(
        x=gs.X("position:Q").title("Position"),
        y=gs.Y("signal:Q").scale(zero=False).title("Signal"),
        color=gs.Color("group:N").legend(title="Group"),
    )
    .properties(data=measurements, title="Points with labels")
)

Neither child declares data, positions, or color. Those properties belong to the layered parent and are inherited by both children. The text layer adds only the text encoding that it alone needs.

Place shared properties at the nearest common parent. Descendants can override an inherited data source, encoding, transform, or view property when their behavior differs. See layering views for the layer-specific options.

Stack aligned tracks vertically

The & operator gives each child its own plot area. It is particularly useful for aligned tracks that share horizontal positions:

signal_track = (
    gs.Chart()
    .mark_point(filled=True, size=90)
    .encode(
        y=gs.Y("signal:Q").scale(zero=False).title("Signal"),
        color="group:N",
    )
    .properties(height=150)
    .with_view(stroke="lightgray")
)
quality_track = (
    gs.Chart()
    .mark_point(filled=True, size=90, color="#6f6f6f")
    .encode(y=gs.Y("quality:Q").scale(domain=[0, 1]).title("Quality"))
    .properties(height=90)
    .with_view(stroke="lightgray")
)

vertical_chart = (
    (signal_track & quality_track)
    .encode(x=gs.X("position:Q").scale(domain=[0.5, 3.5], zoom=True))
    .properties(data=measurements, spacing=20, title="Two aligned tracks")
    .resolve_scale(x="shared", y="independent")
    .resolve_axis(x="shared", y="independent")
)

The parent supplies the data and x encoding. Both tracks therefore use one zoomable horizontal scale. Their y scales remain independent because signal and quality have different units and domains.

The light-gray view strokes and 20-pixel gap make the two schematic child views easy to distinguish while reading the composition.

Vertical concatenation shares x resolution by default because aligned tracks are common in GenomeSpy. The explicit calls above document the intended relationship and keep it visible when the chart becomes more complex.

Compare panels side by side

The | operator keeps panels separate while arranging them horizontally:

control_panel = (
    gs.Chart()
    .transform_filter(gs.datum.group == "control")
    .mark_point(filled=True, size=100, color="#4c78a8")
    .encode(x="position:Q", y=gs.Y("signal:Q").scale(zero=False))
    .properties(title="Control")
    .with_view(stroke="lightgray")
)
treated_panel = (
    gs.Chart()
    .transform_filter(gs.datum.group == "treated")
    .mark_point(filled=True, size=100, color="#e45756")
    .encode(x="position:Q", y=gs.Y("signal:Q").scale(zero=False))
    .properties(title="Treated")
    .with_view(stroke="lightgray")
)

horizontal_chart = (
    (control_panel | treated_panel)
    .properties(data=measurements, spacing=20)
    .resolve_scale(x="shared", y="shared")
    .resolve_axis(x="independent", y="shared")
)

The children filter the inherited data independently. Shared x and y scales make positions directly comparable, while independent x axes let each panel label that shared mapping within its own column. Child sizing, spacing, and separators are documented in view concatenation.

Repeat categories with facets

Use facet() to repeat one plot by category. The container supplies the data; Core splits the records and runs the child plot’s transforms for each category. Facets share scales and axes. FacetFieldDef specifies a single field for a wrapped layout; FacetMapping specifies rows and columns.

facet_chart = gs.facet(
    gs.Chart().mark_point(filled=True, size=90).encode(x="position:Q", y="signal:Q"),
    facet=gs.FacetMapping().column(
        field="group",
        title=gs.Title(text="group", reserve=False, offset=19, baseline="middle"),
        header=gs.FacetHeader(fontSize=13, color="#444444"),
    ),
    data=measurements,
    spacing=gs.FacetSpacing(column=18),
)

Header, spacing and field options use named Python arguments. See the upstream facet guide for layout constraints.

Add plots with surround

Use surround() to add plots above, below, or beside a main chart. Pass the main chart first. Choose sides with SurroundSides:

surround_chart = (
    gs.surround(
        gs.Chart()
        .mark_point(filled=True, size=90)
        .encode(x="position:I", y="signal:Q"),
        surround=gs.SurroundSides(
            top=[
                gs.Chart()
                .transform_aggregate(
                    groupby=["position"],
                    fields=["signal"],
                    ops=["mean"],
                    as_=["meanSignal"],
                )
                .mark_rect()
                .encode(
                    x=gs.X("position:I").axis(None),
                    y=gs.Y("meanSignal:Q").axis(title="Mean signal"),
                    tooltip=["position:I", gs.Tooltip("meanSignal:Q", format=".2f")],
                )
                .properties(height=80)
            ]
        ),
        data=measurements,
        spacing=8,
    )
    .with_scales(x=gs.Scale(domain=[1, 3], paddingInner=0.25, zoom=False))
    .configure_scale(zoom=False)
)

The bars show average signal at each position. GenomeSpy calculates the averages in the browser. Bars line up with the points below.

Side

Aligns with

Set each side plot’s

top, bottom

Main chart’s x positions

height

left, right

Main chart’s y positions

width

Set the main chart’s width and height to size its plot area. Use spacing to set the gap around it. Each side takes a list of charts.

For several plots on one side, gs.SurroundSideSpec(views=[...], spacing=4) sets their gap. It can also give them shared data or settings. See the surround reference for nested layouts and supported chart types.

Add row and column totals

This UpSet-style example uses gs.surround() to place bars above and beside a matrix. The fictional data includes the totals. GenomeSpy filters repeated totals and draws the charts in the browser.

memberships = [
    {"column": 0, "row": 0, "member": True, "columnTotal": 2, "rowTotal": 2},
    {"column": 0, "row": 1, "member": True, "columnTotal": 2, "rowTotal": 2},
    {"column": 0, "row": 2, "member": False, "columnTotal": 2, "rowTotal": 2},
    {"column": 1, "row": 0, "member": True, "columnTotal": 3, "rowTotal": 2},
    {"column": 1, "row": 1, "member": True, "columnTotal": 3, "rowTotal": 2},
    {"column": 1, "row": 2, "member": True, "columnTotal": 3, "rowTotal": 2},
    {"column": 2, "row": 0, "member": False, "columnTotal": 1, "rowTotal": 2},
    {"column": 2, "row": 1, "member": False, "columnTotal": 1, "rowTotal": 2},
    {"column": 2, "row": 2, "member": True, "columnTotal": 1, "rowTotal": 2},
]

column_bars = (
    gs.Chart()
    .mark_rect(color="#555")
    .encode(
        x=gs.X("column:I").axis(None),
        y=gs.Y("columnTotal:Q").axis(title="Column total", tickMinStep=1),
    )
)
column_labels = (
    gs.Chart()
    .mark_text(dy=-8)
    .encode(
        x="column:I",
        y="columnTotal:Q",
        text="columnTotal:Q",
    )
)
column_summary = (
    (column_bars + column_labels)
    .transform_filter(gs.datum.row == 0)
    .properties(height=90)
)

row_bars = (
    gs.Chart()
    .mark_rect(color="#777")
    .encode(
        x=gs.X("rowTotal:Q").axis(title="Row total", tickMinStep=1),
        y=gs.Y("row:I").axis(None),
    )
)
row_labels = (
    gs.Chart()
    .mark_text(align="right", dx=-5, color="white")
    .encode(
        x="rowTotal:Q",
        y="row:I",
        text="rowTotal:Q",
    )
)
row_summary = (
    (row_bars + row_labels).transform_filter(gs.datum.column == 0).properties(width=110)
)

matrix = (
    gs.Chart()
    .mark_point(filled=True, size=180)
    .encode(
        x=gs.X("column:I").axis(None),
        y=gs.Y("row:I").axis(None),
        color=gs.Color("member:N")
        .scale(domain=[False, True], range=["#d8d8d8", "#333333"])
        .legend(None),
    )
    .properties(width="container", height="container")
)

matrix_summary_chart = (
    gs.surround(
        matrix,
        surround=gs.SurroundSides(top=[column_summary], left=[row_summary]),
        data=memberships,
        spacing=8,
        title="Aligned summaries and matrix",
    )
    .with_scales(
        x=gs.Scale(domain=[0, 2], paddingInner=0.15, paddingOuter=0.1, zoom=False),
        y=gs.Scale(domain=[0, 2], paddingInner=0.15, paddingOuter=0.1, zoom=False),
    )
    .configure_scale(zoom=False)
)

Top bars follow the matrix columns. Left bars follow its rows. Each bar chart has its own count scale.

See the UpSet gallery example for a complete plot using mutation data.

Shared, independent, and excluded resolution

A resolution states which child views participate in the same scale, axis, or legend:

  • shared creates one mapping and one domain for participating views. Zooming a shared positional scale updates all participants.

  • independent creates a separate mapping for each child. Use it when fields have different units or unrelated domains.

  • excluded keeps a scale shared inside its local subtree but prevents that resolution from being pulled into an ancestor’s shared scale. It is useful for aligned grids containing side summaries with different units.

Configure these relationships with resolve_scale(), resolve_axis(), and resolve_legend(). An axis can be shared only when its scale is shared. Legend resolution normally follows the corresponding visual scale. The GenomeSpy documentation describes the rules in scale, axis, and legend resolution and the aligned-axis case in shared axes.

multiscale has no dedicated page in this guide. It provides semantic zoom between detail levels through multiscale(). To reuse a view stored in another JSON file, see Import remote view specifications.