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

@bro-code/charts

v2026.2.0

Published

Chart geometry for SVG: scales, ticks, area paths, stacked areas, pie slices, grouped bars and coverage bars, computed as plain data with no framework or DOM, so any app can draw its own charts.

Readme

@bro-code/charts

npm version license

Chart geometry for SVG: scales, ticks, area paths, stacked areas, pie slices, grouped bars and coverage bars, computed as plain data with no framework or DOM, so any app can draw its own charts.

The package computes numbers and SVG path data and nothing else. The app draws the <svg> in whatever it renders with, and owns the colours, tooltips and any animation, so a change to how a chart looks never needs a new version of this package. It has no runtime dependencies and does nothing when imported.

Installation

npm install @bro-code/charts

Usage

An area chart of six monthly lows, as an <svg> string:

import { areaPath, extent, linearScale, nearestIndex } from '@bro-code/charts';

const lows = [120, 80, -40, 15, 60, 95];
const width = 300;
const height = 120;

const x = linearScale([0, lows.length - 1], [0, width]);
const y = linearScale(extent(lows, { includeZero: true }), [height, 0]);
const points = lows.map((low, i) => ({ x: x.map(i), y: y.map(low) }));
const { line, area } = areaPath(points, { baseline: y.map(0) });

const svg = `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
  <path d="${area}" fill="rgb(76 175 80 / 0.25)" />
  <path d="${line}" fill="none" stroke="rgb(76 175 80)" stroke-width="2" />
</svg>`;

// The month under the pointer, for a tooltip:
const pointerX = 130; // from a pointer event, in the chart's coordinates
const month = nearestIndex(points.map((p) => p.x), pointerX); // 2

A stacked area chart of a loan's first payments, each split into its interest (the bottom layer) and its principal (on top), so the top line is the payment:

import { bandPath, extent, linearScale, stack } from '@bro-code/charts';

const interest = [50, 48.73, 47.46, 46.18];
const principal = [254.22, 255.49, 256.76, 258.04];
const layers = stack([interest, principal]);

const x = linearScale([0, interest.length - 1], [0, width]);
const y = linearScale(extent(layers[layers.length - 1].upper, { includeZero: true }), [height, 0]);
const points = (values: number[]) => values.map((value, i) => ({ x: x.map(i), y: y.map(value) }));
const [below, above] = layers.map((layer) => bandPath(points(layer.upper), points(layer.lower)));

const svg = `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
  <path d="${below.area}" fill="rgb(244 67 54 / 0.35)" />
  <path d="${above.area}" fill="rgb(76 175 80 / 0.35)" />
  <path d="${below.line}" fill="none" stroke="rgb(244 67 54)" stroke-width="2" />
  <path d="${above.line}" fill="none" stroke="rgb(76 175 80)" stroke-width="2" />
</svg>`;

CommonJS

const { areaPath, extent, linearScale } = require('@bro-code/charts');

API

Every position is in drawing coordinates: x grows to the right and y grows downward, as in SVG. Path data is written with at most two decimals.

linearScale(domain, range)

Maps domain (data values) linearly onto range (drawing positions). Both are [start, end] pairs and either may run backwards; a value axis is usually linearScale([min, max], [height, 0]). Returns a LinearScale:

| Member | | |---|---| | map(value) | The position of a domain value. Values outside the domain extrapolate past the ends of the range. | | invert(position) | The domain value at a position, extrapolating the same way. |

A domain whose ends are equal (every value the same) maps every value to the middle of the range, and a range whose ends are equal (a chart with no width yet) inverts every position to the middle of the domain, instead of dividing by zero.

extent(values, options?)

The smallest and largest of values, as [min, max]; [0, 0] when there are none. The values are not changed.

| Option | Default | | |---|---|---| | includeZero | false | Stretch the extent to take in zero, so bars and areas have a zero line to stand on. An extent that already spans zero is unchanged. |

ticks(domain, count, options?)

Round values for an axis over domain, at most count of them, in ascending order. They are multiples of a step that is 1, 2 or 5 times a power of ten, the smallest such step that fits, and they run from the multiple at or below the domain's smaller end to the one at or above its larger end. Scale the axis from the first tick to the last, so the top gridline always has a label:

const values = ticks(extent(counts, { includeZero: true }), 5, { integer: true });
const y = linearScale([values[0], values[values.length - 1]], [height, 0]);

| Option | Default | | |---|---|---| | integer | false | Keep the step at 1 or more, for an axis of counts. |

