Typed, tested, zero-dependency plugins & utilities for uPlot.
Nice axis ticks, calendar-aware time splits, log/step/category axes and stacked areas โ small composable functions you drop straight into uPlot's own options.
uPlot is fast and tiny, and it deliberately leaves the judgement calls to you: which tick values
count as "round", where a day starts, how a stacked area is assembled. So every uPlot app ends up
re-writing the same handful of axis.incrs ladders and axis.splits callbacks โ usually with a
subtle bug in them.
uplot-kit is that layer, extracted from a production charting codebase, stripped of its domain
coupling, documented per option and pinned down by 327 tests.
import type uPlot from 'uplot';
import { incrsForBytes, splitsForTime } from 'uplot-kit';
// before โ a hand-rolled ladder, an off-by-one on month lengths, and a hung tab waiting
// to happen the day someone passes a sub-microsecond increment
declare const sixtyLinesOfCalendarMath: uPlot.Axis.Splits;
const before: uPlot.Axis[] = [{ splits: sixtyLinesOfCalendarMath }, { incrs: [1, 2, 5, 10] }];
// after
const after: uPlot.Axis[] = [
{ splits: splitsForTime({ granularity: 'day' }) },
{ incrs: incrsForBytes() }
];Nothing here is a framework, a wrapper, or a theme. It is uPlot's own option values โ built correctly.
v0.1 is in progress and nothing is published to npm yet. What already exists is not a sketch, though: three utility modules are implemented, exported from the barrel, and covered by the suite.
| Area | State |
|---|---|
๐งฎ incrs โ nice increment ladders |
โ Done โ 12 exports, incl. 9 unit facades |
๐ splits โ axis tick generators |
โ Done โ 4 generators + 4 composable decorators |
๐งฑ stacked โ stacked-area helpers |
โ
Done โ stackedData + stackedBands |
๐ Plugins (autosize, axisSync, timeRegions, verticalMarker, timeSelection, boxZoom, seriesFocus) |
๐ง Next wave โ see Roadmap |
| ๐ Demo site + screenshots | ๐ง Planned |
| ๐ฆ npm release pipeline | ๐ง Planned โ publication is gated |
Test Files 6 passed (6)
Tests 327 passed (327)
Not on npm yet โ until the release gate clears, take it from git:
pnpm add uplot github:twister55/uplot-kituplot is the only peer dependency, and there is no dependencies field at all.
import uPlot from 'uplot';
import { incrsForBytes, splitsForTime, splitsWithEdges, splitsWithLimit } from 'uplot-kit';
const DAY = 24 * 60 * 60;
const start = Date.UTC(2026, 0, 1) / 1000; // axis values are Unix seconds
const data: uPlot.AlignedData = [
[start, start + DAY, start + 2 * DAY, start + 3 * DAY],
[1024, 4096, 2048, 16384]
];
const opts: uPlot.Options = {
width: 800,
height: 400,
series: [{}, { label: 'bytes sent', stroke: 'steelblue' }],
axes: [
{
// tick on real calendar day starts, cap the label count, and never blank the
// axis when a zoom happens to contain no boundary at all
splits: splitsWithEdges(splitsWithLimit(splitsForTime({ granularity: 'day' }), 8), {
mode: 'whenEmpty'
})
},
{
// 1 KiB, 4 KiB, 16 KiB โฆ instead of round-looking decimal ticks
incrs: incrsForBytes()
}
]
};
new uPlot(opts, data, document.body);Ladders of "nice" increments, so uPlot picks 15m / 1h / 1 KiB rather than 16.67m or 1023.
| Export | What it gives you |
|---|---|
incrsForBytes() ยท incrsForKilobytes() ยท incrsForMegabytes() ยท incrsForGigabytes() ยท incrsForTerabytes() ยท incrsForPetabytes() |
Power-of-two ladders โ ticks land on 1 KiB / 1 MiB, not 1000 |
incrsForBits() |
Decimal SI 1-2-5 ladder, the convention for throughput |
incrsForIntegers() |
Whole numbers only (1, 2, 5, 10, 20, 25, 50 โฆ) โ never a 2.5 |
incrsForSeconds() ยท incrsForMilliseconds() ยท incrsForMicroseconds() ยท incrsForNanoseconds() |
Wall-clock ladders from 1 ns to 100 years: 5m, 15m, 1h, 1d, โฆ |
incrsByUnit(kind, opts?) |
Runtime dispatcher over all twelve, or a custom (sanitized) array |
incrsStep(step, opts?) |
Exact multiples of a fixed bucket โ 15-minute candles, 7m polls |
incrsLadder(base, minExp, maxExp, mantissas) |
The engine: build your own mantissa ร base^exp ladder |
Every ladder takes { minIncr, maxIncr }, to clamp it to the resolution your data actually has:
import type uPlot from 'uplot';
import { incrsForSeconds } from 'uplot-kit';
// data is bucketed to 5-minute intervals โ never offer a finer tick
const axes: uPlot.Axis[] = [{}, { scale: 'y', incrs: incrsForSeconds({ minIncr: 300 }) }];Why this isn't a one-liner you write yourself. uPlot reads a tick's decimal count from an internal
fixedDecmap, and below1e-6String()flips to exponential notation, the decimal count reads as0, and uPlot's split loop never advances โ a hung browser tab, not a wrong axis.incrsknows exactly which sub-microsecond rungs uPlot pre-registers, keeps those and drops the rest with a one-time console warning. That behaviour is pinned by a test that transcribes uPlot's ownguessDec/roundDec/genIncrsand asserts the loop would advance.
Generators build a SplitsFn; decorators wrap one and return another, so they compose freely.
Generators
| Export | For |
|---|---|
splitsForTime({ granularity, ms, offsetSec, weekStartsOn }) |
Real calendar boundaries โ day / week / month / quarter / year โ widening as you zoom out. Monday-start weeks and fixed UTC offsets included |
splitsForLog({ base, minor, minorMantissas }) |
Log axes (distr: 3) with control over which minor ticks exist |
splitsForStep({ step, anchor }) |
Strict multiples of a fixed step, optionally anchored off-grid |
splitsForCategory({ count, step }) |
Ordinal / category axes (distr: 2) โ integer positions only |
Decorators
| Export | Effect |
|---|---|
splitsWithInclude(inner, values) |
Always include given values (a zero baseline, an SLO line) when they're in range |
splitsWithLimit(inner, maxTicks) |
Thin evenly to at most maxTicks, keeping the spacing regular |
splitsWithFilter(inner, keep) |
Drop ticks by predicate โ removes them, unlike uPlot's label-only axis.filter |
splitsWithEdges(inner, { mode }) |
Add the visible range's edges โ 'always', or 'whenEmpty' as a blank-axis fallback |
import type uPlot from 'uplot';
import { splitsForTime, splitsWithEdges, splitsWithFilter, splitsWithLimit } from 'uplot-kit';
// order matters: filter innermost, edges outermost, so an edge tick is never filtered away
const axes: uPlot.Axis[] = [
{
splits: splitsWithEdges(
splitsWithLimit(
splitsWithFilter(
splitsForTime({ granularity: 'day', weekStartsOn: 1 }),
(t) => t % 2 === 0
),
10
)
)
}
];import uPlot from 'uplot';
import { stackedBands, stackedData } from 'uplot-kit';
const raw: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[10, 20, 30]
];
const opts: uPlot.Options = {
width: 800,
height: 400,
series: [{}, { fill: 'tomato' }, { fill: 'steelblue' }],
bands: stackedBands(raw.length)
};
new uPlot(opts, stackedData(raw), document.body);stackedData turns each series into the running sum of the ones below it โ gaps count as 0
whatever their encoding (null, undefined, NaN or ยฑInfinity), so one missing sample never
corrupts the series stacked above it, and the input is never mutated. The non-finite ones matter
more than they look: uPlot's own gap test is v != null, so a stray NaN or Infinity left in the
data is a value to it. An Infinity anywhere in view, or a NaN that happens to be the first
point in view, turns the whole y range into NaN and blanks the chart rather than the one point โ
and since "first in view" changes with zoom, the NaN case comes and goes. stackedData treats
them as gaps like the rest; in a series excluded by omit, which otherwise keeps its raw values,
they come back as null. stackedBands pairs each series with the one beneath it, carrying no
fill of its own so color stays a per-series choice.
A gap's own series holds the running total across it, so every accumulated row is dense (a series
excluded by omit keeps its raw gaps). That is the same choice Plotly makes by default for a
stacked area โ stackgaps: 'infer zero', whose only alternative is 'interpolate'; there is no
"leave a hole" setting โ and here it is not a free one. uPlot turns a series' gaps into a clip path
and applies it to the band fill of the series above, so a hole in one series would erase the
filled area of its upper neighbour, which still has data there. A line or spline neighbour keeps its
stroke; one drawn with uPlot.paths.bars loses that too. Libraries that do show holes in a stack
(ECharts, Chart.js with fill: 'origin') get them for free by filling every series to the baseline
and painting back to front; uPlot fills each band to the previous series' path instead, so that
option is not on the table.
The rule is uniform: it does not ask whether a band is actually drawn above a given gap. So a gapped
series is drawn as a line lying on its lower neighbour rather than breaking, and a column where no
series has data puts every line on the baseline with its bands collapsed to nothing. Both are
expected, not a failure. For a genuine hole where the topmost stacked series has no data โ the one
place uPlot can render one without erasing anything โ write null back into that series' own
accumulated row wherever its raw row was a gap, which means v == null || !Number.isFinite(v), not
null alone. Note "topmost stacked", not "last row": a series excluded by omit sits in the output
at its own index without being part of the stack.
Read the accumulated rows back with two things in mind. They are lossy: a 0 in one means either
"the running total here is 0" or "no sample", and nothing tells the two apart โ not for your own
tooltip, legend or export, and not for uPlot, which treats a gap cell as a sample like any other. It
paints a point marker there whenever the series shows points (points.show, or on its own once the
data is sparse enough), snaps the hover point to it and prints the held total in the legend.
Whatever has to tell them apart should read the raw row alongside, with the same test the recipe
above uses. For uPlot's own drawing that is two options: series.points.filter returning only the
indices whose raw sample is a reading (and null when its show argument is false), and
cursor.dataIdx returning null for a gap, which hides the hover point and empties that series'
legend value.
And a gap in the lowest series emits a genuine 0, which uPlot scales like any other value, so
[100, 105, null, 102] gives a linear y range of 0..105 rather than 100..105 โ usually what a
stacked area wants, since its areas are read from the baseline. On a log scale (distr: 3) the
range is unaffected (uPlot ranges log scales over positive values only), but the point still gets
placed one decade below the scale minimum, so the gap plunges off the bottom of the plot and comes
back. Give that scale an explicit range, or keep gaps out of the bottom series.
Both take the same omit predicate, so a series hidden in your legend drops out of the stack and
the rest re-stack as if it were never there:
import uPlot from 'uplot';
import { stackedData } from 'uplot-kit';
const raw: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[10, 20, 30]
];
const u = new uPlot(
{
width: 800,
height: 400,
series: [{}, { fill: 'tomato' }, { fill: 'steelblue' }]
},
stackedData(raw),
document.body
);
const hidden = (seriesIdx: number): boolean => u.series[seriesIdx]?.show !== true;
u.setData(stackedData(raw, { omit: hidden }));These aren't aspirations โ they're the constraints every unit in the package is built under.
- ๐ชถ Zero runtime dependencies.
uplotis the only peer.throttle/clamp-sized helpers get inlined at the point of use; ESLint fails the build on an extraneous import. - ๐ฒ Tree-shakeable by construction. ESM,
sideEffects: false, named exports only. ImportincrsForBytesand the other eleven ladders never reach your bundle. - ๐งฏ Nothing in
src/throws. A bad option that has a documented default is named, warned about once on the console, and replaced by the default. A bad required value produces the inert result โ no ticks, no increments. A chart-config typo costs an axis, not the page. - ๐จ No styling on your behalf. Colors, fonts and line widths are options. External state (which series is focused or hidden) enters through predicates, never by the code reaching into your app state.
- ๐ Every export is documented. JSDoc on the factory and on every option field, with
@defaultstated in prose and complete, copy-pasteable@examplesnippets โ because the emitted.d.tsis what your IDE, and your coding agent, actually read. - ๐งช TypeScript strict, tests per unit. Pure utilities test in node; DOM plugins will test in vitest browser mode.
Not in src, not in devDependencies, not in the demos (those are vanilla Vite). Wrappers such as
svelte-uplot are separate, independent projects that do not depend on uplot-kit โ the only
contract between them is uPlot's own options.plugins / uPlot.Plugin.
The whole barrel is ~5.4 kB min+gzip (~13 kB minified). Realistically you ship a fraction of that: every unit tree-shakes independently.
| uPlot | ^1.6 โ verified against 1.6.32 |
| Module format | ESM only, ES2022, browser-targeted |
| Entry point | Exactly one: uplot-kit. No subpaths โ tree-shaking is the granularity |
| Node | Not required by the package. Node 20.19+ / 22.13+ / 24+ is a tooling floor for contributors |
The plugin wave, in the order it's queued:
| Plugin | What it does |
|---|---|
autosize |
Resize the chart to its container |
axisSync |
Align axis gutters across charts so plot areas line up |
timeRegions |
Shaded regions โ weekends, deploys, incidents |
verticalMarker |
A moving "now" line |
timeSelection |
Drag-to-select a time range |
boxZoom |
Rectangular zoom |
seriesFocus |
Hover/legend focus without touching your state |
Behind them: seriesBars (grouped/stacked bars), tzDate (timezone & DST via Intl), dataLabels
โ plus a vanilla demo site with a page per plugin.
pnpm i
pnpm build # tsup โ ESM + d.ts (single entry: the root barrel)
pnpm test # vitest
pnpm check # tsc --noEmit (strict, both project refs)
pnpm lint # prettier + eslint
pnpm lint:pkg # publint + attwRun one file with pnpm vitest run src/splits.test.ts, one case with pnpm vitest run -t 'name'.
Use pnpm โ the lockfile and pnpm-workspace.yaml assume it.
MIT ยฉ Vadim Yelisseyev