Charts
A thin composable layer over Mosaic/vgplot — marks, interactors and axes as JSX, over a bring-your-own DuckDB coordinator.
This is a grammar, not a set of chart types. ChartRoot compiles its children into one
vg.plot(...), so a bar and a line share a plot, any interactor pairs with any mark, and the
chart you need is the one you write — there is no BarChart to fork.
import { ChartRoot, ChartBarY, ChartToggleX, count } from "@kanzo-tech/ui/analytics";Like CodeEditor with CodeMirror, the library wraps the engine and
never owns the runtime: it is bring-your-own-coordinator. The package never imports DuckDB-WASM
and never builds a Coordinator — you wire one in a "use client" island and pass it in, which is
what keeps the analytics stack out of the base bundle and out of every React Server Component.
@uwdata/vgplot, @uwdata/mosaic-core, @uwdata/mosaic-sql and @duckdb/duckdb-wasm are all
optional peers, so import { Button } keeps working for everyone who has not installed them.
Four plots over one table, wired to one shared crossfilter Selection: drag an interval
across the histogram or the hourly line, click a region bar, drag a box on the scatter — each
publishes to the selection, so brushing any one filters the other three. Nothing above is a chart
component; it is four ChartRoots with two marks and an interactor each.
Why a grammar
vgplot is ~40 marks, 19 interactors and ~250 attributes. A chart component — Histogram,
BarChart, ScatterPlot — exposes that as one photograph of it, and assembles the plot inside
itself, so the first thing it cannot do needs a fork: a line on top of bars, a brush where the
preset hardcoded a toggle, two marks against a selection you own.
A grammar has no such edge. The proof that it is expressive enough is that every shape a preset would have covered is ten lines of JSX you can copy.
Two kinds of children
ChartRoot takes children of two different natures, and the distinction is worth internalising
because it explains everything else on this page:
| What they are | Idiom | |
|---|---|---|
| marks, interactors, axes | inert descriptors — they render null | Recharts / Observable Plot |
ChartRoot, ChartLegend, your own parts | real DOM — ark.*, data-slot, asChild | Ark |
A descriptor has no DOM of its own. ChartRoot reads the compile function off the element type,
in source order, and turns the lot into a single vg.plot(...) — the mark never renders.
Marks cannot be Ark parts, and that is not a shortcut. vgplot paints imperatively: it returns
an SVG node that we mount with host.replaceChildren(...). There is no element for a part to own,
no ref to forward, no data-slot to style. What is genuinely Ark here is ChartRoot plus
useChartContext() — a context carrying the config, the selections and the coordinator, so
you can build a legend, a tooltip or a readout of your own without forking anything.
The practical consequence: descriptors cannot read context, because they are compiled before React
would render them. Pass values down from the component that owns the ChartRoot.
Anatomy
<ChartRoot table="sightings" config={config} height={220}>
<ChartBarY x="host" y={count()} fill="host" tip />
<ChartRuleY at={0} />
<ChartAxisX tickRotate={45} />
<ChartAxisY grid />
<ChartToggleX />
<ChartLegend /> {/* real DOM: reads the config off the context */}
</ChartRoot>Three rules cover the ordering:
- An interactor binds to the last mark declared before it. Put
ChartToggleXafter the mark it should drive. - Axes compile to plot attributes (
xTickRotate,yGrid), not to vgplot'saxisX/axisYdecorator marks — an attribute cannot steal the binding from the interactor that follows it.ChartFrame,ChartGridXandChartGridYare marks and do shift it. - Fragments and arrays flatten;
nullandfalsevanish. Conditionals work, which is how you swap an interactor at runtime.
There is no ChartTip: the tooltip is a mark option, so it is the tip prop on any mark.
A descriptor must be a direct child, not a child of one of your components. The compiler walks
the element tree — it never renders it — so it sees <YourThing /> and stops, exactly as Recharts
does. That includes Show: a <Show> element's type is Show, not a
Fragment, so whatever it wraps is silently never plotted. Conditional marks and interactors are
the one place in these docs that stays && or a ternary — those compile to false, and rule 3 is
what makes them vanish cleanly.
Installation
The charts live on the @kanzo-tech/ui/analytics subpath, never the root barrel, because the
Mosaic/DuckDB stack is heavy and optional. Install the peers alongside the library:
pnpm add @uwdata/vgplot @uwdata/mosaic-core @uwdata/mosaic-sql @duckdb/duckdb-wasmBring your own coordinator
The library never touches DuckDB-WASM. You build the Coordinator — over wasmConnector(), a
socket/REST connector, or a shared worker — and hand it to MosaicProvider, which registers it as
vgplot's active coordinator and shares a pair of Selections with every chart beneath it: one the
interactors publish into, one the marks filter by (why two).
DuckDB-WASM is browser-only, so boot it in a "use client" island loaded with ssr: false, render
a skeleton until it is ready, and let the chart hydrate:
"use client";
import { useEffect, useState } from "react";
import { Coordinator, MosaicProvider, loadObjects, wasmConnector } from "@kanzo-tech/ui/analytics";
export default function Analytics({ children }: { children: React.ReactNode }) {
const [coordinator, setCoordinator] = useState<Coordinator | null>(null);
useEffect(() => {
const coord = new Coordinator(wasmConnector());
coord.exec(loadObjects("sightings", rows)).then(() => setCoordinator(coord));
}, []);
if (!coordinator) return <Skeleton className="h-40 w-full" />;
return <MosaicProvider coordinator={coordinator}>{children}</MosaicProvider>;
}Coordinator, Selection and wasmConnector are re-exported from the subpath, along with the
loaders (loadObjects, loadCSV, loadJSON, loadParquet, loadSpatial, loadExtension), so
you wire the backend without a direct @uwdata import. vgplot's own coordinator() setter is
not re-exported: MosaicProvider is the only thing that should ever call it.
One coordinator per page. MosaicProvider registers its coordinator as vgplot's active one,
and that setter is process-wide: mount two providers with two different coordinators on the same
page and the last one wins, leaving the other page's charts empty. Share a single instance (every
example below does — see docs/examples/charts/mosaic-boot.tsx), or keep the demos on separate
routes. Several MosaicProviders over the same coordinator are fine; each still gets its own
crossfilter Selection.
The five charts
The five shapes a chart library is expected to ship, written in the grammar. Each is the same: a
dimmed mark for the full relation (filterBy={null}), the crossfiltered mark on top, one
interactor, two axes. Sixteen more shapes — waffle, ridgeline, hexbin, box plot, small multiples —
are in the gallery.
Histogram
bin() a numeric or temporal column into rectY, and drag an interval into the selection.
Categorical bars
Group a string column, sort by descending count, click a bar to toggle that category. Give both
layers the same sort (and limit, for a high-cardinality column) so they share one x domain. The
ChartHighlight — no props — is what makes the clicked bar visible, and there is a reason it can
be that terse: see feedback on the chart that filters.
Line over an ordered axis
A line is stroked, not filled, so the token lands on stroke. The area underneath is simply a
second mark — "line + area" was never a chart type, it was two marks.
Scatter
Two numeric columns and a 2-D brush.
Stacked series
Map fill to a column and the bars stack by it — the layer copies a column-valued fill to
z, which is the series key Plot stacks on and the one thing Mosaic cannot infer for it
(the details). The config is what binds a hue to
an entity: it pins the plot's colour domain, so filtering a series never repaints the survivors,
and the same object feeds ChartLegend.
Feedback on the chart that filters
Click a bar and every chart filters, including a visible response on the chart you clicked —
<ChartToggleX /> and <ChartHighlight />, neither carrying a prop, exactly as in
categorical bars above. That is the default. It is worth knowing what is
underneath it, because one misplaced prop takes it away.
A crossfilter is defined by hiding a clause from the client that published it:
Selection.crossfilter()is created withcross: true.- The resolver skips a clause for any client listed in
clause.clients, and a toggle's clause lists the marks of the plot it came from. That skip is what stops a chart filtering itself into a single bar the moment you click one. ChartHighlightresolves its predicate withselection.predicate(mark)— without thenoSkipflag thatfilterByuses. So against a crossfilter the predicate comes back empty, the test degrades to "everything matches", and nothing is dimmed.
The chart that publishes the click is therefore the only one that cannot read it back. The fix is
one selection more, not a fork — publish into a non-cross selection and relay it onward — and
ChartRoot builds that selection for you, one per chart:
// inside every ChartRoot, for you
const own = Selection.union(); // interactors publish here; ChartHighlight reads it
// relayed on, into the provider's `selected`, and from there into the crossfilter the marks usePer chart, not per page, and that part is load-bearing. ChartHighlight does not filter: it appends
its predicate to the mark's own query as an extra output column, and on an aggregating mark that
query has a GROUP BY. So a predicate naming a column this plot does not group by is
Binder Error: column "provider" must appear in the GROUP BY clause — the query dies, vgplot
swallows it, and the plot keeps its previous render. No error on screen, no empty state, just a
chart frozen one state behind while its neighbours move.
A page-wide publish target guarantees that the moment a second chart filters by a second column, which is the ordinary case. A per-chart selection cannot: the only clauses in it are the ones this plot's own interactors published, over the columns its own marks group by.
useMosaic() gives you crossfilter (what marks filter by), selected (every chart clause on the
page, flattened — a read model for a filter-chip row, not a publish target) and reset().
Use reset() for a "Clear filters" button: Selection.reset() travels downstream only, so
resetting the crossfilter alone leaves every chart still holding its own pick.
Two legends, and only one of them is a control
ChartLegend is ours and never publishes anything: an ark.ul you place where you like, reading
the root's config, with the label — not the swatch — carrying the accessible channel.
ChartColorLegend is vgplot's, drawn inside the plot, and it both publishes on click and reads the
selection back, dropping unselected swatches to 0.2 opacity.
The difference that looks like a detail and is not: its toggle is built with peers: false, so its
clause is not hidden from the plot it belongs to. Click a swatch and that plot filters itself —
where <ChartToggleColor /> on the mark is skipped by the crossfilter for its own marks, which is
the whole of the section above.
Both legends only stay complete because the config pins the colour domain. Without it the scale
is ordered by the data, so filtering to one verdict leaves the legend holding one swatch.
When you do wire it yourself
- You pass your own
crossfiltertoMosaicProvider.includeis constructor-only, so a selection handed in cannot be relayed into after the fact — the provider relays into it instead of constructing it, and everything below keeps working. - You pass your own
asto aChartRoot. Then you own its wiring: nothing is relayed for you, thoughreset()still clears it. Pair it with a matchingbyon the highlight, and keep to the rule above — one selection per plot, naming only columns that plot groups by. - A click should replace the previous pick, not add to it.
Selection.single()keeps one clause where the default union accumulates (shift/⌘-click adding to a pick isToggle's own behaviour, not ours).
Notes that hold either way:
ChartHighlightbinds to the last mark declared before it, like every interactor. In the dual-layer idiom that is the crossfiltered layer, which is the one you want dimmed.- Interval brushes need none of this: the brush rectangle is its own feedback.
What the presets could not do
Bars and a line in one plot
Two marks, one x scale, one interactor. Under five preset components this needed a sixth component; here it is one more child.
The interactor is a ChartToggleX, and where it sits matters twice over. It binds to the last mark
before it, so it goes between the bars and the line; and it is a toggle rather than a brush because
ChartBarY makes x a band scale even for an integer hour — an interval over a band throws, and
takes the rest of the page down with it (why). Bin the x, or toggle it.
Swap the interactor
Click-to-toggle and drag-to-brush differ by one child. A false child compiles to nothing, so the
plot is rebuilt with the other interactor and the marks stay exactly as written. Removing an
interactor does not withdraw the clause it already published, so the example calls
selection.reset() on the swap.
Bring your own selection
The provider's crossfilter is a default, not a cage. Own the Selection and wire it explicitly:
as on the chart that publishes, filterBy on the ones that read. Everything else on the page
stays still.
An unwrapped mark
The layer wraps every mark vgplot ships but six: axisX, axisY, axisFx, axisFy, gridFx and
gridFy, left out on purpose because axes compile to attributes and an axis mark
would steal the binding from the interactor after it. A plot therefore draws one x axis — and the
scale repeated on the opposite edge, which a long bar list wants, is exactly what ChartRaw is for.
Both anchors are spelled out in the example, because Plot adds its implicit axis only while the plot
has no axis mark for that scale: declare one and you own them all. The scale stays ChartAxisX's —
grid and label come from the descriptor, and the marks inherit them.
A raw directive is otherwise a mark like any other: it takes its place in source order (declare it
after the interactor you did not want it to capture), and it is a function, so hoist it or the
plot rebuilds on every render. An array is fine — spec takes one directive or several.
ChartConfig
Record<string, { label?, color?, icon? }> — series key to presentation, in one place:
const config = {
ok: { label: "OK", color: "var(--chart-1)" },
error: { label: "Error", color: "var(--chart-5)" },
} satisfies ChartConfig;A non-empty config pins the plot's colour scale (colorDomain = the keys, colorRange = the
colours) and feeds ChartLegend. Pinning matters: without it vgplot orders the colour domain by the
data, so filtering out a series repaints the ones that survive.
color takes var(--chart-1), --primary, a hex or an rgb(). Tokens are resolved against the
live DOM at mount and on every theme change, because Observable Plot cannot parse oklch,
color-mix or color(srgb …). The same resolution applies to the fill and stroke props of any
mark — those two channels only, and only when the value looks like a token, so fill="region" stays
the column region. A series without a colour falls back to its slot in the categorical set — as
the token var(--chart-N), not as a baked hex, so one answer follows both the light/dark flip and
whichever client is being served.
chartSeriesColor(config, key) is exported for anything outside the plot that has to agree with it
— a chip beside a row, a heading, a sparkline of your own. It answers what the chart answered:
color if the series declared one, its categorical slot if it did not, and undefined for a key
the config does not carry.
The categorical set belongs to the tenant's palette document,
not to this layer: N hues written into --chart-1..8 by whoever authored the theme. A chart reads the token
and that is the only copy. A second source of truth in this layer — eight literal hexes, or a
table of them — makes the same series come out one colour through ChartConfig and another
through a token.
A theme need not author them: tokens.css declares eight on :root, chosen to clear contrast and
separation on every background the catalogue ships, so a theme that writes none still has a chart
channel and one that writes some overrides them slot by slot.
CHART_SLOTS is 8 because a stylesheet cannot have a variable number of custom properties. How many of those slots carry a real category is the theme's --chart-capacity, which can be lower: past
it compile writes var(--muted-foreground), because naming five categories honestly beats naming
eight that a colour-blind reader sees as five. Past the eighth series, fold into "Other" —
categoricalColor(i) returns the muted token rather than cycling, because a ninth series wearing
slot 1 would claim to be the first one.
useChartContext()
The context behind any DOM part you add — a legend, a tooltip, a caption:
function Total({ rows }: { rows: number }) {
const { color, formatNumber } = useChartContext();
return <p style={{ color: color("hoax") }}>{formatNumber(rows)} sightings</p>;
}| Field | What it is |
|---|---|
color(key) | the series colour, already resolved to rgb(...) — safe in CSS and in Plot |
formatNumber(value, options?) | the chart's Intl.NumberFormat, from the root's locale / numberFormat — one number vocabulary for axes, legend and tooltip |
config, table | what the root was given |
filterBy, as | the selections the marks read and the interactors write |
coordinator | for a part that queries alongside the plot |
It throws outside a ChartRoot; useChartContextOptional() returns null instead, which is how
ChartLegend also works standalone. Descriptors cannot use it — see
two kinds of children.
Escape hatches
Three, because wrapping vgplot must stay a convenience:
-
<ChartRaw spec={…} />— avg.*directive (or an array of them) in its source position, with the same interactor-binding semantics as a wrapped mark. What it is mostly reached for is one of the six axis marks the axis descriptors compile away from:axisX({ anchor: "top" })to repeat the measure scale at the top of a long bar list,axisFyto label a facet. Those six are re-exported from the subpath. No token resolution happens inside a raw spec, so pass a Plot-safe colour —resolveTokenColor(host, "--chart-1")is exported for exactly this, and reads the live cascade rather than a frozen copy of it. -
attributesonChartRoot— raw directives applied last, so they win over anything the layer decided. For the ~225 plot attributes the axes do not cover. -
The Mosaic client protocol, for a view that is not a plot at all — a WebGL canvas, a map, an imperative widget. Subclass
MosaicClientor wrapmakeClient, declare a query, and publish one of the five clauses:clausePoint,clausePoints,clauseInterval,clauseIntervals,clauseMatch. That is the whole of it, and it is what makes such a view a peer of the plots rather than a readout drifting beside them. All seven are re-exported from the subpath, so this costs no direct@uwdataimport.Turning the answer back into values is the half the protocol does not give you, and it is where every client independently writes
as { getChild(name: string): … }— a cast that asserts Arrow's shape rather than checking it, and is wrong the first time a query selects a string.column,fillColumnandnumbersare exported for that: Arrow only offers a typed column when the type allows one, so the fallback is not a nicety.
Neither hatch needs a source builder: ChartRoot already owns the plot(…) call, and a mark's
data / filterBy props already name its relation. Anything past that — an expression builder, a
plot attribute — still comes from @uwdata/vgplot directly, which is what a hatch is.
Both are keyed by identity: a vgplot directive is a function, so building one inline rebuilds the
plot on every render. Hoist it to module scope or useMemo it, and keep the attributes array
referentially stable.
Limitations
- A descriptor wrapped in your own component is invisible.
ChartRootinspects its children's element types, so<MyBar />— even when it returns<ChartBarY />— compiles to nothing. This is Recharts' limitation and it has the same cause. Write a function that returns descriptors and spread them, or make your component wrap the wholeChartRoot, as the examples do. - Descriptors cannot read React context, for the same reason.
- The plot rebuilds when the grammar changes. Props are fingerprinted (SQL expressions by their
SQL, so a fresh
count()is free); functions and selections by identity. - An interval brush needs a continuous scale.
ChartIntervalXover a band scale — bars grouped by a string, or by an integer Plot bands — throwsUnrecognized scale type: bandinside Mosaic's pre-aggregator the moment the pointer enters the plot. The pre-aggregator belongs to the coordinator, so that one mistake blanks the other charts on the page, which report nothing of their own. Brush binned or continuous marks; toggle categorical ones.ChartRootwarns to the console in development when it compiles the pairing, because the runtime symptom points anywhere but at the chart that caused it.
Dashboards
A dashboard is not only plots, and the pieces divide by whether they need the engine.
ChartFilter, ChartSearch and ChartSlider are real DOM controls that publish Mosaic clauses
without being charts, so they filter every plot below them. ChartStat is a headline figure read
from the relation under the same crossfilter, and useChartQuery is what it is built on — reach
for it whenever something that is not a plot has to move with the brush. A widget that runs a plain
coordinator.query() in an effect will sit there reporting unfiltered totals next to filtered
charts, which reads as a bug in the crossfilter.
ChartFilter is a FacetFilter over a GROUP BY, and it takes that
component's searchable. Know what the field covers before you turn it on: it narrows the fetched
page — the limit most frequent values the control asked for, 50 by default — and asks nothing new
of DuckDB. When the page is truncated, both the footnote and the no-match message say so, because a
field that answers no matching values about a database it never queried is worse than no field.
Searching the column itself is ChartSearch, which publishes a match clause and does run a query.
ChartSearch is a filter, not a picker — the clause is a match over whatever you typed, and the
values it offers are completions. It asks for the autocompleteLimit distinct values of the column
(50 by default), narrows those in the browser as you type, and says when that list is capped;
picking one publishes exactly the clause typing it would have. Set autocompleteLimit={0} for a
plain search field with no lookup at all.
Its presentational half, StatTile, is in the root barrel — it
takes a number, so making it cost DuckDB-WASM would be absurd. That split is
the engine rule, the same one behind Table and
DataTableRoot.
The frame and the grid are not library components: a titled card and an auto-fitting grid add
no behaviour, so they are arrangements you copy. The ones these docs use are in docs/lib/. The
gallery wires the whole thing together.
Both halves of the pair, side by side, because that is the only way to see why there are two of them. Click a region bar and watch which numbers move — and which one does not:
Theming
vgplot hard-codes its fills; these charts read tokens off the live DOM instead, so a theme change
re-colours the marks along with everything else — the vgplot analogue of how kanzoHighlighting
re-skins CodeMirror. Axis text inherits --foreground through currentColor, so it stays legible
in light and dark. Tokens are resolved against the chart's own host element, so a scoped theme
override wins.
The compile surface, for a mark this layer does not ship
Every chart child is inert: it renders null and carries its compilation on a static. That
shape is ChartDescriptor<P> and the function on it is ChartCompile<P> — (props, ctx) in,
directives out — so a host that needs a mark this layer does not wrap writes one child rather than
a fork.
What comes out is a ChartDirective, and it is plain data in all four of its cases: a
ChartMarkDirective, a ChartInteractorDirective, a ChartAttributeDirective, or a
ChartRawDirective, which is the escape hatch — a vg.* value the layer does not wrap, passed
through untouched. What goes in is ChartSpecContext, which carries the relation every table-backed
mark reads from; ChartSpecOptions extends ChartFacetOptions with the rest of what a spec needs,
and ChartMargin is the four numbers around it.
The mark's own shapes are ChartMarkProps and ChartMarkSource — data comes from the table, from a
literal array, or from a query — and ChartInteractorProps is the interactor equivalent. A series
is configured with ChartSeriesConfig and read back as a ChartSeriesEntry, a facet with
ChartFacetOptions, and a filter offers ChartFilterOptions.
Two of these are not about charts at all and are exported because the inputs are. MosaicInputOptions<T>
and MosaicInputState<T> are what a control needs to be a Mosaic client — the shape behind every
filter input on this page — and IdSetClientOptions is the same for a client that publishes a set
of ids rather than a range. ExprValue is anything Mosaic will accept where an expression is
expected, and NumericArray is the union of typed arrays a column may arrive in:
Float32Array | Float64Array | Uint32Array | Uint16Array | Int32Array.
API Reference
MosaicProvider
| Prop | Type | Default |
|---|---|---|
coordinator | Coordinator | — |
crossfilter | Selection | a fresh Selection.crossfilter({ include: selected }) |
useMosaic() returns { coordinator, crossfilter, selected }; useCrossfilter() and
useSelected() return one half each. All three throw outside a MosaicProvider. Passing your own
crossfilter collapses selected onto it —
what that costs.
ChartRoot
| Prop | Type | Default |
|---|---|---|
table | string | — (omit it when every mark carries its own data) |
filterBy | Selection | null | the provider's crossfilter; null = unfiltered |
as | Selection | the provider's selected, relayed into that crossfilter |
config | ChartConfig | {} |
height | number | 200 (width is measured from the container) |
margin | number | { top, right, bottom, left } | vgplot's default |
aspectRatio | number | — |
attributes | readonly unknown[] | — |
locale | string | the runtime locale |
numberFormat | Intl.NumberFormatOptions | — |
className / plotClassName | string | — |
Marks
Props are the vgplot / Observable Plot mark options (x, y, z, r, fill, stroke,
opacity, sort, limit, inset, …) plus the common sugar: filterBy (this mark's selection,
null for the full relation), data (literal rows instead of the table), at (position a rule or
tick without a table), and tip.
One option is filled in for you. On the stacking marks — ChartBarY/X, ChartArea/Y/X,
ChartRect/Y/X, ChartWaffleY/X — a column-valued fill or stroke is copied to z
unless you pass one, because that is Plot's own default and Mosaic breaks it
(why). z={null} opts out.
ChartBarY ChartBarX ChartWaffleY ChartWaffleX ChartLine ChartLineY ChartLineX
ChartArea ChartAreaY ChartAreaX ChartDot ChartDotX ChartDotY ChartCircle
ChartHexagon ChartImage ChartRect ChartRectY ChartRectX ChartCell ChartCellX
ChartCellY ChartRuleY ChartRuleX ChartTickX ChartTickY ChartText ChartTextX
ChartTextY ChartVector ChartVectorX ChartVectorY ChartSpike ChartArrow ChartLink
ChartHeatmap ChartRaster ChartRasterTile ChartContour ChartDenseLine ChartDensity
ChartDensityY ChartDensityX ChartHexbin ChartRegressionY ChartErrorbarY ChartErrorbarX
ChartVoronoi ChartVoronoiMesh ChartDelaunayLink ChartDelaunayMesh ChartHull ChartGeo ·
decorators: ChartFrame ChartGridX ChartGridY ChartHexgrid ChartSphere ChartGraticule ·
escape hatch: ChartRaw.
Six of vgplot's marks are deliberately absent — axisX, axisY, axisFx, axisFy, gridFx,
gridFy — because they are attributes here. ChartRaw still takes them.
Interactors
as chooses where the interaction publishes (default: the root's as, which is the provider's
selected). Everything else is the vgplot interactor option — peers, brush, pixelSize,
empty, …
ChartIntervalX ChartIntervalY ChartIntervalXY ChartToggleX ChartToggleY
ChartToggleColor ChartNearestX ChartNearestY ChartRegion ChartPanZoom
Six of these answer to two names, and both are real. pick and brush say what the gesture
is — click a category, drag a range — where vgplot's toggle and interval say how the machine
models it. The pairs are the same object, not wrappers:
| Gesture | vgplot's spelling |
|---|---|
ChartPickX ChartPickY ChartPickColor | ChartToggleX ChartToggleY ChartToggleColor |
ChartBrushX ChartBrushY ChartBrushXY | ChartIntervalX ChartIntervalY ChartIntervalXY |
Reach for the gesture name when you are writing a chart; the vgplot name is there so anyone arriving from Mosaic's own documentation lands on the same component. This table exists because the alias block claimed the docs taught these and they did not — both spellings were live in code, one of them undocumented, which is how a reader ends up believing they are two different interactors.
ChartHighlight is the odd one out: it reads a selection (by) and dims the mark before it
rather than publishing. It cannot read a crossfilter's own clause, which is why by defaults to
selected and not to the crossfilter (the whole
story). ChartPanZoom carries no selection at all — it
rewrites the scales.
Axes
ChartAxisX / ChartAxisY, each compiling to prefixed plot attributes:
| Prop | Type |
|---|---|
anchor | "top" | "bottom" | null (X) · "left" | "right" | null (Y) — null hides the axis, keeps the scale |
label | string | null |
ticks | count, interval name, or explicit values |
tickFormat tickSize tickRotate | |
grid line zero percent nice reverse | boolean |
domain scale inset padding |
Anything else goes through ChartRoot's attributes.
ChartFacetX and ChartFacetY are the same descriptor over the fx and fy scales — the ones a
faceted plot puts its small multiples on. Same props, minus the four that only mean something on a
measure: a facet is always a band, so scale, nice, zero and percent are not there. anchor
still hides the axis while keeping the panels.
Legends
ChartLegend— our DOM (ark.ul,data-slot="chart-legend"). Reads the root'sconfig, or takes its ownconfig/seriesprop and works standalone. The label carries identity; the key is aSwatch, so it isaria-hidden— colour is never the only channel.ChartColorLegend— vgplot's, drawn inside the plot and interactive: clicking a swatch publishes into the selection. Passas={null}for a static key.
Helpers
Re-exported so a whole chart is written without a direct @uwdata import: the aggregates
count sum avg min max median quantile stddev mode bin sql; the six axis
marks the descriptors withhold on purpose, axisX axisY axisFx axisFy gridFx gridFy,
which are what ChartRaw is for; the boot vocabulary Coordinator Selection
wasmConnector and the load* loaders.
A set is re-exported when it is closed and named. vgplot's ~250 plot attributes and mosaic-sql's expression builders are open, so they stay a direct import — taking them raw is what an escape hatch is.
For a descriptor of your own, chartDescriptor(displayName, compile) mints one; compile returns
plain data ({ kind: "mark", mark: "barY", source, options }), never a vg.* call.
Every part's props are exported as an interface — ChartAxisXProps, ChartAxisYProps,
ChartColorLegendProps, ChartFacetXProps, ChartFacetYProps, ChartFilterProps,
ChartHighlightProps, ChartLegendProps, ChartPanZoomProps, ChartRawProps, ChartRootProps,
ChartSearchProps, ChartSliderProps, ChartStatProps and MosaicProviderProps — so a wrapper
can take the same props without restating them.