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 rules, or a drawing clause, an operator or mark such as arrow or enclose 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.

This is the Python mirror of the JS relate page; the surface is identical, with Python conventions (options as keyword arguments, z_above / z_below snake-cased).

Usage ​

Name each node you want to relate with .name("key"), then chain .relate() on the layer. The callback receives one ref per parameter it declares without a default, named after the parameter. A parameter with a default keeps its default and gets no ref, so the loop idiom lambda a, b, gap=gap: [...] binds the loop value as usual. A **rest parameter also receives every other name inside the layer, for dynamic lookups like rest[key].

python
from gofish import layer, rect, text, Constraint

layer([
    rect(w=200, h=150, fill="#e2ebf6").name("bg"),
    text(text="Title", font_size=18).name("label"),
]).relate(
    lambda bg, label: [
        Constraint.align([label, bg], x="middle", y="end"),
    ]
)

Names ​

A parameter name 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 @mark 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.

  • 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. A name with no match inside the layer, or two matches at the same smallest distance, raises when the chart renders. A nested node cannot be resized from outside, so the target of "span" or "size" must be a direct child.
python
from gofish import Constraint, circle, enclose, layer, rect, spread

planets = ["mercury", "venus", "earth"]
layer([
    enclose(
        [
            spread(
                [circle(r=6 + 4 * i, fill="#4a90d9").name(p) for i, p in enumerate(planets)],
                dir="x",
                spacing=30,
                alignment="middle",
            )
        ],
        padding=12,
        fill="#e2ebf6",
        stroke="none",
    ).name("row"),
    rect(w=40, h=12, fill="#e94560").name("label"),
]).relate(
    lambda mercury, row, label: [
        Constraint.align([mercury, label], x="middle"),
        Constraint.distribute([row, label], dir="y", spacing=10),
    ]
)

Drawing clauses ​

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

python
from gofish import Constraint, arrow, layer, rect

layer([
    rect(w=70, h=40).name("a"),
    rect(w=70, h=40).name("b"),
]).relate(
    lambda a, b: [
        Constraint.distribute([a, b], dir="x", spacing=60),
        arrow([a, b], bow=0, stretch=0),
    ]
)
  • 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.
  • 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, raises when the chart renders, and the message names the clauses.
  • Paint order. Drawing clauses paint after the plain children, in clause order.
  • Scope. A string ref("name") is legal only inside a .relate() clause, where it resolves from the related layer; anywhere else it raises and says to move it into .relate(). A create_name token ref reaches across scopes and works anywhere.
  • Flattening. Nested lists flatten, and None or False entries are skipped.

Constraint.align ​

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

python
Constraint.align(refs, *, x=None, y=None)
ParameterTypeDescription
refslist[Ref]The refs to align (the kwargs from the callback).
xstr | list[str]Edge/center/origin to align on x (omit to leave x untouched).
ystr | list[str]Edge/center/origin to align on y.

The anchor is "start" | "middle" | "end" | "baseline". The first three anchor a ref by its bounding-box edge or center. "baseline" anchors a ref 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([content], 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([ref], x=0, y=0, anchor="baseline") instead. Pass a single value to share one anchor across every ref; pass a list to assign one anchor per ref positionally (the list length must equal the number of refs) — e.g. x=["middle", "start"] aligns the first ref's center to the second ref's start. The first already-placed ref acts as the anchor; unplaced refs move to match it. When both x and y are given, x is resolved before y.

When no ref 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).

"span" and "size" ​

x / y also accept "span" and "size" — an interval statistic rather than a point anchor, equating the size cell itself:

  • "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).

As with the point-anchor form, the source is the first already-placed ref; every other listed ref is a target.

