Every chart in Chartist is drawn by layers: line(), bar(), pie(), the grid and the labels. They’re plain objects with a few functions, and yours work exactly like the built-in ones. A layer is a good way to add a new kind of mark to a chart, and to share it: it’s a function that returns an object, with no setup and nothing to install.
Layers and plugins do different jobs. A layer draws something of its own: lollipops, a heat strip, error bars. A plugin changes what’s drawn and reacts to the chart’s events: tooltips, legends, animations. If your idea is “a new kind of chart”, it’s a layer.
A first layer
A lollipop chart: a stem from zero and a dot for each value. The layer is in src/lollipop.ts; the chart uses it next to the grid and the labels.
import { alphaNumerate, node, type Layer } from 'chartist';
// The roles this layer gives its nodes, so listeners and plugins can find
// them with a typed context.
declare module 'chartist' {
interface NodeRoles {
lollipop: {
ctx: {
readonly seriesIndex: number;
readonly valueIndex: number;
readonly value: number;
};
attrs: { readonly cx: number; readonly cy: number; readonly r: number };
};
}
}
export interface LollipopOptions {
/** Series to draw, by name or index; default: all. */
readonly series?: readonly (number | string)[];
/** Axis ids; default: the first X and the first Y axis. */
readonly x?: string;
readonly y?: string;
}
/** A stem from zero and a dot for each value. */
export function lollipop(options: LollipopOptions = {}): Layer {
return {
type: 'lollipop',
// Drawn where bars are, so the chart gets the bar chart's class, which
// centers the X labels.
chartType: 'bar',
coordinates: ['cartesian'],
axes: { x: options.x, y: options.y },
series: options.series,
// The stems start at zero, so the Y axis must include it.
extent: ({ axes }) => (axes.y ? { [axes.y]: { low: 0, high: 0 } } : {}),
compose({ area, axes, normalized, seriesIndexes }) {
const { x, y } = axes;
if (!x || !y || !normalized) {
throw new Error('Layer "lollipop" needs an x and a y axis');
}
// Centered in each step, where bars sit.
const shift = x.kind === 'step' ? (x.step ?? 0) / 2 : 0;
const base = area.y1 - y.project(0);
return normalized.series.map((values, local) => {
// The series' index in the whole chart, for its class and color.
const seriesIndex = seriesIndexes?.[local] ?? local;
let group = node(
'g',
{},
`ct-series ct-series-${alphaNumerate(seriesIndex)}`
);
values.forEach((value, valueIndex) => {
// Values are normalized to { x, y }, or undefined for a hole.
const v =
value && typeof value === 'object' && 'y' in value
? value.y
: undefined;
if (v === undefined) {
return;
}
const cx = area.x1 + shift + x.project(value, valueIndex, values);
const cy = area.y1 - y.project(value, valueIndex, values);
group = group
.append(
node('line', { x1: cx, y1: base, x2: cx, y2: cy }, 'ct-stem')
)
.append(
node('circle', { cx, cy, r: 6 }, 'ct-lollipop', {
role: 'lollipop',
ctx: { seriesIndex, valueIndex, value: v }
})
);
});
return group;
});
}
};
}Use it like any layer: in layers of chart(), or on a preset with layers: { below: [...], above: [...] }.
What a layer is made of
| Field | What it does |
|---|---|
type |
A name for error messages, such as lollipop |
coordinates |
Where it can draw: ['cartesian'], ['polar'] or both. Using it elsewhere throws |
compose |
Draws the layer: gets the data, axes and chart area, returns scene nodes |
axes |
The ids of the X and Y axis to draw on; by default the first of each |
series |
The series to draw, by name or index; by default all of them |
extent |
Widens the axes beyond the layer’s values, such as to include zero |
refine |
Changes nodes that any layer drew, after all of them have drawn |
chartType |
The chart class it gives chart() without a className: 'bar' centers the X labels, for example |
Only type, coordinates and compose are required. A layer is data: create it with a function that takes options, and keep no state in it. The same layer object may draw several charts, and every resize draws again.
compose: drawing
compose(context) runs on every render, after the axes are created. It returns the layer’s top-level nodes, usually one group per series. Its context has:
| Field | What it is |
|---|---|
area |
The drawing area in px: x1, x2 (left, right), y1 (bottom), y2 (top), width, height |
axes |
The bound x and y axes, and all axes of the chart |
normalized |
The layer’s series as the axes see them: { x, y } values, undefined for holes |
data |
The layer’s series as given, with names, classes and meta |
seriesIndexes |
Each series’ index in the whole chart, when the layer selects some |
layerIndex |
The layer’s position in layers |
measureText |
Measures text with the page’s fonts, for layers that lay out labels |
From values to px. axis.project(value, index, series) gives the distance in px from the start of the axis. Y grows upward and SVG downward, so a Y position is area.y1 - y.project(value), and an X position area.x1 + x.project(value). A step axis places by index instead of value; its step is the width of one step.
Nodes. Create them with node(element, attributes, classes, { role, ctx }) and nest them with append(). A node with a role and a ctx (its data context) is what plugins and listeners see: they find it by role and read its values. Declare your roles in NodeRoles, as the example does, and node() and the listeners check their context.
Colors. Put the series’ nodes in a group with ct-series ct-series-<letter> (alphaNumerate(seriesIndex) gives the letter): the default stylesheet sets --ct-color on it, and your CSS reads it. Use the chart-wide index from seriesIndexes, so a series keeps its color whichever layer draws it.
Keys come for free: nodes with a series and value index get one from the layer index, so the scene stays stable between renders.
Axes and series
A layer draws on one X and one Y axis. Give it x and y options and pass them on as axes, as the built-in layers do, so users can bind it to any axis by id. The series option works the same way: pass it on as series, and compose gets only those series, with their chart-wide indexes in seriesIndexes.
The selection also decides what the axes fit: each axis covers the series of the layers bound to it. A layer that draws no series (a reference line, an annotation) sets series: [], so it doesn’t shape any axis.
extent adds to that. It runs before the axes are created and returns, by axis id, the range the layer needs:
import type { Layer } from 'chartist';
export const withZero = (layer: Layer): Layer => ({
...layer,
// Keep zero on the layer's Y axis, wherever its values are.
extent: ({ axes }) => (axes.y ? { [axes.y]: { low: 0, high: 0 } } : {})
});
The axis covers every layer’s extent and the data together. An axis’s own low and high still win.
refine: changing what others drew
Some layers need to change other layers’ nodes: the band marks the points inside it. That’s refine(node, context). Once every layer has composed, it’s called for each node with a role, from all layers, before the compose events reach plugins:
import type { Layer } from 'chartist';
/** Marks the highest point of every line with ct-max. */
export const highlightMax = (): Layer => ({
type: 'highlightMax',
coordinates: ['cartesian'],
// Draws nothing of its own.
compose: () => [],
refine(current, { normalized }) {
if (current.role !== 'point' || !normalized) {
return undefined;
}
const ys = normalized.series
.flat()
.flatMap(value =>
value && typeof value === 'object' && 'y' in value ? [value.y] : []
);
const { value } = current.ctx;
const y =
value && typeof value === 'object' && 'y' in value ? value.y : undefined;
return y === Math.max(...ys) ? current.addClass('ct-max') : undefined;
}
});
Refining has rules, so layers can’t break each other:
- One node at a time.
refinegets the node it’s called for, not the scene. To compare with other values, work from the data in the context, as above. - No structural changes. Return the node with changed attributes, classes or context, or
undefinedto leave it. Whatever you return keeps the node’s key and children, and refining can’t drop a node. Removing and rearranging nodes is a plugin’s job. - Your own context. The context is the one your
composegot: your layer’s data and axes. A layer withseries: []sees no data here either; leaveseriesout, as above, to see all of it. - Order. Every layer composes first, so a layer can refine the nodes of layers listed after it. Refining layers run in the order of
layers, and plugins see the result in theircomposeevents, where they can still change it. - No state. Don’t count or remember between calls: the same layer object may refine several charts.
Leave refine out when you don’t need it: charts without a refining layer skip the step.
Sharing a layer
A layer is a function, so a package of layers is a module of functions. Depend on chartist as a peer dependency, export a function per layer with typed options, and ship a small stylesheet with your classes, using --ct-color and the other --ct-* properties so the user’s theme applies.
What you get for free:
- Presets: users add your layer to
lineChart()andbarChart()withlayers, nochart()needed. - Server rendering: layers are pure, so they work in
toScene()andrenderToString(). - The terminal:
@chartist-js/terminaldraws nodes of unknown roles by their element (lines, paths, circles, text). - Plugins: your nodes go through the
composeevents like the built-in ones.
In the API reference: Layer, LayerContext and node.
Next: events and plugins, to change charts instead of drawing on them, or the target line and band layers.