createMark: turning a shape into a frontend mark
createMark is the factory that wraps a low-level shape function (Rect, Ellipse, Petal, Text, Image) and produces the frontend mark (rect, ellipse, petal, text, image) used inside chart(...).mark(...).
It lives at src/ast/withGoFish.ts:419.
The design is inspired by Krist Wongsuphasawat's Encodable ("Encodable: Configurable Grammar for Visualization Components", IEEE VIS 2020 — arxiv:2009.00722), which factors a visualization component's grammar into per-component channel declarations plus a parser that turns user-supplied encoding specs into rendering parameters. createMark is the same idea adapted to GoFish's shape + node-tree model (see "Prior art" at the bottom of this doc).
This file explains what createMark does, why it exists, and how to add a new mark by calling it.
What it does
A low-level shape takes plain pixel-space numbers:
Rect({ w: 50, h: 100, fill: "tomato" });A frontend mark takes data-aware inputs — either a plain value, or a field name to pull from the data:
rect({ w: 50, h: "value", fill: "category" });
// ^^ ^^^^^^^ ^^^^^^^^^^
// literal sum the look up category
// "value" field in the color palettecreateMark is the bridge. You give it the low-level shape and a per-prop channel annotation describing how that prop encodes data; it returns a function that performs the encoding at render time and forwards the resulting shape props to the underlying low-level builder.
Anatomy of a createMark call
From src/ast/shapes/rect.tsx:631:
export const rect = createMark(Rect, {
w: "size",
h: "size",
fill: "color",
stroke: "color",
});Two arguments:
- The low-level shape function (
Rect). TakesShapeProps, returns aGoFishNode. This is the thing that actually allocates layout and renders. - Channel annotations (
{ w: "size", h: "size", fill: "color", ... }). A partial map from prop name → channel type. Props not in this map pass through unchanged.
The factory's signature (withGoFish.ts:419):
function createMark<ShapeProps, C extends ChannelAnnotations<ShapeProps>>(
shapeFn: (opts: ShapeProps) => GoFishNode,
channels: C
): <T>(opts: DeriveMarkProps<ShapeProps, C, T>) => NameableMark<T | T[] | ...>;Channel types
Two are wired up today, both defined at src/ast/channels.ts:
| channel | accepts | does |
|---|---|---|
"size" | number | (keyof T & string) | Value | string → inferSize (sums field across data); number → pass-through |
"color" | string | (keyof T & string) | Value | string → inferColor (color palette lookup if field, else literal) |
If your prop should be a position offset (mean rather than sum), see the inferPos helper — createOperator uses it via channel annotations of its own; createMark could grow a "pos" channel the same way if a future shape needs one.
inferSize and inferPos are two instantiations of one numeric-inference factory, inferNumeric(agg) — they differ only in the aggregation (sumBy vs meanBy, imported through lodash's per-helper entrypoints so this path is safe under native ESM). Both take an optional third argument, a resolved Measure: a string/field() accessor's produced value is tagged with its unit-of-measure so the underlying-space layer can unify scales per measure (see Underlying Space). When the caller doesn't pass one (e.g. createMark's size channel), the inferer resolves it locally via resolveMeasure(data, accessor) — explicit field(name, measure) annotation, else transform provenance riding the data array (bin() tags its output), else the field name as a weak default; a contradictory annotation-vs-provenance pair throws at the channel. createOperator hoists resolveMeasure to once per channel and passes the result down, since the accessor and provenance are loop-invariant across split entries.
A prop that does not appear in the annotations map (e.g. Rect.cornerRadius) is passed through to shapeFn exactly as the user wrote it.
What happens at render time
Walking withGoFish.ts:431-477:
- Unwrap the input. Marks are called with one of three shapes —
T(single datum),T[](array), or{ item, key }(an item paired with a key set by an upstream operator). Step 1 normalizes them to(d, key). - Wrap to an array.
data = Array.isArray(d) ? d : [d]. Theinfer*helpers all expect an array. - Apply each channel. For each prop in the user's
markOpts:Value-wrapped (v(...)) → pass through unchanged. (Already final.)"size"channel →inferSize(markValue, data). IfmarkValueis a string, sum that field acrossdata; if a number, use as-is."color"channel →inferColor(markValue, data). If the string matches a field in the first datum, wrap it as aValueso the color scale picks it up; otherwise treat the string as a literal color. A value read from a named field (a field-name string orfield(...)) records that field as its provenance,DatumValueImpl.field, so the color scale knows which field it maps; a function accessor records none. It also records the field's type from the chart'sschema(DatumValueImpl.fieldType), read offdata, so a color scale over an ordered column lists its domain in that order. (Aderivekeeps its input's column types on its result, so they reach the mark.)"dims"channel → the axis-name-keyeddimsoption (rect({ dims: { theta: { size: "count" } } })). Each slot is its own channel, and its kind comes from its structure, not its name:sizeis a"size"channel, and a bare value ormin/center/maxis a"pos"channel (mapAxisDimsindims.ts;axisSlotKindinchannels.ts). AValuein a slot passes through, as it does at the top level. The kind does not depend on which axis the name means, so it can run here, before the axis-name pass knows the enclosing coord and writes the slots onto the mark's dims. Sodims: { r: { size: "count" } }aggregates exactly likeh: "count".- Anything else → pass through.
- Call the low-level shape. The encoded shape props go into
shapeFn, producing theGoFishNode. A component body (the no-channelsform) may instead return a mark, such aslayer([...])orspread(opts, [...]), or a chart builder. That result goes throughresolveMarkResult, the same path a combinator uses for each child, with a fresh name context because the component is a naming boundary. So a component is written with the same lowercase operators as a chart, and there is no separate node-level spelling to reach for. An expand mark's array of slice nodes passes through unchanged. - Tag the node with
datum = dso downstream coordinators (label placement,selectAllprojections) can find its row. The factory does not name the node after its data key: the key is data, and a name made from it could clash with a name the user wrote (see Name Resolution & Scoping).
live() channels
A color or raw channel value may be a live(...) reactive callback (the reactivity layer); channels.ts widens those two channel unions to accept a LiveValue. One helper does the split for every mark factory — splitLiveChannels(opts, datum) in interaction/live.ts — and it does two things: it evaluates each live callback once, untracked and under the inLiveEval flag, to get the resolve-time value the pipeline measures and infers scales with (so its input reads wire event dispatch but do not become pipeline dependencies), and it hands back the raw callbacks to stamp on the produced node as __gfLive[channel]. Lowering (_node.ts) later bakes each callback, bound to the node's datum, into the paint-time side table so paint re-evaluates it reactively.
The split has one implementation and two callers: createMark's channel loop, and createRelationalMark — which needs the two halves separately (which channels are live is known at construction, before any datum exists), so the helper is composed from liveChannelsOf and withLiveStatics. withLiveStatics is also where the LiveValue arm leaves the TYPE: it returns StripLive<O>, which is why line/ribbon's produce reads o.fill and o.opacity straight through instead of casting each channel back.
What an operator accepts as a child
The other half of withGoFish.ts is createNodeOperator / createNodeOperatorSequential, which every low-level operator (layer, spreadX, Frame, …) is built from. They flatten the children array, await its promises, and then reify each child into a GoFishAST, through one reifyChild shared by both loops: a thunk is called, and whatever comes out — like every other child — goes to resolveMarkResult (marks/markResult.ts), the single place that knows all the shapes. Five get in:
- an already-built node (or a
GoFishRef) — used as is; - a mark (a function) — invoked with
undefineddata, which is how a barerect({ … })becomes a node insidespreadX([...]), and how a control mark (slider(...)) is rebuilt on every resolve; - a thunk (sequential form only) — called, then reified again;
- a chart builder —
chart(...).mark(...), with or without.layer(...)tiers — resolved through its ownresolve(). ALayerBuildermust go through its own, not the root tier's: that is where a rootcoordis hoisted around every tier, so resolving the tiers by hand would drop the shared projection; - a
.relate()operand (aRelateOperand, a child of a drawing clause such asarrow(opts, [a, b])) — becomes a stringrefof the name it carries, which resolves from the relating layer (see Name Resolution & Scoping).
markResult.ts imports neither chartBuilder nor createOperator, so withGoFish, createOperator and chartBuilder all use the one resolveMarkResult rather than each keeping a local builder-child dispatch, and the dependency between the two builder modules runs one way: chartBuilder imports createOperator (for nameableMark), never the reverse. resolveMarkResult knows a builder by the method it calls, withLayerContext, not by its class, which is what lets it sit below chartBuilder.
The builder case is the reverse direction of .layer(node), which has always accepted low-level nodes: a chart composes inside an operator (spreadY([map, controls])) exactly as a node composes inside a chart.
What an operator accepts as a child
The other half of withGoFish.ts is createNodeOperator / createNodeOperatorSequential, which every low-level operator (layer, spreadX, Frame, …) is built from. They flatten the children array, await its promises, and then reify each child into a GoFishAST, through one reifyChild shared by both loops: a thunk is called, and whatever comes out — like every other child — goes to resolveMarkResult (marks/markResult.ts), the single place that knows all the shapes. Five get in:
- an already-built node (or a
GoFishRef) — used as is; - a mark (a function) — invoked with
undefineddata, which is how a barerect({ … })becomes a node insidespreadX([...]); - a thunk (sequential form only) — called, then reified again;
- a chart builder —
chart(...).mark(...), with or without.layer(...)tiers — resolved through its ownresolve(). ALayerBuildermust go through its own, not the root tier's: that is where a rootcoordis hoisted around every tier, so resolving the tiers by hand would drop the shared projection; - a
.relate()operand (aRelateOperand, a child of a drawing clause such asarrow(opts, [a, b])) — becomes a stringrefof the name it carries, which resolves from the relating layer (see Name Resolution & Scoping).
markResult.ts imports neither chartBuilder nor createOperator, so withGoFish, createOperator and chartBuilder all use the one resolveMarkResult rather than each keeping a local builder-child dispatch, and the dependency between the two builder modules runs one way: chartBuilder imports createOperator (for nameableMark), never the reverse. resolveMarkResult knows a builder by the method it calls, withLayerContext, not by its class, which is what lets it sit below chartBuilder.
The builder case is the reverse direction of .layer(node), which has always accepted low-level nodes: a chart composes inside an operator (spreadY([map, controls])) exactly as a node composes inside a chart.
.name(), .label(), .zOrder(), and .translate()
createMark returns a NameableMark, which is the base mark plus chainable methods:
mark.name("layerName")— registers each produced node into the chart's layer context soselectAll("layerName")can pull the array of refs (orref("layerName")the single node, when the layer holds exactly one). It also stashes the passed name on the returned mark function viastashLayerName(defined inmarkResult.ts, called by every.name()implementation, and carried forward by every modifier chained after it), so.layer()'s producer-tier auto-naming and a sequenced tier's naming can detect a user-chained name without parsing the__serializetag. AcreateNametoken is filed in the layer context under its own symbol (layerKey), so those tiers find a token-named mark's nodes too, while no stringselectAllorrefcan reach them. (An earlierChartBuilder.connect()method used this same stashed name; it was deleted in favor of.layer(), which generalizes the pattern to every tier — see below.)LayerBuilder.wireTiers()looks for the stashed name on the previous tier's mark the same way.connect()used to.mark.label(accessor, options?)— callsnode.label(...)on every produced node, deferring label placement to the layout phase.mark.zOrder(value)— sets each produced node's paint-order hint, wherevalue: ZOrderValue<T> = number | ((datum: T) => number). A callback is evaluated against the per-instance datum, so paint order can be data-driven (e.g. raise one category over the rest) without splitting the mark into separately-named layers; the bake pass orders each layer's children by(zOrder, index). The constant form round-trips through the IR; a callback is dropped from the emitted IR (like a function.labelaccessor).mark.transition({ enter, update, exit })records how the mark looks in each phase of an animation (animation.grow(),animation.fadeIn(), and so on) on every produced node. With notime.sequencein the flow, the build-in reads theentereffects back off the resolved tree (src/animation/install.ts). Under a sequence, the chart builder reads the records off the tier's resolved marks (chainedUpdates) and draws onetime.transition()for each way of moving it finds (the same curve and ease,sameTween), inside the tier's own frame, so the next.layer(...)still takes the tier's marks as its scope. Each transition moves only the marks that chained its tween, however many of them a keyframe holds. The chained mark may sit anywhere inside the tier's mark (layer([trail, head.transition(...)]), acreateMarkcomponent), because the records are on the nodes. This is the build-in prototype (draft PR #901) and is JavaScript-only.mark.translate({ x?, y? })— wraps the produced node in a structural translation node. This is deliberately not equivalent to mergingx/yinto the mark's own options: a mark or operator may already givex/ydomain-specific channel meanings.
These methods wrap or rebuild the base mark rather than mutating it, so naming, labeling, or positioning one mark never affects another.
These methods are not hand-rolled here. createMark calls nameableMark, which is one application of the shared modifier factory in createOperator.ts: a ModifierConfig ({ name, apply, tag? }) plus attachModifiers(base, configs). apply(node, layerContext, datum, ...args) mutates each produced node (once per node — every slice for an expand mark like cut) and receives the per-instance datum, so a modifier like .zOrder can derive a value from the data; tag stamps metadata on the wrapped mark function once (propagating the __serialize tag and stashing the layer name — a mark no longer carries an axis-field tag, since axis titles now derive from each node's resolved space measure). attachModifiers wires the set onto the base and adds the export terminals (render / toSVG / toSVGElement / save / toDisplayList) from the shared terminals.ts registry, re-decorating each method's result with the same set so chains stay extensible and the mark-kind tag rides along. .name() defers its layer registration via a __layerRegistration tag collected in a single post-resolve DFS walk (collectLayerRegistrations), so registry order follows parent-iteration order, not async-completion order. The same factory backs makeRelatableMark (which adds .relate()) and the combinator marks — one wiring, not three copies.
.translate() is structural: attachModifiers maps the base mark to a new mark whose produced node is wrapped by a translation node. This keeps the modifier independent from the wrapped mark's channel grammar.
Adding a new mark
Suppose you write a new low-level shape Diamond({ w, h, fill, stroke }). The high-level diamond mark is one line:
export const diamond = createMark(Diamond, {
w: "size",
h: "size",
fill: "color",
stroke: "color",
});Now consumers can write:
chart(data).mark(diamond({ w: "value", fill: "category" }));…and the encoding (sum value, look up category in the palette) happens for free. Anything Diamond's ShapeProps adds that isn't a "size" or "color" channel — say rotation: number — passes through verbatim.
Adding a new channel type
Today's channels are "size" and "color". To add (say) "angle":
- Add
"angle"to theChannelTypeunion inchannels.ts. - If a numeric aggregation fits, instantiate the existing factory —
export const inferAngle = inferNumeric(meanBy)(or whatever aggregation makes sense) — and measure tagging comes along for free. Otherwise writeinferAngle(accessor, data, measure?)next to it with the same signature. - Extend
DeriveMarkProps's conditional with the input type for"angle". - Extend the
if (channelType === "size") ... else if (channelType === "color")chain inwithGoFish.tsto handle it.
createOperator has its own channel handling (see The Operator Factory) and would need the same treatment if the new channel should be available in operator opts as well.
createRelationalMark: connectors as marks
line and ribbon (src/ast/marks/chart.ts) are not built on createMark — they aren't a single low-level shape with channel-annotated props, they're a connector that consumes other marks' produced nodes. They're built on the sibling factory createRelationalMark(type, produce), where produce(opts, children) builds the underlying connect node; everything else is shared dual-form plumbing the factory handles once for both line and ribbon.
createRelationalMark dispatches on the shape of its arguments into four call forms:
- Low-level combinator form — an explicit
children: GoFishAST[]array is passed as the second argument (used standalone inside a manuallayer([...])). Connects exactly those children. - Pairwise
{ from, to }form —opts.from/opts.toname two columns holding refs; one connector per row (node-link edges), afterresolvehas turned endpoint ids into refs. - Bag form — applied directly to a
GoFishRef[](e.g.selectAll(...)or the previous tier's marks via.layer()): one connector through all the refs, UNLESS the mark is fused over a flow, in which caseChartBuildercomputes a split and partitions the bag withsplitEntries— the same helpergroup()'ssplithook uses — producing one connector per group (e.g.ribbon({})fused overstack({ by: "species" })draws one band per species; see "Default grouping" below). A refs-bag chart spells the same split structurally instead, via an upstreamgroup().
A connector's datum, and its live() channels
A leaf mark carries the row it was drawn from. A connector threads a whole group, so its datum is the group: each field of its operands' data, projected with homogeneity collapse (groupDatumOf). A path through one species' daily positions collapses species to that species and day to undefined — which is exactly what a channel callback should see, and what pointer().datum() hands back when that path is hovered.
live(...) channels on a connector are treated exactly as a leaf mark's, by one path: liveChannelsOf collects them and the factory stamps them on each produced node, where INTERNAL_lower bakes them into the datum-bound paint slots, so a later pulse patches the attribute with no re-resolve; and the opts produce is built from are the author's opts with each live channel replaced by its value at the connector's datum (withLiveStatics). That resolve-time read is also what REGISTERS the input with the interaction runtime, and it is what makes the FIRST paint already draw the right thing. (The factory used to delete the live channels from the opts first, on the theory that the connector would fall back to its own defaults; withLiveStatics put them straight back, so the deletion only ever hid them from the serialized opts.)
zBelow-by-default paint order
Every node a relational mark produces, in every call form above, is tagged with the operand nodes/refs it was built from (tagRelationalOperands, which stashes __relationalOperands on the node). The layer combinator (graphicalOperators/layer.tsx) reads that tag and installs a default zBelow(self, operand) paint-order constraint — not a hardcoded z-index — so the connector paints under whatever it references. Because it's a real constraint, it composes with any other constraint in the layer; an explicit .zOrder(...) or .relate(...) chained on the connector's own mark overrides the default (the tag is only consulted when neither has been set). This is what lets line()/ribbon() sit under the marks they connect with no zOrder incantation needed, in every call form including the low-level combinator one.
Blank-fusion: .mark(R(opts)) sugar
The bag form above is also reachable through a syntactic rewrite at ChartBuilder.mark() (src/ast/marks/chartBuilder.ts): placing a relational mark directly in .mark() position elaborates to an invisible anchor tier plus a connector tier —
.mark(R(opts)) ⇒ .mark(blank(anchor(opts))).layer(R(opts))The anchor tier is invisible in the strong sense: a blank node lowers to no display items at all (see Render Pass 4), so the one anchor per row costs layout and a selectAll target but no DOM element and no hit-test entry. Since nothing can make it paint, blank() takes no paint-only options (no stroke or corner radius); its fill stays only to seed the color scale.
anchor(opts) is exactly opts's {w, h, emX, emY} subset (pickAnchorOpts in chart.ts) — the rest (fill, stroke, curve, along, …) stays on the connector unchanged, since produce only reads the fields it knows about. The factory tags every bag-form mark it returns with a __relationalFusable = { opts, inferred, makeAnchor } descriptor (makeAnchor is a pre-bound blank(...) call, kept out of chartBuilder.ts to avoid a chart.ts ↔ chartBuilder.ts import cycle); modifierMethod in createOperator.ts propagates the tag through .name()/.label()/.zOrder() chaining so those still target the connector. The pairwise {from, to} form is never tagged.
ChartBuilder.mark() only rewrites when the chart's own data still needs anchors drawn for it (!usesPreviousLayerMarks() && !(data instanceof GoFishRef)) — a relational mark applied directly to an already-drawn refs bag (chart(selectAll("bars")).mark(ribbon(opts)), or an empty-scope chart() tier inside .layer(...)) keeps its direct bag-form meaning unfused, since there's nothing to anchor; along on either of those, or on the pairwise form, is a builder-time error rather than a silent no-op (rejectAlongWithoutFlow in chartBuilder.ts, and the pairwise branch in chart.ts). A split connector's fill may be a shared field name rather than a literal color; resolveGroupFill in chart.ts resolves it per group via inferColor (same channel helper createMark uses) before it reaches Connect, reading a representative row off the group's ref bag (so the resolved paint carries its field the same way).
Default grouping: a fused connector's split, and along
A fused relational mark doesn't fall back to one connector through the whole bag — it gets a default split and travel direction computed from the flow it fuses over (issue #752; full rule in notes/design/relational-mark-default-split.md). This is what lets a ridgeline write ribbon({ h: "count" }) with no split option at all, when spread({ by: "month", dir: "y" }) already said the grouping one line up, and what makes the barley slope chart draw one line per site×variety instead of a single zigzag across every panel. Relational marks have no option that spells the split directly — by was removed entirely; the only option is along, which names the flow tier that becomes the path (the split is always the complement, and is never user-spelled).
The computation happens in ChartBuilder, not chart.ts, because it needs this.operators — the flow tiers assembled by .flow(...) — which only the builder has in hand. Two call sites run it:
- The
.mark(R(opts))blank-fusion rewrite (above), right before it splitsoptsinto anchor and connector. ChartBuilder.layer(child), whenchildis a bare relational-mark tier (.mark(blank({h})).layer(ribbon({}))) consuming this tier's own marks — the two-tier form the sugar above elaborates into.
Both guard on the same "fuses over THIS chart's own flow" boundary dataNeedsAnchors already checks — !usesPreviousLayerMarks() && !dataIsRefs(this.data) — so a refs-bag chart (chart(selectAll(...))) or the nested chart().flow(group({by})).mark(line()) idiom never gets a default injected; both keep their pre-#752 meaning exactly, and using along on either throws instead (see the previous section).
Crucially, the computed default is written into a separate mutable cell, inferred, not into opts — tagRelationalFusable stamps { type, opts, inferred, anchorKeys, makeAnchor }, and opts stays the untouched record of what the user wrote (the same object __serialize.opts reads, so mutating it would corrupt the emitted IR and make an inferred split look user-specified). The connector's mark closure reads the split off inferred only — there's no opts.by to check anymore — and resolves dir = opts.dir ?? inferred.dir. The by-split-vs-plain-bag dispatch that used to happen at createRelationalMark call time happens inside the closure, at bag-arrival time: the computation can only run after the mark is constructed (it needs the rest of the flow), so the branch it feeds has to be decided later too.
The rule itself, briefly: resolve a travel axis (an explicit dir; else a data-driven h/w on the mark or its anchor tier — h puts the value in y so travel is x, and vice versa; else the innermost flow tier that positions anchors) and a path tier (the innermost tier positioning along the travel axis). along, when given, replaces this whole resolution: findTierIndexByAlong (chartBuilder.ts) scans the flow tiers for one whose by names the given field (fieldNameOf in datumProjection.ts matches a string by on itself, a field(...) accessor on .name, and never a function-form by — the design note's "Matching" clause), and throws, naming the field and the flow's available keys, if none match. The matched tier's travel axis mirrors classifyOperator's own arrangement/value split (alongTravelAxis): an arrangement tier (spread/stack) travels its own dir; anything else (a scatter) travels flow order, leaving dir unset so line/ribbon's own ?? "x" applies — an explicit opts.dir still wins over either.
The same pass writes one more cell, inferred.parameterAxis: the axis the path tier places its groups on by its own by field, when it does. It reads the path tier's fields (its axisFields, which createOperator copies onto the __arrangement tag), so scatter({ by: "year", x: "year" }) and spread({ by: "year", dir: "x" }) both report "x", and scatter({ by: "year", x: "miles" }) reports nothing. That axis draws the connection variable itself. line/ribbon pass it to connect, where the step curve lets that coordinate advance while every other one holds (a staircase on a line chart over years, straight jumps on a connected scatterplot). The smooth curves ignore it.
Either way, once the path tier index is settled, the path tier's own by orders the path and never splits; every other flow tier's by becomes one term of a synthesized composite split key (ChartBuilder's computeDefaultBy, built from splitKeyFn in datumProjection.ts — the same projection-through-GoFishRef.datum helper splitEntries uses, so string/field/function by forms behave identically to a real operator by). Each operator declares how it arranges its groups (createOperator's arrangement config, read back by chartBuilder.ts's classifyOperator), so an operator that declares nothing simply takes no part in the rule. One subtlety that declaration has to resolve, which the design note's step-3 prose doesn't spell out: a spread/stack tier's dir is the axis it lays its groups out along, so a bare fallback (no h/w, no explicit dir anywhere) resolves the travel axis to that SAME axis (walking the arrangement is the natural path); a scatter's x/y are literal per-item coordinates — a value channel exactly like h/w — so the travel axis there is the axis it does not position. Both resolutions are validated against the design note's worked examples (its own "Intended?" column), not just its prose.
The path tier's own by is also written into the cell, as inferred.along. It is the connection variable: the field the connector threads its operands along, whether along named the tier or the rule above inferred it. line and ribbon receive the cell as produce's third argument and pass the key on to Connect. Only a smooth connector reads it: it projects each operand's value of the key through its underlying rows (projectBy in datumProjection.ts, which applies a key function to each row rather than to the ref) and uses the values as the knots of its monotone curve when they are numbers in order along the run (issue #635). So a connected scatterplot threaded along year bends by years rather than by the distances between its points on screen (runKnots in connect.tsx). A line that threads a sequence's keyframes uses the keyframes' own times first, which are what its cut is made by.
A temporal connector: time.transition()
The factory takes a third argument, config, whose only key today is temporal: true — and time.transition() (src/ast/marks/time.ts) is the one mark built with it. A temporal connector is a relational mark in every respect that matters here: it consumes a run of already-placed marks, owns no size claim, and gets its split from the complement of its path tier. What it cannot do is infer its path tier from the spatial arrangement, because the tier it threads (time.sequence(...)) positions nothing in x or y. So applyDefaultRelational resolves along from the flow's time tier (findTimeTier, reading the __timeTier tag a sequence stamps on its operator) when the fusable is temporal, and the rest of the rule runs unchanged: the time tier orders the run, every other tier splits, and the Gapminder animation gets one moving dot per country without an option being written.
What the connector moves is the whole keyed mark. The tween node behind it (src/ast/graphicalOperators/tween.tsx) builds one run per leaf of each keyframe mark, counting the label Texts the label pass attached to it, and pairs the leaves across keyframes by position. So a labeled bar in a bar chart race carries its name with it.
A keyed mark may also be missing from some keyframes, as a brand is from the years it is not in a top ten. The time tier carries the sequence's keyframes (knots), and between two neighboring keyframes the mark moves if it has a row at both, fades in place (held at its keyframe, opacity ramping over the stretch) if it has a row at only one, and is absent if it has neither. Options to restyle that enter and exit are not built. With no sequence in the flow the run's own knots count as neighbors, so the mark bridges its gaps.
The clock reaches the mark the same way the split does. produce now receives the inferred cell as a third argument, so time.transition's produce reads inferred.time.clock() during resolve — which is what registers the playhead as a pipeline dependency and makes the chart re-resolve per emitted value. The cell therefore moved up one scope inside the factory (it used to be declared inside the bag branch), since every call form now passes it to produce.
The paint fix from the same design note rides along for free: split and plain-bag now share one code path in createRelationalMark's bag-form mark, so resolveGroupFill runs on both — per group on the split branch (where it's a no-op safety net, since each group is homogeneous by construction), and over the whole bag as one group on the plain-bag branch, where it now throws a loud, specific error if a field-valued fill disagrees across the bag instead of silently painting whatever the first row happens to have.
Prior art
createMark is most directly inspired by Encodable (Wongsuphasawat, IEEE VIS 2020 — paper, code). Encodable's createEncoderFactory({ channelTypes, defaultEncoding }) produces an Encoder that the component author uses internally; users of the component supply encoding specs (field, scale, format) that the encoder resolves into rendering parameters. The shape map is one-to-one:
| Encodable | createMark |
|---|---|
createEncoderFactory({ channelTypes }) | createMark(shapeFn, channels) |
channelTypes: { x: "X", color: "Color" } | channels: { w: "size", fill: "color" } |
Encoder returned to component author | Mark<T> returned, called per datum |
ChannelEncoder parses field/literal | inferSize/inferPos/inferColor parse the value |
GoFish's twist is that a mark also produces a node in a layout AST rather than a render directly, and the channel set is smaller (size, pos, color) — Encodable's vega-lite-flavored channel taxonomy is richer. The Operator Factory extends the same pattern to layout operators (split + per-partition application). Operator channels add one layout-only wrinkle: entry-position channels may opt into categorical discrete placement, which produces layout slots rather than datum-scaled positions.
Pointers
- The factory:
src/ast/withGoFish.ts:419. - The channel helpers (
inferSize,inferPos,inferColor) and theDeriveMarkPropsconditional:src/ast/channels.ts. - The five existing call sites:
rect,ellipse,petal,text,imageinsrc/ast/shapes/. - The companion factory for layout operators: The Operator Factory.
- The factory's optional
serializeconfig (third argument) tags the produced mark with__serializemetadata that the frontend-IR emitter reads — see Frontend IR (Serialization). - Encodable: paper arxiv:2009.00722, source github.com/kristw/encodable.
