The underlying space tree
Every node in a GoFish scenegraph carries two pieces of information about its spatial structure: the kind of data space the node has established on each of its two axes (x and y), and any per-axis Monotonic that captures how visual size depends on a scale factor. Together these form an intermediate representation called the underlying space tree.
The data structure lives at src/ast/underlyingSpace.ts. The traversal that builds it lives at _node.ts's resolveUnderlyingSpace(). Layout, axis rendering, posScale construction, and ordinal scale building all consume the tree afterwards.
This doc explains what the tree is, why it exists, what each space kind means, and where to look in the code. If you're adding an operator that introduces or transforms an axis, this is the abstraction you're working with.
What and why, in brief
A data-driven graphic maps data space to visual space. Typically data space is described by a data schema like {lake: string, count: number}. Visual space is typically described using shapes and screen positions (i.e., SVG or Canvas attributes).
Most of the logic in GoFish lives in between data and visual space, for example computing scales and performing layout. The underlying space tree keeps that logic organized. Here are some kinds of things we need to figure out about a graphic that underlying space helps us answer:
- If we overlay a scatterplot and a line chart in the same region of the screen (such as drawing a regression line), what should the axis domains be? What about when the two charts have different data spaces on one axis (like in a dual axis chart)?
- If we draw a bar chart with vertically centered bars, what is the y-axis?
- If we create faceted chart regions, how should those faceted regions relate to each other?
- What if an operator arranges shapes in free space, but those objects have data-driven sizes that need to be scaled to fit the available screen space? (As when using the spread operator.)
In all of these cases, we have some information about data spaces and their encodings to positions and sizes of shapes. Operators compose this information together to create more complex relationships between data and visual space. Underlying space keeps track of this information explicitly so that we can more easily write algorithms that resolve scales and draw axes. For example, to resolve scale domains in the case of the overlaid scatterplot and line chart, we first have to determine whether the two charts' domains can be merged and then we can merge the domains. This information is later used to draw axes for the combined chart. We need to store intermediate results about these domains, and that's basically the role of the underlying space data structure.
The one equation, and three roles for one unknown
Every continuous axis is, in the end, one affine map — per σ-scope (the region over which a single scale is shared):
px(d) = pxMin + σ·(d − domainMin) σ = pixels per data unitσ (sigma) is the slope: pixels per unit of data. domainMin is the low edge of the data interval. Per node and axis there is exactly one position unknown — the baseline, the screen coordinate of the node's local data-0. Three things that the word "origin" historically ran together must be kept distinct, because each lives at a different stage of the pipeline:
- alignment is a constraint: equations between per-node baselines (
baseline_A = baseline_B, and analogous relations for other anchors). It says nothing about pixels; it only records which baselines must agree. - placement (
free | determined | conflict) is the abstract value: is the baseline subsystem under-determined, solvable, or inconsistent? This is all that bottom-up space resolution can know — it runs before pixels exist, so it computes the determinacy of the baseline, not the baseline itself. - the intercept is the concrete value: the solved shared baseline of a σ-scope, in pixels —
pxMinabove, read asposScale(0). It exists only after σ and the frame anchor resolve, so it is always a derived read, never stored state.
A false friend to never conflate with that intercept: the width Monotonic carries its own intercept — the σ-independent pixel part of an extent (spacing, fixed chrome), the intercept of the size-vs-σ line size = slope·σ + intercept. That is an intercept of the size equation, not of the data→screen map; the two never mean the same thing.
The single scale carrier. Layout threads one per-axis record downward (see Layout dispatch): the AxisScale = { sigma?, map? } (domain.ts). sigma is the slope σ for unanchored extents — a free magnitude has no committed baseline, so its intercept is implicit in where its parent places it (baseline placement + transform.translate) and never travels with the scale. map is the whole anchored map, with the intercept explicit as data rather than closed over a function: px(d) = pxMin + sigma·(d − domainMin), evaluated by pxOf (the old posScale(0) intercept is pxOf(map, 0)). So "anchored" shows up operationally as "has a map"; "unanchored" as "has only a sigma." map carries its own slope, which need not equal the top-level sigma — a sub-budget layer scales a mark's size and its data position against different pixel extents. This single record replaced the former two parallel channels (scaleFactors = slope-only, posScales = whole map) in Stage 4 of the σ-affine plan.
Why an explicit IR
Conventional grammars of graphics treat a scale as a function from a data domain to a visual range. Quantitative x-scale: [30, 50] mpg → [0, 100] px. Color scale: species name → palette entry. Convenient — but too unstructured. If scales are arbitrary functions, the system can change their domains and ranges freely, slot them in anywhere, and inference doesn't know which combinations are meaningful.
In practice every visualization system relies on stronger invariants than "function from domain to range" can express. Domains can be merged only when they're compatible. Spatial continuous ranges aren't independent parameters at all — they're derived from available layout space. Some extents have meaningful origins; others only have meaningful differences. Some operators glue subspaces together; others separate them. Coordinate transforms preserve, warp, or erase parts of the underlying structure.
Discrete position scales make the mismatch concrete. D3 and Vega-Lite use point and band scales to handle categorical positions. Operationally, a band scale gives each category a continuous position together with a uniform bandwidth. That's already the abstraction carrying layout information indirectly. It also breaks down for bar-like charts whose elements have different widths, because the allocation of space is no longer a uniform function of category.
This kind of richer semantics shows up in the implementation of every serious grammar system, even when it isn't reified:
- Vega-Lite parses each child view recursively, assigns scale-resolution policies (shared vs independent), and conditionally merges child scale components when their types are compatible. Compatibility groups several scale types together (e.g. temporal + ordinal-position). The merged result is a flat record keyed by channel — the tree structure of view composition guides merging, then disappears.
- Observable Plot distributes inference across channels (
fill,stroke,opacity,symbolfirst infer which named scale they should use), a scale-name registry, scale-type inference (using user-specified types, mark-imposed channel types, explicit domains, channel values, color schemes, special defaults likergetting a sqrt scale), domain-union inference, and range inference that depends on both domain and scale kind. Modular, but no single spatial IR owns the accumulated semantics — Plot'sstacktransform, for example, rewrites a length channel intoy1/y2so they can later participate in ordinary scale inference.
Each piece can be clean in isolation, but without an explicit source of truth for the inferred spatial semantics, scale and domain facts have to be passed around and reconstructed across the implementation. That's particularly limiting in GoFish, where users define new operators and new spaces — not just new marks inside a fixed scale-resolution pipeline.
GoFish's solution is to give the inference an explicit shared data structure to contribute to. Marks introduce local spatial facts; operators merge or separate them; coordinate transforms annotate them; and later passes consume the tree for layout, scale construction, and guide generation.
The three space kinds
Each axis (x and y) of each node carries one of continuous, ordinal, or undefined. The continuous kind stores two facts: a width (a σ-affine size Monotonic) and a dataDomain (a data-space fact: the axis range, if any). The placement (the layout fact: is this extent positioned) is not a third stored field — it is a derived view of dataDomain's shape, a bare determinacy lattice read via spacePlacement(space):
// underlyingSpace.ts
type Placement = "free" | "determined" | "conflict";
type DataDomain = Interval | "delta" | undefined;
type CONTINUOUS_TYPE = {
kind: "continuous";
width: Monotonic; // the σ-affine SIZE: slope·σ + intercept
dataDomain: DataDomain; // data-space extent AND the sole placement carrier
measure?: Measure;
};
type ORDINAL_TYPE = { kind: "ordinal"; domain?: string[]; measure?: Measure; ... };
type UNDEFINED_TYPE = { kind: "undefined"; ... };
// placement is in bijection with dataDomain's shape:
const spacePlacement = (s: CONTINUOUS_TYPE): Placement =>
s.dataDomain === undefined ? "free" // sized, position not yet committed (a bar's height)
: s.dataDomain === "delta" ? "conflict" // no absolute position possible (a centered streamgraph band)
: "determined"; // committed to a DATA interval (a scatter point's x)The committed coordinate itself (the old placement.at) is not a separate payload: it is simply the dataDomain interval's min, read back with continuousInterval(space)?.min. Storing it twice was redundant — it always equaled dataDomain.min — so placement collapses to a bare lattice.
ORDINAL carries a measure too (the grouping field, e.g. "lake") — the discrete analogue of CONTINUOUS's measure. It's set from the grouping operator (spread's by) when the ordinal space is built (distributeSpaceFold → ORDINAL(keys, measure)) and preserved through unionChildSpaces. So spaceMeasure(space) reads a measure off both continuous and ordinal kinds (only UNDEFINED is measureless), which is what lets an axis name itself off its own resolved space — a continuous axis by its unit, an ordinal axis by its grouping field (see the layout passes).
A companion predicate, isPositioningSpace, folds the two axis-bearing kinds together: it holds for POSITION (a data axis) and ORDINAL (a category axis) but not for SIZE (a mark's own extent) or UNDEFINED. In other words it answers "does this space lay marks out along an axis?" — the question you ask when you want the axis a set of siblings is arranged on rather than each sibling's own size. Its first consumer is the connector's curve: "auto": a line / ribbon reads the underlying space its endpoints resolved to and, when that space is a positioning one whose measure is continuous, smooths the path (centripetal Catmull–Rom) instead of drawing straight segments — so a line over a continuous x auto-curves while one over discrete categories stays polylinear.
The guide a space supports keys on dataDomain (data-space), never on placement:
dataDomain | guide | typical placement | example |
|---|---|---|---|
an Interval | quantitative (absolute) axis | determined | scatter x, stacked-bar y |
"delta" | magnitude / delta guide | conflict | streamgraph centered count |
undefined | none (a legend, not an axis) | free | a bar's height before placing |
(kind ordinal) | labels at laid-out keys | — | bars by category, facets |
(kind undefined) | no guide | — | an aesthetic / literal-px dim |
These map directly onto the σ-affine layout solve (see the one equation, Size resolution, and the solver): width is the abstract SIZE (slope·σ + intercept), and placement is the abstract value of the baseline — its determinacy (free / determined / conflict), not its pixel intercept. The underlying-space pass is essentially an abstract interpretation of the solve: it computes the structure (which extents are sized, which are positioned) bottom-up before the concrete pixels — and therefore the concrete baseline intercept — exist. Deriving placement from dataDomain is what lets alignment ask "is this child already positioned?" directly (see The contract) instead of reconstructing it.
This shape is the endpoint of issue #586's collapse. The old POSITION/SIZE/DIFFERENCE were one semantic thing — a data-driven extent — observed at three pipeline stages; carrying that as three kinds baked a stage distinction into the type. A first cut collapsed them to a single overloaded origin: number | "free" | "impossible" scalar, but that conflated the layout fact with the data fact (a baseline magnitude vs a data axis anchored at 0 — which build no posScale vs a posScale). Keeping the layout fact derived fromdataDomain keeps them distinct while storing only one field. There is no scalar "anchor" builder type either — a second spelling of the same three-way split would just be Placement with a payload again. The three placement cases ARE the three named constructors (POSITION anchored, SIZE free, DIFFERENCE conflict), plus anchorAt(space, min) for re-anchoring an existing space at a data coordinate (the domain min, not a zero point) while preserving its σ-affine width. Each constructor fixes dataDomain, from which placement falls out:
- a former
SIZE(rect({ h: "count" })) hasdataDomain: undefined→ placementfree; - a former
POSITION([a, b])hasdataDomain: [a, b]→ placementdetermined(ata); - a former
DIFFERENCE(w)hasdataDomain: "delta"→ placementconflict.
The pre/post-solve distinction is handled by when σ is substituted, not by which kind: σ is always width.inverse(size), and the extent at σ is always width.run(σ). The one genuine state transition is middle-alignment drops the anchor — centering scrambles the children's baselines, so the result has dataDomain: "delta" (the streamgraph), which derives placement conflict. A conflict placement is absorbing: no alignment re-anchors it.
isDIFFERENCE keys on dataDomain === "delta" — the same field placement derives from — so the two never disagree. The anchored data interval is read back with continuousInterval(space), which is simply the dataDomain when it is an interval (used by posScale construction and axis nicing) and undefined otherwise; its .min is the committed baseline coordinate (the old placement.at).
These kinds map closely to Stevens's statistical data types, but not cleanly: an Interval dataDomain covers both interval and ratio, and "delta" is weaker than interval — only within-instance differences are defined. ordinal isn't "a band scale"; it's a statement that the values are discrete keys whose spatial allocation is the responsibility of layout. undefined represents spaces with no data-driven information (the literal-pixel value is handled at layout time by computeAesthetic).
The contract
Each node implements _resolveUnderlyingSpace:
type ResolveUnderlyingSpace = (
childSpaces: Size<UnderlyingSpace>[], // one [x, y] tuple per child
childNodes: GoFishAST[],
shared: Size<boolean>, // [shared on x, shared on y]
constraints: ConstraintSpec[] // this node's positioning constraints
) => FancySize<UnderlyingSpace>;Returns the node's own [xSpace, ySpace], computed bottom-up from the already-resolved child spaces. The traversal is memoized at _node.ts's resolveUnderlyingSpace().
The constraints argument lets constraints participate in space resolution — each positioning-constraint kind carries a space fold, a typing rule that composes its targets' spaces into the layer's claim on that axis:
Constraint.positioncontributes a fragment: the layer folds the datum coordinates into a POSITION domain on the constrained axis (collectPositionDomains), unioned with the children's spaces. (Literal-pixel coordinates are not data and don't contribute; neither do discrete scatter slots, which resolve directly from the already-known layer size.) That domain is what the layer later turns into a data→pixel scale to resolve those constraints.Constraint.distributecontributes the stack fold (distributeSpaceFold,constraints/distribute.ts): data-driven continuous targets compose toSIZE(Monotonic.add(...) + spacing·(n−1))(afreemagnitude); withglue: true(stack semantics) the extents are committed to an anchoredPOSITION([0, Σ]); constant-sized keyed targets fall back to ORDINAL. (A former POSITION's pixel extent at σ=1 iswidth.run(1) = b−a, so the unifiedwidth-based sum subsumes the old separate POSITION-sum branch.)Constraint.aligncontributes the alignment fold (alignSpaceFold→resolveAlignmentSpace) on its axis.Constraint.nestcontributes the nesting fold (nestedSpace,constraints/nest.ts) and a deterministic dependency plan (constraints/nestPlan.ts). It is the first size-setting constraint: on each constrained axisouter = inner + 2·padding, with padding always known, so the unknown is which side is derived. The nest plan dispatches on which side carries the size (an ownargs.dims, a composite that shrink-wraps, or any inside-out-derived outer from the same-axis nest graph): inner sized and outer not → inside-out (outer = inner + 2·padding); outer sized, or neither (the layer sizes outer) → outside-in (inner = outer − 2·padding— CSS padding). Only the inside-out direction folds a space here: outer's request is aMonotonic.addsof inner's, which stays monotone (hence invertible), so a nested pair participates in auto-fit exactly like a stack — a parent spread/layer solving a scale factor sees outer as inner shifted up by the constant padding. The layer derives these outer spaces in dependency order (source before derived) so chained nests compose (A⊇B⊇C: C's request feeds B's, B's feeds A's), then feeds them into the union below. The outside-in direction derives nothing at space-resolution time — outer's own claim (or fill/undefined) flows through the union normally, andinner = outer − 2·paddingis handled purely as a layout-time pixel proposal. (Likewise when an inside-out inner is not SIZE — fixed-pixel or position-pinned content — there is no rule to fold; the proposalinner.dims + 2·paddingsizes outer.) At most one nest may derive a given (node, axis), and a nest that resolves inside-out on one axis and outside-in on the other is rejected as mixed — the layer enforces both at constraint-collection time (see size-claims).The interval form of
Constraint.position({ x: [min, max] }, lowered byconstraints/position.tsto two strong edge pins — astartpin atminand anendpin atmax) is the second size-setting constraint: pin BOTH edges of a target on an axis and the size falls out — the relationplace()'s position-only protocol cannot express. It is built on the linear-system bbox (constraints/bbox.ts, #39): a per-axis 2-unknown system in(min, size)where each box key (min/max/center/size) is one equation; two independent keys are rank 2, so the rest are inferred (two edges ⇒ a size), and a third, dependent write is a structured over-determination report rather than a silent last-writer-wins. An interval's datum endpoints feed the axis's POSITION domain viacollectPositionDomains(like a point coordinate does), andcomposeConstraintSpacestreats an interval position as an extent-establisher (like a distribute), so the cross-axisalignfold still runs — a histogram is an interval position on x plus analignon y, and it is that align fold (SIZE→POSITION) that makes the count axis. The solved(min, size)is bridged into GoFish's(local box, translate)split by stamping[0, size]into the local box and deriving the absoluteminthrough the placement ledger.scatteruses both forms ofConstraint.position: plainx/y→ a point coordinate, rangexMin/xMax/yMin/yMax→ an interval coordinate (the operator no longer has a bespoke layout). A categorical scatter channel such asx: "lake"lowers to discrete placement coordinatesi / count · axisSize; those are placement coordinates, not datum values, so they become numeric placement facts without affecting the layer's data domain.
The layer composes these per axis — children not covered by a constraint max-union in as overlay siblings. On an axis a constraint does cover, that fold is authoritative and overrides the layer's default unionChildSpaces — even when the fold is UNDEFINED. This matters for an align over ORDINAL cross-axis children: the alignment fold is UNDEFINED (no anchored axis), and if the default union were allowed to win it would resurrect an ORDINAL space and a spurious axis (a waffle's chunked-row index leaking a row "axis"). The covered-axis fold — UNDEFINED included — is what composeConstraintSpaces reports (constraints/compose.ts). At layout time the layer then solves the budget: a fold-produced SIZE claim is inverted against the layer's allotted size to derive a local scale factor, and distribute-covered fill children are proposed slices from the shared proposal plan (buildDistributeSliceMap, constraints/proposalPlan.ts, using allocateSlices from constraints/folds.ts). When distribute segments overlap on the same child axis, they are treated as a placement-relation graph rather than a spread-like flex slice, so the ambiguous size proposal is skipped instead of picked by declaration order. This is what makes constraint-assembled layers reach the same expressive ceiling as the spread pipeline, auto-fit included (issue #475). Composition beyond one distribute (+ one align) per axis falls back to unionChildSpaces; the general algebra is sketched in constraints-as-core. resolveLayerBaseSpaces is the default bottom-up axis resolver before composed constraint overrides: union child spaces, apply transform.scale to free magnitudes, and merge datum-valued position/span domains with constraint measures taking precedence. childLayoutSizeProposal is the final per-child proposal priority before nest: grid cell size, else distribute slice for that named child, else the full layer box. buildLayerConstraintLayoutPlan packages the per-layer execution plan — which children skip baseline placement, nest source-before-derived order, and datum-position target axes — so the layer executes deterministic artifacts rather than recomputing them inline. Nest sizing is split into a dependency plan and concrete layout arithmetic: buildNestPlan decides, per constrained pair, whether the source size flows inside-out (outer = inner + 2·padding) or outside-in (inner = outer − 2·padding) and orders children so the source has been laid out first. The bottom-up space pass applies only the inside-out portion via applyNestSpacePlan; once the source has concrete dimensions, applyNestLayoutProposal does the corresponding layout-time arithmetic on the derived axes. Grid is also selected through the proposal plan (selectGridConstraint): because a grid owns both track partitions for a layer — and bypasses the space/size fold entirely — it is a whole-layer layout mode, not a composable constraint. selectGridConstraint therefore enforces two exclusivity rules (it is the one site both the space pass and the layout pass flow through): more than one grid constraint is a proposal conflict rather than a declaration-order choice, and a grid mixed with any non-z-order constraint (align / distribute / position / nest) throws — that sibling would be applied by placement but never enter the space fold, so it would silently half-apply. z-order constraints (zAbove / zBelow) are render-time paint order and compose freely alongside a grid. Grid has no public factory; it is table's private elaboration target. The same proposal plan marks datum-valued position targets (buildPositionTargetDims) so the layer does not also forward the consumed data→pixel scale to that child axis; literal pixel pins are not marked because they do not consume a data scale. buildPositionScalePlan chooses the effective scale the placement solver consumes: inherited/self-scaled base first, otherwise a local scale from the layer POSITION space when the layer owns a datum-position axis. Child scale forwarding itself is the same plan (childPosScalesFor): unowned axes forward inherited/base scales, while owned axes forward the layer's effective scale only to non-target children whose own space is POSITION.
After sizing, the layer emits placement constraints into a per-axis rank-2 solve (constraints/placementSolver.ts) that resolves each (node, axis) box (min, size) — not just a single min unknown. The fact datatype lives in constraints/placementFacts.ts: the anchor program (axes: [AnchorFact[], AnchorFact[]]) of anchor pins, anchor relations, and participants. A fact names a node anchor (start/middle/end/baseline) directly, with no numeric offset pre-evaluated at lowering-time — the offset from min is derived later, in the solver, once sizes are known. Named constraints first lower to this inspectable program; solving consumes it rather than mutating solver state during lowering. Constraint-specific lowerers live with their constraints: align.ts, distribute.ts, position.ts, nest.ts, and grid.ts own their policy choices, while placementLowering.ts orchestrates them and placementProgramLowerer.ts emits anchor facts (guarding only that the target exists). During lowering, PlacementOwnershipPlan records pre-existing placements, authoritative position overrides, and axes claimed by position facts (a point pin or an interval's edges) so legacy read-vs-write policy is explicit data rather than scattered set checks.
The solve is two phases per axis. Cell closure feeds each node's STRONG anchor pins into a per-axis linear-system bbox (constraints/bbox.ts): two independent edges are rank 2, so the size falls out (the interval/span case) — this is where a target's size is determined, with the node's own weak layout size the default when no strong equation reaches it. A bbox over-determination (two conflicting intervals on one target) is a named-owner conflict naming both owners. Then the difference graph (constraints/differenceGraph.ts): with sizes known, every anchor reduces to min + offset — start/baseline at 0, middle at size/2, end at size for a size-strong cell (read off the closed box), else the node's local-frame anchor offset. position, align, distribute, nest, and grid pins/relations over those reduced min values go through BFS components + pin offsets + distribute/normalized-origin fallbacks. Every solved cell writes back through one path: a size-strong cell sets its extent (setExtent({min, max})), a position-only cell pins its min anchor — replacing the old three-way branch and the size side-channel.
The placement-coordinate compiler preserves the literal/datum distinction until facts are emitted: literals are pixels, while datum coordinates elaborate through the already-solved data→pixel scale plus any post-scale offset. This keeps the unified constraint semantics without a generic dense linear solver: strong facts win, relation cycles are checked for contradiction, and components without an absolute pin are normalized so the minimum solved coordinate in that component is 0. Ordered distribute components are the exception: their directed chain source is a deterministic sequence origin, so negative spacing remains authored overlap instead of being erased by min-normalization. If a graphic needs a floating component to appear at a particular absolute coordinate, that placement must be explicit. The legacy per-constraint apply helpers have been retired from the constraint path; spread, scatter, table, axes, and hand-written constraints all lower to the same solver entrypoint. An incompatible same-solve interval + point position on the same target/axis reports an over-determined placement instead of letting one silently yield to the other.
Placement-time alignment dispatches on the same resolution. align emits relations between child anchors; it no longer chooses an absolute fallback baseline for an otherwise-floating system. If no explicit position (point or interval), self-placement, or other strong pin fixes a connected component, the solver normalizes that component so its minimum solved coordinate is 0. A user who needs the aligned system to appear at a particular place must say so explicitly with a placement constraint.
That normalization is also what keeps data-positioned children safe. A faceted scatter panel over [1955, 2010], whose placement is determined, should not be pulled to posScale(0) (data-zero, far below 1955). So the placement solver reads Placeable.placementOn(dir): a target whose subtree already commits a data position (placement determined/conflict) on a posScale axis, with a non-middle anchor, is left alone — align shares the frame (it still unions the children's dataDomain) but supplies no baseline. When alignment does write an anchor relation, it asks Placeable.localAnchor(axis, anchor) for the anchor's coordinate in the target's local box. GoFishNode.localAnchor() derives that from the node's intrinsic dimensions (including baseline/min/center/max), so relation solving can handle asymmetric boxes such as text and negative bars without relying on the display transform.
Because placement is first-class, this is the whole mechanism — no flag, no scoping. (Historically the same effect needed a guardDataPositioned flag on spread/scatter aligns plus a per-axis fromSize boolean reconstructed from the pre-fold child spaces in the layer; the flag was a proxy for the placement fact, and the reconstruction read it indirectly. Both are gone — the per-child placement read is strictly more general, handling a mix of positioned and free children that the old all-or-nothing axis guard could not.) See the spec for the "space as abstract interpretation" framing this falls out of.
Three patterns cover most operators:
Leaf shapes (rect, ellipse, petal, text, image) decide the kind from their props. A rect with data-bound h emits SIZE(Monotonic.linear(value, 0)) on y (a free magnitude); the same rect with literal y and y2 emits POSITION([y, y2]). Constants (no data-bound dim) emit UNDEFINED — the literal pixel value is handled at layout time by computeAesthetic, not via the underlying-space tree. (The old anomaly where a literal-pixel min plus a data size made DIFFERENCE while an absent min made SIZE is gone: both are CONTINUOUS, differing only in their placement/dataDomain — an off-scale pixel min is a difference, an absent min is a free magnitude.)
Compositional operators (spread, stack, layer, enclose) combine children's spaces. spread({ glue: false }) keeps the magnitude along the stack direction so a parent can solve for shared scale factors via Monotonic.inverse. spread({ glue: true }) (i.e. stack) sums children's extents into a POSITION([0, sum]) — the operator commits the data-driven magnitudes to an anchored axis. Since the operator/constraint unification, these folds have one home: spread's resolver isdistributeSpaceFold on the stack axis and alignSpaceFold on the cross axis — the same functions the constraint path uses (see The contract). layer and overlay-style operators use unionChildSpaces (alignment.ts), which keeps the symbolic Monotonic when every child is a baseline magnitude (placement: free) and otherwise unions data intervals. UNDEFINED children carry no opinion and are ignored throughout, so a fixed-pixel (UNDEFINED) sibling never vetoes the magnitude-preserving path (it would otherwise degrade the union to an unanchored extent).
Coordinate-transform operators (coord) annotate the resulting space with the transform that will later map underlying positions to display positions, but otherwise pass the kind through.
Worked example: stacked bar chart
chart(seafood)
.flow(spread({ by: "lake", dir: "x" }), stack({ by: "species", dir: "y" }))
.mark(rect({ h: "count", fill: "species" }));Each rect starts with a data-driven height and no data-driven y position: [UNDEFINED, SIZE(Monotonic.linear(count, 0))] — a magnitude anchored at origin 0.
The vertical stack (which is spread({ glue: true, dir: "y" })) glues each lake's species rects together. Its stack-direction children are all continuous magnitudes, so it sums their widths at scale 1 and emits POSITION([0, total_lake_sum]) on y. The alignment direction (x) of the stack is UNDEFINED because each rect's x is UNDEFINED.
The horizontal spread separates lakes. Its children are now stacks with [UNDEFINED on x, POSITION([0, total]) on y]. Stack direction (x): no children are continuous, but they're named (the "by" key produces lake keys) → ORDINAL(["Lake A", ..., "Lake F"]). Alignment direction (y): all children are anchored continuous → POSITION(unionAll([0, total_i])) = POSITION([0, max_total]).
So the root underlying space is [ORDINAL(lakes), POSITION([0, max_total])]. The y-axis renders quantitative ticks (POSITION); the x-axis renders ordinal labels at laid-out positions (ORDINAL); both follow from the tree, with no special "bar chart" rule.
The stack's size → position transition is the important step. A single rect with a data-driven height doesn't by itself establish where that height lives in a shared coordinate system — it only says it has a quantitative extent. The stack gives those extents a common origin and glues them edge-to-edge, producing a position space from zero to the bar total. The spread doesn't glue; it separates.
Size resolution
To map data to screen space, we need to figure out how to scale it to fit. As a rule of thumb, we want all of underlying space to be visible. As a consequence, bar charts should never be truncated, because each bar is fully embedded in the underlying space. On the other hand, a scatterplot's points may be truncated on the edges of the frame since their sizes are not embedded in the underlying space of the graphic.
Continuous space resolution. For position and difference spaces, we are basically mapping some interval of minimum and maximum values to available physical space. This can be performed by a traditional scale function. For now, we assume these scales are always linear and lean on data pre-processing and coordinate transforms to introduce non-linearities.
Discrete space resolution. Layouts like spread's arrange things using pixel-based spacing (like putting 8 pixels of spacing between bars) so we can't compute a scale function right away. Instead, we assume we are looking for some linear scale factor (data could be scaled using a non-linear scale function before this) and we have to figure out how to scale the shapes that are being placed by creating a function from the scale factor to the output size if we use that scale factor. Then we solve.
A shape can have three kinds of sizes:
- fixed (eg,
rect({w: 10})) - inferred (eg,
rect({w: undefined})) - data-driven (eg,
rect({w: 'foo'}))
These correspond to three kinds of intrinsic sizes:
- fixed: constant, non-zero size, no dependency on scale factor
- inferred: constant, zero size, no dependency on scale factor (this seems a bit weird and may be changed later)
- data-driven: size depends on scale factor
In truth, data-driven sizes seem to act like the inferred case as well, because they can take on any size given to them (although they sometimes have a minimum size, such as a spread operator where even if the shapes have 0 size, the spacing between the shapes yields some minimum overall size).
Layout dispatch
After resolveUnderlyingSpace, layout proceeds on a single principle: a continuous extent's scale factor is width.inverse(size), and an anchored one also builds a position scale. Before the #586 collapse this was a three-way switch on the kind (SIZE inverted a Monotonic, POSITION divided by an interval width, DIFFERENCE divided by a width); a former POSITION/DIFFERENCE width is just linear(extent, 0), so width.inverse(size) = size / extent reproduces both divisions, and the switch folds away:
gofish.tsx (root):
if root[axis] is a free magnitude → sigma = width.inverse(canvas)
if root[axis].dataDomain is an interval → map = an AxisMap over it
pass one `AxisScale` = { sigma?, map? } downward per axis — a child reads
`sigma` for size, `map` for data position (they're mutually exclusive at root)
layer.layout, on an axis the node scopes (node.shared[axis] — set by
`spread`/`stack`'s `sharedScale`; default [false, false] is a no-op):
if myUSpace[axis].kind === "continuous" → space.width.inverse(size[axis])
else → undefined (ORDINAL/UNDEFINED don't need a continuous scale factor)Leaf shapes never need to compute their own scale factors — they receive the per-axis AxisScale via the scales parameter and read its sigma in computeSize (and its map via pxOf for data position).
spread/stack no longer have their own layout — they elaborate to layer + align + distribute (spread.tsx), so the dispatch above lives entirely in layer.layout. buildChildScalePlan is the shared layout-time planner: explicit self-scaled axes first derive local maps/scale factors, a layer whose constraints fold to a SIZE claim then inverts that fold against its allotted size (fold.inverse(size[axis])) to derive a local scale factor for its constrained children (returning failures so layer can warn before falling back), and a sharedScale scope finally runs the per-axis solve in the pseudocode above. layer recombines the per-axis σ and map into one AxisScale per child at child.layout. The result is a fresh childScaleFactors array handed to descendants — no node ever mutates the inherited σ. That is the claim-hoisting form of sharedScale (#549): a scale solves at the lowest node where its measure stops being shared, and the result flows to descendants only, never leaking to siblings.
This dispatch is the practical embodiment of the underlying-space-kind distinction. It also happens to make the rendering pipeline more readable: once you know the kind, you know which arithmetic applies.
Scales generalize flex factors
A size scale whose range resolves to the parent's extent is doing exactly what CSS flexbox does with flex factors — and GoFish's version is strictly more general.
In flexbox, flex: 1 and flex: 2 on two children split the container's space in a 1:2 ratio. The numbers are weights; the container's extent is the range; the layout normalizes the weights to fill it. That is a scale, narrowly construed: a domain (the sibling weights) mapped onto a range (the container box) so the pieces sum to the whole.
This is precisely SIZE resolution. A row of datum(n)-sized children under a shared size scale composes into a Monotonic whose inverse against the available extent solves for the scale factor that makes the siblings fill it (see Layout dispatch). space.width.inverse(size) is the normalization step; the datum(n) weights are the flex factors. The cut operator's relative form, cut(source, { size: [datum(1), datum(2)] }), slices a region in a 1:2 ratio by normalizing those weights over the source's extent — flexbox, expressed as data.
So flex factors are the degenerate case of a size scale: weights that happen to be literal layout constants rather than data. GoFish generalizes them along three axes the CSS model can't reach:
- The weights can be data.
datum(n)is a literal weight, but the same machinery takes a field name (rect({ h: "count" })) so the proportions come from the rows, not the spec. - The scale can be shared. A
flexfactor is local to one container; a GoFish size scale can be shared across sibling charts or facets, so the same weight means the same pixels everywhere it appears — proportions that compose across the page, not just within one box. - Absolute sizing coexists. Flexbox bolts
flex-basis/ fixed widths alongside the factors as a separate mechanism. GoFish folds both into one field/datum/literal trichotomy (issue #266): a literal10is absolute pixels,datum(n)is a relative weight, a field name is a per-row weight. Mixing the two in onecutis not a conflict but exactly flex resolution: the absolutes are fixed-basis claims, and the size scale's range is the parent extent minus those fixed claims, so thedatum(n)weights normalize over the remainder —cut(source, { size: [100, datum(1), datum(2)] })fixes a 100px cap and splits what's left 1:2. The mixed case makes the identification sharper, not weaker: "fixed widths next to flex items" is just a size scale whose range has been shortened by the fixed children.
The payoff is conceptual economy: "fill the container proportionally" is not a bespoke layout mode, it is what a size scale already does once its range is the parent's extent.
Self-scaling regions: an explicit pixel size absorbs an axis
The root resolves its scales against the canvas: POSITION → a posScale onto the pixel box, SIZE → invert the Monotonic against the canvas size. A layer (or frame) given an explicit pixel size on a dim does the same thing one level down — "a chart embeds the way it renders." On that dim it becomes a self-contained scaling region: its data space is absorbed internally rather than contributed to whatever shared space its parent is building.
The motivating case is a marginal histogram, seaborn-jointplot style: a center scatter in data units, with a count histogram pinned along each edge. The histograms are sized to a fixed pixel band (chart(data, { h: 80 })), and their count axis must not union into the scatter's shared x/y domains — counts and beak-length millimeters are foreign units. The explicit pixel size is exactly the signal that this region carries its own scale.
The rule lives in layer's resolver and layout (graphicalOperators/layer.tsx), in two halves:
resolveUnderlyingSpace. After resolving each axis normally, for any dim that has an explicit pixel size and whose resolved space has a baseline (hasBaseline—placementisfreeordetermined, i.e. not a difference), the real space is stashed andUNDEFINEDis reported upward. A parent layer'sunionChildSpacesthen ignores that axis (UNDEFINED carries no opinion — see The contract) instead of polluting a shared domain with the absorbed region's units. ORDINAL and difference (placement: conflict) extents are left untouched.layout. The stashed space gets a local scale built against the layer's own pixel box: an anchored extent is both a coordinate scale (posScaleFromSpace(stashed, size[dim])) and a σ-magnitude (a scale factor fromstashed.width.inverse(size[dim])), so the layer builds both and each child reads the one it needs. These locals override the inherited posScale / scale factor on that dim — definitionally, since the inherited scale is in the parent's foreign units. If the size can't be resolved (NaN), the locals are left undefined and the dim degrades to the inherited path rather than producing NaN scales.
Note that a histogram's count axis is anchored, not origin-less, at the frame boundary. Under start/end/baseline alignment, resolveAlignmentSpace (alignment.ts) folds the baseline magnitudes into POSITION([0, max]) — it commits the data-driven extents to an anchored axis so they can be aligned. Without the self-scaling rule, that count POSITION would union straight into the shared axes as if it were data units; the rule is what keeps the absorbed axis from leaking.
The space reported upward is plain UNDEFINED for now. Issue #508's proposed CONSTANT kind — "this axis has a known fixed pixel extent" (a genuinely constant width Monotonic, linear(0, w), with no inverse, as opposed to the through-origin linear(w, 0) of a scaling extent) — is the eventual, more honest home for what a self-scaling region contributes to its parent.
Measures: units are types
The self-scaling region above is the heavy hammer — give a sub-chart an explicit pixel size and its axis stops talking to the outside entirely. But the marginal histogram has a subtler need at the shared boundary. When the top count histogram and the center scatter overlay on x, the union should succeed (both are beak-length millimeters along x) and the count axis, folded into a position interval, should not pollute that millimeter domain. The shared union has to tell "same units, merge" from "foreign units, refuse" without a human reading the field names.
That distinction is a measure: a unit-of-measure tag carried on a space. CONTINUOUS carries an optional measure?: Measure (Measure is just a string — a field name like "Beak Depth (mm)", or "count"). It is the dead source? slot's replacement, but with teeth: spaces now unify per measure.
// underlyingSpace.ts
export type CONTINUOUS_TYPE = { kind: "continuous"; width: Monotonic; dataDomain: DataDomain; measure?: Measure; ... };Merging. Two helpers in underlyingSpace.ts decide what happens when two measures meet. undefined is always permissive — it means "no claim", unifies with anything, and yields the other side (this is why getMeasure returns undefined rather than a "unit"/"unknown" sentinel: a measureless value must merge silently into a tagged one).
mergeMeasures(a, b, context)— unify as types. Equal measures unify to themselves; two different defined measures are a type error and it throws. This is the guard on the shared union:unionChildSpaces' mixed/data-positioned interval collection (alignment.ts) andresolveAlignmentSpace's non-baseline branch (not every child afreemagnitude) use it, so overlaying a count axis onto a millimeter axis fails loudly instead of corrupting the domain.forgetOnConflict(a, b)— a conflict forgets (returnsundefined) rather than throwing. Used where composing differently-measured magnitudes is legitimate: stacking two different fields' extents produces a real magnitude that carries no single unit, so the baseline-magnitude path (every childplacement: free) inunionChildSpacesforgets on conflict, andresolveAlignmentSpace's baseline reduce uses it too.
So the rule of thumb: aligning/overlaying siblings throws on a unit clash; composing them into a new extent forgets.
Where measures come from is itself a small type system with three sources, checked (not silently prioritized) in resolveMeasure (channels.ts): the channel aggregators use lodash's per-helper entrypoints for native ESM compatibility, but their semantics are still sumBy for size and meanBy for position.
- Explicit annotation —
field(name, measure)/datum(v, measure)(data.ts). A real type claim about the channel's unit. - Inferred provenance — a transform tags its output array.
bin()(transforms.ts) attaches a field→measure map under the well-knownMEASURE_PROVENANCEsymbol (data.ts): itsstart/end/sizecolumns are still in the source field's units (e.g. millimeters), andcountis"count". The symbol rides the array, not each row, so it survivesderive(...). Also a real type claim. - Field-name default — a bare string accessor's field name. A weak binding, not a claim; it yields to either of the above.
resolveMeasure reads annotation and provenance together: if both are present and disagree, it throws immediately at the channel — before any space union runs — naming the field and both measures. Otherwise annotation refines the weak default, and with no annotation the result is provenance ?? field-name. This completes the field/datum/literal trichotomy of issue #266: a literal has no field identity (no measure), a bare field name is a weak default, and an annotation or provenance is a hard claim. inferSize/inferPos tag the value(...) they emit with this resolved measure, which is what eventually lands on the space.
Provenance must reach mark channels, not only operator channels. An operator resolves each channel's measure once from its whole input array (which carries the MEASURE_PROVENANCE symbol), but a mark channel runs per split leaf — and a leaf is a fresh sub-array (groupBy/filter/slice) that doesn't inherit the symbol. So the operator re-tags each array leaf with its parent's provenance at the split site (copyMeasureProvenance, data.ts, applied in createOperator), letting a mark bound to a transform-output field (e.g. a bin's start/end/ size) read the source measure off its own data instead of falling back to the literal field name — which would otherwise turn a legitimate same-unit overlay into a false conflict. (Residual, tracked in #534: single-Datum leaves and the Python derive-RPC bridge still need a wrap-time / RPC-carried tag.)
This same size-vs-position measure comparison drives embedding (baseEmbedded, data.ts): inside a coordinate space, a dim's size becomes a swept coord extent only when its measure matches the dim's own position measure — a foreign-measure size (a bubble's area) stays a flat point. See the embedding-resolution pass under layout passes.
Constraint-domain measures. A position constraint's datum coordinate carries the same resolved measure, and collectPositionDomains folds those per axis with mergeMeasures — so a layer's own positioning constraints in clashing units (an interval coordinate with one endpoint in mm and the other in inch) throw at the source. The layer's resolveAxis (layer.tsx) then treats this constraint-domain measure as the axis's unit: it prefers the constraint measure and falls back to the children's POSITION measure only when the coordinates are untagged (literal pixels). It deliberately does not strict-unify the two — a self-scaling child (a scatter's pie glyph) can leak its own inner unit into the children's space, and that leak is not a competing claim about the scatter's data axis. This restores the unit tag the scatter operator's reduction onto constraints had dropped.
Propagation through the baseline → anchored conversion. A histogram's count axis is all baseline magnitudes (origin 0) at the children, and resolveAlignmentSpace's start/end/baseline path folds them into POSITION([0, max]). That conversion carries the merged child measure forward (a forgetOnConflict reduce) — it is load-bearing, because it is exactly how the count POSITION acquires its "count" tag so a later overlay union can recognize it as foreign and refuse.
The error and its remedies. A clash from mergeMeasures reads:
Cannot unify underlying spaces with different measures:
"A"and"B". If these are the same units, assert that withfield(name, measure)ordatum(v, measure). If they are different units, give the inner chart an explicitw/hso it becomes a self-scaling region.
The two remedies are the two escape hatches this essay already describes: annotate to declare the units are the same (collapsing them to one measure), or wrap the foreign region in an explicit pixel size so it absorbs its own axis (the self-scaling region above) and never reaches the shared union at all.
Stage 2. This is Stage 1: one measure per axis, unified or refused. The sequel is a measure-keyed family of underlying spaces per axis — true multi-scale, where a single axis can host several measures at once (dual axes). That is also the natural place for axis titles to read a measure off the space they describe (cf. issues #452, #386).
Axis inference
Conceptually, axis inference splits into two independent questions:
- What guide could this space support? Answered by the space's
dataDomain. An intervaldataDomainpermits a quantitative axis. A"delta"one permits a magnitude guide but not an axis with a meaningful zero. Afreemagnitude (nodataDomain) wants a legend, not an axis. ORDINAL permits labels at laid-out keys. UNDEFINED contributes nothing. - Should that guide be drawn here? Independent of the kind. The root of a stacked bar may have a POSITION y-space that permits a quantitative axis; a nested stack inside a more complex diagram might have the same kind without deserving its own visible axis. Conversely, a facet operator might explicitly request labels for the ORDINAL spaces it creates.
Both questions are now answered by a tree walk. resolveAxes (_node.ts) performs (2): a top-down pass that tags each node's axis.x / axis.y as true (this node owns a visible axis on that dimension), "budget" (a layer sibling owns it), or false (suppressed via an operator's axes: override). It honors per-operator overrides and short-circuits coordinate-transform subtrees (polar axes are handled separately by coord.tsx). The space then answers (1): anchored CONTINUOUS → quantitative ticks, unanchored → delta labels, ORDINAL → labels at laid-out keys.
Selection is no longer tied to the root. A faceted chart tags an axis on each facet-owning node, and an outer operator can suppress an axis its child would otherwise produce. The flags are consumed by the axis elaboration pass (elaborateAxes, src/ast/axes/elaborate.tsx), which wraps each flagged node in a Layer of ordinary tick/label shapes constrained to the inferred domain — so axes are not a privileged node type and the layout engine carries no axis-specific budget machinery. See Axes for the full elaboration story.
Discrete non-position channels
The tree is for spatial channels (x and y). Discrete non-position channels — color, symbol, texture, stroke pattern, marker shape — don't create an underlying spatial structure and aren't represented here. They still need shared resolution (categories should map consistently across a graphic; users should be able to override defaults; operators should be able to introduce or delimit scopes), but the right model may be closer to a theming API than to axis inference: a discrete color or symbol channel resolves by looking up a category in an inherited theme scope, with local operators or marks able to override the palette.
The current code does this with a unit.color map on scaleContext (seeded by resolveColorScale in _node.ts), which is enough for GoFish today but is not yet a general theming system. Future work. See Color Scale Resolution for what is implemented today.
Adding a new operator
Three things to consider:
- What kinds of children does it expect? If your operator only ever sees anchored, data-positioned children, you don't need to handle the symbolic-magnitude path. If it can be the parent of a data-driven stack, you do.
- What kind does it produce? Pick the most informative result that honestly describes the space. A spread-style operator that lays children out side-by-side without summing should keep the magnitude (a
CONTINUOUSat origin 0, symbolic in σ) along its stack direction. An operator that fixes children to specific coordinates should produce an anchoredCONTINUOUS(aPOSITION). An operator that introduces a categorical axis should produce ORDINAL. - Does it transform spaces or merely pass them through? A coord transform annotates without changing the kind.
encloseandwrap- style overlays useunionChildSpaces.positionis a pass-through. Match the existing patterns ingraphicalOperators/and don't reinvent the merge logic per-operator.
If your operator is layout-time-only (no contribution to the kind tree), return [UNDEFINED, UNDEFINED] and rely on the children to drive inference upward through your wrapper (e.g. via unionChildSpaces from a parent layer).
Prior art
The general lesson — that graphical structure determines scale structure — is shared with Vega-Lite's resolver, Observable Plot's distributed inference, and Atom's recursive layout (Park et al. 2017). GoFish's contribution is generalizing that lesson into an explicit per-node intermediate representation rather than a set of operator-specific conventions. Anyone can add an operator that contributes, transforms, or consumes underlying-space facts; nothing in the layout, posScale, or guide pipelines is privileged.
The design also borrows from compiler architecture, especially typed intermediate representations and the value of an explicit elaboration pass that turns a convenient surface specification into a more precise representation that later passes can consume without re-inferring the same facts.
For a longer treatment, see the "Underlying Space Tree" section of GoFish's thesis chapter (parts/theory/underlying-space.typ in the companion thesis repo).
Pointers
- The data definitions and constructors:
src/ast/underlyingSpace.ts. - The traversal driver:
_node.ts'sresolveUnderlyingSpace(). - Per-shape resolvers:
src/ast/shapes/{rect,ellipse,petal,text,image}.tsx. - Per-operator resolvers (each colocated with the operator):
src/ast/graphicalOperators/{spread,layer,scatter,enclose,porterDuff,position,connect,arrow,table,coord}.tsx. - Overlay union helpers:
src/ast/graphicalOperators/alignment.ts. - Constraint space folds + the shared slice allocator:
src/ast/constraints/{distribute,align,folds}.ts. - The Monotonic algebra used by continuous-extent composition:
src/util/monotonic.ts. - Layout consumption:
gofish.tsx'slayout()for root-level dispatch;layer.tsx'slayoutfor the per-scope scale-factor solve and the constraint budget inversion (spread/stackelaborate tolayer, so they have nolayoutof their own). - Companion factory docs: The Mark Factory, The Operator Factory.
