Skip to content

Chart.js โ€‹

Charts built on Chart.js. A controlMonoChart(...) controller projects your rows into chart data โ€” the same controller-plus-.prop shape as controlMonoTable โ€” and the <mono-chart-*> elements own the canvas.

ts
import 'mono-helper/ui/chart'
import { controlMonoChart } from 'mono-helper'

const chart = controlMonoChart(rows, {
  labelField: 'month',
  series: [
    { field: 'sales', label: 'Sales' },
    { field: 'costs', label: 'Costs' },
  ],
})
vue
<mono-chart-bar :control-chart.prop="chart" height="320" title="Sales vs costs" />

Install

Chart.js is an optional peer โ€” it isn't bundled, so nothing pays for it unless you chart something.

sh
pnpm add chart.js@4.5.1

The version is pinned exact. If it's missing, the element renders an inline message naming the package and the command rather than failing silently.

Elements โ€‹

ElementType
<mono-chart>whatever type says โ€” radar, polarArea, scatter, bubble โ€ฆ
<mono-chart-bar>bar
<mono-chart-line>line
<mono-chart-pie>pie
<mono-chart-doughnut>doughnut

The presets are <mono-chart> with type locked, so the markup states its intent. Everything else โ€” props, controller binding, lifecycle โ€” is identical.

Basic โ€‹

labelField names the category axis; each series entry becomes one dataset. Field names accept the same path expressions as the table (Job.Budget, Items.[0].Total).

Chart types โ€‹

One controller can feed several elements โ€” the projection is shared, only the type differs.

Aggregating rows โ€‹

Charts almost always want summarised data, but a data source hands you raw rows. groupBy buckets them and each series' agg collapses a bucket to one number โ€” sum (default), avg, count, min or max.

ts
const chart = controlMonoChart(transactions, {
  groupBy: 'region',
  series: [{ field: 'amount', label: 'Revenue', agg: 'sum' }],
})

Reactive data โ€‹

The controller is a subscribable store, exactly like controlMonoTable. When the bound source emits changed, it re-syncs, re-projects and notifies โ€” and the element updates the existing Chart.js instance instead of recreating the canvas, so the change animates rather than flashing.

Notifications are coalesced on a microtask, so several mutations in one tick produce a single update.

From a DataSource โ€‹

Bind a devextreme DataSource and the controller drains it โ€” a remote source is paged, but a chart wants the whole set, so it reads every row in chunks rather than charting page one. Set loadAll: false to chart just the current page.

With groupBy and explicit series, a remote OData store is not drained at all: the controller sends ONE GET <store url>?$apply=filter(<base โˆง source filter>)/groupby((groupBy),aggregate(โ€ฆ)) through the source's own store โ€” its url, beforeSend and auth โ€” and charts the buckets that come back, the same clause the odata.aggregate path builds. The clause goes into the url (urlOverride), not through devextreme's customQueryParams (a quoted literal, and a function call on OData v4). A CustomStore gets no such request, a server that answers with plain entities is detected and drained instead, and a backend that rejects the clause (4xx / 501) is remembered and not asked again. serverApply: false switches it off; without series the fields are unknown up front, so the chart drains as before.

Server-side roll-up ($apply) โ€‹

Draining is the wrong shape for a summary chart: a country-total over 100k rows would be ~1000 paged requests carrying every column, to produce a dozen bars. Point the controller at the endpoint with odata and give it an aggregate โ€” the server groups with OData's $apply and returns one row per bucket, in a single request. A filter folds inside the clause, so it's applied before the aggregation.

The demo below runs against the public TripPin service. It is a small dataset, but the shape is the point: one request, one row per bucket. The measure is $count because TripPin has no populated numeric column.

Requires the mono-utils peer

The request goes through monoFetchOdata. It's an optional peer loaded on demand โ€” a chart that never sets odata neither needs nor loads it.

DataSource options โ€‹

