layer
Stack another tier over the current one. .layer(child) takes a chart(...) pipeline, a bare relational mark (line(...), ribbon(...)), or a bare leaf mark (text(...), rect(...), …) as its argument. .layer(...) returns a LayerBuilder, so tiers keep chaining (.layer(a).layer(b)); at render time they stack into one figure.
.layer() is the one way to overlay a connector over a chart's own marks — there is no separate .connect() method. It gives every tier the previous tier's marks as scope, uniformly:
- An empty
chart()scope (no data) inherits the previous tier's marks as chart data, so a full.flow().mark()pipeline can group, re-partition, or otherwise process them before drawing. - A bare relational mark (
line(...),ribbon(...)) used directly, with no wrappingchart(), reads the previous tier's marks as the bag of refs it connects — this is the one-line sugar for the simple case. - A bare leaf mark (
rect(...),text(...), …) used directly is a component-level annotation: it ignores the scope entirely, since its channels have no ref-bag semantics to read.
Blank-fusion: skip .layer() entirely for a fresh chart
When the connector's anchors don't exist yet either — there's no earlier tier to draw from, just raw data — line(...)/ribbon(...) can go straight in .mark() position and skip .layer() altogether:
from gofish import chart, spread, stack, field, ribbon
chart(seafood, axes=True).flow(
spread(by="lake", dir="x", spacing=64),
stack(by=field("species").sort("count"), dir="y"),
).mark(
ribbon(h="count", fill="species", opacity=0.8)
).render(w=400, h=320)No split option on the ribbon: it's fused directly over this chart's own flow, and a fused relational mark splits at the flow's own grouping by default — one tier lays the band's path, every other grouping splits it. Here the spread(lake) tier lays the path and stack(by="species") splits, giving one band per species with nothing restated. When a different tier should lay the path, name it with along (see ribbon's Default grouping and line's) — it's only needed at all for a path choice the flow's shape doesn't already imply.
Desugaring
A relational mark placed directly in .mark() position elaborates to an invisible anchor tier plus a connector tier:
.mark(R(opts)) ⇒ .mark(blank(anchor(opts))).layer(R(opts))anchor(opts) is exactly the w/h/emX/emY subset of opts — the purely spatial keys blank() itself accepts. Everything else (fill, stroke, strokeWidth, strokeDasharray, opacity, curve, dir, mixBlendMode, along, source, target) stays on the connector, matching what you'd write by hand:
# Fused
chart(seafood, axes=True).flow(
spread(by="lake", dir="x", spacing=64),
stack(by="species", dir="y"),
).mark(ribbon(h="count", fill="species", opacity=0.8))
# ...is sugar for the explicit two-tier form:
chart(seafood, axes=True).flow(
spread(by="lake", dir="x", spacing=64),
stack(by="species", dir="y"),
).mark(blank(h="count")).layer(
ribbon(fill="species", opacity=0.8)
)A .name(...)/.label(...)/.zOrder(...) chained onto the relational mark names/labels/orders the connector (the anchor tier still gets .layer()'s usual auto-naming). This rule only fires when the chart's data still needs anchors drawn for it — a relational mark applied directly to an existing bag of refs (chart(selectAll("bars")).mark(ribbon(...)), or the bare-mark tier form documented above) keeps its unfused, direct-connect meaning; only the pairwise from_/to form is never fused either, since it already consumes ref-bearing rows in .mark() position.
Reach for the explicit .mark(blank(...)).layer(...) form instead when the anchor needs options besides w/h/emX/emY (a visible rect anchor, for instance — see ribbon's bar-chart example) or when you want the anchor and connector opts kept visually separate.
Ribbon — one-line sugar
The simple case — draw a ribbon over the marks you just drew, split into one band per group — is a single .layer(ribbon(...)) call, with no split option needed: the ribbon is fused over this chart's own flow, so it picks up the flow's grouping by default (see ribbon's Default grouping). Pass along="<field>" only when a different tier than the one inference picks should lay the path:
from gofish import chart, spread, stack, field, rect, ribbon
chart(seafood, axes=True).flow(
spread(by="lake", dir="x", spacing=64),
stack(by=field("species").sort("count"), dir="y"),
).mark(rect(h="count", fill="species")).layer(
ribbon(opacity=0.8)
).render(w=400, h=320)Desugaring
.layer(ribbon(...)) is sugar for the general chart()-tier form, which is itself the same manual wiring you'd write with layer([...]) and selectAll:
# One-line sugar
chart(data, axes=True).flow(
spread(by="lake", dir="x", spacing=64),
stack(by="species", dir="y"),
).mark(rect(h="count", fill="species")).layer(
ribbon(opacity=0.8)
)
# ...is sugar for the general chart()-tier form:
chart(data, axes=True).flow(
spread(by="lake", dir="x", spacing=64),
stack(by="species", dir="y"),
).mark(rect(h="count", fill="species").name("bars")).layer(
chart() # empty scope = the previous tier's marks
.flow(group(by="species"))
.mark(ribbon(opacity=0.8))
)
# ...which is itself sugar for the fully manual form:
layer([
chart(data, axes=True).flow(
spread(by="lake", dir="x", spacing=64),
stack(by="species", dir="y"),
).mark(rect(h="count", fill="species").name("bars")),
chart(selectAll("bars")).flow(group(by="species")).mark(
ribbon(opacity=0.8)
).zOrder(-1),
])The general chart()-tier form (middle example) stays fully supported — reach for it when the fused default (or along) isn't enough, e.g. when the re-partition needs to compose with other operators in its own .flow() (derive, a second group(), a different dataset entirely — see "Node-link" below), or when the split isn't named by any single flow tier at all. This is also the only place a connector still composes with a re-partition the way by used to: an explicit flow(group(by="...")) on the tier splits first, and the ribbon/line then draws one connector per resulting group.
Connector — a bare relational mark tier
For a line or ribbon with no re-partitioning at all, pass the connector mark directly — no by, no wrapping chart(). It reads the previous tier's marks as its bag of refs and threads one connector through all of them, painted underneath:
from gofish import chart, scatter, circle, line
chart(driving_shifts, axes=True).flow(
scatter(by="year", x="miles", y="gas")
).mark(circle(r=4, fill="white", stroke="black", strokeWidth=2)).layer(
line(stroke="black", strokeWidth=2)
).render(w=500, h=300)Node-link — a tier with its own data
Pass chart(table) to drive the tier from a different dataset, then resolve its reference columns back into the drawn nodes and connect them with line(from_=, to=):
from gofish import chart, scatter, resolve, selectAll, circle, line
nodes = [
{"id": "a", "grp": 0},
{"id": "b", "grp": 1},
{"id": "c", "grp": 1},
{"id": "d", "grp": 2},
]
edges = [
{"source": "a", "target": "b"},
{"source": "a", "target": "c"},
{"source": "b", "target": "d"},
{"source": "c", "target": "d"},
]
chart(nodes).flow(scatter(by="id", x="grp", y="id")).mark(
circle(r=14, fill="#4e79a7").name("nodes")
).layer(
chart(edges)
.flow(resolve(["source", "target"], from_=selectAll("nodes")))
.mark(line(from_="source", to="target", stroke="#888"))
).render(w=360, h=360)Annotation — a bare mark tier
Pass a bare mark (text(...), rect(...), …) instead of a chart(...) to add a component-level annotation tier: a datumless overlay — a threshold rule, a caption — with no data pipeline of its own. Its accessor channels ignore the datum, so use plain values.
from gofish import chart, spread, rect, text, datum
sales = [
{"quarter": "Q1", "revenue": 30},
{"quarter": "Q2", "revenue": 80},
{"quarter": "Q3", "revenue": 55},
{"quarter": "Q4", "revenue": 72},
]
chart(sales, axes=True).flow(spread(by="quarter", dir="x")).mark(
rect(h="revenue", fill="#6b9bd1")
).layer(
rect(y=datum(60), h=3, w=400, fill="#333") # threshold rule (revenue units)
).layer(
text(x=20, y=24, text="target: 60", fill="#333") # caption
).render(w=400, h=300)Signature
.layer(child: ChartBuilder | Mark) -> LayerBuilderParameters
| Parameter | Type | Description |
|---|---|---|
child | ChartBuilder | Mark | The next tier. A chart(...) pipeline stacks a data-driven tier — an empty chart() scope inherits the previous tier's marks; chart(table) drives it from another dataset (resolve back with resolve). A bare relational mark (line(...), ribbon(...)) reads the previous tier's marks as its bag of refs. A bare leaf mark (rect(...), text(...), …) is a component-level annotation overlay (datumless, ignores the scope). |
Returns a LayerBuilder — chain .layer(...) again for more tiers, then .render().
Semantics
- Uniform scope — every tier is handed the previous tier's marks as scope, whether that tier is an empty
chart()(binds the scope as chart data), a bare relational mark (reads the scope as its ref bag), or a bare leaf mark (ignores the scope). Under the hood.layer()names the previous tier's mark and binds the next tier toselectAll(thatName)— the same wiring you'd write by hand, done for you. - Shared registry — tiers resolve in order sharing one layer context, so a later tier's
selectAll("name")finds an earlier tier's.name("name"). - Chart-level options — axes and color from the root
chart(data, ...)apply to the whole stack. - Paint order — tiers paint in chain order (later tiers on top), like a manual
layer([...])— except relational marks (line(...),ribbon(...), in any call form: bag, fused/split, pairwisefrom_/to, or the low-level combinator form inside a manuallayer([...])), which default to paintingzBelowwhatever they reference. This is a real paint-order constraint, not a hardcoded z-index, so it composes with other constraints — aline(...)orribbon(...)tier needs no.zOrder(...)incantation to sit under the marks it connects. An explicit.zOrder(...)or.constrain(...)on the connector's own chain overrides the default. - Field references on refs —
by/resolveread bare field names off the refs (by="species", notby="datum.species"); a ref descends into its row bag automatically.
.layer(child) vs. the layer([...]) operator
This page documents the v3 builder method ChartBuilder.layer(child), which stacks a chart(...) tier over the current one and auto-wires an empty chart() scope to the previous tier's marks. It's sugar over the lower-level layer([...]) operator (which composes an explicit list of already-built charts) — .layer() does the naming + selectAll wiring for the common "draw, then build over what I drew" case.