ticks([0, 37], 5) is [0, 10, 20, 30, 40], ticks([-15, 40], 5) is [-20, 0, 20, 40], and ticks([0, 1], 5) is [0, 0.5, 1] (a step of 0.2 would need six), while ticks([0, 1], 5, { integer: true }) is [0, 1]. Decimal ticks carry no float noise (0.3, not 0.30000000000000004). The ends may come in either order, and equal ends give that one value. An end that isn't finite, or a count that isn't a whole number of 3 or more, throws a RangeError: a domain spanning zero always needs three ticks, below, zero and above.

areaPath(points, options)

Path data for an area chart through points ({ x, y }, in drawing order), joined with straight segments or a smooth curve. Returns { line, area }: line runs through every point, for the stroke, and area is that line closed to the baseline, for the fill.

| Option | Default | | |---|---|---| | baseline | | The y the area closes to: the zero line (y.map(0)) or the bottom of the chart. Above the points, the area closes upward. | | curve | 'linear' | 'linear' joins the points with straight segments. 'monotone' joins them with cubic Bézier segments that pass through every point and never go past a point's neighbours, for the line and the area alike. |

A monotone curve uses Steffen's method, as d3's curveMonotoneX does: every control point's y lies between its segment's two end ys, so a run of equal values stays flat, a peak is never drawn higher than it is, and a series of counts never dips below its zero line. It needs each point's x to be greater than the one before, and throws a RangeError naming the first point that isn't.

No points give empty strings. One point gives a zero-length line, which a round stroke-linecap shows as a dot, and an area with no width, with either curve. Two points under 'monotone' give one straight Bézier segment.

bandPath(upper, lower, options?)

Path data for the band between two lines, as for one layer of a stacked area chart: upper and lower are the same number of points ({ x, y }, in drawing order). Returns { line, area }: line runs through upper, for the stroke, and area runs along upper and back along lower, closed, for the fill.

| Option | Default | | |---|---|---| | curve | 'linear' | As for areaPath. Under 'monotone', the lower edge is the curve lower's own line would be, drawn backwards, so a band and the one under it (whose line is this band's lower) share their edge exactly, with no gap or overlap. |

No points give empty strings, and one point a band with no width. Lines of different lengths throw a RangeError, and so does a point whose x isn't greater than the one before, under 'monotone'.

stack(series)