The chart takes the same base-query options as controlMonoTable: dataSourceOptions (devextreme knobs โ€” filter, select, expand, sort, customQueryParams) and odataOptions ($filter, $select, $expand, $orderby, other $params). Each takes a value, a getter, or a { value } box (a Vue ref / computed) and is read fresh at every reload() โ€” so a getter over reactive state always contributes its current value, and the demo above no longer needs a hand-written get options().

Where it lands depends on the path:

PathWhat the base does
bound DataSource, loadAll: true (default)rides on every drain chunk, AND-ed under the filter the source itself carries; select / sort / expand shape the chunk requests
bound DataSource, loadAll: falsewritten onto the source before load(), adopting whatever filter the consumer set there
odatamerged into odata.options; on the $apply path the composed filter folds inside the clause
arrayfilter is applied as a predicate to the rows
ts
const chart = controlMonoChart(null, {
  groupBy: 'Gender',
  series: [{ field: 'Rows', agg: 'count' }],
  odata: { baseUrl, url: '/People', aggregate: ({ apply, filter, withFilter }) => withFilter(apply, filter) },
  odataOptions: () => (onlyA.value ? { $filter: "contains(FirstName,'A')" } : {}),
})
watch(onlyA, () => chart.refresh())

refresh() is an alias of reload() here โ€” a chart has no paging to preserve, so the call after a state change is the same either way. setDataSourceOptions() / setOdataOptions() replace an option and reload; resolvedDataSourceOptions() is the merged result of the last query.

Raw chart.js data โ€‹

Skip the mapping entirely: hand the controller a chart.js { labels, datasets } object and it's used verbatim. A per-dataset type gives you mixed charts.

Theme โ€‹

Charts are wired to the theme like every other component โ€” and unlike the rest of the library there is no attribute-styled markup here: a chart is a canvas, so the port is the bridge into Chart.js. The default palette is Basecoat's own chart colours, --chart-1 โ€ฆ --chart-5, resolved by name at paint time; the legend and title take --foreground, the ticks --muted-foreground, the grid --border, the tooltip is the flavour's popover โ€” an HTML element over the canvas, painted with the select panel's corner, ring and shadow โ€” the type is the page font at text-xs, a pie's slice hairline is --background, and a bar's corner is the flavour's field corner (square in lyra / sera, pills in maia / luma). So a chart with no color or colors follows the active color preset, flavour and mode โ€” nothing is fixed hex.

A canvas doesn't re-cascade

