Skip to content

relate ​

.relate() relates named nodes inside a layer. Its callback returns a list of clauses. A clause is a constraint, which places nodes relative to each other with declarative alignment and distribution rules, or a drawing clause, an operator or mark such as arrow or background drawn over the named nodes. It is the low-level alternative to spread when you need precise control over how individual elements relate — for example, aligning a label to the edge of a background, or drawing an arrow from a label to the node it describes.

Usage ​

Name each node you want to relate using .name("key"), then chain .relate() on the layer. The callback receives an ordinary object with one operand for every name inside the layer. Destructure the names you need.

ts
layer([
  rect({ w: 200, h: 150, fill: "#e2ebf6" }).name("bg"),
  text({ text: "Title", fontSize: 18 }).name("label"),
])
  .relate(({ bg, label }) => [
    Constraint.align({ x: "middle", y: "end" }, [label, bg]),
  ])
  .render(container, { w: 300, h: 200 });
js
gf.layer([
  gf.rect({ w: 200, h: 150, fill: gf.color.blue[1] }).name("bg"),
  gf.rect({ w: 60, h: 30, fill: gf.color.blue[4] }).name("label"),
  gf.rect({ w: 60, h: 30, fill: gf.color.red[4] }).name("badge"),
])
  .relate(({ bg, label, badge }) => [
    gf.Constraint.align({ x: "end", y: "end" }, [label, bg]),
    gf.Constraint.align({ x: "start", y: "start" }, [badge, bg]),
  ])
  .render(root, { w: 300, h: 200 });

Names ​

A name in the callback resolves the same way as ref("name"), starting at the related layer: the closest node with that name inside the layer wins, and the search never crosses a createMark boundary. So an operand can be nested anywhere inside the layer, not only a direct child, and a direct child beats a node with the same name nested deeper.

The object holds exactly the names inside the layer. A name that is not there reads as undefined, so destructuring defaults and optional checks work as usual:

ts
layer(items).relate(({ a, b, note, pad = 8 }) => [
  Constraint.distribute({ dir: "x", spacing: pad }, [a, b]),
  ...(note ? [Constraint.align({ y: "end" }, [a, note])] : []),
]);
  • Direct child. The constraint places it.
  • Nested node. It is fixed to the direct child that contains it. If that child is also named in a constraint, the two move together. If not, the child stays where it was laid out and the nested node is a fixed point the other operands move to.
  • Errors. Using a missing name (an undefined handle) as an operand throws as soon as .relate() runs, and the message lists the names inside the layer. Two matches at the same smallest distance throw when the chart renders. A nested node cannot be resized from outside, so the target of "span" or "size" must be a direct child.
js
const planets = ["mercury", "venus", "earth"];
gf.layer([
  gf
    .enclose({ padding: 12, fill: gf.color.blue[1], stroke: "none" }, [
      gf.spread(
        { dir: "x", spacing: 30, alignment: "middle" },
        planets.map((p, i) =>
          gf.circle({ r: 6 + 4 * i, fill: gf.color.blue[4] }).name(p)
        )
      ),
    ])
    .name("row"),
  gf.rect({ w: 40, h: 12, fill: gf.color.red[4] }).name("label"),
])
  .relate(({ mercury, row, label }) => [
    gf.Constraint.align({ x: "middle" }, [mercury, label]),
    gf.Constraint.distribute({ dir: "y", spacing: 10 }, [row, label]),
  ])
  .render(root, { w: 300, h: 120 });

Drawing clauses ​

A clause that is not a constraint is an operator or mark, and it becomes a child of the layer. An operand in its children stands for the named node, like ref("name"). A drawing clause can also hold fresh marks next to the operands, as in spread({ dir: "y" }, [bar, text(...)]).

