Early alpha.
chart-studioandchart-studio-uiare active work-in-progress packages. APIs, package structure, and behavior may change without much notice. They are not recommended for production use yet.
Composable charting for React with two adoption paths, plus optional devtools:
- use the headless core if you want chart state, filtering, grouping, and transformed data
- use the optional UI layer if you also want ready-made controls and a Recharts canvas
- add devtools in development if you want a visual panel for your data model and datasets
Choose the path that matches your app:
Headless core (@matthieumordrel/chart-studio) |
Ready-made UI (@matthieumordrel/chart-studio-ui) |
Devtools (@matthieumordrel/chart-studio-devtools) |
|
|---|---|---|---|
| Description | Chart state and data plumbing without a bundled UI; you bring your own controls and chart renderer. | Headless runtime plus @matthieumordrel/chart-studio-ui so the package can render controls and a Recharts canvas. |
Dev-only panel that renders an interactive graph of your data model (datasets, relationships, associations, materialized views) and row-level data for inspection. |
| What you get | useChart; optional schema via defineDataset(...).chart(...); transformed chart data; filtering, grouping, metrics, and time bucketing. |
Everything from the headless column; <Chart>, <ChartToolbar>, <ChartCanvas>; granular controls from @matthieumordrel/chart-studio-ui. |
ChartStudioDevtools from @matthieumordrel/chart-studio-devtools/react; graph layout, virtualized tables, search, and context/issue inspection. |
| Requirements | react >= 18.2.0 |
react >= 18.2.0, react-dom >= 18.2.0, recharts >= 3.0.0 (v2 is not supported), lucide-react >= 0.577.0, tailwindcss >= 4.0.0 |
chart-studio headless layer and ui-layer in the project. |
| When to use | You already have a design system or chart library and only need chart state and data plumbing. | You want ready-made controls and a Recharts-based canvas without building that UI yourself. | You use defineDataModel / dashboard-style models and want to debug or explore schema and data during development. |
bun add @matthieumordrel/chart-studio reactTo install from the alpha dist-tag instead (when you publish prereleases there):
bun add @matthieumordrel/chart-studio@alpha reactimport { useChart } from '@matthieumordrel/chart-studio'Uses @matthieumordrel/chart-studio for the headless runtime and @matthieumordrel/chart-studio-ui for the optional React UI layer.
bun add @matthieumordrel/chart-studio @matthieumordrel/chart-studio-ui react react-dom recharts lucide-react tailwindcssPrereleases published under the alpha dist-tag: add @alpha to both package names. Default npm installs use latest.
Import the package theme once in your app stylesheet:
@import 'tailwindcss';
@import '@matthieumordrel/chart-studio-ui/theme.css';import { useChart } from '@matthieumordrel/chart-studio'
import { Chart, ChartToolbar, ChartCanvas } from '@matthieumordrel/chart-studio-ui'Install as a dev dependency; chart-studio, chart-studio-ui, and their peers should already match your app.
bun add -D @matthieumordrel/chart-studio-devtoolsimport { ChartStudioDevtools } from '@matthieumordrel/chart-studio-devtools/react'See packages/chart-studio-devtools/README.md for getSnapshot, subscribe, and props.
import { useChart } from '@matthieumordrel/chart-studio'
import { Chart, ChartToolbar, ChartCanvas } from '@matthieumordrel/chart-studio-ui'
import { data } from './data.json'
export function JobsChart() {
const chart = useChart({ data })
return (
<Chart chart={chart}>
<ChartToolbar />
<ChartCanvas height={320} />
</Chart>
)
}- Pass your raw data to
useChart(). - Add an optional
schemawithdefineDataset<Row>().chart(...)when you need labels, type overrides, derived columns, or control restrictions (allowed metrics, groupings, chart types, etc.). - Either render your own UI from the returned state, or use the components from
@matthieumordrel/chart-studio-ui.
For the simple case, the public contract is:
useChart({ data })stays the zero-config pathuseChart({ data, schema })is the explicit single-chart pathdefineDataset<Row>().chart(...)is the single explicit way to build that schema.columns(...)is the authoring entry point: override raw fields, exclude fields, and add derived columns- raw fields you do not mention in
.columns(...)still infer normally unless you exclude them xAxis,groupBy,filters,metric,chartType,timeBucket, andconnectNullsrestrict that one chart's public controlsinputsis an additive escape hatch for externally controlled data-scope state; it does not replace the simpleuseChart({ data })oruseChart({ data, schema })path- pass the builder directly to
useChart(...), or call.build()if you need the plain schema object
There are two different scaling paths in chart-studio:
Stay on the single-chart path when each chart reads one flat row shape, even if one screen renders several unrelated charts.
Use:
useChart({ data })for zero-config chartsdefineDataset<Row>().chart(...)when one chart owns its own explicit contractdefineDataset<Row>()when several charts should reuse one row contractuseChart({ sources: [...] })only for source-switching inside one chart
Stop here when:
- charts do not need relationship-aware shared filters
- datasets are already flattened for the charts that consume them
- each chart can execute honestly against one dataset at a time
Move up to the model + dashboard path when several datasets are structurally related and you want shared filters, referential validation, or safe lookup-preserving cross-dataset fields.
Recommended path for new dashboard work:
defineDataModel().dataset(...).infer(...)model.chart(...)for lookup-preserving chartsmodel.materialize(...)only when the chart grain changesdefineDashboard(model)useDashboard(...)
Use defineDataset<Row>().chart(...) when one chart owns its own explicit contract:
const schema = defineDataset<Job>()
.columns((c) => [
c.date('createdAt'),
c.category('ownerName'),
c.number('salary')
])
.chart()
.xAxis((x) => x.allowed('createdAt'))
.metric((m) => m.aggregate('salary', 'sum'))
const chart = useChart({ data: jobs, schema })This is the single explicit path for declaring a chart schema. The dataset
owns the column contract, and .chart(...) layers chart-specific control
restrictions on top.
Use defineDataset<Row>() when several charts should share one .columns(...)
contract and one optional declared key:
import { defineDataset, useChart } from '@matthieumordrel/chart-studio'
const jobs = defineDataset<Job>()
.key('id')
.columns((c) => [
c.field('id'),
c.field('ownerId'),
c.date('createdAt', { label: 'Created' }),
c.number('salary', { format: 'currency' }),
c.derived.category('salaryBand', {
label: 'Salary Band',
accessor: (row) => (row.salary >= 100_000 ? 'High' : 'Base')
})
])
const jobsByMonth = jobs
.chart('jobsByMonth')
.xAxis((x) => x.allowed('createdAt').default('createdAt'))
.groupBy((g) => g.allowed('salaryBand'))
.metric((m) => m.count().aggregate('salary', 'sum'))
const chart = useChart({ data: jobsData, schema: jobsByMonth })Rules for the dataset-first path:
- dataset
.columns(...)is the canonical reusable meaning of columns dataset.chart(...)provides the chart-definition surface for control restrictionsdataset.chart(...)inherits dataset columns, so charts do not reopen.columns(...)- declared dataset keys can be validated at runtime with
dataset.validateData(data)orvalidateDatasetData(dataset, data)
Use defineDataModel() when datasets are related and you want the model to own
safe inference, validation, and reusable dashboard semantics:
import {
defineDataModel,
defineDataset,
} from '@matthieumordrel/chart-studio'
const hiringModel = defineDataModel()
.dataset('jobs', defineDataset<Job>()
.key('id')
.columns((c) => [
c.date('createdAt'),
c.category('status'),
c.number('salary', { format: 'currency' }),
]))
.dataset('owners', defineDataset<Owner>()
.key('id')
.columns((c) => [
c.category('name', { label: 'Owner' }),
c.category('region'),
]))
.infer({
relationships: true,
attributes: true,
})
const jobsByOwner = hiringModel.chart('jobsByOwner', (chart) =>
chart
.xAxis((x) => x.allowed('jobs.createdAt', 'jobs.owner.name').default('jobs.owner.name'))
.filters((f) => f.allowed('jobs.status', 'jobs.owner.region'))
.metric((m) =>
m
.aggregate('jobs.salary', 'avg')
.defaultAggregate('jobs.salary', 'avg'))
.chartType((t) => t.allowed('bar', 'line').default('bar'))
)
hiringModel.validateData({
jobs: jobsData,
owners: ownersData,
})What the model can infer today:
- obvious one-hop lookup relationships from one dataset into another
- reusable shared-filter attributes backed by those relationships
- safe lookup-preserving model chart fields such as
jobs.owner.name - the base dataset for a model chart when every qualified field is anchored to the same dataset id
How to prepare data for safe inference:
- declare one real key per dataset with
.key(...); single-column lookup keys work best - for the common case, use lookup datasets keyed by
idand foreign keys named<singularDatasetId>Idsuch asownerId,teacherId, orcustomerId - if a lookup key is already named
somethingId, that same field name can be inferred as a foreign key candidate on related datasets - give lookup datasets a visible label-like column such as
name,title, orlabelwhen you want inferred shared filters to feel good by default - leaving a raw field out of
.columns(...)is not exclusion; useexclude(...)only when you want that field removed from the chart contract
Important limits of the current model layer:
- inference is conservative; ambiguous candidates are ignored until you declare
.relationship(...)or suppress a false positive with.infer({ exclude: ['datasetId.columnId'] }) - many-to-many stays explicit through
association(...) - model-aware charts allow one lookup hop only; they do not infer row-expanding traversal
- if you use unqualified field ids, or fields anchored to multiple datasets,
add
.from('datasetId') validateData(...)hard-fails on duplicate declared keys, orphan foreign keys, and malformed association edges- charts still execute against one flat dataset at a time
- lookup-preserving model charts compile into the same explicit runtime core;
expanded chart grains still require
model.materialize(...) - explicit
.relationship(...),.attribute(...), and.association(...)remain available when inference is not enough - linked metrics do not exist yet
Use model.materialize(...) when one chart truly needs a flat cross-dataset
analytic grain:
const jobsWithOwner = hiringModel.materialize('jobsWithOwner', (m) =>
m
.from('jobs')
.join('owner', { relationship: 'jobs.ownerId -> owners.id' })
.grain('job')
)
const rows = jobsWithOwner.materialize({
jobs: jobsData,
owners: ownersData,
})
const chart = useChart({
data: rows,
schema: jobsWithOwner
.chart('jobsByOwner')
.xAxis((x) => x.allowed('ownerName').default('ownerName'))
.groupBy((g) => g.allowed('ownerRegion').default('ownerRegion'))
.metric((m) => m.aggregate('salary', 'sum').defaultAggregate('salary', 'sum')),
})If you need a many-to-many chart grain such as job-skill, declare an explicit
association(...) on the model and then expand through it with
.throughAssociation(...).grain(...).
Rules for materialized views:
materialize(...)is explicit; the model does not become a hidden query engine- prefer
model.chart(...)for safe lookup-preserving fields; reach formaterialize(...)when the chart grain actually changes grain(...)is required so the output row grain stays visible.join(...)is for lookup-style joins that preserve the base grain.throughRelationship(...)and.throughAssociation(...)are the explicit row-expanding paths- many-to-many flattening stays visible because
association(...)andthroughAssociation(...)are both opt-in - related-table columns reuse the linked dataset definitions, so you do not need repeated per-dataset derived columns like
ownerName,ownerRegion, orskillName - the materialized view is chartable like a normal dataset and exposes
materialize(data)for explicit reuse and caching
Use defineDashboard(model) when several reusable charts belong to one
dashboard:
import {
DashboardProvider,
defineDashboard,
useDashboard,
useDashboardChart,
} from '@matthieumordrel/chart-studio'
const hiringDashboard = defineDashboard(hiringModel)
.chart('jobsByOwner', jobsByOwner)
.sharedFilter('owner')
.build()
function HiringOverview() {
const dashboard = useDashboard({
definition: hiringDashboard,
data: {
jobs: jobsData,
owners: ownersData,
},
})
return (
<DashboardProvider dashboard={dashboard}>
<HiringChart />
</DashboardProvider>
)
}
function HiringChart() {
const jobsChart = useDashboardChart(hiringDashboard, 'jobsByOwner')
return (
<Chart chart={jobsChart}>
<ChartCanvas />
</Chart>
)
}Rules for dashboard composition:
defineDashboard(model)is intentionally thin: chart registration, shared-filter selection, and optional dashboard-local shared filters- dashboard charts may come from
dataset.chart(...),model.chart(...), ormodel.materialize(...).chart(...) - chart registration is explicit by id
useDashboard(...)is the runtime boundary; it resolves model-aware charts and explicit materialized views against real data- pass the explicit dashboard runtime into dashboard hooks, or inside a matching
DashboardProviderpass the dashboard definition useDashboardChart(...)resolves the reusable chart by id and keeps React in charge of placementuseDashboardDataset(...)exposes the globally filtered rows for non-chart consumers like KPI cards or tables
Shared dashboard filters layer on top of dashboard composition.
Reuse model-level relationship semantics when the same filter concept should work across several dashboards:
const dashboard = defineDashboard(hiringModel)
.chart('jobsByOwner', jobsByOwner)
.sharedFilter('owner')Add one-off dashboard-local filters when the concept is specific to one dashboard:
const dashboard = defineDashboard(hiringModel)
.chart('jobsByOwner', jobsByOwner)
.sharedFilter('status', {
kind: 'select',
source: { dataset: 'jobs', column: 'status' },
})
.sharedFilter('activityDate', {
kind: 'date-range',
targets: [
{ dataset: 'jobs', column: 'createdAt' },
],
})Rules for shared dashboard filters:
sharedFilter('owner')can reuse either an inferred model attribute or an explicit one- the model may infer useful attributes, but the dashboard still decides which ones become visible shared filters
- shared filters are explicit; nothing is guessed from chart configs
- shared filters narrow dataset slices before chart-local
useChart(...)filters run - local and global filters compose by intersection
- when a shared filter targets the same chart-local column, the dashboard owns that filter for that chart by default
- cross-dataset ambiguity requires an explicit model
attribute(...)or explicit target choice
| Type | What it is for |
|---|---|
date |
time-series X-axis |
category |
categorical X-axis, grouping, filtering |
boolean |
grouping, filtering |
number |
metrics such as sum, avg, min, max |
If you want to expose only a subset of groupings, metrics, chart types, or axes, use the fluent defineDataset<Row>().chart(...) builder:
import { defineDataset, useChart } from '@matthieumordrel/chart-studio'
type Row = { periodEnd: string; segment: string; revenue: number; netIncome: number }
const schema = defineDataset<Row>()
.columns((c) => [
c.date('periodEnd', { label: 'Period End' }),
c.category('segment'),
c.number('revenue'),
c.number('netIncome')
])
.chart()
.xAxis((x) => x.allowed('periodEnd'))
.groupBy((g) => g.allowed('segment'))
.metric((m) =>
m
.count()
.aggregate('revenue', 'sum', 'avg')
.aggregate('netIncome', 'sum')
)
.chartType((t) => t.allowed('bar', 'line'))
.timeBucket((tb) => tb.allowed('year', 'quarter', 'month'))
const chart = useChart({ data, schema })Why this pattern:
columnsdefines types, labels, and formats for raw fields; usec.exclude(...)to remove a column from the chart- Derived columns use
c.derived.*(...)helpers for computed values from each row xAxis,groupBy,metric,chartType, andtimeBucketrestrict the allowed options- invalid column IDs and config keys are rejected at compile time
- metric restrictions preserve the order you declare, so the first allowed metric becomes the default
If you want to render your own UI or your own charting library, use only the core state:
import { defineDataset, useChart } from '@matthieumordrel/chart-studio'
type Job = {
dateAdded: string
ownerName: string
salary: number
}
const jobSchema = defineDataset<Job>()
.columns((c) => [
c.date('dateAdded', { label: 'Date Added' }),
c.category('ownerName', { label: 'Consultant' }),
c.number('salary', { label: 'Salary' })
])
.chart()
export function JobsChartHeadless({ data }: { data: Job[] }) {
const chart = useChart({ data, schema: jobSchema })
return (
<div>
<div>Chart type: {chart.chartType}</div>
<div>Rows: {chart.transformedData.length}</div>
<pre>{JSON.stringify(chart.transformedData, null, 2)}</pre>
</div>
)
}The headless core has no styling requirements.
@matthieumordrel/chart-studio-ui is Tailwind CSS v4 based and uses semantic classes such as:
bg-backgroundtext-foregroundborder-borderbg-popovertext-muted-foreground
For those classes to render correctly, your app needs Tailwind CSS v4 wired into its build and real values behind tokens like background, foreground, border, and popover.
You can use the UI package in two ways:
This is the easiest setup:
@import 'tailwindcss';
@import '@matthieumordrel/chart-studio-ui/theme.css';This does three things for you:
- Tailwind utilities for the package components
- automatic scanning of the package UI classes
- default fallback values for all semantic UI tokens
- built-in light and dark default themes
Because theme.css is Tailwind source, your app still needs a normal Tailwind CSS v4 integration such as @tailwindcss/vite, the PostCSS plugin, or the Tailwind CLI.
If your app already defines matching shadcn-style variables, those values take over automatically. If not, the built-in defaults are used.
The shipped theme supports dark mode through either:
.dark[data-theme="dark"]
If you do not want to import @matthieumordrel/chart-studio-ui/theme.css, you must still ensure Tailwind CSS v4 scans the package UI classes and you can provide all the required semantic tokens yourself in your app theme.
If neither of those is true, use the headless core and render your own controls.
You do not need shadcn itself to use @matthieumordrel/chart-studio-ui.
If you import @matthieumordrel/chart-studio-ui/theme.css, every token below gets a built-in fallback automatically.
If your app already defines some of these variables, your values override the defaults for those specific tokens only. Missing ones still fall back to the package defaults.
These are the tokens currently expected by the UI layer:
| Token | Purpose |
|---|---|
background |
control backgrounds and input surfaces |
foreground |
primary text |
muted |
subtle backgrounds and hover states |
muted-foreground |
secondary text and icons |
border |
outlines and separators |
popover |
dropdowns and floating panels |
popover-foreground |
popover text color |
primary |
selected and active states |
primary-foreground |
text on filled primary surfaces |
ring |
focus-visible ring color |
Minimal example:
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--border: oklch(0.922 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.708 0 0);
}How this works in practice:
- import
@matthieumordrel/chart-studio-ui/theme.cssand do nothing else: the package uses its own defaults - toggle dark mode with either
.darkor[data-theme="dark"]: the package uses its built-in dark defaults - import
@matthieumordrel/chart-studio-ui/theme.cssand define only a few variables: your values win for those variables, defaults cover the rest - skip
@matthieumordrel/chart-studio-ui/theme.css: you must define the whole token contract yourself and make sure Tailwind still sees the package classes
That makes the package usable out of the box while still being easy to theme.
Chart series colors also support shadcn-style chart variables:
| Token | Purpose |
|---|---|
chart-1 |
first series color |
chart-2 |
second series color |
chart-3 |
third series color |
chart-4 |
fourth series color |
chart-5 |
fifth series color |
These are also optional when you import @matthieumordrel/chart-studio-ui/theme.css.
If your app defines --chart-1 through --chart-5, those colors are used automatically.
If they are not defined, chart-studio falls back to a built-in OKLCH palette, with separate light and dark defaults. That is why you may see blue, rose, cyan, or other fallback colors in charts when your app does not provide chart variables.
Minimal example:
:root {
--chart-1: oklch(0.62 0.19 260);
--chart-2: oklch(0.58 0.2 20);
--chart-3: oklch(0.7 0.18 145);
--chart-4: oklch(0.68 0.16 80);
--chart-5: oklch(0.6 0.15 320);
}- Use
@matthieumordrel/chart-studiofor the headless core. - Use
@matthieumordrel/chart-studio-uifor the optional UI components.
No. This is still an early alpha, under active development, and not recommended for production use yet.
Only for the UI layer. The headless core works without it.
Only for @matthieumordrel/chart-studio-ui, and the current UI package expects Tailwind CSS v4. The headless core does not require it.
Yes, but there are two different meanings:
- independent datasets: use separate
useChart(...)calls, oruseChart({ sources: [...] })when one chart should switch between flat sources - related datasets: use
defineDataModel().dataset(...).infer(...), thenmodel.chart(...)for safe lookup-preserving charts - explicit expanded grains: use
model.materialize(...) - screen-level composition and shared state: use
defineDashboard(model)plususeDashboard(...)
The current chart runtime still executes one flat dataset at a time. Multi-source source-switching is separate from linked data models, explicit materialized views, and dashboard composition.
If you are starting fresh with a dashboard, use the model-first path:
defineDataModel(...), model.chart(...) / model.materialize(...),
defineDashboard(...), and useDashboard(...).
import { defineDataset, useChart } from '@matthieumordrel/chart-studio'
const chart = useChart({
sources: [
{
id: 'jobs',
label: 'Jobs',
data: jobs,
schema: defineDataset<Job>()
.columns((c) => [c.date('dateAdded', { label: 'Date Added' })])
.chart()
},
{ id: 'candidates', label: 'Candidates', data: candidates }
]
})Each source may use defineDataset<Row>().chart(...). The chart still reads one
active source at a time, so this is not dashboard composition and not
cross-dataset execution.
Yes. Use inputs for externally controlled data-scope state:
const chart = useChart({
data: jobs,
schema,
inputs: {
filters,
onFiltersChange: setFilters,
referenceDateId,
onReferenceDateIdChange: setReferenceDateId,
dateRange,
onDateRangeChange: setDateRange
}
})Rules:
inputsonly covers data-scope state: filters, reference date, and date range- presentation controls such as
xAxis,groupBy,metric, andchartTypestay chart-local dateRangeis{ preset, customFilter }- when an input is controlled, chart setters request changes through the matching callback
chart.filtersand related date state are always the sanitized effective state for the active source- this still does not create a dashboard runtime or shared state between charts; use
defineDashboard()for that
- date X-axis:
bar,line,area - category or boolean X-axis:
bar,pie,donut pieanddonutdo not supportgroupBy
If the components render but look plain, compressed, or layout incorrectly, the most common cause is that the package theme file is not imported.
Start with:
@import 'tailwindcss';
@import '@matthieumordrel/chart-studio-ui/theme.css';Also make sure your app has Tailwind CSS v4 actually running in the build. For Vite that usually means @tailwindcss/vite; other setups should use the equivalent Tailwind integration.
If you are importing the package source directly in a local playground or monorepo, make sure Tailwind is scanning those source files too.
If your app already uses shadcn-style tokens, also make sure tokens such as background, foreground, muted, border, popover, primary, ring, and optionally chart-1 through chart-5 are defined in your theme.
All builders use immutable method chaining — each call returns a new builder so the chain is order-independent and composable.
| Method | What it does | Parameters | Type safety |
|---|---|---|---|
.key(id) |
Declare the unique row identifier | Field ID or array of field IDs | Only accepts keys of Row |
.columns(fn) |
Define column types, labels, formats, exclusions, and derived columns | Callback receiving a ColumnHelper (see below) |
Duplicate column IDs are rejected at compile time |
.chart(id?) |
Open the chart-control surface | Optional chart ID string | Returns a DatasetChartBuilder bound to the resolved columns |
.build() |
Materialize into a plain schema object | — | — |
.validateData(data) |
Runtime key-uniqueness and completeness check | Row[] |
Throws on duplicate or missing keys |
| Method | What it does | Parameters | Type safety |
|---|---|---|---|
c.field(id, opts?) |
Declare or override a raw field | Field ID, optional { type?, label?, format? } |
ID must be a key of Row |
c.date(id, opts?) |
Declare a date column | Field ID, optional { label?, format? } |
Only accepts fields whose value is string | number | Date |
c.category(id, opts?) |
Declare a categorical column | Field ID, optional { label?, format? } |
Only accepts string fields |
c.number(id, opts?) |
Declare a numeric column | Field ID, optional { label?, format? } |
Only accepts number fields |
c.boolean(id, opts?) |
Declare a boolean column | Field ID, optional { trueLabel?, falseLabel? } |
Only accepts boolean fields |
c.exclude(id) |
Remove a field from the chart contract | Field ID | ID must be a key of Row |
c.derived.date(id, opts) |
Computed date column | New ID, { accessor, label?, format? } |
ID must not collide with raw fields |
c.derived.category(id, opts) |
Computed categorical column | New ID, { accessor, label?, format? } |
Accessor receives Row, must return string |
c.derived.number(id, opts) |
Computed numeric column | New ID, { accessor, label?, format? } |
Accessor receives Row, must return number |
c.derived.boolean(id, opts) |
Computed boolean column | New ID, { accessor, label?, format? } |
Accessor receives Row, must return boolean |
These methods restrict which options users can select. They apply identically whether you reach .chart() from a dataset, a model, or a materialized view.
| Method | What it does | Parameters | Type safety |
|---|---|---|---|
.xAxis(fn) |
Restrict available X-axis columns | Callback receiving a SelectableControlBuilder |
Only date/category/boolean column IDs are offered |
.groupBy(fn) |
Restrict available grouping columns | Callback receiving a SelectableControlBuilder |
Only category/boolean column IDs are offered |
.filters(fn) |
Restrict available filter columns | Callback receiving a SelectableControlBuilder (no .default()) |
Only category/boolean column IDs are offered |
.metric(fn) |
Restrict available metrics | Callback receiving a MetricBuilder (see below) |
Only numeric column IDs are offered for aggregates |
.chartType(fn) |
Restrict available chart types | Callback receiving a SelectableControlBuilder |
Values narrowed to 'bar' | 'grouped-bar' | 'percent-bar' | 'line' | 'area' | 'percent-area' | 'pie' | 'donut' | 'table' |
.timeBucket(fn) |
Restrict available time bucket intervals | Callback receiving a SelectableControlBuilder |
Values narrowed to 'year' | 'quarter' | 'month' | 'week' | 'day' |
.connectNulls(bool) |
Connect lines across null values | boolean |
— |
.build() |
Materialize into a plain schema object | — | — |
| Method | What it does | Parameters | Type safety |
|---|---|---|---|
.allowed(...ids) |
Set which options are selectable | One or more option values | Narrows the option union to only the listed values |
.hidden(...ids) |
Hide options from the UI | One or more option values | Must be within the allowed set |
.default(id) |
Fallback when the current selection becomes invalid | Single option value | Must be allowed and not hidden; unavailable on .filters() |
| Method | What it does | Parameters | Type safety |
|---|---|---|---|
.count() |
Whitelist the row-count metric (without this or .aggregate(), all metrics are available by default) |
— | — |
.aggregate(col, ...aggs) |
Whitelist specific numeric aggregations | Column ID, one or more of 'sum' | 'avg' | 'min' | 'max' |
Column must be a number column |
.hideCount() |
Hide count from the default set without whitelisting | — | Compile error if count was explicitly whitelisted and then hidden |
.hideAggregate(col, ...aggs) |
Hide specific aggregations from the default set without whitelisting | Column ID, aggregation functions | Can only hide metrics that are visible |
.defaultCount() |
Set count as the fallback metric | — | Compile error if count is not visible |
.defaultAggregate(col, agg) |
Set a specific aggregation as fallback | Column ID, single aggregation function | Must be visible (allowed and not hidden) |
| Method | What it does | Parameters | Type safety |
|---|---|---|---|
.dataset(id, def) |
Register a dataset in the model | Dataset ID, defineDataset<Row>() result (must have .key()) |
ID must be unique across all registered datasets |
.relationship(id, config) |
Declare a one-to-many lookup | Relationship ID, { from: { dataset, key }, to: { dataset, column } } |
Dataset and column references validated against registered datasets |
.association(id, config) |
Declare a many-to-many relationship | Association ID, explicit edge data or derived config | Validates both explicit and derived forms against registered datasets |
.attribute(id, config) |
Define a reusable shared-filter attribute | Attribute ID, { kind, source, targets } |
— |
.infer(opts?) |
Auto-discover relationships and attributes | { relationships?, attributes?, exclude? } |
Conservative: ambiguous candidates are skipped |
.chart(id, fn) |
Create a model-aware chart with lookup-preserving fields | Chart ID, callback receiving a chart builder | Qualified field IDs like 'jobs.owner.name' validated at compile time |
.materialize(id, fn) |
Create a cross-dataset materialized view | View ID, callback receiving a materialization builder (see below) | — |
.validateData(data) |
Runtime validation of all relationships and associations | Object with all dataset arrays | Throws on duplicate keys, orphan foreign keys, malformed edges |
.build() |
Materialize into a plain model object | — | — |
| Method | What it does | Parameters | Type safety |
|---|---|---|---|
.from(datasetId) |
Set the base dataset (determines the starting grain) | Dataset ID | Must be a registered dataset |
.join(alias, config) |
Lookup join (preserves grain) | Alias string, { relationship, columns? } |
Relationship must exist in the model; optional columns projects specific fields |
.throughRelationship(alias, config) |
Expand rows through a one-to-many relationship | Alias string, { relationship, columns? } |
Relationship must exist in the model |
.throughAssociation(alias, config) |
Expand rows through a many-to-many association | Alias string, { association, columns? } |
Association must exist in the model |
.grain(id) |
Declare the output row grain | Grain identifier string | Required when the grain changes (e.g. after .throughAssociation()) |
.chart(id?) |
Open the chart-control surface for the materialized view | Optional chart ID | Returns a standard DatasetChartBuilder over the flattened row type |
.materialize(data) |
Execute the materialization and return flat rows | Object with all dataset arrays | Returns typed flat rows |
.build() |
Materialize into a plain view object | — | — |
| Method | What it does | Parameters | Type safety |
|---|---|---|---|
.chart(id, chart) |
Register a chart for the dashboard | Chart ID, result from dataset.chart(), model.chart(), or view.chart() |
Chart must carry builder metadata |
.sharedFilter(id) |
Expose a model-level attribute as a shared filter | Attribute ID | Must match a model attribute (explicit or inferred) |
.sharedFilter(id, config) |
Add a dashboard-local select filter | Filter ID, { kind: 'select', source: { dataset, column }, targets?, label? } |
Dataset and column references validated |
.sharedFilter(id, config) |
Add a dashboard-local date-range filter | Filter ID, { kind: 'date-range', targets: [{ dataset, column }, ...], label? } |
Target dataset and column references validated |
.build() |
Materialize into a plain dashboard object | — | — |
These are known limitations and areas being considered for future versions. None of these are committed — they represent directions the library may grow based on real usage.
The UI layer currently only supports Recharts. If you want to use ECharts, Plotly, or another renderer, you can use the headless core but lose the built-in toolbar and canvas composition. A renderer adapter pattern for <ChartCanvas> could make the UI layer renderer-agnostic.
The pipeline supports sum, avg, min, and max. Derived columns can access multiple fields of a single row (e.g. row.revenue - row.cost), but there is no support yet for metrics that depend on other rows or on aggregated results — things like "% of total", running totals, percentiles, or post-aggregation ratios (e.g. total revenue / total orders).
There is currently no built-in support for drill-down, click-to-filter, brush selection, or linked charts. The headless state can be wired manually to achieve some of these, but first-class interactivity primitives would make this significantly easier.
Dashboard composition and shared dashboard filters are now available, but each chart instance still operates on one flat dataset at a time. Overlaying series from different schemas (e.g. revenue on the left Y-axis and headcount on the right) would require separate chart instances today. Dual-axis cross-dataset execution, automatic denormalization, and linked metrics are not yet supported.
defineDataset<Row>() owns the reusable .columns(...) contract, and
defineDataset<Row>().chart(...) is the single explicit path for declaring a
chart schema. Both feed the same chart-definition surface that you pass directly
to useChart(...) or inferColumnsFromData(...).
- release from a clean, fully committed worktree only
bun run release:checkbun run release -- --bump patchbun run release -- --bump minorbun run release:publish→ npm dist-taglatest(default)bun run release:publish:alphaor... -- --tag alpha→alpha- release tags are shared across both published packages
This library draws heavy inspiration from TanStack Table and Elysia. I want to thank both teams for the incredible path they have paved that makes a project like this possible.
I also want to thank Recharts for an excellent charting library that our entire UI package builds on as a lower layer.