Switching theme repaints every other component for free: the class swap re-cascades and CSS redraws. A chart can't โ€” it's a bitmap painted from values resolved once, so it would keep the old palette. The elements listen for the theme-changed event applyTheme() fires, and watch the .dark class on <html> / <body> and the OS colour scheme, so a mode toggle that only swaps the class (VitePress's, or your own) repaints too. If you swap the theme classes by hand instead of calling applyTheme(), dispatch that event yourself:

ts
window.dispatchEvent(new CustomEvent('theme-changed'))

Colors โ€‹

Anywhere a colour is accepted you can use a mono color name โ€” Basecoat's chart-1 โ€ฆ chart-5, or the roles primary, secondary, accent, success, warning, danger, info โ€” or any CSS colour, mixed freely. Use color for one accent, colors for a palette, or series[].color per dataset; the more specific one wins. The default palette is chart-1 โ€ฆ chart-5 followed by success, warning, danger, so a sixth series is still its own colour.

Why names have to be resolved

A <canvas> can't resolve var() โ€” raw var(--success) paints transparent. The element reads the token off the DOM and hands Chart.js the real value, which is why names work at all.

CSS variables โ€‹

Every name resolves through a public --mono-chart-<name> override, so a scope can retarget what a name means without touching chart code; the inks and chrome have knobs of the same shape. They inherit, and pierce the shadow boundary.

VariableDefaultControls
--mono-chart-1 โ€ฆ --mono-chart-5--chart-1 โ€ฆ --chart-5The default palette, and the chart-n names
--mono-chart-primary / -secondary / -accent--primary / --muted-foreground / --chart-2The role names (Basecoat's --accent is a surface, so accent is the second chart colour)
--mono-chart-success / -warning / -danger / -info--success / --warning / --destructive / --infoThe state names
--mono-chart-text / -muted / -grid--foreground / --muted-foreground / --borderLegend + title ink, tick ink, grid + axis lines
--mono-chart-surface--backgroundThe hairline between pie / doughnut slices
--mono-chart-tooltip-bg / -tooltip-color / -tooltip-radius / -tooltip-ring / -tooltip-shadowthe select panel's popover: --mono-select-dropdown-* โ†’ --popover / --popover-foreground / --mono-radius-md / a 1px --foreground 10% ring / --mono-shadow-mdThe tooltip โ€” an HTML popover placed over the canvas, so the flavour's corner, ring and shadow reach it (luma's 3xl + shadow-lg, lyra's square, maia's 2xl)
--mono-chart-font / -font-family--mono-text-xs / --font-sansEvery label's type (read as computed px, so a rem works)
--mono-chart-radius--mono-radius-mdThe tooltip's corner and the empty / error message box
--mono-chart-bar-radiusthe field corner: --mono-input-outline-radius โ†’ --mono-input-radius โ†’ --mono-radius-mdA bar's free-end corner โ€” square in lyra / sera, a pill in maia / luma, with nothing set (Chart.js clamps it to half a bar)

Deprecated and honoured as no-ops until 2.0: the --theme-* bridge (--theme-primary, --theme-text, --theme-border โ€ฆ) โ€” every default reads a Basecoat token now.

Customising โ€‹

Every layer is live in one demo: color / colors on the element, series[].color per dataset, --mono-chart-* to retarget what a name means, and raw Chart.js options for everything else.

Anything Chart.js supports is reachable through options on the controller or chart-options on the element โ€” both are deep-merged over the generated defaults, so you keep the theming and override only what you name.

Overriding the variable is the theme-level lever โ€” every chart that asked for success follows, with no chart code touched. The same works for the default palette (--mono-chart-1 โ€ฆ -5) and the chrome:

css
.sales-dashboard {
  --mono-chart-success: #0f9d58;
  --mono-chart-danger: #d93025;
  --mono-chart-grid: transparent; /* no grid lines on this dashboard */
}

Controller API โ€‹

ts
const chart = controlMonoChart(source, options)

source is an array, a devextreme DataSource, a raw chart.js data object, or null.

OptionPurpose
typeDefault chart type ('bar')
labelFieldRow field for the category axis
groupByBucket rows by this field before charting
series[]{ field, label?, agg?, color?, type?, dataset? } โ€” one dataset each
colors[]Palette โ€” mono color names or CSS colors
optionsRaw chart.js options, deep-merged
propsThe element's own props, declared centrally โ€” see Element props
odataFetch through monoFetchOdata (configBaseUrl / baseUrl / url / method / options). Add aggregate to roll up on the server โ€” see Server-side roll-up
loadAllDrain a remote source instead of charting one page (default true)
serverApplyLet a bound OData store roll the data up with one $apply instead of draining (default true; needs groupBy + series) โ€” see From a DataSource
dataSourceOptions / odataOptionsThe sticky base every query goes out on โ€” see DataSource options
MemberPurpose
itemsRows currently backing the chart
loadingTrue while the source loads
data() / options()The projected chart.js data / merged options
props() / setProps(patch)Read / merge the central element props
setType / setSeries / setColors / setDataReproject
bind(source) / reload() / refresh()Swap or re-read the source (refresh is an alias of reload)
setDataSourceOptions / setOdataOptions / resolvedDataSourceOptionsReplace the base and reload / read the merged base
onPointClickAssignable sink โ€” receives the label, value and the rows that produced the point
subscribe(cb) / dispose()Lifecycle

onPointClick reports the underlying rows, which is what makes drill-down work: click a bar and you get back the bucket that built it.

ts
chart.onPointClick = ({ label, value, rows }) => {
  console.log(label, value, rows) // rows = every record in that bucket
}

Element props โ€‹

Declare the element's own props in the controller's props block and every bound <mono-chart*> needs nothing but :control-chart.prop="chart" โ€” the same arrangement as controlMonoTable({ props }), minus the per-element nesting, since the presets are one element with type fixed.

ts
const chart = controlMonoChart(rows, {
  groupBy: 'month',
  series: [{ field: 'amount', label: 'Revenue', agg: 'sum' }],
  props: { height: 320, legend: 'bottom', stacked: true },
})

chart.props()                      // { height: 320, legend: 'bottom', stacked: true }
chart.setProps({ legend: 'right' }) // merges, notifies, every bound element re-applies

props() returns one object with a stable identity, so a controller can drive several charts and keep them in sync. setProps is a merge โ€” keys you omit keep their current value.

Setting props on the element still works

Writing height / legend / stacked directly on <mono-chart-bar> is still supported. Where both declare the same key, the controller wins; keys the controller never mentions are left to the template.

Server rendering โ€‹

A chart can't be server-rendered โ€” Chart.js paints onto a canvas at runtime. The SSR build (mono-helper/ui/shadow/chart) emits a correctly-sized empty <canvas> and draws on hydration, so layout doesn't shift. controlMonoChart itself is a pure factory and is safe to import on the server.

Props โ€‹

controlMonoChart is also exported as monoChart, and :control-chart is also accepted as :data-chart โ€” the older spellings still work.

Importimport { ChartProps } from 'mono-helper'
PropValueDefaultDescription
controlChartMonoChartController<any>โ€”The chart controller. Bind with `.prop`: `:control-chart.prop="chart"`.
dataChartMonoChartController<any>โ€”Renamed โ€” `:data-chart` / `:dataChart` alias `controlChart` (both work).
type'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polarArea' | 'scatter' | 'bubble'โ€”Chart type. Ignored by the presets, which fix their own. When a controller is bound this overrides the controller's `type`.
dataMonoChartDataโ€”Raw chart.js data, for use WITHOUT a controller. Bind with `.prop`: `:data.prop="{ labels, datasets }"`.
chartOptionsRecord<string, unknown>โ€”Raw chart.js options, merged over the defaults. Bind with `.prop`.
widthstring | numberโ€”Canvas box sizing. A CSS length string or a number (px).
heightstring | numberโ€”โ€”
aspectRationumberโ€”`width / height` when no explicit height is set. Default `2`.
legend'left' | 'right' | 'top' | 'bottom' | booleanโ€”Show the legend (default `true`), or place it: `top` / `bottom` / `left` / `right`.
titlestringโ€”Chart title rendered by chart.js.
stackedbooleanโ€”Stack bar/line datasets. Default `false`.
colorstringโ€”Single accent for every dataset โ€” a mono color name (`primary`, `success`, `danger`, โ€ฆ) or any CSS color. `colors` wins when both are set.
colorsstring | string[]โ€”Palette. A comma-separated attribute (`colors="success,danger,warning"`) or an array via `.prop`. Entries may be mono color names or CSS colors, mixed.
cssClassChartCssClassโ€”Per-part class overrides. Object, or a JSON string via the `css-class` attribute.
cssClassNamestringโ€”A single class added to the root.

Optional dependency โ€‹

chart.js is declared as an optional peerDependency pinned to 4.5.1, and is loaded with a dynamic import('chart.js/auto') the first time an element renders. It is externalized from the build, so dist/ui/chart.js stays around 13 kB and resolves against your app's copy at runtime โ€” install it yourself, and nothing else in mono-helper is affected if you don't.