Skip to content

MatthieuMordrel/chart-studio

Repository files navigation

chart-studio

Early alpha. chart-studio and chart-studio-ui are 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

Start Here

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.

Headless — install and import

bun add @matthieumordrel/chart-studio react

To install from the alpha dist-tag instead (when you publish prereleases there):

bun add @matthieumordrel/chart-studio@alpha react
import { useChart } from '@matthieumordrel/chart-studio'

UI — install and import

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 tailwindcss

Prereleases 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'

Devtools — install and import

Install as a dev dependency; chart-studio, chart-studio-ui, and their peers should already match your app.

bun add -D @matthieumordrel/chart-studio-devtools
import { ChartStudioDevtools } from '@matthieumordrel/chart-studio-devtools/react'

See packages/chart-studio-devtools/README.md for getSnapshot, subscribe, and props.

Smallest Working Example (Single Source)

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>
  )
}

How It Works

  1. Pass your raw data to useChart().
  2. Add an optional schema with defineDataset<Row>().chart(...) when you need labels, type overrides, derived columns, or control restrictions (allowed metrics, groupings, chart types, etc.).
  3. Either render your own UI from the returned state, or use the components from @matthieumordrel/chart-studio-ui.

Stable Single-Chart Contract

For the simple case, the public contract is:

  • useChart({ data }) stays the zero-config path
  • useChart({ data, schema }) is the explicit single-chart path
  • defineDataset<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, and connectNulls restrict that one chart's public controls
  • inputs is an additive escape hatch for externally controlled data-scope state; it does not replace the simple useChart({ data }) or useChart({ data, schema }) path
  • pass the builder directly to useChart(...), or call .build() if you need the plain schema object

Choose the Right Boundary

There are two different scaling paths in chart-studio:

Independent dataset(s)

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 charts
  • defineDataset<Row>().chart(...) when one chart owns its own explicit contract
  • defineDataset<Row>() when several charts should reuse one row contract
  • useChart({ 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

Related dashboard

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:

  1. defineDataModel().dataset(...).infer(...)
  2. model.chart(...) for lookup-preserving charts
  3. model.materialize(...) only when the chart grain changes
  4. defineDashboard(model)
  5. useDashboard(...)

Authoring Layers

1. Single-chart explicit path

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.

2. Dataset-first reuse

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 restrictions
  • dataset.chart(...) inherits dataset columns, so charts do not reopen .columns(...)
  • declared dataset keys can be validated at runtime with dataset.validateData(data) or validateDatasetData(dataset, data)

3. Model-level linked 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 id and foreign keys named <singularDatasetId>Id such as ownerId, teacherId, or customerId
  • 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, or label when you want inferred shared filters to feel good by default
  • leaving a raw field out of .columns(...) is not exclusion; use exclude(...) 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

4. Materialized views

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 for materialize(...) 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(...) and throughAssociation(...) 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, or skillName
  • the materialized view is chartable like a normal dataset and exposes materialize(data) for explicit reuse and caching

5. Dashboard composition

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(...), or model.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 DashboardProvider pass the dashboard definition
  • useDashboardChart(...) resolves the reusable chart by id and keeps React in charge of placement
  • useDashboardDataset(...) exposes the globally filtered rows for non-chart consumers like KPI cards or tables

6. Shared dashboard filters

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

Column Types

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

Declarative Schema and Control Restrictions

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:

  • columns defines types, labels, and formats for raw fields; use c.exclude(...) to remove a column from the chart
  • Derived columns use c.derived.*(...) helpers for computed values from each row
  • xAxis, groupBy, metric, chartType, and timeBucket restrict 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

Headless Example

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>
  )
}

Styling Requirements

The headless core has no styling requirements.

@matthieumordrel/chart-studio-ui is Tailwind CSS v4 based and uses semantic classes such as:

  • bg-background
  • text-foreground
  • border-border
  • bg-popover
  • text-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:

1. Recommended: import the built-in theme

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"]

2. Advanced: define everything yourself

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.

Minimum UI theme contract

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.css and do nothing else: the package uses its own defaults
  • toggle dark mode with either .dark or [data-theme="dark"]: the package uses its built-in dark defaults
  • import @matthieumordrel/chart-studio-ui/theme.css and 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.

Optional chart color tokens

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);
}

Common Questions

Which import path should I use?

  • Use @matthieumordrel/chart-studio for the headless core.
  • Use @matthieumordrel/chart-studio-ui for the optional UI components.

Is this production-ready?

No. This is still an early alpha, under active development, and not recommended for production use yet.

Do I need Recharts?

Only for the UI layer. The headless core works without it.

Do I need Tailwind?

Only for @matthieumordrel/chart-studio-ui, and the current UI package expects Tailwind CSS v4. The headless core does not require it.

Can I use multiple datasets?

Yes, but there are two different meanings:

  • independent datasets: use separate useChart(...) calls, or useChart({ sources: [...] }) when one chart should switch between flat sources
  • related datasets: use defineDataModel().dataset(...).infer(...), then model.chart(...) for safe lookup-preserving charts
  • explicit expanded grains: use model.materialize(...)
  • screen-level composition and shared state: use defineDashboard(model) plus useDashboard(...)

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.

Can outside state drive one chart's filters or date range?

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:

  • inputs only covers data-scope state: filters, reference date, and date range
  • presentation controls such as xAxis, groupBy, metric, and chartType stay chart-local
  • dateRange is { preset, customFilter }
  • when an input is controlled, chart setters request changes through the matching callback
  • chart.filters and 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

What chart types are available?

  • date X-axis: bar, line, area
  • category or boolean X-axis: bar, pie, donut
  • pie and donut do not support groupBy

Troubleshooting

The UI looks mostly unstyled

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.

Builder API Reference

All builders use immutable method chaining — each call returns a new builder so the chain is order-independent and composable.

Dataset definition — defineDataset<Row>()

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

Column helpers — inside .columns((c) => [...])

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

Chart controls — after .chart()

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

SelectableControlBuilder — inside .xAxis(), .groupBy(), .filters(), .chartType(), .timeBucket()

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()

MetricBuilder — inside .metric()

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)

Data model — defineDataModel()

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

Materialization builder — inside .materialize()

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

Dashboard — defineDashboard(model)

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

On the Radar

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.

Renderer flexibility

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.

Richer aggregation

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).

Chart interactivity

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.

Multi-dataset composition

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.

Schema Builder Ergonomics

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

  • release from a clean, fully committed worktree only
  • bun run release:check
  • bun run release -- --bump patch
  • bun run release -- --bump minor
  • bun run release:publish → npm dist-tag latest (default)
  • bun run release:publish:alpha or ... -- --tag alphaalpha
  • release tags are shared across both published packages

Acknowledgements

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.

About

Headless, composable charting for React with an optional batteries-included UI layer.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors