Dashboard
A dashboard over any relation for finding what it holds — a filter bar and tiles (figures, charts, tables), chosen from the relation's stats, edited where they sit, and kept as a JSON spec the host saves.
Dashboard is the charts grammar composed for a relation you have not seen. It reads the
relation's fields from one DuckDB SUMMARIZE, draws a dashboard from them — or from a spec you
saved — and lets the reader edit every tile of it. The host brings three things: a
MosaicProvider, the relation, and somewhere to keep the spec. The relation can be a table or a
join over a graph of types, and the dashboard cannot tell the two apart.
The references are Grafana's panel model — tiles of a kind in an ordered layout, each with a width —
edited in place the way Notion and Linear edit a block, Metabase's filter bar drawn as Linear's chips,
Metabase's X-ray for the dashboard a relation gets before anybody has edited one, and Looker's
explores and Malloy's sources for a relation that joins. What it leaves out is their query layer:
every tile reads the one relation the Dashboard is given, under one crossfilter.
Usage
import { Dashboard, MosaicProvider, type DashboardSpec } from "@kanzo-tech/ui/analytics";
function Sightings({ coordinator, saved, save }: Props) {
return (
<MosaicProvider coordinator={coordinator}>
<Dashboard table="sightings" value={saved} onChange={save} rowNoun="sightings" />
</MosaicProvider>
);
}valueisundefineduntil somebody edits: the automatic dashboard is drawn, and a host persists nothing for a relation nobody has touched.onChangemakes it editable. It is called with the whole next spec — the automatic parts included, the first time — so the host stores what it is handed and never merges. Reset to automatic calls it withundefined: the host deletes what it stored, and the dashboard follows the stats again —undefinedmeaning automatic in both directions, as Metabase's X-ray stays unsaved until it is saved.- Without
onChangeit is read-only: no pencils, no Add tile, no remove buttons. Filters, picks and brushes still work; reading is not editing.
Wiring that to a backend — a debounced save, a read-only role, your own labels and colours — is the host recipe.
Anatomy
Dashboard — reads the fields, draws the spec or the automatic one
├── DashboardFilters — the chip bar, its readout, "Clear", "Add tile"
├── DashboardStat — a `stat` tile
├── ChartCard — a `chart` tile
├── DetailTable — inside a `table` tile: the rows under the selection, paged by DuckDB
└── TileEditor — the popover a tile is added and edited in, anchored to the tileThe figures come first, as one band whose row they share equally, wrapping when they run out of
room: a figure is one number and its trend, and beside a chart it would stand as tall as the chart.
Charts and tables flow left to right through a three-column grid beneath it, each as wide as its
span. Every part is
exported and works on its own inside a MosaicProvider; Dashboard is what they make when the spec
decides the arrangement. Arranging the parts yourself is the other
way.
How a relation is read
useFieldStats(table) runs one SUMMARIZE — over SELECT * FROM the relation, built with
mosaic-sql, so a join is summarized like a table — and classifies every column twice — by kind, from
its DuckDB type through Mosaic's own jsType (so a column is numeric here exactly when it is numeric
to every mark), and by role, from its name and its approximate distinct count.
| Kind | DuckDB types |
|---|---|
numeric | every integer width up to HUGEINT, signed or not; DOUBLE, FLOAT, REAL, DECIMAL |
temporal | DATE, TIME, TIMESTAMP, TIMESTAMP_MS, TIMESTAMP_NS, TIMESTAMP WITH TIME ZONE |
categorical | VARCHAR, ENUM, UUID, JSON, BOOLEAN |
| — not a field | lists, arrays, structs, maps, blobs, GEOMETRY, and types Mosaic does not know (INTERVAL, BIT, UHUGEINT, TIMESTAMP_S, TIME WITH TIME ZONE) |
The last three read as a number and two times, and are still not fields: every vgplot mark runs
Mosaic's jsType on its columns before it queries, and throws on them. Cast such a column in the
relation you hand over — ts::TIMESTAMP — and it is a field like any other.
| Role | When | Used as |
|---|---|---|
identifier | named id, *_id, *Id, uri or iri; or text with more than 50 values and at least half as many values as rows | searched, never grouped |
measure | every other number | aggregated, binned, either side of a scatter |
dimension | every time; text with ≤ 50 values, or fewer values than half the rows | grouped, filtered, a series, a panel |
The filter control and the charts a field can be follow from those two words alone:
| Field | Filter control | Charts it can be |
|---|---|---|
| A time | timeline — a brushable histogram over time, first in the row | the axis of a line, an area or a histogram; a tile's trend |
| A number (measure) | ChartSlider, a range | an axis, a measure, either side of a scatter; a tile's trend |
| A category (dimension) | ChartFilter, a facet filter with counts | bars; series (2–8 values); panels (2–6 values) |
| A key (identifier text) | ChartSearch, a match over what you type | — |
A numeric key (dense_id) | none | — |
Past eight series a chart wants to be a table, not more hues; past six panels small multiples stop being comparable. Those are the dataviz limits, and the editors enforce them.
A relation with a time, and one control of every kind — the timeline over closed, facet filters,
a search over id and a slider over reward:
A column named like a plot channel — x, y, x1, x2, y1, y2, fx, fy, z, fill,
stroke — is never a field, and the plots read the relation without it. A layout's coordinates on a
graph corpus are the usual case. Why, and what it costs.
The automatic dashboard
autoDashboard(fields) is the spec drawn while value is undefined — Metabase's X-ray, from the
stats alone and nothing else:
- Filters (at most six): the first time, up to three categories, the first text key, up to two measures.
- Figures (the band): a row count and the mean of up to two measures, each trending along the first time when there is one.
- Charts (at most six): the first six that
recommendproposes for an overview. - A table, full width: the first eight fields — categories, then times, measures and keys.
The last tile of a row is widened so no row ends in a hole.
It is exported for a host that wants the automatic spec without the component — to seed a saved dashboard, or to diff against one.
recommend
recommend(fields, intent?) is every chart a table of rules proposes for those fields, heaviest
first. It is CompassQL's and Draco's pipeline — enumerate what every rule matches, rank it,
explain each — with the weights set by hand and no solver, since a rule reads at most two
fields. Each answer is { spec, rationale, rule }: a card valid for the fields, one sentence saying
why, and the rule that proposed it. Its ids are derived from the rule and the fields, so the same
fields always give the same answer.
The rules are not the library's own: each is a case of Show Me (Mackinlay, Hanrahan and Stolte, 2007), the automatic presentation behind Tableau's chart picker, and a rule no Show Me case covers does not belong in the table. The weights are the one part with no published value.
rule | Show Me | Reads | Draws | overview | answer | At most | Rationale |
|---|---|---|---|---|---|---|---|
time-measure | Lines (continuous) | a time and a measure | line of the measure's total | — | 100 | 3 | day is a time and n a measure, so a line of total n over time |
category-measure | Bars | a category and a measure | bar of the measure's total | — | 90 | 3 | type has 3 values and n is a measure, so a bar of total n per value |
time | Lines (continuous) | a time | line of counts, two thirds wide | 100 | 50 | 1 | seen is a time, so a line of counts along it |
category | Bars | a category of 2 or more values | bar of counts | 80 | 40 | 3 | type has 3 values, so a bar of counts per value |
measure | Histogram | a measure | histogram | 60 | 30 | 2 | bounty is a measure, so a histogram of how its values spread |
measure-pair | Scatter plot, with a trend line | two measures | regression of one on the other, two thirds wide | 40 | 70 | 1 | bounty and leagues are both measures, so a fit of one against the other |
intentis"overview"(the default) for a dashboard over raw rows — what each field does on its own — or"answer"for one chart of a result set, whose rows are usually aggregated already, so a measure beside what it was grouped by comes first. A rule with no weight under an intent is not proposed for it.- Ties keep the table's order, and a rule takes fields in the relation's order, up to its at most. A key is never drawn, and neither is a category with one value.
- Add tile opens the editor on the best chart of the first field a rule draws on its own, preferring a field the dashboard does not chart yet.
A card says why it is there. autoDashboard records the rule behind each card it proposes as
the card's origin ({ rule }), and while a card has one, ChartCard puts that rule's rationale
before the interaction hint and an Automatic badge beside the title. Edit the type, a field, the
measure, a series, a panel or the title and the origin goes, and both with it; the width is layout
and does not count. A card added by hand has no origin, whatever the rules would say of it. The
origin is part of the saved spec, and the sentence is read from the rule over the card's own fields
on every render.
The parts
DashboardFilters
One filter bar above everything it scopes — Metabase's filter bar, drawn as Linear's chips. Each
filters entry names a field and is a chip reading region: Saltmere ▾, or region: Any ▾ while
it filters nothing. The chip opens its control, chosen from that field's stats and never written in
the spec: a category is a facet list, a key a search, a measure a range slider, a time a brushable
timeline. A field that has no control (a numeric key) or no longer exists is skipped. A control
stays mounted while its popover is shut, because a control that unmounts retracts its clause.
After the chips, the readout: 12 of 40 sightings (rowNoun is the plural), and a quiet
Clear while anything on the page filters, which is useMosaic().reset(). Children trail it — it
is where Dashboard puts Add tile. With onChange, each chip gets a remove button and + Filter
lists the fields that have a control and no filter yet. A clause something else published — a pick
on a chart, a lasso — counts in the readout and goes with Clear; FilterChips is the part that
lists every clause, for a host that wants them named.
DashboardStat
A stat tile: a Stat whose figure is ChartStat — the measure, re-queried under the crossfilter. With a
trend it adds the same measure along that field as a StatTrend sparkline, and a StatDelta
comparing the last step with the one before.
- A time trend is binned into about two dozen steps; a number with at most 60 values is its own step (the hours of a day are 24 already), and a longer one is binned like a histogram.
- The delta is in the step's own unit — vs previous closed, vs previous hour — because a relation
without dates has no "last quarter" to compare with. A
sharemeasure's delta is inpp. goodWhenUp: falsepaints a rise as bad news: a hoax rate, an error count.
Figures format by measure: a share as 12.5%, everything else to at most one decimal, compact
past 10,000.
ChartCard
A chart tile, drawn. The chart is a ChartRoot with the
marks the type calls for and the interactor its scale allows — pick on a band, brush on a range,
the rule that keeps a brush off a band scale.
type | Label | Draws | x | y | Interaction |
|---|---|---|---|---|---|
bar | Bar | horizontal bars, the 12 largest groups | a category | a measure | click a bar |
line | Line | a line; a time or a number with > 60 values is binned | a time or a number | a measure | drag a range |
area | Area | an area, stacked by series | a time or a number | a measure | drag a range |
histogram | Histogram | binned columns | a time or a number | a measure | drag a range |
dot | Scatter | one dot per row | a number | a number (op: "value") | drag a box |
regression | Fit | the dots, a least-squares line and its 95% band | a number | a number (op: "value") | drag a box |
Series (color) is a category of 2–8 values, on every type but regression. Panels
(facet) is a category of 2–6 values, on every type but bar: a facet is an encoding, not a chart
type — any line, area, histogram or scatter splits into small multiples sharing one pair of scales,
which is how Vega-Lite models it too. The domains are chosen over the whole relation — the top
bars by the measure, the top series and panels by count — so a filter never reorders a bar or
repaints a survivor.
Without a series, the whole relation stays drawn behind the selection in grey: the context a
filtered chart otherwise loses. A card with an origin shows
why, under an Automatic badge. A card that names a field the relation no longer has draws The
relation has no hall any more. in place of the chart.
With onEdit, the header carries a pencil, and so do a figure's and a table's: what it opens is the
host's, and in Dashboard it is the TileEditor.
DetailTable
The rows behind the charts: one page at a time, sorted and counted by DuckDB, so a relation of any
size costs one page of DOM. A header click cycles ascending, descending, unsorted; a new selection
returns to page one. Its columns are a table tile's, chosen in the editor.
It is the root barrel's Table, not /table's: TanStack's row models sort and page in the browser,
and here the browser never holds more than the page.
TileEditor
The one editor — a popover anchored to the tile it edits, the way Notion and Linear edit a block where it sits. Add tile and every tile's pencil open the same popover, in this order: the kind as cards with an icon, like a chart's mark (Figure, Chart, Table — a kind the relation cannot draw is disabled), the fields that kind reads, the title, and — for a chart or a table — the width (a third, two thirds, full), with the position among the figures or among the grid's tiles. The tile is the preview: it draws the draft in its own slot under the page's crossfilter, and grows or shrinks with the width; a tile being added gets a slot at the end of its band or grid, brought into view as the popover opens. Nothing reaches the dashboard until Add or Save; Cancel, Escape and a click outside drop the draft. A new position applies on save, so the tile never moves under the popover.
TileEditor draws no tile. Hand it the draft as tile with onChange, draw that draft in the
tile's own view, and pass the view's element as anchor; mount the editor while it is open. The view
stays mounted from the pencil to Save, so opening the editor does not rebuild its plot.
Dashboard loads it the first time somebody edits, so a read-only dashboard never fetches the
editor's code.
- A chart asks for its mark — a mark the relation cannot draw is disabled —
x, the measure, series and panels. Every change goes through the same normalisation: an encoding the new mark can keep is kept, one it cannot is replaced by the first field that fits, and an optional one that fits nothing is dropped. A written title describes the encodings it was written for, so changing them retires it. - A figure asks for the measure and the field it trends along.
- A table asks for its columns; a newly ticked column goes last.
- Changing the kind carries what carries over — a chart's aggregate becomes the figure, a figure's measure the chart's — and keeps the id, and the width between a chart and a table. A figure that becomes a chart moves from the band to the grid, and back.
Each kind is a row in three tables — how one is made, titled and converted (plain TypeScript, no React), how it is drawn, and what its editor asks for — each mapped over the kinds, so a kind missing from any of them does not compile and neither the board nor the editor switches on a kind.
FilterChips and useClauses
useClauses(selection) is a selection's live clause list. FilterChips draws it as removable
badges — the crossfilter by default — and renders nothing while it is empty. A chip's label is read
from the clause: hour 2 – 5 for a range, region Saltmere for one value, beast · 3 selected for
more. Removing one calls retract, so the pick that made it un-highlights too.
Because they read Selection.clauses, they report filters they did not publish: a brush, a bar
someone clicked, a lasso on a graph canvas. A semi-join wears its publisher's label — Lasso · 13
selected, a rule's name — so <FilterChips /> in a dock's header is the page's scope: every clause
each client there is filtered by, each removable where it was published.
useFieldStats, queryFieldStats and fieldStats
useFieldStats(table, { exclude }) returns { fields, columns, error }: fields are the
classified FieldStats, columns every column of the relation in its own order, fields or not.
Both are null until the summary lands, and it re-asks when the relation or exclude changes —
compared by value, so an inline array is fine. error is the thrown value, whole and typed
unknown, and it is also handed to the provider's onFailure; narrow it before reading .message.
queryFieldStats(coordinator, table, options) is the same read as a promise resolving to
{ fields, columns }, for a host outside React or one that needs the fields before it renders.
fieldStats(rows, options) is the classification on its own: SUMMARIZE rows in, the same
{ fields, columns } out, nothing queried. queryFieldStats is SUMMARIZE on the coordinator and
then this. Reach for it when the host runs the summary itself — on its own connection, or with an
AbortSignal the coordinator does not take. A fossil corpus answers with its rows as arrays, so
they are zipped with the column names first:
const summary = await corpus.sql(`SUMMARIZE ${relation}`, { signal });
const rows = summary.rows.map((row) => Object.fromEntries(summary.columns.map((name, i) => [name, row[i]])));
const { fields, columns } = fieldStats(rows as SummarizeRow[], { exclude: ["dense_id"] });A row is a plain object under DuckDB's column names — column_name, column_type,
approx_unique, min, max, count — with values as strings, numbers or bigints, the way any
DuckDB client returns them.
plotRelation
plotRelation(table, { fields, columns }) returns { table, fields } as the plots can read them:
the relation itself, or a projection without the columns named like a plot channel, and the fields
without those. Dashboard calls it on its own summary; a host placing the parts over a relation
with a layout's x/y calls it on useFieldStats' answer and hands the parts both halves —
why.
exclude is for the host's bookkeeping — a key, an internal identity, a layout's coordinates under
names that are not channels. An excluded column is still in the relation (a clause on it still
applies); it is just never offered as a field.
Arranging the parts yourself
The parts take the same table and fields. A tile is editable when it is given an onEdit, and
what that opens is the host's — here, the same TileEditor Dashboard uses, on the bar chart alone:
Dashboard does two things the parts do not: it drops the channel-named columns from the relation
and from the fields, and it owns the spec. A host composing parts over a relation with an x column
does the first itself — how.
Interaction
Every part is on the provider's crossfilter. A pick on a bar chart filters every other tile, and dims
the rest of its own bars instead of filtering itself away. A brush on a line, a histogram or the
timeline filters by a range. The readout counts what survives. A clause published by something else
on the page — a ChartFilter you placed above the dashboard, a lasso on a graph — is applied the
same way, and Clear retracts it too.
With publish, the tiles crossfilter each other in a selection of the dashboard's own, and the page
sees one clause instead: the one publish maps them to. Everything above still holds inside the
dashboard, and the page's clauses still reach every tile — see what the page
sees.
Relations
The table can be any relation, and a relation that joins is one more TableExpr. A relation is
a root type and the hops taken from it — Looker's explore, Malloy's source — over a join graph:
types, each a table with a key, and the edge tables between them. A type on its own is a relation
with no hops, so there is no second path for it.
import { Dashboard, RelationPicker, relationIdentities, relationKey, relationQuery } from "@kanzo-tech/ui/analytics";
<RelationPicker graph={graph} value={relation} onValueChange={setRelation} />
<Dashboard
key={relationKey(graph, relation)}
table={relationQuery(graph, relation)}
exclude={relationIdentities(graph, relation).map((i) => i.column)}
value={byRelation[relationKey(graph, relation)]}
/>relationQuery(graph, relation)is one mosaic-sqlQuery: every type along the path joined through its edge tables, every column named by its step —Person.country, andPerson2.countrywhen the path visits a type twice. The root's key keeps its own name.relationKey(graph, relation)is the canonical identity a saved dashboard is keyed by:Person,Person>knows>Person,Person<hasCreator<Post.relationIdentities(graph, relation)is each step's key column and the type it identifies: what a clause on the relation crosses to anything else keyed the same way with, as a semi-join on identity. The root's key is the relation's own, so aclauseSemiJoinon it — a lasso on a graph — filters a joined relation exactly as it filters the root's table.relationHops(graph, type)lists the hops that leave a type, out-edges then in-edges.RelationPickeris built on it: the root as a select, each hop as label → type, the last hop removable, and + Hop offering the edges that leave the last type.
What the page sees
A tile's clause on a relation names the relation's columns — "Person.country" IN ('ES') — and
nothing else on the page has them: a graph beside the dashboard would refuse it as
graph/unfilterable, and another relation's dashboard would fail to bind it. So a relation's
dashboard publishes in the page's vocabulary instead, a semi-join on the root's key:
import { semiJoinOf } from "@kanzo-tech/ui/analytics";
const table = useMemo(() => relationQuery(graph, relation), [graph, relation]);
const publish = useMemo(() => semiJoinOf(relationIdentities(graph, relation)[0].column, table, { label: key }), [graph, relation, table, key]);
<Dashboard key={key} table={table} publish={publish} … />The tiles publish into the dashboard's own crossfilter and filter by it, so they crossfilter each
other on the relation's columns exactly as before. The page gets one clause for all of them,
dense_id IN (SELECT dense_id FROM <relation> WHERE <every tile's clause>), whose source is the
dashboard: the graph greys out every vertex but the roots of the rows the tiles keep, another
relation's dashboard is filtered to the same vertices through its own root key, and FilterChips
names it by label. The clauses are mapped together, not one by one — on a joined relation,
a person with a row where a and b is not a person with a row where a, and one where b.
Retracting goes both ways. A tile that lets go withdraws the page's clause; removing the page's clause — its chip, the page's Clear — clears the tiles' brushes and picks. In the other direction, a lasso on the graph reaches every tile as itself, so the graph stays exempt from its own pick and is not filtered by it back through the dashboard.
semiJoinOf is one map among any: publish takes a ClauseMap, (clauses, source) => clause,
and the bridge that carries it is @kanzo-tech/mosaic's bridgeSelection(inner, outer, map),
built on mosaic-core's public Selection API alone. Without publish, a
dashboard's clauses are the page's, which is right for a dashboard over one table beside charts over
the same table.
The JoinGraph is plain data a reader builds from its own catalog — JoinType (name, table,
key, columns) and JoinEdge (name, label, source, destination, table, and the src
and dst columns holding each end's key). For a fossil corpus, @kanzo-tech/graph's
readJoinGraph builds it from the catalog the graph already reads.
Series vocabulary
config maps a field to a ChartConfig, for a field drawn as color that has meanings of its own —
a status column wearing the status colours and their icons:
<Dashboard
table="sightings"
config={{
verdict: {
confirmed: { label: "Confirmed", color: "var(--success)", icon: CheckCircle2Icon },
hoax: { label: "Hoax", color: "var(--destructive)", icon: TriangleAlertIcon },
},
}}
/>A configured field is drawn in the config's order and colours, with its labels in the legend. A field without one takes the categorical scheme in order of frequency over the whole relation, so a filter never repaints a survivor.
When the relation cannot be read
A failed SUMMARIZE — a relation that does not exist, a catalog that is not attached — draws an
alert, The relation could not be summarized, with DuckDB's message, and reports the error to the
provider's onFailure. A failed card, tile or input says so in its own frame and reports the same
way. Nothing fails blank.
API Reference
Dashboard
| Prop | Type | Default |
|---|---|---|
table | TableExpr — a name, a mosaic-sql node for a relation in another catalog, or a relationQuery | — |
value | DashboardSpec | the automatic dashboard |
onChange | (spec: DashboardSpec) => void | — (read-only without it) |
exclude | readonly string[] | — |
config | Readonly<Record<string, ChartConfig>> | — |
rowNoun | string | "rows" |
publish | ClauseMap — what the page sees of the tiles' clauses; semiJoinOf for a relation | — (the tiles' clauses are the page's) |
Every other prop goes to the root div (data-slot="dashboard").
DashboardFilters
| Prop | Type | Default |
|---|---|---|
table | TableExpr | — |
fields | readonly FieldStat[] | — |
filters | readonly DashboardFilterSpec[] | — |
onChange | (filters: DashboardFilterSpec[]) => void | — (no remove, no + Filter) |
rowNoun | string | "rows" |
children | ReactNode | — trailing controls, after Clear |
DashboardStat
| Prop | Type | Default |
|---|---|---|
table | TableExpr | — |
fields | readonly FieldStat[] | — |
stat | StatTile | — |
onEdit | () => void | — (no pencil) |
ChartCard
| Prop | Type | Default |
|---|---|---|
table | TableExpr | — |
fields | readonly FieldStat[] | — |
card | ChartTile | — |
config | Readonly<Record<string, ChartConfig>> | — |
onEdit | () => void | — (no pencil) |
DetailTable
| Prop | Type | Default |
|---|---|---|
table | TableExpr | — |
fields | readonly FieldStat[] | — |
columns | readonly string[] | — in order; a name that is not a field is skipped |
pageSize | number | 25 |
TileEditor
| Prop | Type | Default |
|---|---|---|
table | TableExpr | — what the tile reads |
fields | readonly FieldStat[] | — |
tile | Tile | — the tile to edit, or to add when tiles does not hold it. Mounted is open |
tiles | readonly Tile[] | [] — where the tile sits, and what a change of kind avoids repeating |
config | Readonly<Record<string, ChartConfig>> | — |
onSave | (tile: Tile, index: number) => void | — |
onRemove | () => void | — (no Remove) |
onClose | () => void | — Cancel, Escape and a click outside |
RelationPicker
| Prop | Type | Default |
|---|---|---|
graph | JoinGraph | — |
value | Relation | — |
onValueChange | (relation: Relation) => void | — |
function relationQuery(graph: JoinGraph, relation: Relation): Query;
function relationKey(graph: Pick<JoinGraph, "edges">, relation: Relation): string;
function relationIdentities(graph: JoinGraph, relation: Relation): { column: string; type: string }[];
function relationHops(graph: Pick<JoinGraph, "edges">, type: string): RelationHop[];
interface Relation { root: string; path: readonly Hop[] }
interface Hop { edge: string; direction: "out" | "in" }
interface RelationHop { hop: Hop; label: string; to: string }
interface JoinGraph { types: readonly JoinType[]; edges: readonly JoinEdge[] }
interface JoinType { name: string; table: TableExpr; key: string; columns: readonly string[] }
interface JoinEdge { name: string; label: string; source: string; destination: string; table: TableExpr; src: string; dst: string }FilterChips
| Prop | Type | Default |
|---|---|---|
selection | Selection | the provider's crossfilter |
useClauses(selection: Selection): readonly SelectionClause[].
Field stats
function useFieldStats(table: TableExpr, options?: FieldStatsOptions): FieldStatsState;
function queryFieldStats(coordinator: Coordinator, table: TableExpr, options?: FieldStatsOptions): Promise<FieldStats>;
function fieldStats(rows: readonly SummarizeRow[], options?: FieldStatsOptions): FieldStats;
function autoDashboard(fields: readonly FieldStat[]): DashboardSpec;
function recommend(fields: readonly FieldStat[], intent?: RecommendIntent): Recommendation[];
type RecommendIntent = "overview" | "answer"; // default "overview"
interface Recommendation { spec: ChartTile; rationale: string; rule: string }
interface FieldStat {
name: string;
type: string; // DuckDB's type, as SUMMARIZE reports it
kind: FieldKind; // "numeric" | "temporal" | "categorical"
role: FieldRole; // "dimension" | "measure" | "identifier"
distinct: number; // approx_unique: an estimate
min?: number; // the extent of a numeric or temporal field —
max?: number; // a temporal one in epoch milliseconds
}
interface FieldStatsOptions { exclude?: readonly string[] }
interface FieldStats { fields: FieldStat[]; columns: string[] }
interface FieldStatsState { fields: FieldStat[] | null; columns: string[] | null; error: unknown }
interface SummarizeRow {
column_name: unknown; column_type: unknown; approx_unique: unknown;
min: unknown; max: unknown; count: unknown;
}The spec's types — DashboardSpec, Tile, TileKind, TileSpan, StatTile, ChartTile,
TableTile, DashboardFilterSpec, DashboardMeasure, DashboardChartType and DashboardAggregate —
and Dashboards with parseDashboard and parseDashboards are exported and documented field by field on
Dashboard spec. The props types are exported as DashboardProps,
DashboardFiltersProps, DashboardStatProps, ChartCardProps, DetailTableProps,
TileEditorProps, RelationPickerProps and FilterChipsProps.
Chart gallery
Sixteen chart types written with the charts grammar — each one a ChartRoot and a handful of marks, none of them a component.
Dashboard spec
The JSON a Dashboard is drawn from and hands back on every edit — every field, what it accepts, how an invalid one is treated, how a host keys it by relation, and the one way to read it back.