Layout and Render Passes in GoFish Graphics
This document explains the order and mechanics of layout and render passes in the GoFish graphics system, with specific examples and code references.
Overview
The GoFish rendering pipeline transforms a declarative chart specification into a rendered SVG visualization through a series of well-defined passes. The process can be divided into two main phases:
- Layout Phase: Computes positions, sizes, and spatial relationships
- Render Phase: Lowers the laid-out tree into a flat display-list IR, then paints that IR with a backend (SVG today)
Entry Point: The gofish() Function
The rendering process begins with the gofish() function in src/ast/gofish.tsx. This function orchestrates the entire pipeline:
const runGofish = async (): Promise<LayoutData> => {
const session: RenderSession = {
scopeContext: new Map(),
scaleContext: { unit: { color: new Map() } },
keyContext: {},
};
try {
const contexts = {
session,
};
const layoutResult = await layout(
{ w, h, x, y, transform, debug, defs, axes },
child,
contexts
);
return {
...layoutResult,
scaleContext: session.scaleContext,
keyContext: session.keyContext,
};
} finally {
// session is per-run and naturally discarded here
}
};Layout Phase
The layout phase is handled by the layout() function, which performs multiple passes over the chart tree.
Pass 1: Context Initialization
Location: src/ast/gofish.tsx:272-275
Three per-run session contexts are initialized:
scopeContext: Manages variable scoping and data bindings (type:Map)scaleContext: Stores computed color scales and scale mappings (type:{ unit: { color: Map<any, string> } })keyContext: Maps string keys to nodes for axis labeling (type:{ [key: string]: GoFishNode })
These are attached to the render session and propagated to the node tree, rather than stored as module-global mutable state. This establishes clean state for the rendering process and ensures no interference between multiple chart renders.
Pass 2: Color Scale Resolution
Location: src/ast/gofish.tsx:172
child.resolveColorScale();Implementation: src/ast/_node.ts:175-192
This pass traverses the tree and:
- Identifies color encodings (e.g.,
fill: "category"in bar charts) - Assigns colors from the
color6palette - Stores mappings in
scaleContext.unit.color
Example: In a bar chart with fill: "category", each unique category value gets assigned a color from the palette.
Pass 3: Name Resolution
Location: src/ast/gofish.tsx:173
child.resolveNames();Implementation: src/ast/_node.ts:194-201
Maps named nodes to the scope context, enabling references between chart elements. This resolves variable names and data bindings, mapping data field names to their corresponding values and establishing scope relationships between parent and child nodes.
Pass 4: Key Resolution
Location: src/ast/gofish.tsx:174
child.resolveKeys();Implementation: src/ast/_node.ts:203-210
Assigns unique keys to nodes. These keys are critical for:
- Axis labeling: Ordinal axes use keys to position category labels
(Legends do not use keys — they are elaborated from the resolved color map; see Legends.)
Example: In a bar chart using spread("category", { dir: "x" }), each bar gets a key like "category-value", which is later used to position the x-axis labels.
Pass 5: Size Domain Inference
Location: src/ast/gofish.tsx:175
const sizeDomains = child.inferSizeDomains();Implementation: src/ast/_node.ts:225-232
Determines the intrinsic size requirements for each dimension. For rect shapes, this is implemented in:
Location: src/ast/shapes/rect.tsx:171-176
inferSizeDomains: (shared, children) => {
return {
w: computeIntrinsicSize(dims[0].size),
h: computeIntrinsicSize(dims[1].size),
};
};The computeIntrinsicSize() function returns a Monotonic function that maps from data values to pixel sizes. This is used later during layout to determine how much space each element needs.
Pass 5.5: Coordinate-Space Alias Resolution
Location: src/ast/gofish.tsx (child.resolveAliases()), src/ast/_node.ts (resolveAliases)
A coordinate transform may declare axis-name aliases for the marks inside it — polar()/clock() expose { x: "theta", y: "r" }, so a mark can be authored with theta/r (positions) and thetaSize/rSize (extents) instead of x/y/w/h. resolveAliases is a top-down pass (run before underlying space, which reads the resolved dims) that walks the tree carrying the active alias scope: it rebinds the scope at every coord node that declares aliases (a nested coord rebinds for its subtree), resolves each mark's stashed _pendingAliases into the canonical x/y/w/h facets of its dims, and throws if an alias is used outside any declaring coord or names an alias the enclosing coord doesn't declare (hygiene). Like the later embedding pass it mutates the shared args.dims element in place so the captured layout/space closures observe the resolution. The operator dir accepts the angular/radial aliases too (elaborateDirection maps theta→0, r→1 generically, since dir is baked at operator construction before its coord exists). See Authoring Coordinate Transforms.
Pass 6: Underlying Space Resolution
Location: src/ast/gofish.tsx:176
const [underlyingSpaceX, underlyingSpaceY] = child.resolveUnderlyingSpace();Implementation: src/ast/_node.ts:212-223
This is one of the most important passes. It determines the underlying space type for each dimension, which affects how scales are computed and how axes are rendered.
Underlying Space Kinds (defined in src/ast/underlyingSpace.ts). Since the #586 collapse there are only three kinds — continuous, ordinal, undefined — and the old POSITION / DIFFERENCE / SIZE trichotomy is now three origin states of the single continuous kind (read via the isPOSITION / isDIFFERENCE / isBaselineMagnitude predicates):
CONTINUOUS: one data-driven extent, awidthMonotonic in σ plus anorigin:origin: number— POSITION: anchored at a data coordinate (e.g.x: value(5)); builds a position scale, niced, absolute axis.origin: "free"— SIZE: a baseline magnitude, sized but unplaced (e.g.h: "value"with no min); no position scale.origin: "impossible"— DIFFERENCE: unanchorable, only differences are meaningful (stacked/centered); delta axis over[0, width].
ORDINAL: Discrete categorical scale (e.g.,spread("category"))UNDEFINED: No data-driven encoding
See Underlying Space for the full treatment of this intermediate representation.
Constraints participate too. resolveUnderlyingSpace passes a node's positioning constraints to its resolver (a fourth constraints argument). A layer folds the datum coordinates of its Constraint.position constraints into a POSITION domain on that axis (collectPositionDomains), unioned with the children's spaces — so a position constraint contributes a fragment of this pass, which is what lets the layer build a data→pixel scale to resolve those constraints at layout time. See Operators vs Constraints.
Example for Bar Chart Rectangles:
Location: src/ast/shapes/rect.tsx:92-169
For a vertical bar chart where:
- X-axis:
spread("category")→ORDINALspace - Y-axis:
h: "value"→SIZEspace (if no min) orPOSITIONspace (if min is specified)
The logic in resolveUnderlyingSpace checks:
if (!isValue(dims[0].min) && !isValue(dims[0].size)) {
underlyingSpaceX = ORDINAL([]);
} else if (isAesthetic(dims[0].min) && isValue(dims[0].size)) {
underlyingSpaceX = DIFFERENCE(getValue(dims[0].size)!);
} else if (!isValue(dims[0].min) && isValue(dims[0].size)) {
underlyingSpaceX = SIZE(getValue(dims[0].size)!);
} else {
const min = isValue(dims[0].min) ? getValue(dims[0].min) : 0;
const size = isValue(dims[0].size) ? getValue(dims[0].size) : 0;
const domain = interval(min, min + size);
underlyingSpaceX = POSITION(domain);
}Pass 7: Axis Elaboration
Location: src/ast/gofish.tsx (layout()), src/ast/axes/elaborate.tsx
If the chart-level axes option enables a dimension, resolveAxes walks the tree top-down flagging which node owns an axis on each dimension. Ownership records a signature per claimed dim: a continuous axis claims it opaquely (single-owner — the root-most one wins, descendants defer to the chart-level scale), but ordinal axes nest — a node claims its own ordinal axis even under an ancestor ordinal axis, as long as it is a different grouping (a finer level). So a grouped or faceted chart renders one ordinal axis per grouping level (per facet) — e.g. a spread(lake)+stack(species) bar gets an outer lake axis and a per-lake species axis. Then resolveNiceDomains rounds POSITION domains to tick-friendly bounds, and then elaborateAxes rewrites the tree: each axis-owning node is wrapped in Layer tiers containing ordinary rect/text/spread axis shapes wired with align/distribute/position constraints. Axes are not a privileged node type and there is no axis-specific code later in the pipeline — after this pass they are just nodes. Because the rewrite inserts new nodes and moves keys onto wrappers, the affected resolution passes (color, names, labels, underlying space, nice domains) rerun on the new tree.
See Axes for the full elaboration story (the two-tier structure, origin pins, negative-space gutters, and the continuous/difference/ordinal kinds).
Axis-title elaboration follows the axis block and runs before the legend. The chart-level title text for each dim is read off the resolved space's measure — a continuous axis names itself by its unit, an ordinal axis by its grouping field. There is no syntactic field-name fallback: a space with no measure simply has no title. The measure is captured pre-elaboration (before the axis block inserts the inner per-facet ordinal axis nodes, whose finer grouping would otherwise bubble up and win the root union), so the chart-level title names the OUTERMOST grouping (lake, not the inner species). elaborateAxisTitles then wraps the chart in one more Layer carrying up to two title Text nodes — the x-title horizontal below the plot, the y-title rotated to read bottom-to-top in the left gutter — each centered on the axis line it describes via a ref() stand-in (elaborateAxes hands back those axis-line nodes as titleAnchors; an ordinal or absent axis has no line, so the title falls back to centering on the plot node). Two extra references thread through here:
plotNode— the original root content, captured beforeelaborateAxes, so it survives every wrapping pass and stands in as the fallback title anchor.contentNode— the node captured just before the title wrap (and so before the legend wrap too). It is whatfinalW/finalHread off below, so a long title or a tall legend can never inflate the inferred canvas; their extents past the content are reserved separately as measured gutters instead.
The ordering is deliberate: titles must be seated before the legend, because the legend distributes off the titled content's bbox — and conversely the title's centering must never see the legend column (it would drag the title off-center). This is the same elaborate-into-ordinary-nodes treatment axes and legends get; the former bespoke render-time title path is gone. See Axes for the title recipe and the sibling-facet anchor limitation.
Legend elaboration follows the title block in the same layout(), gated on a non-empty color map (not on the axes option). elaborateLegend wraps the chart root in a Layer holding the content plus a swatch column of rect/text rows, seated to the right with align/distribute constraints — the same elaborate- into-ordinary-nodes treatment axes get. See Legends.
Pass 8: Position Scale Computation
Location: src/ast/gofish.tsx:183-202
const posScales = [
underlyingSpaceX.kind === "position"
? computePosScale(
continuous({
value: [underlyingSpaceX.domain!.min, underlyingSpaceX.domain!.max],
measure: "unit",
}),
w
)
: undefined,
underlyingSpaceY.kind === "position"
? computePosScale(
continuous({
value: [underlyingSpaceY.domain!.min, underlyingSpaceY.domain!.max],
measure: "unit",
}),
h
)
: undefined,
];For POSITION spaces, this creates linear scales that map from data values to pixel coordinates. These scales are used during layout to position elements.
Pass 8.5: Embedding Resolution
Location: src/ast/gofish.tsx (child.resolveEmbedding()), src/ast/_node.ts (resolveEmbedding)
resolveEmbedding is the sole author of each dim's embedded flag — the flag the shape renders switch on for point (0 embedded axes) / line (1) / area (2). It runs top-down after underlying space resolves and before layout, and mutates the shared args.dims element in place (like resolveAliases) so the captured render closure observes it. Explicit emX/emY (and connect's embed()) lock the flag to true and are never recomputed.
A dim embeds iff its size is a data value or unsized (baseEmbedded, data.ts) AND — the Route B measure gate, only inside a coordinate space — its size's measure matches the dim's own position measure (min/center/max). A size in a measure foreign to where the mark sits (a scatter bubble's area ≠ its position units) stays ink: a flat point at the mapped center, not a swept wedge. The discriminator is mark-local because a polar coord forgets its axis measure; a positioned mark's own position measure is the axis measure it sits on. This consumes the measure provenance #534 carried to mark channels. The revocation is coord-scoped, so Cartesian behavior matches the former construction-time inference. (Route A — relational, measure-free embedding — is not yet implemented; tracked under #618.)
Pass 9: Layout Calculation
Location: src/ast/gofish.tsx:208
child.layout([w, h], [undefined, undefined], posScales);Implementation: src/ast/_node.ts:234-252
This is where the actual positioning and sizing happens. Each node's layout function is called with:
- Available space:
[w, h] - Scale factors:
[undefined, undefined](computed internally) - Position scales:
posScales(forPOSITIONspaces)
It applies layout algorithms (stacking, positioning, etc.), calculates intrinsic dimensions for each node, and handles nested layouts and complex arrangements.
Inferring an omitted w/h. The chart-level w and h are optional. An omitted dimension is resolved per axis from that axis's root underlying space:
- A POSITION or data-driven SIZE axis (a scatter axis, or bar heights
= value) has data to scale into pixels, so it falls back to a concrete canvas (DEFAULT_CANVAS_SIZE = 400). - An ORDINAL or UNDEFINED axis (a bar chart's category axis, or a bare fixed-size shape) has nothing to scale, so it lays out unsized: marks keep their default sizes (a mark treats a non-finite size as "use my default" via its
Number.isFiniteguards) and the operator shrinks to fit.
layout() therefore distinguishes the concrete canvasW/canvasH (used to build the position scales and root scale factors) from the layoutW/layoutH it hands to child.layout (where a shrink-to-fit axis is left unsized). Shared-measure equal scale (#582) adds one reconciliation step here, after the per-axis scales are built and before child.layout: when spaceMeasure(x) === spaceMeasure(y) (the two axes are the same unit), each axis's pixels-per-data-unit — a POSITION domain's canvas / range or a baseline-magnitude σ — is equated to the binding min(...) so one data unit measures the same on both axes (circles stay circular, maps stay undistorted); the binding axis fills, the other gets a recentered posScale. It is type equality, not a knob, and a single-coordinate-space coupling — it does not reach sizes solved in separate nested operator scopes. After layout it reads the chart's final extent back off the root via child.dims[i].size, so an unsized axis still yields a concrete SVG size (e.g. a no-width bar chart gets default-width bars and a width of n·barWidth + spacing). A user-supplied dimension is always authoritative. This computed extent — not the raw option — is what the render pass uses to size the SVG. The legend is now part of the laid-out tree (it is elaborated into the node tree during layout, see Pass 7), so it is included in this computed extent when w/h are omitted. (When a dimension is shrink-to-fit, Pass 10 pins the content's min edge to 0 so it fills [0, size] exactly; the per-side overhangs below then measure 0 on that axis — there is no gutter to reserve because the canvas already is the content extent.) When a dimension is given, layout() additionally measures how far the laid-out tree extends past the authoritative extent on each of the four sides — including content a constraint seated beyond the canvas, e.g. a marginal histogram's bands above and to the right of a scatter — and the render pass reserves exactly that, replacing the former fixed LEGEND_MARGIN constant. The right side is split into two measured overhangs: a rightOverhang for a legend swatch column (gated on whether a legend was added) and a rightContentOverhang for any non-legend content displaced past the right edge — see Render Pass 2 below for why the split is necessary. See Legends.
Literal pixel sizes are invisible to the underlying-space tree (a fixed-size shape resolves to
UNDEFINED, notSIZE), which is why the unsized path relies on the marks' default-size guards and the bbox readback rather than reading an intrinsic size from the space. Tracking constant sizes in the space system is a separate change.
Example: Rect Layout Function
Location: src/ast/shapes/rect.tsx:177-250
For a bar chart rectangle, the layout function:
Computes position (x, y):
typescriptconst x = computeAesthetic(dims[0].min, posScales?.[0]!, undefined); const y = computeAesthetic(dims[1].min, posScales?.[1]!, undefined);Computes size (width, height):
typescript// If both min and size are data-driven, compute from position scale if (isValue(dims[0].min) && isValue(dims[0].size)) { const min = x; const max = computeAesthetic( value(getValue(dims[0].min)! + getValue(dims[0].size)!), posScales[0], undefined ); w = max - min; } else if (isValue(dims[0].size) && posScales?.[0]) { // Size-only: compute from position scale with baseline at 0 const minPos = posScales[0](0); const maxPos = posScales[0](getValue(dims[0].size)!); w = maxPos - minPos; } else { // Use size scale factor w = computeSize(dims[0].size, scaleFactors?.[0]!, size[0]); }Returns intrinsic dimensions and transform:
typescriptreturn { intrinsicDims: [ { min: w >= 0 ? 0 : w, size: w, center: w / 2, max: w >= 0 ? w : 0 }, { min: h >= 0 ? 0 : h, size: h, center: h / 2, max: h >= 0 ? h : 0 }, ], transform: { translate: [x, y] }, };
The intrinsicDims represent the element's size in its local coordinate system (with min typically at 0), while transform.translate positions it in the parent's coordinate system.
Pass 10: Placement
Location: src/ast/gofish.tsx
const placeRoot = (axis, value, shrinkToFit) =>
shrinkToFit
? child.pinAnchor(axis, value, "min")
: child.place(axis, value, "baseline");
placeRoot("x", x ?? transform?.x ?? 0, w === undefined);
placeRoot("y", y ?? transform?.y ?? 0, h === undefined);Implementation: src/ast/_node.ts
Pins the whole chart into the container by landing one anchor of the root's bbox at a target coordinate. Which anchor depends on whether the axis is sized:
Given dimension → pin the baseline (local
0) to0. The canvas box is the baseline-anchored[0, given], and any content seated outside it (axis labels below0, ticks abovegiven) is reserved as the per-side overhangs in the render pass.Shrink-to-fit dimension (
w/homitted, sofinalH = size) → pin theminedge to0. The canvas box is the content's full[min, max]extent, so the content fills[0, size]exactly and the overhang formulas (-min,max - finalH) compute0for that axis with no special-casing.Leaving
minoff origin in this case is the #574 double-count: a negativemin(content below/left of baseline) makesbottomOverhang = -minre-reserve a phantom band ~equal to the offset, so the canvas comes out ~2× the content; a positivemin(a self-placed diagram seated at, say,(20, 20)) both gaps the near side and overhangs the far side. Pinningminto 0 collapses both, and it keeps the overhang reservation purely a given-dimension concern.The min-pin uses
pinAnchor, not the write-onceplace(): a chart whose root carries its own transform (a hand-built diagram like the pulley) has already self-placed that axis, andplace()short-circuits on a placed axis.pinAnchoris the authoritative override — it rebuilds the axis ledger so the pin lands regardless — and for an unplaced root it matches whatplace(…, "min")would have done.
Constraint placement works in anchor coordinates, not just node origins. The Placeable protocol in _node.ts exposes localAnchor(axis, anchor) so the placement solver can turn start/middle/end/baseline relations into equations over a node's absolute min. GoFishNode.localAnchor() reads the node's intrinsic dimensions in its own local frame, which keeps baseline and asymmetric-box alignment independent from whatever display transform is later projected for rendering.
Pass 11: Ordinal Scale Building
Location: src/ast/gofish.tsx:216-223
const ordinalScales: [OrdinalScale | undefined, OrdinalScale | undefined] = [
isORDINAL(underlyingSpaceX) && keyContext
? buildOrdinalScaleX(keyContext, child)
: undefined,
isORDINAL(underlyingSpaceY) && keyContext
? buildOrdinalScaleY(keyContext, child)
: undefined,
];Implementation: src/ast/gofish.tsx:65-119
For ORDINAL spaces, this builds scales that map category keys to pixel positions. The function:
- Iterates through
keyContextto find all nodes with keys - Computes their final positions (accounting for transforms)
- Returns a function
(key: string) => number | undefined
Example: In a bar chart with spread("category", { dir: "x" }), each bar has a key like "category-A", "category-B", etc. The ordinal scale maps these keys to their x-positions for axis labeling.
Render Phase
After layout completes, the render phase turns the laid-out tree into pixels in two passes: lower (walk the baked scenegraph, emit a flat display-list IR of positioned primitives in absolute pixels) and paint (a single backend turns that IR into output — SVG today). There is no per-shape SVG emission in between: each shape/operator owns a lower() method that describes itself as display-list items, and one backend paints the whole list. The full as-built model is Rendering; this section covers how render() drives it.
Entry Point: The render() Function
Location: src/ast/gofish.tsx (render())
The render function is called from gofish() after layout data is available:
return render(
{
width: data.width,
height: data.height,
svgPadding,
defs,
rightOverhang: data.rightOverhang,
rightContentOverhang: data.rightContentOverhang,
topOverhang: data.topOverhang,
leftOverhang: data.leftOverhang,
bottomOverhang: data.bottomOverhang,
},
data.child
);render() no longer takes axes/axisFields or the scale/space context — all the chrome is in the laid-out tree by now, so render only needs the computed extent and the measured per-side overhangs to size the SVG. It computes the gutter reserves, builds the toPixel coordinate map (below), lowers the baked tree, and paints each item into an <svg>.
Render Pass 1: Chrome Reservation
Location: src/ast/gofish.tsx (render())
render() draws no chart chrome of its own — no axis lines, tick marks, tick labels, ordinal category labels, or titles, and no legend swatches. All of it was elaborated into ordinary nodes during layout (see Pass 7: Axis Elaboration, the title block that follows it, and the legend block) and renders as part of the node tree like any other shape. The former bespoke render-time path (hand-written <text> title elements behind fixed Y_TITLE_MARGIN / X_TITLE_MARGIN gutters) has been deleted, so render() has zero chart-chrome special cases left.
What render() does do is size the SVG around the measured extent of that chrome, on all four sides. layout() hands it five gutter measurements: leftOverhang, bottomOverhang, and topOverhang (negative-space gutters and top overflow off the outermost wrapper: tick/label rows, the seated y-title and x-title, and any content a constraint seated above the canvas), plus the two right-side overhangs — rightOverhang (the legend swatch column) and rightContentOverhang (non-legend content displaced past the right edge). The render pass reserves exactly enough on each side:
const EDGE_GAP = 8; // breathing room between gutter content and the SVG edge
const reserve = (o: number) =>
o > 0 ? Math.ceil(Math.max(pad, o + EDGE_GAP)) : pad;
const leftReserve = reserve(leftOverhang);
const bottomReserve = reserve(bottomOverhang);
const topReserve = reserve(topOverhang);
// right side: legend column + non-legend displaced content
// width = leftReserve + width + rightOverhang + reserve(rightContentOverhang)The o > 0 guard keeps a chart with padding: 0 and no chrome at zero reserve (don't invent EDGE_GAP px on an empty gutter). Because a gutter that fits within the existing pad is absorbed by it, an untitled chart with a small gutter stays byte-identical to the pre-chrome output. The measured-overhang policy also fixes a latent bug: the old fixed 40px margins silently clipped any gutter wider than themselves (long y tick labels, or content a constraint seated past the canvas — marginal histogram bands, wide diagram nodes), whereas reserve() grows to fit whatever the laid-out content actually needs.
Why the right side is special. Left, bottom, and top each have a single kind of overhang (chrome or displaced content) and run through reserve() uniformly. The right side carries two kinds that must be reserved differently: a legend column historically reserves legendOverhang + pad, while displaced content (like a marginal band) should run through reserve() like the other gutters. The two cannot be unified by magnitude — a single-row legend overhangs by roughly the same few pixels as a wide rightmost x-tick label, yet the legend must be added to the width while the tick spill must be absorbed into pad. Only the color-scale flag (legendAdded) can tell them apart, so the legend keeps its own gated rightOverhang term; everything else flows through rightContentOverhang and reserve(). This is the one place a chart-chrome flag still influences sizing — kept deliberately, because the distinction is semantic, not geometric.
Render Pass 2: SVG Container Creation
Location: src/ast/gofish.tsx (render())
<svg
width={leftReserve + width + rightOverhang + reserve(rightContentOverhang)}
height={topReserve + height + bottomReserve}
xmlns="http://www.w3.org/2000/svg"
>The SVG container is sized to the content (width/height, read off the pre-chrome content node in layout) plus the measured reserves on each side. There is no inner flip <g> — the y-flip is folded into toPixel (next pass), so the display-list items paint directly under the <svg>.
Render Pass 3: The Coordinate Fold (toPixel)
Location: src/ast/gofish.tsx (render())
SVG is y-down (top-left origin); a continuous-y chart wants y-up (bars grow upward). The old renderer reconciled the two with two stacked SVG transforms — a per-shape scale(1,-1) and a root flip <g transform="scale(1,-1) translate(…)">. Both are folded into an affine map on the render session, but the map is now decided per scope rather than globally (issue #629): the bake walk tags each baked draw entry with the placed y-band it draws in (its FlipScope), and the lower driver builds that entry's toPixel from it:
const baseDown: ToPixel = ([gx, gy]) => [gx + leftReserve, gy + topReserve];
const toPixelFor = (flip?: FlipScope): ToPixel =>
flip === undefined
? baseDown // ambient y-down
: ([gx, gy]) => baseDown([gx, 2 * flip.baseY + flip.height - gy]); // y-upA continuous-y subtree mirrors y about its own band; an ordinal-y neighbor (a heatmap beside a bar chart) keeps the ambient y-down map. The root plot content mirrors about the canvas frame [0, finalH] stamped on contentNode._rootFlipScope here in layout() (where finalH is known) — the exact frame the old global flip used, so a single cohesive chart is pixel-identical; a mixed free-space dashboard flips only its continuous subtrees, each about its own band. Chrome (axis titles, legend, colorbar) is stamped _ambientYDown: the bake box-mirrors its BOX about the plot's frame (so it seats beside the flipped plot exactly as before) while its INTERIOR renders y-down — legend rows read top→bottom with no reverse. Because the flip and the gutter offset live in toPixel, the lower pass produces items already in final absolute pixels — no outer flip group, no per-shape transform. toPixel is affine, so straight paths stay straight (a warped path just maps each control point through it). See Rendering for the full per-scope mechanism.
Render Pass 4: Lowering the Baked Tree
Location: src/ast/gofish.tsx (render()), src/ast/displayList/lower.ts
const paintBaked = () => lowerToDisplayList(child).map(paintSVG);lowerToDisplayList(child) bakes the resolved tree into a globally z-ordered list of { node, transform } entries (see Flattening the Scenegraph) and lets each entry lower itself via INTERNAL_lower(coordTransform?, transformOverride?):
export const lowerToDisplayList = (root) =>
bake(root).flatMap((d) => d.node.INTERNAL_lower(undefined, d.transform));The display list is the concatenation of every node's DisplayItem[] fragment. Unlike the old INTERNAL_render, INTERNAL_lower does not pre-recurse children: a node reaching it is either a leaf or a bake boundary (coord, box, connect, arrow, enclose, the compositors) that re-walks its own subtree with its absolute transform composed in. A node with no lower() throws.
Render Pass 5: Per-Shape Lowering and Painting
Each shape/operator owns a lower(ctx) → DisplayItem[] — the extension point that replaced _render. It receives { intrinsicDims, transform, renderData, coordinateTransform, toPixel } and returns this node's fragment of the display list: an axis-aligned rect lowers to a rect item; a bar in a non-linear (e.g. polar) coordinate space lowers to a warped path item (its points mapped through both the coordinate transform and toPixel); text lowers to a text item; and so on. The "draw-rect vs draw-path" decision a rect used to make at render time is decided once, during lowering. Shared helpers live in src/ast/displayList/lowerHelpers.ts (pathToPixelSVG, rectItemFromBox, lowerStyle).
A single backend then paints each item. paintSVG (src/ast/displayList/paintSVG.tsx) emits SolidJS JSX for the live path; its pure-string sibling displayListToSVG (in gofish-ir) emits markup with no DOM. Both consume the same list and a cross-check test keeps them in lockstep. Because items are in final absolute pixels, painting is verbatim — a rect item becomes <rect x y width height …/>. See Rendering for the IR's item kinds (rect/ellipse/path/text/image/group/composite/mask) and the toDisplayList({ w, h }) terminal that stops at the IR for non-SVG consumers.
Render Pass 6: Axis Rendering (removed)
The bespoke axis-rendering pass that used to live here (hand-written SVG for continuous/ordinal axes, ~400 lines of gofish.tsx) was deleted. Axes are now elaborated into ordinary GoFish nodes during layout (see Pass 7: Axis Elaboration and Axes), so they render through the normal node-tree pass above with no special casing. Axis titles were the last artifact still drawn here; they too are now elaborated during layout (see Render Pass 1), so nothing axis-related is drawn directly at render time.
Render Pass 7: Legend Rendering (removed)
The bespoke legend-rendering pass that used to live here (a <For> over scaleContext.unit.color hand-placing swatches behind a fixed LEGEND_MARGIN) was deleted. Color legends are now elaborated into ordinary GoFish nodes during layout (see Pass 7: Axis Elaboration, which the legend pass follows, and Legends), so they render through the normal node-tree pass above with no special casing.
Complete Example: Bar Chart Rendering
Let's trace through a complete bar chart example:
barChart(data, {
x: "category",
y: "value",
orientation: "y",
});Step 1: Chart Construction
Location: src/charts/bar.ts:88-97
const builder = chart(data)
.flow(spread("category", { dir: "x" }))
.mark(rect({ h: "value" }));This creates:
- A
chartnode with the data - A
spreadoperator that groups by "category" and spreads along x - A
rectmark with height driven by "value"
Step 2: Layout Passes
- Color Resolution: No colors specified, so this is a no-op
- Key Resolution: Each bar gets a key like
"category-A","category-B", etc. - Size Domain Inference: For each rect,
inferSizeDomainsreturns a monotonic function for height - Underlying Space Resolution:
- X-axis:
ORDINAL(fromspread) - Y-axis:
SIZE(height is data-driven, no position)
- X-axis:
- Axis Elaboration (if
axesenabled): the chart is wrapped in layers carrying the y tick marks/labels (constraint-pinned at their data values) and the per-category x labels (ref-bound to the bars) - Layout Calculation:
- X-positions computed by
spreadoperator (ordinal spacing) - Y-positions set to 0 (bars start at baseline)
- Heights computed from data values using size scale factors
- X-positions computed by
- Ordinal Scale Building: Maps category keys to x-positions
Step 3: Render Pass
Lower: each bar's
lower()emits a singlerectdisplay-list item — a linear-space, one-dimension-data-driven bar lowers to an axis-aligned rectangle in absolute pixels (its y-up box mapped throughtoPixel):typescript// X is aesthetic (positioned by spread), Y is data-driven const gxMin = displayDims[0].min ?? 0; const width = displayDims[0].size ?? 0; // Inferred by spread const height = displayDims[1].size ?? 0; // From data // rectItemFromBox maps the y-up box through toPixel → { kind: "rect", x, y, w, h } return [ rectItemFromBox(gxMin, gxMin + width, 0, height, toPixel, { style }), ];Axes: already part of the node tree (elaborated during layout), so the category labels, the y tick marks, and the axis titles lower alongside the bars — nothing axis-related is drawn separately.
Paint:
paintSVGturns eachrectitem into<rect x y width height …/>; the whole list paints directly under the<svg>with no flip group.
Debug Support
The system includes debugging capabilities. When the debug option is set:
if (debug) {
debugNodeTree(child);
console.log("scopeContext", scopeContext);
}- Node Tree Debugging: Visualizes the complete chart tree structure
- Context Logging: Outputs all context information for inspection
- Development Aid: Helps identify layout issues and optimization opportunities
Performance Considerations
- Single Traversal: Each pass traverses the tree only once when possible.
- Per-run sessions: Contexts are scoped to a single render session and discarded afterward, so there is no leakage between renders.
Key Takeaways
- Layout is separate from rendering: All spatial calculations happen in the layout phase
- Underlying space determines scale types: The underlying space resolution pass is critical for determining how to scale and render
- Keys enable axis labeling: The key resolution pass enables ordinal axes to find and position category labels
- Rendering adapts to coordinate spaces: each shape's
lower()adapts what display-list item it emits (an axis-alignedrectvs a warpedpath) based on which dimensions are data-driven and what coordinate transform is active - Contexts flow through passes: The three session contexts (scope, scale, key) are populated during layout and used during rendering
Code References Summary
- Main entry point:
src/ast/gofish.tsx - Node implementation:
src/ast/_node.ts - Rect shape:
src/ast/shapes/rect.tsx - Bar chart helper:
src/charts/bar.ts - Underlying space types:
src/ast/underlyingSpace.ts
