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() when the layout is an explicit wrapping grid. Composition is hierarchical, so layers and concatenations can be nested. The GenomeSpy documentation introduces the same model in view composition.

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.

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.

Advanced grid layouts

An UpSet-style layout combines a top summary, a left summary, and a matrix. A two-column grid needs an empty top-left cell so the summaries align with the matrix:

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)
    .resolve_scale(y="excluded")
)

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)
    .resolve_scale(x="excluded")
)

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=gs.step(34), height=gs.step(34))
)

empty_cell = gs.Chart([]).mark_point().properties(width=0, height=0)

grid_chart = (
    gs.concat(
        empty_cell,
        column_summary,
        row_summary,
        matrix,
        columns=2,
    )
    .properties(data=memberships, spacing=4, title="Aligned summaries and matrix")
    .with_scales(
        x=gs.Scale(domain=[-0.5, 2.5], paddingInner=0.15, paddingOuter=0.1),
        y=gs.Scale(domain=[-0.5, 2.5], paddingInner=0.15, paddingOuter=0.1),
    )
    .resolve_scale(x="shared", y="shared")
)

The child order fills the grid row by row:

Empty placeholder

Column summary

Row summary

Membership matrix

The column summary shares its x index scale with the matrix but excludes its quantitative y scale. The row summary does the converse. The matrix and summaries therefore stay aligned without forcing row counts, column counts, and matrix indices into incompatible resolutions.

The parent owns the data and shared index-scale domains. This keeps row and column order in one place and links later zooming or panning across the matrix and its summaries.

The UpSet plot and oncoprint examples use this layout at full size.