Skip to content

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 wrapping chart(), 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:

python
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:

python
# 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:

python
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:

python
# 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:

python
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)

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=):

python
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.

python
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

python
.layer(child: ChartBuilder | Mark) -> LayerBuilder

Parameters

ParameterTypeDescription
childChartBuilder | MarkThe 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 to selectAll(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, pairwise from_/to, or the low-level combinator form inside a manual layer([...])), which default to painting zBelow whatever they reference. This is a real paint-order constraint, not a hardcoded z-index, so it composes with other constraints — a line(...) or ribbon(...) 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 refsby / resolve read bare field names off the refs (by="species", not by="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.