ts
layer([
  rect({ w: 70, h: 40 }).name("a"),
  rect({ w: 70, h: 40 }).name("b"),
]).relate(({ a, b }) => [
  Constraint.distribute({ dir: "x", spacing: 60 }, [a, b]),
  arrow({ bow: 0, stretch: 0 }, [a, b]),
]);
js
gf.layer([
  gf.rect({ w: 70, h: 40, fill: gf.color.blue[1] }).name("a"),
  gf.rect({ w: 70, h: 40, fill: gf.color.blue[1] }).name("b"),
])
  .relate(({ a, b }) => [
    gf.Constraint.distribute({ dir: "x", spacing: 60 }, [a, b]),
    gf.arrow({ bow: 0, stretch: 0, stroke: gf.color.blue[4] }, [a, b]),
  ])
  .render(root, { w: 240, h: 80 });
  • Order. The layer lays out its plain children, runs its constraints, then lays out each drawing clause after the clauses that place the nodes it reads. So the arrow above runs between the final positions of a and b, whatever the order of the clauses in the list. A drawing clause that reads another drawing clause (through ref("name") on a name given inside that clause) is laid out after it.
  • Cycles. A clause cannot read its own result. A drawing clause that refers to itself, two drawing clauses that refer to each other, or a constraint that moves a drawing clause which reads the nodes that constraint places, throws when the chart renders, and the message names the clauses.
  • Paint order. Drawing clauses paint after the plain children, in clause order. Use .zOrder(n) on a clause to move it.
  • Scope. A drawing clause relates nodes inside its own layer. A string ref("name") is legal only inside a .relate() clause, where it resolves from the related layer; anywhere else it throws and says to move it into .relate(). A createName token ref reaches across scopes and works anywhere.
  • Flattening. As with an operator's children, the list may nest arrays, hold promises (such as a For(...)), and contain null, undefined, or false, which are skipped.

Constraint.align ​

Aligns a set of children to a shared edge or center on one or both axes. At least one of x or y must be specified.

ts
Constraint.align({ x?, y? }, [ref1, ref2, ...])
OptionTypeDefaultDescription
xAlignValue | AlignAnchor[]—Value/edge/center/origin to align on the x axis (omit to leave x untouched)
yAlignValue | AlignAnchor[]—Value/edge/center/origin to align on the y axis (omit to leave y untouched)

AlignValue is AlignAnchor | "span" | "size", where AlignAnchor is "start" \| "middle" \| "end" \| "baseline". The first three anchor a child by its bounding-box edge or center. "baseline" anchors a child by its origin (its local 0 point) instead of its box. With no placed sibling the fallback is the axis origin: the scale's zero (posScale(0)) on a scaled axis, the layer's origin on a pixel-pure one. On a pixel-pure axis, align({ y: "baseline" }) thus means "stay where you were laid out" — regardless of how far its box overhangs the origin (a bar dipping below zero, axis labels hanging under a chart). For an unconditional origin pin regardless of axis, use Constraint.position({ x: 0, y: 0, anchor: "baseline" }). Pass a single value to share one anchor across every child (the common case); pass an array to assign one anchor per child positionally — the array length must equal the number of children ("span"/"size" are whole-constraint values and cannot appear inside a per-child array).

The first already-placed child in the list acts as the anchor on each specified axis (read at that child's anchor). Unplaced children are moved to match it (placed at their own anchor). If no child is placed yet, the fallback depends on the axis's underlying space: a scaled axis uses the scale origin posScale(0), a pixel-pure axis uses the layer's own edge (start = 0, middle = midpoint, end = full extent, baseline = layer origin). When both x and y are given, x is resolved before y.

"span" and "size" ​

"span" and "size" name an interval statistic rather than a point anchor — they equate the size cell itself, not just a position on it:

  • "span" — the target adopts the source's both endpoints: position AND size (e.g. a border rect that exactly bounds a placed group).
  • "size" — the target adopts only the source's length, without moving (e.g. a divider that matches a stack's width but is positioned independently).

Like the point-anchor form, the source is the first already-placed child; every other listed child is a target.

ts
layer([group, rect({ fill: "none", stroke: "#333" }).name("border")]).relate(
  ({ group, border }) => [
    Constraint.align({ x: "span" }, [group, border]), // border adopts group's left AND right
    Constraint.align({ y: "span" }, [group, border]), // together: border exactly bounds the group
  ]
);

Unbound-target scope: "span"/"size" only apply when the target has no intrinsic size on that axis (a bare rect({}) with no w/h/data binding on that axis). If the target already has an intrinsic size, this is an ownership conflict — GoFish throws a structured error naming the constraint and the target's own size option, rather than silently clobbering or skipping it. Fractional/two-sided anchors, offsets, and cross-axis length matching are not supported.