python
layer([group, rect(fill="none", stroke="#333").name("border")]).relate(
    lambda group, border: [
        Constraint.align([group, border], x="span"),  # border adopts group's left AND right
        Constraint.align([group, border], y="span"),  # 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 raises 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. "span"/"size" are whole-constraint values and cannot appear inside a per-ref list.

Per-ref anchors ​

The list form of x / y expresses "edges share" relations directly — the per-ref generalization of the single-anchor form, instead of going through a distribute with a negative spacing:

python
# "A's center aligns with B's start" — shared-edge layouts where two refs
# overlap by a known fraction of their bbox.
Constraint.align([a, b], x=["middle", "start"])

# "B's end touches C's start" — adjacent placement.
Constraint.align([b, c], x=["end", "start"])

Constraint.distribute ​

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

python
Constraint.distribute(refs, *, dir, spacing=None, anchor=None, order=None, glue=None, weights=None)
ParameterTypeDefaultDescription
dir"x" | "y"—Required. Axis to distribute along.
spacingint8Gap between each element (forced to 0 when glue is set).
anchor"edge" | "start" | "middle" | "end" | "baseline""edge"Spacing 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.
glueboolFalseStack semantics: children touch, and their data-driven extents commit to one positional axis.
weightslist[float]—Per-child budget weights (one per child, positional) — how fill children share the layer's slice space.

The first already-placed ref acts as an anchor; unplaced refs after it are distributed forward, and those before it backward so they stack flush.

python
layer([
    rect(w=80, h=40).name("a"),
    rect(w=80, h=60).name("b"),
    rect(w=80, h=30).name("c"),
]).relate(
    lambda a, b, c: [
        Constraint.align([a, b, c], x="start"),
        Constraint.distribute([a, b, c], dir="y", spacing=8),
    ]
)

Space resolution and auto-fit ​

distribute (and align) don't just position refs 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 budget slices (equal, or per weights) 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.

python
layer([
    rect(w=60, h=datum(30), fill="#e63946").name("a"),
    rect(w=60, h=datum(50), fill="#457b9d").name("b"),
    rect(w=60, h=datum(20), fill="#2a9d8f").name("c"),
]).relate(
    lambda a, b, c: [
        Constraint.align([a, b, c], x="start"),
        Constraint.distribute([a, b, c], dir="y", glue=True),
    ]
)

Constraint.position ​

Places a ref at an x and/or y coordinate — the data-driven counterpart to align/distribute, which only relate refs 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.

python
Constraint.position(refs, *, x=None, y=None, anchor=None)
ParameterTypeDefaultDescription
xint | datum—x coordinate — literal pixel or datum(n) (scaled).
yint | datum—y coordinate — literal pixel or datum(n) (scaled).
anchorstr"middle"Which anchor of the ref lands on the coordinate ("start", "middle", "end", or "baseline" for the ref's origin).

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.

A datum coordinate supports pixel-offset arithmetic — "this data position, plus pixels", applied after the scale mapping:

python
# Seat a line 6px outside the y = 0 grid position, wherever 0 lands.
Constraint.position([line], y=datum(0) - 6, anchor="end")

The offset shifts the resolved position without affecting the inferred domain (datum(0) - 6 still contributes 0 to the scale). It works anywhere a datum is accepted — shape coordinates too, not just constraints. (The JS equivalent is datum(0).offset(-6).)

python
from gofish import layer, rect, datum, Constraint

# 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.
tick_values = [0, 50, 100, 150, 200, 250, 300]

layer(
    [rect(w=1, h=300, fill="#999").name("axis")]
    + [_tick(v).name(f"t{i}") for i, v in enumerate(tick_values)]
).relate(
    lambda **g: [
        Constraint.align([g["axis"]], y="start"),
        *[
            Constraint.position([g[f"t{i}"]], y=datum(v))
            for i, v in enumerate(tick_values)
        ],
    ]
)

Constraint.nest ​

Sizes one ref 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.

python
Constraint.nest(refs, *, x=None, y=None)
ParameterTypeDescription
refslistExactly [outer, inner] — outer nests inner.
xfloatPer-axis padding (px) on x (omit to leave x unconstrained).
yfloatPer-axis padding (px) on y (omit to leave y unconstrained).

At least one of x / y must be given; refs must be exactly two. 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. The derived outer size enters the layer's size request, so 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.
python
from gofish import layer, rect, Constraint

# inner 60x40, padding 10 -> outer 80x60; inner centered (inner.min = 10).
layer([
    rect(fill="#dbe6f3").name("outer"),
    rect(w=60, h=40, fill="#e63946").name("inner"),
]).relate(
    lambda outer, inner: [
        Constraint.nest([outer, inner], x=10, y=10),
    ]
)

Constraint.z_above / Constraint.z_below ​

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

python
Constraint.z_above(a, b)  # a paints in front of b (on top in z)
Constraint.z_below(a, b)  # a paints behind b (under in z)

z_below(a, b) is equivalent to z_above(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 the constraints don't pin, the existing default order is preserved (.z_order(n) hints first, then declaration order); a cycle (z_above(a, b) + z_above(b, a)) raises an error at render time.

python
layer([
    rect(w=80, h=40, fill="lightgray").name("bg"),
    rect(w=60, h=60, fill="steelblue").name("box"),
    text(text="label", font_size=14).name("label"),
]).relate(
    lambda bg, box, label: [
        # box paints over bg; label paints over both.
        Constraint.z_above(box, bg),
        Constraint.z_above(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 @mark 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: z_above(rope, A) with z_below(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.

When to use this vs .z_order(n) ​

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

The two compose: .z_order(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:

spreadConstraint equivalent
spread(items, dir="y", alignment="start")align(x="start") + distribute(dir="y")
spread(items, dir="x", alignment="end", spacing=10)align(y="end") + distribute(dir="x", spacing=10)
spread(items, dir="x", spacing=60, anchor="middle")distribute(dir="x", spacing=60, anchor="middle")
spread(items, dir="y", reverse=True)distribute(dir="y", order="reverse")
stack(items, dir="y")distribute(dir="y", glue=True)
spread(items, dir="x", stackWeights=[2, 1])distribute(dir="x", weights=[2, 1])

When no ref 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, so you can mix manually-positioned children with constraint-placed ones:

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

Subset selection ​

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

python
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(
    lambda a, b, c, d: [
        Constraint.align([a, b, c, d], x="end"),
        Constraint.distribute([a, b], dir="y", spacing=5),   # tight grouping
        Constraint.distribute([c, d], dir="y", spacing=30),  # loose grouping
    ]
)