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: Generates SVG elements from the laid-out tree
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 and legends (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
- Legend generation: Keys identify which nodes to include in 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 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 Types (defined in src/ast/underlyingSpace.ts):
POSITION: Continuous position scale (e.g.,x: value(5),y: value(10))DIFFERENCE: Difference scale for stacked/grouped chartsSIZE: Size-only encoding (no position)ORDINAL: Discrete categorical scale (e.g.,spread("category"))UNDEFINED: No data-driven encoding
See Underlying Space for the full treatment of this intermediate representation.
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: 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: 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.
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 9: Placement
Location: src/ast/gofish.tsx:209
child.place({ x: x ?? transform?.x ?? 0, y: y ?? transform?.y ?? 0 });Implementation: src/ast/_node.ts:284-309
Applies final positioning offsets. This is typically used for positioning the entire chart within its container.
Pass 10: 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 generates SVG elements.
Entry Point: The render() Function
Location: src/ast/gofish.tsx:346-842
The render function is called from gofish() after layout data is available:
return render(
{
width: w,
height: h,
defs,
axes,
scaleContext: data.scaleContext,
keyContext: data.keyContext,
sizeDomains: data.sizeDomains,
underlyingSpaceX: data.underlyingSpaceX,
underlyingSpaceY: data.underlyingSpaceY,
posScales: data.posScales,
ordinalScales: data.ordinalScales,
},
data.child
);Render Pass 1: Context Restoration
Location: src/ast/gofish.tsx:378-379
scaleContext = scaleContextParam;
keyContext = keyContextParam;The global contexts are restored so that render functions can access them.
Render Pass 2: Axis Tick Calculation
Location: src/ast/gofish.tsx:381-405
If axes: true, tick marks are computed for continuous axes using D3's nice() and ticks() functions.
Render Pass 3: SVG Container Creation
Location: src/ast/gofish.tsx:407-417
<svg
width={width + PADDING * 6 + (axes ? 100 : 0)}
height={height + PADDING * 6 + (axes ? 100 : 0)}
xmlns="http://www.w3.org/2000/svg"
>The SVG container is created with padding and extra space for axes.
Render Pass 4: Coordinate Transform
Location: src/ast/gofish.tsx:416-421
<g
transform={`scale(1, -1) translate(${PADDING * 4}, ${-height - PADDING * 4})`}
>The coordinate system is flipped (Y-axis inverted) to match mathematical conventions, and the chart is positioned with padding.
Render Pass 5: Node Tree Rendering
Location: src/ast/gofish.tsx:419-421
<Show when={transform} keyed fallback={child.INTERNAL_render()}>
<g transform={transform ?? ""}>{child.INTERNAL_render()}</g>
</Show>The node tree is rendered recursively via INTERNAL_render().
Implementation: src/ast/_node.ts:315-332
public INTERNAL_render(
coordinateTransform?: CoordinateTransform
): JSX.Element {
return this._render(
{
intrinsicDims: this.intrinsicDims,
transform: this.transform,
renderData: this.renderData,
coordinateTransform: coordinateTransform,
},
this.children.map((child) =>
child.INTERNAL_render(
this.type !== "box" ? coordinateTransform : undefined
)
)
);
}Render Pass 6: Shape-Specific Rendering
Each shape type has its own render function. For rectangles, this is in:
Location: src/ast/shapes/rect.tsx:251-449
The rect render function handles three cases based on which dimensions are data-driven:
Case 1: Both Dimensions Aesthetic (Point-like)
Location: src/ast/shapes/rect.tsx:298-322
When neither dimension is embedded (data-driven), the rect is rendered as a transformed point:
if (!isXEmbedded && !isYEmbedded) {
const center: [number, number] = [
(displayDims[0].min ?? 0) + (displayDims[0].size ?? 0) / 2,
(displayDims[1].min ?? 0) + (displayDims[1].size ?? 0) / 2,
];
const [transformedX, transformedY] = space.transform(center);
// ... render rect at transformed position
}Case 2: One Dimension Data-Driven (Line-like)
Location: src/ast/shapes/rect.tsx:325-399
When one dimension is embedded (e.g., bar height in a bar chart), the rect is rendered as a line or path:
if (isXEmbedded !== isYEmbedded) {
const dataAxis = isXEmbedded ? 0 : 1;
const aestheticAxis = isXEmbedded ? 1 : 0;
const thickness = displayDims[aestheticAxis].size ?? 0;
// For linear spaces, render as simple rect
if (space.type === "linear") {
// ... render rect with data-driven dimension
} else {
// For non-linear spaces, render as path
const linePath = path([...], { subdivision: 1000 });
const transformed = transformPath(linePath, space);
return <path d={pathToSVGPath(transformed)} ... />;
}
}Example: In a vertical bar chart:
- X-axis is aesthetic (spread by
spread()operator) - Y-axis is data-driven (
h: "value") - Each bar is rendered as a rectangle with fixed width and data-driven height
Case 3: Both Dimensions Data-Driven (Area-like)
Location: src/ast/shapes/rect.tsx:401-449
When both dimensions are embedded, the rect is rendered as an area:
// If we're in a linear space, render as a rect element
if (space.type === "linear") {
// ... render rect
} else {
// For non-linear spaces, render as transformed path
const corners = path([...], { closed: true, subdivision: 1000 });
const transformed = transformPath(corners, space);
return <path d={pathToSVGPath(transformed)} ... />;
}Render Pass 7: Axis Rendering
Location: src/ast/gofish.tsx:422-832
If axes: true, axes are rendered based on the underlying space types:
Continuous Y-Axis (POSITION)
Location: src/ast/gofish.tsx:434-479
For POSITION spaces, a continuous axis is rendered with tick marks and labels:
<Show when={isPOSITION(underlyingSpaceY)}>
{(() => {
const [yMin, yMax] = nice(
underlyingSpaceY.domain!.min,
underlyingSpaceY.domain!.max,
10
);
const yTicks = ticks(yMin, yMax, 10);
return (
<g>
<line ... /> {/* Axis line */}
<For each={yTicks}>
{(tick) => (
<>
<text ...>{tick}</text> {/* Tick label */}
<line ... /> {/* Tick mark */}
</>
)}
</For>
</g>
);
})()}
</Show>Ordinal X-Axis
Location: src/ast/gofish.tsx:683-741
For ORDINAL spaces, category labels are positioned using the ordinal scale:
<Show when={isORDINAL(underlyingSpaceX) && ordinalScales[0] && keyContext}>
{(() => {
const scale = ordinalScales[0]!;
const domain = isORDINAL(underlyingSpaceX) ? underlyingSpaceX.domain : undefined;
const labelKeys = domain && domain.length > 0 ? domain : [];
return (
<g>
<For each={labelKeys}>
{(key) => {
const xPos = scale(key);
return (
<text
transform="scale(1, -1)"
x={xPos}
y={-minY + 5}
text-anchor="middle"
>
{key}
</text>
);
}}
</For>
</g>
);
})()}
</Show>Example: In a bar chart with spread("category", { dir: "x" }):
- Each bar has a key like
"category-A","category-B", etc. - The
ORDINALunderlying space hasdomain: ["category-A", "category-B", ...] - The ordinal scale maps each key to its x-position
- Labels are rendered at those positions
Render Pass 8: Legend Rendering
Location: src/ast/gofish.tsx:801-830
Color legends are rendered from the scaleContext.unit.color map:
<For
each={Array.from(
(scaleContext?.unit && "color" in scaleContext.unit
? scaleContext.unit.color
: new Map()
).entries()
)}
>
{([key, value], i) => (
<g transform={`translate(${width + PADDING * 3}, ${height - i() * 20})`}>
<rect x={-20} y={-5} width={10} height={10} fill={value} />
<text ...>{key}</text>
</g>
)}
</For>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:
- 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
Rect Rendering: Each bar is rendered using Case 2 (one dimension data-driven):
typescript// X is aesthetic (positioned by spread), Y is data-driven const baseX = displayDims[0].min ?? 0; const baseY = 0; // Baseline const width = displayDims[0].size ?? 0; // Inferred by spread const height = displayDims[1].size ?? 0; // From data return <rect x={baseX} y={-baseY - height} width={width} height={height} ... />;Axis Rendering:
- X-axis: Ordinal axis with category labels positioned using ordinal scale
- Y-axis: Continuous axis (if
axes: true) showing value scale
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: The rect render function adapts its rendering strategy 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