Per-child anchors ​

ts
// "A's center aligns with B's start" — useful for shared-edge layouts
// where two children overlap by a known fraction of their bbox.
Constraint.align({ x: ["middle", "start"] }, [A, B]);

// "B's end touches C's start" — adjacent placement.
Constraint.align({ x: ["end", "start"] }, [B, C]);

This is the per-child generalization of the single-anchor form. It expresses "edges share" relations directly, instead of through a distribute with a negative spacing.

js
gf.layer([
  gf.rect({ w: 80, h: 40, fill: gf.color.blue[3] }).name("a"),
  gf.rect({ w: 120, h: 40, fill: gf.color.red[3] }).name("b"),
  gf.rect({ w: 60, h: 40, fill: gf.color.green[3] }).name("c"),
])
  .relate(({ a, b, c }) => [
    gf.Constraint.align({ x: "end" }, [a, b, c]),
    gf.Constraint.distribute({ dir: "y" }, [a, b, c]),
  ])
  .render(root, { w: 300, h: 200 });

Constraint.distribute ​

Stacks a set of children end-to-end along an axis, with optional spacing.

ts
Constraint.distribute({ dir, spacing, anchor, order }, [ref1, ref2, ...])
OptionTypeDefaultDescription
dir"x" | "y"—Axis to distribute along
spacingnumber8Gap between each element (forced to 0 when glue is set)
anchor"edge" | "start" | "middle" | "end" | "baseline""edge"Whether spacing is measured between facing edges ("edge"), or as a fixed pitch between a chosen point on each element: anchor[i+1] = anchor[i] + spacing
order"forward" | "reverse""forward"Order to place elements
gluebooleanfalseStack semantics: children touch, and their data-driven extents commit to one positional axis

The first already-placed child acts as an anchor. Unplaced children after it are distributed forward (increasing position); unplaced children before it are distributed backward so they stack flush against the anchor's leading edge.

Space resolution and auto-fit ​

distribute (and align) don't just position children after layout — they participate in underlying-space resolution, exactly like the operators built on them. A distribute over data-sized children composes their size claims (sum + spacing) into the layer's claim on that axis; when the layer is then given a size (an explicit w/h, or an allotted budget from its parent or a coordinate transform), it solves for the scale factor that makes the children fit, and proposes equal budget slices to children with no size claim of their own. With glue: true the composed extents commit to an anchored positional axis instead — that's a stacked bar chart. In other words: a constraint-assembled layer auto-fits the same way a spread/stack does.

js
gf.layer([
  gf.rect({ w: 80, h: 40, fill: gf.color.blue[3] }).name("a"),
  gf.rect({ w: 80, h: 60, fill: gf.color.red[3] }).name("b"),
  gf.rect({ w: 80, h: 30, fill: gf.color.green[3] }).name("c"),
])
  .relate(({ a, b, c }) => [
    gf.Constraint.align({ x: "start" }, [a, b, c]),
    gf.Constraint.distribute({ dir: "y", spacing: 8 }, [a, b, c]),
  ])
  .render(root, { w: 300, h: 200 });

Constraint.position ​

Places a child at an x and/or y coordinate — the data-driven counterpart to align/distribute, which only relate children to each other. It mirrors how you position a shape: each coordinate is either a literal pixel value or a datum (datum(n)). A literal is placed as-is; a datum is mapped through a scale the layer infers from the datum coordinates of its position constraints (their union is the layer's domain on that axis, mapped onto the layer's pixel size). This is how a hand-drawn continuous axis places each tick at its value rather than assuming uniform spacing.

ts
Constraint.position({ x?, y?, anchor? }, [ref]);
OptionTypeDefaultDescription
xnumber | Value | [a, b]—x coordinate — point (literal/datum(n)) or interval [a, b]
ynumber | Value | [a, b]—y coordinate — point (literal/datum(n)) or interval [a, b]
anchorAlignment"middle"Which anchor of the ref lands on a point coordinate

At least one of x / y is required. Only datum coordinates feed the layer's inferred scale; literal pixels are placed directly and don't define the domain.

