Analytics
The charts layer and the mini BI kit on one subpath — what they are, how they talk to each other, what you install, and why none of it is on the root barrel.
@kanzo-tech/ui/analytics is a subpath, not a folder. Everything on it speaks to a live DuckDB
relation through Mosaic: a part declares the query it wants, the
coordinator runs it, and a brush on one chart filters every other part on the page without either
of them knowing about the other.
It holds two layers, and the second is made of the first:
- The grammar —
ChartRoot, about sixty marks, the interactors, the inputs andChartStat: Observable Plot's vocabulary as JSX, compiled to onevg.plot(...)per root. Charts teaches it and the gallery draws every mark. - The BI kit —
Dashboardand its parts. A relation you have not seen, read from what its data says of it — a graph's description and counts, or oneSUMMARIZE— drawn as a filter row, tiles, chart cards and the rows, and kept as a JSON spec the host saves. Dashboard teaches it.
The mental model
Three things exist on a page that uses this subpath, and every part is one of them or reads one of them.
One engine, one provider
The engine owns the connection to DuckDB and, inside it, the Mosaic coordinator: the query cache
and the clients (anything that asks it for data). You ask for it — engine() boots DuckDB-WASM in the
tab and hands it back — and pass it to MosaicProvider. The provider never boots one, which is
what keeps DuckDB out of every Server Component, and no host holds the coordinator.
const e = await engine();
<MosaicProvider engine={e} onFailure={(error) => report(error)}>
{/* every chart, input, stat and dashboard on the page */}
</MosaicProvider>One engine per page. The provider registers its coordinator as vgplot's active one, and that setter is process-wide: a second coordinator leaves the first set of charts empty. Several providers may share the one engine, each with its own selections — that is how the examples on these pages stay independent of each other.
onFailure is the single path a failed query takes to the host. Every chart, stat, input and
dashboard under the provider calls it with the thrown value, whole — DuckDB's error, or engine()'s
Problem with its code — and also says it failed in its own frame, so a failed part is never a
blank one.
One selection
A Mosaic selection is a set of clauses; a clause is one part's filter — a predicate, the
values behind it, and the source that published it. The provider owns the page's one selection, a
crossfilter that every part filters by and publishes into; nobody passes one. Crossfilter resolution
means each part sees every clause except its own: brush a histogram and the others narrow, while
the histogram keeps drawing the whole distribution under its brush. A chart dims its own bars after a
pick with ChartHighlight, which reads that chart's own clauses —
the long version.
A clause says which relation it was published on, and a part which relation it reads; given the
provider's joins, a clause on one relation reaches a part on another crossed by key, or keeps none
of it where there is no way across — across relations.
The two operations a page-level control wants are Mosaic's own:
| Call | What it clears |
|---|---|
useMosaic().selection.reset() | every clause on the page, each source called back so its brush or pick clears |
useMosaic().selection.reset([clause]) | that clause only — what a chip's remove button does |
Roots, marks, interactors
A ChartRoot is one plot. Its children are descriptors — inert elements that render no DOM:
- marks (
ChartBarY,ChartLineY,ChartRectY,ChartDot, …) say what to draw, with channels that are column names or SQL aggregates (x="hour" y={count()}); - interactors (
ChartToggleY,ChartIntervalX,ChartHighlight, …) say what a gesture publishes, or what a selection dims; - axes and facets (
ChartAxisX,ChartFacetX) are plot attributes.
The root reads its children's props and compiles one plot. Mosaic turns each mark into SQL —
SELECT bin("hour") AS "x", count(*) AS "y" FROM sightings WHERE … GROUP BY "x" — so the browser
holds aggregates, never rows.
What publishes into the crossfilter
Anything can, and a dashboard's chips show all of it, whoever published it:
- a chart's interactor: a pick (
region IN ('Saltmere')), a brush (hour BETWEEN 2 AND 5); - an input:
ChartFilter,ChartSearch,ChartSlider, which are Mosaic clients without being charts; - a view drawn outside the plot.
@kanzo-tech/graphis one more client of the coordinator, drawing a GraphAr graph on the GPU; a lasso or a click publishes oneclauseSemiJoinon the graph's identity (dense_id) from that client, so the crossfilter skips it for the graph and applies it to every chart. The same call is open to any panel, with the keys or with a statement that selects them:selection.update(clauseSemiJoin("dense_id", ids | query, { source, label })). Retract it withnull, never[]: an empty list is a clause whose predicate isFALSE, which drops every row.
That is the clause rule. A clause is column predicates on the same relation, which only a
relation with those columns can answer — why
a part of your own says which relation it reads — or a
semi-join on identity, key IN (…), which names the key alone: a subquery's columns are another
relation's. The page's selection writes the second form itself: a column clause published on one
relation reaches a part on another as a semi-join on the key they share. A dashboard over a vertex
table follows a lasso on the canvas beside it because the semi-join names dense_id, and every
vertex table has one.
The graph view
has the rule and what would reverse it.
Why it is not on the root barrel
import { Button } from "@kanzo-tech/ui" has to work for a host that has never heard of DuckDB.
A single static import of an optional peer from the root barrel breaks that for everyone —
not with a warning, with ERR_MODULE_NOT_FOUND at load.
So the database half lives behind /analytics, and the door is held shut by a test that packs the
real tarball, installs it with none of the optional peers, and imports the root barrel. That check is
the only one that sees the built artefact rather than the source, and it is the reason this page can
state the boundary as a fact rather than an intention.
The parts that take a number and import no engine stay on the root barrel: Stat with StatTrend
and StatDelta, Table, Card. ChartStat and DashboardStat are their connected halves —
the engine rule.
What to install
The peers are optional in the manifest and required in practice — optional means "npm will not install them for you", not "the code works without them".
pnpm add @kanzo-tech/ui @kanzo-tech/mosaic @uwdata/mosaic-core @uwdata/mosaic-sql @uwdata/vgplot @uwdata/mosaic-plot valibot @tanstack/react-queryA page that reads a relation's fields — Dashboard, useFieldStats, and the parts an Ask answer
draws — reads them as a TanStack Query query on your
QueryClient, so one QueryClientProvider sits above it. It is the one you already render if your
app uses TanStack Query; the library brings no client of its own
(why). Without it those hooks throw TanStack's own No QueryClient set. The
charts on their own read through Mosaic and need none of it.
const queries = new QueryClient();
<QueryClientProvider client={queries}>
<MosaicProvider engine={e}>
<Dashboard relation="sightings" />
</MosaicProvider>
</QueryClientProvider>@kanzo-tech/mosaic is the one door to Mosaic: the engine, the selections, the clauses,
Query and the aggregates are imported from it, and /analytics holds the charts and re-exports
none of it. mosaic-core and mosaic-sql are its peers. valibot checks a dashboard spec.
DuckDB-WASM is not among them. The database in the tab is
engine(), and @kanzo-tech/mosaic depends on the
exact @duckdb/duckdb-wasm release it boots, so it arrives with the library.
If one is missing you find out at import time, which is the good failure. The bad one — a chart that
renders empty and says nothing — is what onFailure and the in-frame failure exist to rule out.
Reaching the data directly
useChartQuery runs a query beside the charts and hands back rows, re-run under the crossfilter —
the supported way to read a relation without drawing it. A widget that runs a plain
coordinator.query() in an effect reports unfiltered totals next to filtered charts, which reads as
a bug in the crossfilter. useFieldStats is the other read the kit is built on: a relation's
columns, classified, read once per relation for the whole page on your QueryClient. And what a page reads once and draws as it is — a schema, a catalog — is
useQueryRows, which suspends on the statement.
Charts
The grammar: roots, marks, interactors, selections, and what fails silently.
Gallery
Every mark, drawn, with the code that drew it.
Dashboard
The mini BI kit: a relation's fields, a filter row, tiles, chart cards and the rows.
Dashboard spec
The JSON a host saves, field by field, and how to persist it.
Host recipe
Wiring a Dashboard to a backend: debounced saves, read-only, your own vocabulary.
Pitfalls
The failures that point somewhere other than their cause.
Graph
The sibling package on the same crossfilter — a GPU canvas instead of a plot.