Stacks series, one number array per layer, all the same length, bottom layer first. Returns one StackLayer per layer, { index, lower, upper }: at each index, lower is the running total under the layer (the layer below's upper, or 0 for the first) and upper the total up to and including it. Map both with a scale and draw each layer with bandPath; the top layer's upper is the total, for the value domain (extent(top.upper, { includeZero: true })).

Values are meant to be zero or more: a negative value takes the running total down, so its layer folds back over the one below. A non-finite value counts as 0. Layers of different lengths throw a RangeError naming the layer.

pieSlices(values, options)

One PieSlice per value, in the order given, going clockwise. Angles are in degrees, clockwise from 12 o'clock.

| Option | Default | | |---|---|---| | cx, cy | | The centre. | | radius | | The outer radius. | | innerRadius | 0 | The radius of the hole, for a donut. | | startAngle | 0 | Where the first slice starts; 180 (or -180) starts at the bottom. | | padAngle | 0 | Degrees of gap between neighbouring slices, taken half from each end of each slice. |

| Slice field | | |---|---| | index, value | The value's place in values, and the value. | | fraction | Its share of the total, from 0 to 1; 0 for every slice when all the values are 0. | | startAngle, endAngle | Its angles before padding, so the slices tile the full turn. | | path | Path data for the slice; empty when it draws nothing. | | centroid | { x, y } at the middle of its angle, halfway between the inner and outer radius: a place for a tooltip or label. |

A value that is the whole total is drawn as a full circle (a ring for a donut), with no padding. A zero value, every value when all are zero, and a slice no wider than padAngle draw nothing. A negative value throws a RangeError naming its index.

groupedBars(series, options)

Grouped bars for series, one number array per series (charges and deposits, say), all the same length: group i holds value i of each series. Returns { bars, baseline }: baseline is the y of the zero line, and bars run left to right, group by group, each group's bars in series order. Each Bar is { series, index, value, x, y, width, height }, index being its group.

| Option | Default | | |---|---|---| | width, height | | The size of the chart. | | groupPadding | 0.1 | Share of each group's band left empty, half on each side of the group. | | barPadding | 0 | Share of each group's width left empty between its bars. |

The groups split width into equal bands with no outer padding, so equal grid columns under the chart (month labels, say) line up with them. The value domain spans zero and every value, so a negative value hangs below the zero line; with no values or only zeros, the zero line is the bottom. Series of different lengths throw a RangeError.

coverageBar(values, target, options)

A horizontal bar of values laid end to end from the left (passive income, then earned income, say), measured against a marker at target (the month's expenses). The scale runs from 0 to the larger of the total and the target, so a segment that ends past the marker covers the target on its own, with what comes before it. Returns { segments, track, target }: one CoverageSegment per value, in the order given, as { index, value, x, width, path }; track, path data for the whole bar from 0 to width, rounded like the far end, to draw behind the segments; and target, the marker's x.

| Option | Default | | |---|---|---| | width, height | | The size of the bar. | | gap | 0 | Space between neighbouring segments, taken from the start of the later one. | | radius | 0 | Corner radius of the bar's far end, kept within the last segment's width and half the height. |

Each segment ends where its running total reaches, so the marker lines up with the totals exactly whatever the gap. A gap follows only a segment that draws, and a zero value, or a segment no wider than the gap, draws nothing (its width is 0 and its path empty). Only the last segment that draws is rounded, at its right end; the bar's start stays square. With no values and a target of 0, everything sits at 0. A negative value or target throws a RangeError.

import { coverageBar } from '@bro-code/charts';

const bar = coverageBar([passive, earned], expenses, { width: 300, height: 28, gap: 2, radius: 4 });
const svg = `<svg viewBox="0 -6 300 40" width="300" height="40">
  <path d="${bar.track}" fill="rgb(0 0 0 / 0.08)" />
  ${bar.segments.map((s, i) => `<path d="${s.path}" fill="${['rgb(25 158 112)', 'rgb(57 135 229)'][i]}" />`).join('')}
  <line x1="${bar.target}" x2="${bar.target}" y1="-6" y2="34" stroke="rgb(239 83 80)" stroke-width="3" />
</svg>`;

nearestIndex(positions, x)

The index of the position closest to x, the first of a tie; -1 when there are none. For an area chart's tooltip, pass the points' x positions and the pointer's x.

bandIndex(x, count, width)

Which of count equal bands across width holds x, clamped to the first and last band; -1 when count is 0. For a bar chart's tooltip, bandIndex(x, groupCount, width) gives the group under the pointer, and bandIndex again across that group's bars (from its first bar's x to its last bar's x + width) gives the bar.

Types

The package exports LinearScale, ExtentOptions, Interval (a readonly [number, number] pair), TickOptions, Point, AreaOptions, AreaPath, BandOptions, StackLayer, PieOptions, PieSlice, GroupedBarsOptions, GroupedBars, Bar, CoverageBarOptions, CoverageBar and CoverageSegment as types.

License

MIT - see LICENSE.

Changelog

[2026.2.0] - 2026-11-14

Added

  • stack(series): each layer's running lower and upper totals, for a stacked area chart, bottom layer first.
  • bandPath(upper, lower, { curve }): the line and the filled band between two lines, straight or monotone; under 'monotone' a band's lower edge is the band below's line drawn backwards, so stacked bands share their edges exactly.
  • coverageBar(values, target, { width, height, gap, radius }): a horizontal bar of values laid end to end against a target's marker, scaled to the larger of the total and the target, with gaps that keep each segment ending at its running total and a rounded far end.

[2026.1.0] - 2026-11-12

Added

  • ticks(domain, count, { integer }): round tick values (multiples of 1, 2 or 5 times a power of ten) covering the domain, at most count of them, with integer keeping the step at 1 or more.
  • areaPath's curve option: 'monotone' draws the line and the area as a smooth curve that never passes a point's neighbours; the default, 'linear', draws straight segments exactly as before.

[2026.0.0] - 2026-10-29

Added

  • Initial release.
  • linearScale(domain, range), with map and invert, reversed domains and ranges, and equal-ended domains and ranges mapped to their middle.
  • extent(values, { includeZero }).
  • areaPath(points, { baseline }): the line and the filled area, with straight segments.
  • pieSlices(values, options): pie and donut slices with a start angle and padding, full-circle and empty slices, and a centroid for each.
  • groupedBars(series, options): bars in equal bands with group and bar padding, and negative values below the zero line.
  • nearestIndex(positions, x) and bandIndex(x, count, width), for finding what is under the pointer.