charts.stack
Stack several series for stacked bars and areas: on zero, normalised to 100%, or diverging around zero.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 14 tests, run in TypeScript, Python and Rust.
What it does
Stacks several series so they can be drawn as stacked bars or stacked areas. `series[i][j]` is series `i` at position `j` (a category or an x value); the result has the same shape, and each entry is that value's band `[y0, y1]` in data units. Map both edges through a scale and hand them to `charts.shape`'s `areaPath` (as `y0`/`y1`) or `barRects` (as `base`/`value`).
Series are stacked in the order given, first at the bottom. The offsets are d3-shape's:
For example
stack(series ×3, zero)→ ×3 zero: each series sits on the one before itstack(series ×3, zero)→ ×3 zero: a negative value pulls its band below the running top, as d3's stackOffsetNonestack(series ×3, zero)→ ×3 zero: binary noise is rounded away (0.1 + 0.2 is 0.30000000000000004)
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function stack(series: readonly (readonly number[])[], offset: StackOffset): readonly (readonly StackedValue[])[]
| series | float[][] | series[i][j] is series i at position j; every series the same length |
| offset | StackOffset | zero stacks in order, expand scales each position to a total of 1, diverging stacks positives up and negatives down |
| returns | StackedValue[][] | the same shape as series: each value's band [y0, y1], rounded to 6 places |
The types it declares, generated into your project
export type StackOffset = "zero" | "expand" | "diverging";
/** One value's band in the stack, in data units: y0 is the lower edge, y1 the upper. */
export interface StackedValue {
readonly y0: number;
readonly y1: number;
}
Your code names it in one line, in the file that uses it
import { stack } from "#fune/charts.stack@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
import type { StackedValue, StackOffset } from "./charts_stack_types.ts";
/**
* Stack series position by position, as d3.stack does with the series in the
* given order: stackOffsetNone (zero), stackOffsetExpand and
* stackOffsetDiverging. Running totals are kept unrounded; only the returned
* edges are rounded, so rounding never accumulates up the stack.
*/
export function stack(series: readonly (readonly number[])[], offset: StackOffset): readonly (readonly StackedValue[])[] {
if (offset !== "zero" && offset !== "expand" && offset !== "diverging") {
throw new RangeError(`stack offset must be zero, expand or diverging, received ${offset}`);
}
const n = series.length;
if (n === 0) return [];
const m = series[0].length;
for (const s of series) {
if (s.length !== m) throw new RangeError("every series must have the same number of values");
for (const v of s) {
if (typeof v !== "number" || !Number.isFinite(v)) throw new TypeError(`stack values must be finite numbers, received ${v}`);
if (offset === "expand" && v < 0) throw new RangeError(`expand needs values of zero or more, received ${v}`);
}
}
const lower: number[][] = series.map(() => new Array<number>(m));
const upper: number[][] = series.map(() => new Array<number>(m));
for (let j = 0; j < m; j++) {
if (offset === "diverging") {
let up = 0;
let down = 0;
for (let i = 0; i < n; i++) {
const v = series[i][j];
if (v > 0) {
lower[i][j] = up;
up += v;
upper[i][j] = up;
} else if (v < 0) {
upper[i][j] = down;
down += v;
lower[i][j] = down;
} else {
lower[i][j] = 0;
upper[i][j] = 0;
}
}
} else {
let total = 0;
if (offset === "expand") for (let i = 0; i < n; i++) total += series[i][j];
let top = 0;
for (let i = 0; i < n; i++) {
// Each share is divided by the total first, then summed, as d3 does.
const v = offset === "expand" ? (total > 0 ? series[i][j] / total : 0) : series[i][j];
lower[i][j] = top;
top += v;
upper[i][j] = top;
}
}
}
return series.map((_, i) => lower[i].map((y0, j) => ({ y0: roundFloat(y0, 6), y1: roundFloat(upper[i][j], 6) })));
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add charts.stack
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./charts.stack-1.0.0-typescript.fune, or fetch it from a terminal with fune pull charts.stack@1.0.0:typescript.
The whole function, every language, is one file too: charts.stack-1.0.0.fune, 15,255 bytes, sha256 ee6034afe10372edb9e49cb658d077cb040170c09a161bae7b4b58c9a7a1a5d0. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before charts.stack
after — your function gets the result and the arguments, and returns the final result.
// fune: after charts.stack
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.round-float in charts.stack
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.stack --steps.
// fune: step charts.stack after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| zero: each series sits on the one before it | series ×3, zero | → | ×3 |
| zero: a negative value pulls its band below the running top, as d3's stackOffsetNone | series ×3, zero | → | ×3 |
| zero: binary noise is rounded away (0.1 + 0.2 is 0.30000000000000004) | series ×3, zero | → | ×3 |
| expand: each position scaled to a total of 1 | series ×3, expand | → | ×3 |
| expand: thirds round to 6 places and the top is exactly 1 | series ×3, expand | → | ×3 |
| expand: a position whose values are all zero stays at zero | series ×2, expand | → | ×2 |
| diverging: positives stack up from zero, negatives down from zero | series ×3, diverging | → | ×3 |
| diverging: a zero value is an empty band at zero | series ×3, diverging | → | ×3 |
| a single series | series ×1, zero | → | ×1 |
| no series | , zero | → |
Show the other 4 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| series with no positions | series ×2, diverging | → | ×2 |
| series of different lengths are an error | series ×2, zero | → | error: every series must have the same number of values |
| expand with a negative value is an error | series ×2, expand | → | error: expand needs values of zero or more |
| an unknown offset is an error | series ×1, silhouette | → | error: stack offset must be zero, expand or diverging |
More from the author
- `zero` (d3.stackOffsetNone): each band starts where the one below ended. A negative value makes a band whose top is below its bottom, overlapping the one beneath, which is what d3 does and is fine for areas of mostly positive data. For bars with negatives use `diverging`. - `expand` (d3.stackOffsetExpand): each value is first divided by its position's total, then stacked, so every position runs from 0 to 1, a 100% chart. Multiply by 100 for percentages (or format with `charts.format`'s `formatPercent`). A position whose values are all zero stays at 0. Negative values have no meaning as shares of a total and are an error (d3 produces nonsense for them). - `diverging` (d3.stackOffsetDiverging): positive values stack upwards from zero and negative values downwards from zero, each in series order; a zero value is an empty band at 0. This is the right one for bars that go both ways, such as profit and loss.
Running totals are kept at full precision and only the returned edges are rounded, to 6 decimal places by `math.round-float`. So `0.1 + 0.2` stacks to `0.3`, not `0.30000000000000004`, and an `expand` stack ends at exactly 1.
Every series must have the same length; there are no gaps (pass 0 for a missing value, which is what a stack means by it). d3's stackOrder options are not offered: reorder the series before calling.
Source: d3-shape, "Stacks" (github.com/d3/d3-shape#stacks): stack, stackOffsetNone, stackOffsetExpand, stackOffsetDiverging.
Files
| Path | Bytes |
|---|---|
| README.md | 1,968 |
| impl/python.py | 2,395 |
| impl/rust.rs | 3,212 |
| impl/typescript.ts | 2,364 |
| vectors.json | 2,566 |