Interval form. A coordinate may be a two-element [min, max] interval instead of a point. The ref's min edge lands at min, its max edge at max, and the two edges determine its size on that axis (each endpoint is a literal pixel or datum(n), never a categorical position). This is the size-setting range form the scatter operator's xMin/xMax/yMin/yMax channels lower to — use it directly to make a ref span a data range:

ts
// Bar occupying x ∈ [datum(0), datum(count)] — its width is set by the two
// edges, not by an intrinsic size.
Constraint.position({ x: [datum(0), datum(count)] }, [bar]);

anchor and override apply to point coordinates only; an interval already pins both edges. Both endpoints of an interval unify their measures, so a range in mixed units is an error.

A datum coordinate can carry a pixel offset applied after the scale mapping — "this data position, plus pixels":

ts
// Seat a line 6px outside the y = 0 grid position, wherever 0 lands.
Constraint.position({ y: datum(0).offset(-6), anchor: "end" }, [line]);

The offset shifts the resolved position without affecting the inferred domain (datum(0).offset(-6) still contributes 0 to the scale). It works anywhere a Value is accepted — shape coordinates too, not just constraints. In Python the same thing is written with plain arithmetic: datum(0) - 6.

ts
// A continuous y-axis: each tick centered at its data value. Passing `datum(v)`
// maps it through the y-scale the layer derives from these constraints (domain
// [0, 300] → plot height). A bare number would be a raw pixel instead.
layer([
  rect({ w: 1, h: 300 }).name("axis"),
  ...tickValues.map((v, i) => tick(v).name(`t${i}`)),
]).relate((g) => [
  Constraint.align({ y: "start" }, [g.axis]),
  ...tickValues.map((v, i) =>
    Constraint.position({ y: datum(v) }, [g[`t${i}`]])
  ),
]);

Constraint.nest ​

Sizes one child to wrap (or be wrapped by) another with a fixed padding — the first size-setting constraint. Given [outer, inner], the relation outer = inner + 2·padding holds on each constrained axis, and inner is centered inside outer there.

ts
Constraint.nest({ x?, y? }, [outer, inner]);
OptionTypeDefaultDescription
xnumber—Per-axis padding (px) on the x axis (omit to leave x unconstrained)
ynumber—Per-axis padding (px) on the y axis (omit to leave y unconstrained)

At least one of x / y must be specified; [outer, inner] must be exactly two refs. Padding is always known — the unknown per axis is which side is derived, resolved from which side carries the size:

  • Inside-out (outer = inner + 2·padding): the inner is sized and the outer is not — a box that shrink-wraps its content. Because the derived outer size enters the layer's size request, a nested pair inside an auto-fit context (a spread of nested pairs) participates in the scale solve.
  • Outside-in (inner = outer − 2·padding): the outer carries the size and the inner is claim-less — exactly CSS padding.
  • Center only: when neither side is sized, the layer fills the outer, then resolves outside-in over that filled box.
ts
// inner 60×40, padding 10 → outer 80×60; inner centered (inner.min = 10).
layer([
  rect({ fill: "#dbe6f3" }).name("outer"),
  rect({ w: 60, h: 40, fill: "#e63946" }).name("inner"),
]).relate(({ outer, inner }) => [
  Constraint.nest({ x: 10, y: 10 }, [outer, inner]),
]);
js
gf.layer([
  gf.rect({ fill: gf.color.blue[1], stroke: gf.color.blue[3] }).name("outer"),
  gf.rect({ w: 60, h: 40, fill: gf.color.red[4] }).name("inner"),
])
  .relate(({ outer, inner }) => [
    gf.Constraint.nest({ x: 10, y: 10 }, [outer, inner]),
  ])
  .render(root, { w: 200, h: 160 });

Constraint.zAbove / Constraint.zBelow ​

Declare a partial-order relation between two named children for paint order (z-order) only. They do not affect position.

ts
Constraint.zAbove(a, b); // a paints in front of b (on top in z)
Constraint.zBelow(a, b); // a paints behind b (under in z)

zBelow(a, b) is equivalent to zAbove(b, a); both are provided so the spec reads naturally either way.

A layer paints only its own children, and the render topologically sorts them against the z-order constraints. Within the order constraints don't pin, the existing default order is preserved (.zOrder(n) hints first, then declaration order). A cycle (zAbove(a, b) + zAbove(b, a)) throws an error at render time.

