npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 category

Specs 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 validateChartSpec against 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.timeZone says 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. Pass timeZone when 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