Build linked genome tracks¶
A genome browser is a set of aligned tracks that describe the same genomic region. The tracks share one horizontal locus scale, so a zoom or pan applies to all of them, but each track keeps the vertical scale appropriate for its own measurements.
This page builds a two-track browser from a quantitative BigWig signal and categorical BigBed annotations.
Define one track at a time¶
The first track maps BigWig intervals to x and GC content to y:
signal_track = (
gs.Chart(gs.lazy.bigwig("https://data.genomespy.app/genomes/hg38/hg38.gc5Base.bw"))
.mark_rect(color="#4c78a8", minWidth=0.5, tooltip=None)
.encode(
x=gs.Locus("chrom", "start"),
x2=gs.Locus("chrom", "end"),
y=gs.Y("score:Q").scale(domain=[0, 100]).axis(title="GC (%)"),
)
.properties(
name="gc-content",
height=90,
title=gs.title("GC content", orient="left"),
)
)
This track deliberately does not set assembly, an x domain, or x-axis
options. Those properties describe the relationship between tracks and will
belong to their common parent.
The annotation track layers interval rectangles and labels over one inherited BigBed source:
intervals = gs.Chart().mark_rect(minWidth=0.5, tooltip=None)
interval_labels = (
gs.Chart()
.mark_text(fitToBand=True, color="black", size=10, tooltip=None)
.encode(text=gs.Text("ucscLabel:N"))
.properties(
opacity=gs.dynamic_opacity(
unitsPerPixel=[500, 100],
values=[0, 1],
)
)
)
annotation_track = (
(intervals + interval_labels)
.properties(
name="ccre-annotations",
data=gs.lazy.bigbed(
"https://data.genomespy.app/sample-data/encodeCcreCombined.hg38.bb"
),
height=gs.step(18),
title=gs.title("Candidate regulatory elements", orient="left"),
)
.encode(
x=gs.Locus("chrom", "chromStart"),
x2=gs.Locus("chrom", "chromEnd"),
y=gs.Y("ucscLabel:N").axis(None),
color=gs.Color("ucscLabel:N").legend(title="cCRE class"),
)
)
The layered parent owns the data, locus encodings, category lane, and color.
Both child marks inherit those properties. Only the text child adds a text
encoding and zoom-dependent opacity.
height=gs.step(18) gives each categorical lane an 18-pixel step. This is more
robust than guessing one fixed height before knowing how many categories are
visible.
Concatenate and link the tracks¶
Use & to place the signal above the annotations. The concatenated parent owns
the shared genomic context:
browser = (
(signal_track & annotation_track)
.properties(
assembly="hg38",
scales=gs.scales(x=gs.Scale(domain=REGION)),
axes=gs.axes(
x=gs.GenomeAxis(
orient="bottom",
title="Genomic position",
chromGrid=True,
)
),
spacing=8,
)
.resolve_scale(x="shared", y="independent")
.resolve_axis(x="shared", y="independent")
.configure_view(stroke="lightgray")
)
Try zooming or panning in either track. Both tracks move because they participate in the same x scale resolution.
Titles, heights, and spacing¶
Track-local properties stay on each track:
a title names the measurement or annotation source;
heightcontrols the track’s plot area;y-axis settings describe that track’s units;
mark and encoding choices describe that track’s rows.
The concatenated parent owns spacing, because spacing describes the gaps
between its children. In this example, structured titles use orient="left"
to label the tracks consistently without consuming a separate header row.
Fixed heights work well for continuous signal tracks. Use step() for
categorical or packed lanes whose content height depends on a row count.
Scroll a tall track¶
When the content height should remain large enough for readable lanes, constrain only its visible viewport:
scrollable_annotations = annotation_track.properties(viewportHeight=120)
height remains the content height, while viewportHeight limits the space
occupied on screen and adds scrolling when necessary. Avoid replacing a
lane-based content height with a small fixed height; that compresses marks
instead of making them scrollable. See
scrollable viewports.
Reveal detail with semantic zoom¶
Semantic zoom changes what a view shows as the genomic scale changes. It is different from ordinary geometric zoom, which only enlarges the same marks.
The annotation labels use:
gs.dynamic_opacity(
unitsPerPixel=[500, 100],
values=[0, 1],
)
unitsPerPixel measures how many genomic units fit in one screen pixel. As the
view moves from 500 toward 100 bases per pixel, the labels fade from invisible
to visible. The interval rectangles remain present at every zoom level, so the
user retains context while details appear.
Use semantic zoom when labels, sequence bases, read mismatches, or other dense details become meaningful only at close range. Keep a simpler overview layer visible rather than making the entire track disappear.
The GenomeSpy documentation describes this technique in
zoom-driven layer opacity.
Two related mechanisms are
multiscale, which
switches between whole detail levels, and
score-based semantic zoom
for thinning dense point marks.
The stacked genome browser gallery example extends this pattern with multiple BigWig signals, sequence, and transcript annotations. The composed genome browser shows how independently authored imported views can use a parent-owned locus scale and axis. The linked brush example adds an always-visible overview whose interval selection navigates several detail tracks.