ts
layer([
  rect({ w: 80, h: 40, fill: "lightgray" }).name("bg"),
  rect({ w: 60, h: 60, fill: "steelblue" }).name("box"),
  text({ text: "label", fontSize: 14 }).name("label"),
]).relate(({ bg, box, label }) => [
  // box paints over bg; label paints over both.
  Constraint.zAbove(box, bg),
  Constraint.zAbove(label, box),
]);

Cross-tier references ​

Z-order refs see the same names as every other operand: any node inside the layer, through nested layers and operators but not into a createMark component (see Name Resolution & Scoping). Unlike placement operands, a z-order name applies to every node it matches there.

A constraint orders the two children of the layer that contain its operands, each child as a whole. When both operands lie in the same child, the constraint orders them inside that child instead, one level down. So one child cannot paint between two marks of another child: zAbove(rope, A) with zBelow(rope, B), where A and B share a child and rope does not, is a cycle. Make the rope a sibling of the pulleys, for example as a drawing clause of their layer, and the constraints can reach it from an outer layer.

ts
layer([
  layer([
    PulleyCircle({ r: 25 }).name("A"),
    PulleyCircle({ r: 25 }).name("B"),
  ]).relate((c) => [
    /* … */
    line({ ... }, [c.A, c.B]).name("rope"),
  ]),
]).relate((c) => [
  Constraint.zAbove(c.rope, c.A),  // rope paints over A …
  Constraint.zBelow(c.rope, c.B),  // … but is covered by B
]);

When to use this vs .zOrder(n) ​

  • Use .zOrder(n) when you want a global tier (e.g. "all ropes go behind all wheels").
  • Use Constraint.zAbove / zBelow when you want a relational exception (e.g. "this specific rope sits between these two specific wheels").

The two compose: .zOrder(n) sets the default order; z-order constraints override it for the pairs they name.

spread equivalences ​

Constraints are the primitive spread and stack are built on — literally: the operators delegate their space resolution, budget slicing, and placement walks to the same machinery the constraint path uses. These pairs are equivalent, including scale solving and auto-fit, not just placement:

OperatorConstraint equivalent
spread({ dir: "y", alignment: "start" }, items)align({ x: "start" }) + distribute({ dir: "y" })
spread({ dir: "x", alignment: "end", spacing: 10 }, items)align({ y: "end" }) + distribute({ dir: "x", spacing: 10 })
spread({ dir: "x", spacing: 60, anchor: "middle" }, items)distribute({ dir: "x", spacing: 60, anchor: "middle" })
spread({ dir: "y", reverse: true }, items)distribute({ dir: "y", order: "reverse" })
stack({ dir: "y" }, items)distribute({ dir: "y", glue: true })

When no child is pre-placed, the cross-axis alignment fallback depends on the axis, not the API — spread and the align constraint resolve the same fallback, so the pairs above are exact. A scaled (POSITION) axis falls back to the scale origin posScale(0) (so SIZE-derived bars hang from the zero line); a pixel-pure axis falls back to the layer-box edge (start → 0, middle → midpoint, end → full extent).

Partial placement ​

Constraints only apply to the axes you specify. Unmentioned axes fall back to 0. This lets you mix manually-positioned children with constraint-placed ones:

ts
layer([
  rect({ w: 80, h: 40, y: 20 }).name("a"), // y manually set
  rect({ w: 120, h: 40 }).name("b"),
  rect({ w: 60, h: 40 }).name("c"),
]).relate(({ a, b, c }) => [
  // Only constrain x — each element keeps its own y
  Constraint.align({ x: "end" }, [a, b, c]),
]);

Subset selection ​

A single layer can have multiple constraints that each target different subsets of children:

ts
layer([
  rect({ w: 100, h: 50 }).name("a"),
  rect({ w: 80, h: 50 }).name("b"),
  rect({ w: 120, h: 50 }).name("c"),
  rect({ w: 60, h: 50 }).name("d"),
]).relate(({ a, b, c, d }) => [
  Constraint.align({ x: "end" }, [a, b, c, d]),
  Constraint.distribute({ dir: "y", spacing: 5 }, [a, b]), // tight grouping
  Constraint.distribute({ dir: "y", spacing: 30 }, [c, d]), // loose grouping
]);