@qkix/chartkit-core
v0.4.0
Published
Chart specification and server-side SVG rendering for Chartkit - turns a ChartSpec into a finished SVG string with no DOM, no framework and no client-side JavaScript
Maintainers
Readme
@qkix/chartkit-core
Charts as server-rendered SVG. A ChartSpec goes in, a finished SVG string
comes out - no DOM, no framework, and nothing for the browser to run.
What this is
import { renderChart } from '@qkix/chartkit-core';
const result = renderChart({
version: 2,
type: 'bar',
title: 'Quarterly revenue',
description: 'Revenue by quarter, rising to a peak in Q4.',
data: {
source: 'inline',
labels: ['Q1', 'Q2', 'Q3', 'Q4'],
series: [{ name: 'Revenue', values: [420, 610, 385, 720] }],
},
});
if (result.ok) {
page.innerHTML = result.svg; // a string. That is the whole runtime cost.
}Why not wrap an existing chart library
Because of color, which sounds trivial and is not.
Chart libraries that render on a server bake their colors into the markup, so
the colors are chosen where the chart is built. But a reader's light or dark
preference is known in the browser - usually as a class on <html> that a
toggle flips. Baked colors cannot follow that: you end up rendering two copies
and swapping them, or falling back to prefers-color-scheme and ignoring the
toggle your own site ships.
Every color here is a CSS custom property with a fallback:
fill="var(--chart-series-1, #4269d0)"
stroke="var(--chart-axis, currentColor)"So a chart inherits the page's theme by doing nothing, and a site restyles every chart it has ever published by setting a few properties - no rebuild, no re-render. For a CMS plugin that is the difference between a widget pasted onto a page and part of the design.
Several series
stackMode decides what happens inside a category band. It is an option rather
than a chart type, because it changes the arrangement and not the mark -
axes, legend and baseline are identical either way, and a single-series chart
looks the same in both.
options: {
stackMode: 'grouped';
} // one bar per series, side by side - the default
options: {
stackMode: 'stacked';
} // series piled into one bar per categorySpecs written against version 1 used barMode. They still render - renderChart
migrates them in memory - but new specs should be written with stackMode.
The difference is not only visual. A grouped axis spans the values, because each bar is read on its own. A stacked axis spans the totals: three series each reading 40 reach 120, and an axis topping out at 40 would draw two of the segments off the plot.
Positive and negative values stack away from the baseline in opposite directions, each with its own running offset. Sharing one would let a −50 cancel a +50, hiding a segment and shrinking the axis below the height the bar actually needs.
A series with no value at a category contributes nothing and the stack closes up - segments keep their own color rather than inheriting the missing one's.
Legend
Drawn automatically when there is more than one series, and suppressed with
legend: false. With a single series the title already says what the bars are.
Entries wrap onto as many rows as they need, and the height that falls out is fed back into the layout before the plot is sized - otherwise the chart with eight series, the one that most needs its legend, is exactly the one whose legend runs off the side.
Pie and donut
type: 'pie' and type: 'donut'. These have no axes, no baseline and no
categories along a scale, so they share almost nothing with the rest except the
title, the legend and the accessible description.
The colors mean something different too. In a bar or line chart a color is a series; in a pie there is one series and a color is a category, so the legend names the slices - it is the only thing that does.
Two inputs are refused rather than reinterpreted, because quietly reinterpreting them loses data while looking like it worked:
| Input | Why it is rejected | | -------------------- | ------------------------------------------------------------------------------- | | more than one series | a pie shows shares of a whole; rendering only the first silently drops the rest | | a negative value | a slice cannot have a negative share of anything |
A zero or a null is simply no wedge. If everything is zero, nothing is
drawn at all - a full circle in one arbitrary color would read as "all of it is
this category", which is a lie about data that does not exist.
Slices keep the author's order rather than being sorted by size, so slice order and legend order agree.
A slice is labeled with its share only when the label fits inside the wedge. That is measured rather than guessed at from the angle: how much room a slice has depends on the radius and on how wide the text is, so a 6% slice on a large chart has room the same slice on a small one does not. Slices too thin to label are named by the legend.
Theming
| Property | Applies to | Falls back to |
| ------------------------- | ------------------------ | --------------------- |
| --chart-series-1 … -8 | series fills | a built-in palette |
| --chart-axis | axis lines and baselines | currentColor |
| --chart-text | titles and labels | currentColor |
| --chart-grid | gridlines | currentColor at 15% |
:root {
--chart-series-1: #0969da;
--chart-series-2: #1a7f37;
}One chart that needs its own colors
The stylesheet is the right home for a palette: it keeps every chart on brand, it survives a restyle, and nobody types a hex code into a CMS. It has nothing to say about the one chart that needs a single series picked out.
options.colors is that escape hatch, and it is sparse - null or a missing
entry leaves that index to the stylesheet:
options: {
// Second series red, everything else still follows --chart-series-N.
colors: [null, '#d1373b'],
}An index means whatever the legend is naming: a series in a bar, line or area chart, a slice in a pie or donut.
A color set here is written into the markup literally rather than as a var()
fallback, so it beats the page's stylesheet. That is deliberate and it is the
opposite of the default: a chart naming a color is overruling the house style on
purpose, and a house style that could silently repaint it would make the option
useless in the one case it exists for.
Values are CSS colors - hex, rgb(), hsl(), or a named color. Anything else
is reported as an issue rather than written
into the markup: url(https://…) is a valid paint value that would make a chart
fetch from a third party as it renders.
Errors are returned, not drawn
renderChart returns a result rather than throwing, and never renders a
placeholder:
const result = renderChart(spec);
if (!result.ok) {
// [{ path: 'data.series[0].values[2]', message: 'value must be finite' }]
console.error(result.issues);
}A spec that fails validation is bad content, and content problems belong in front of whoever can fix them - an editor, a build log - rather than disguised as an empty chart on a live page. Every problem is reported at once, with a path, because fixing a pasted spreadsheet one error per attempt is miserable.
The spec
type ChartSpec = {
version: 2; // CHART_SPEC_VERSION
type: 'bar' | 'line' | 'area' | 'pie' | 'donut';
title?: string;
description?: string; // → <desc>, for screen readers
data:
| { source: 'inline'; labels: string[]; series: Series[] }
| { source: 'media'; fileId?: number; labels: string[]; series: Series[] };
options?: {
width?: number; // viewBox units, not pixels
height?: number;
valueFormat?: ValueFormat; // Intl.NumberFormat options
yAxis?: { min?: number; max?: number };
stackMode?: 'grouped' | 'stacked'; // was `barMode` in version 1
xAxis?: {
type?: 'category' | 'time'; // defaults to category
format?: TimeFormat; // Intl.DateTimeFormat options
bounds?: { min?: string; max?: string }; // ISO 8601
};
};
};
type Series = { name: string; values: (number | null)[] };Two things in there are deliberate.
Versions
| Version | What changed |
| ----------- | --------------------------------------------------------------------- |
| 1 | The first. options.barMode chose grouped or stacked bars. |
| 2 (current) | barMode became stackMode, now that stacking applies to areas too. |
renderChart migrates an older spec in memory before drawing it, so publishing
a new Chartkit never blanks charts already in a database. Migrating the stored
content is a separate, opt-in step.
version from the first commit. A spec is stored nested inside a Better
Blocks document, and Better Blocks knows nothing about its shape - it only knows
to hand each chart node to migrateChartSpec. That contract has to exist before
anyone stores a spec, because adding it later means changing the block
registration API, which is a breaking change in another package.
null is a hole, not a zero. A missing measurement is not a measurement of
zero: it draws no bar, and it does not drag the axis toward the origin.
source is a discriminant from the start, even though it began with one
member, so adding a source later does not invalidate stored documents. Reading
live from a Strapi collection is deliberately not here: it is a design problem
about permissions and caching, and a resolver that ignores Strapi's permission
model leaks draft content into public API responses while looking perfectly
innocent in review.
A time axis
By default the labels along the bottom are names, placed in the order given, one slot each. Ask for a time axis and they become instants instead, placed by when they are:
{
version: 2,
type: 'line',
data: {
source: 'inline',
labels: ['2026-01-01', '2026-01-02', '2026-01-17'],
series: [{ name: 'Signups', values: [12, 19, 64] }],
},
options: { xAxis: { type: 'time' } },
}Those three readings are a day apart and then a fortnight apart, and on a time axis they are drawn that way. As categories they would sit at equal intervals and the gap would be invisible - a chart that is wrong in a way nobody notices, which is the whole reason this exists.
Four things follow from placing by time rather than by position:
- Labels must be dates. ISO 8601, as strings, because that is what a date in
JSON should be. A label that does not parse is reported by
validateChartSpecagainst its own index, rather than quietly re-spaced. - Order stops mattering. Readings are drawn in time order whatever order the rows arrive in, so an export that came out unsorted no longer draws as a line doubling back on itself.
- Ticks come from the calendar, not from the data. A year of daily readings gets a handful of month boundaries, not 365 thinned labels. The granularity is chosen from the span and then refined until no two ticks read alike - a date-only format across one afternoon would otherwise write the same string six times.
- Bars get a width. A continuous axis has no band to take one from, so every bar is as wide as the closest pair of readings allows, and the axis gains half a bar of headroom at each end so the first and last are not cut in half.
- Everything is UTC. Ticks land on UTC boundaries and labels are formatted in
UTC unless
format.timeZonesays otherwise. A chart is a pure function of its spec, and local-time ticks would make the same spec render different geometry on a developer's machine, in CI and in production. PasstimeZonewhen you want a chart read in a particular place.
Set format to say how instants are written, and bounds to fix either end:
options: {
xAxis: {
type: 'time',
format: { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' },
bounds: { min: '2026-01-01T00:00:00Z' },
},
}This is opt-in on purpose. Labels that happen to parse as dates are not
necessarily meant to be read as dates - 2024 and 2025 may be two categories
with equal weight - and re-spacing an existing chart because its labels look
date-shaped is the kind of helpfulness nobody asked for.
Accessibility
The SVG is role="img" with aria-labelledby pointing at its own <title> and
<desc>, so a screen reader announces one named image instead of walking fifty
anonymous <rect> elements. Pass idPrefix when a page holds more than one
chart, so their ids cannot collide.
Write the description. "Bar chart" plus a list of numbers conveys far less
than a sentence saying what the numbers show.
The fixture set
import { fixtures } from '@qkix/chartkit-core/fixtures';Every input that breaks chart geometry - negative values, all zeros, a single
point, fifty categories, labels forty characters long, values spanning six
orders of magnitude, and markup in a label. They drive the snapshot tests and
the gallery in examples/chartkit-gallery, so what is asserted and what gets
looked at are the same charts.
They are exported because a chart that looks right on tidy data tells you nothing: tidy data is the case that works by accident. Anyone extending this package should render against these before believing their layout code.
The gallery earns its keep. Bar-width capping, category-label thinning and duplicate-axis-label detection were all added because a chart looked wrong on that page while every test passed. None of them is visible in a diff.
No runtime dependencies
d3-scale, d3-shape and d3-array do the scale and path maths, and are
bundled at build time rather than declared as dependencies - they are ESM-only,
so a CJS build could not require() them. Bundling also lets tree shaking cut
them to the handful of functions this package calls, so consumers never ship the
rest of d3.
License
MIT
