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:
gf.chart(seafood, { axes: true })
.flow(
gf.spread({ by: "lake", dir: "x", spacing: 64 }),
gf.stack({ by: gf.field("species").sort("count"), dir: "y" })
)
.mark(gf.ribbon({ h: "count", fill: "species", opacity: 0.8 }))
.render(root, { 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(opts)), 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:
gf.chart(seafood, { axes: true })
.flow(
gf.spread({ by: "lake", dir: "x", spacing: 64 }),
gf.stack({ by: gf.field("species").sort("count"), dir: "y" })
)
.mark(gf.rect({ h: "count", fill: "species" }))
.layer(gf.ribbon({ opacity: 0.8 }))
.render(root, { 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:
const locations = Object.entries(lakeLocations).map(([lake, { x, y }]) => ({
lake,
x,
y,
}));
gf.chart(locations, { axes: true })
.flow(gf.scatter({ by: "lake", x: "x", y: "y" }))
.mark(gf.circle({ r: 4, fill: "white", stroke: "black", strokeWidth: 2 }))
.layer(gf.line({ stroke: "black", strokeWidth: 2 }))
.render(root, { w: 400, 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 }):
const nodes = [
{ id: "a", grp: 0 },
{ id: "b", grp: 1 },
{ id: "c", grp: 1 },
{ id: "d", grp: 2 },
];
const edges = [
{ source: "a", target: "b" },
{ source: "a", target: "c" },
{ source: "b", target: "d" },
{ source: "c", target: "d" },
];
gf.chart(nodes)
.flow(gf.scatter({ by: "id", x: "grp", y: "id" }))
.mark(gf.circle({ r: 14, fill: "#4e79a7" }).name("nodes"))
.layer(
gf
.chart(edges)
.flow(gf.resolve(["source", "target"], { from: gf.selectAll("nodes") }))
.mark(gf.line({ from: "source", to: "target", stroke: "#888" }))
)
.render(root, { 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 or closures over your own signals.
const sales = [
{ quarter: "Q1", revenue: 30 },
{ quarter: "Q2", revenue: 80 },
{ quarter: "Q3", revenue: 55 },
{ quarter: "Q4", revenue: 72 },
];
gf.chart(sales, { axes: true })
.flow(gf.spread({ by: "quarter", dir: "x" }))
.mark(gf.rect({ h: "revenue", fill: "#6b9bd1" }))
.layer(gf.rect({ y: gf.datum(60), h: 3, w: 400, fill: "#333" })) // threshold rule (revenue units)
.layer(gf.text({ x: 20, y: 24, text: "target: 60", fill: "#333" })) // caption
.render(root, { 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
layerContext, so a later tier'sselectAll("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 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 array of already-built charts) — .layer() does the naming + selectAll wiring for the common "draw, then build over what I drew" case.
