charts.bar-chart
Complete bar chart geometry as data, grouped or stacked: plot area, axes, gridlines, bar rectangles, colours, legend.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 11 tests, run in TypeScript, Python and Rust.
What it does
A complete vertical bar chart as data, grouped or stacked: the plot area, a category x axis, a number y axis, gridlines, one rectangle per bar with its series, category, value and colour, and a legend. Nothing is drawn; any renderer turns it into pixels, and every language produces the same numbers.
It is assembled from `charts.scale` (band and linear scales), `charts.ticks`, `charts.format`, `charts.stack`, `charts.shape` (bar rectangles), `charts.axis`, `charts.layout` and `charts.palette`.
For example
barChart(width 300, height 200, categories Q1, Q2, series ×2, mode grouped)→ width 300, height 200, plot …, x axis …, y axis …, grid lines ×3, bars ×4, colors #e69f00, #56b4e9, legend ×2 grouped: two series side by side in each categorybarChart(width 300, height 200, categories x, y, series ×2, mode stacked)→ width 300, height 200, plot …, x axis …, y axis …, grid lines ×4, bars ×4, colors #e69f00, #56b4e9, legend ×2 stacked: positives stack up from zero and a negative hangs below itbarChart(width 200, height 150, categories a, b, c, series ×1, mode grouped)→ width 200, height 150, plot …, x axis …, y axis …, grid lines ×3, bars ×3, colors #e69f00, legend one series: full-width bands, no legend; widths are differences of rounded edges (37.87, 37.86)
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 barChart(spec: BarChartSpec): BarChart
| spec | BarChartSpec | |
| returns | BarChart | every pixel value rounded to 2 places; draw it with any renderer |
The types it declares, generated into your project
export type BarMode = "grouped" | "stacked";
/** One series: a value per category. */
export interface BarSeries {
readonly name: string;
/** one per category, in the same order */
readonly values: readonly number[];
}
/** What to draw. */
export interface BarChartSpec {
readonly width: number;
readonly height: number;
/** the x axis, left to right; no repeats */
readonly categories: readonly string[];
/** no two with the same name */
readonly series: readonly BarSeries[];
/** grouped side by side, or stacked (positives up, negatives down from zero) */
readonly mode: BarMode;
}
/** One bar, ready to draw. */
export interface ChartBar {
readonly series: string;
readonly category: string;
/** the data value, for a tooltip */
readonly value: number;
/** "#rrggbb", the series' colour */
readonly color: string;
readonly rect: Rect;
}
/** The whole chart; legend[i] and colors[i] are series i. */
export interface BarChart {
readonly width: number;
readonly height: number;
readonly plot: PlotArea;
readonly xAxis: Axis;
readonly yAxis: Axis;
/** horizontal, one per y tick */
readonly gridLines: readonly Line[];
/** series by series, each in category order */
readonly bars: readonly ChartBar[];
/** one per series, from the Okabe-Ito palette */
readonly colors: readonly string[];
/** empty for a single series */
readonly legend: readonly LegendItem[];
}
Your code names it in one line, in the file that uses it
import { barChart } from "#fune/charts.bar-chart@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { bandAxis } from "./charts_axis_band_axis.ts";
import { gridLines } from "./charts_axis_grid_lines.ts";
import { numberAxis } from "./charts_axis_number_axis.ts";
import type { BarChart, BarChartSpec, ChartBar } from "./charts_bar_chart_types.ts";
import { formatTick } from "./charts_format_format_tick.ts";
import { legendRows } from "./charts_layout_legend_rows.ts";
import { plotArea } from "./charts_layout_plot_area.ts";
import type { LegendItem } from "./charts_layout_types.ts";
import { palette } from "./charts_palette.ts"; ← from charts.palette ^1.0.0 · built alongside by fune
import { bandScale } from "./charts_scale_band_scale.ts";
import { linearScale } from "./charts_scale_linear_scale.ts";
import { niceDomain } from "./charts_scale_nice_domain.ts";
import { barRects } from "./charts_shape_bar_rects.ts";
import type { BarSpec } from "./charts_shape_types.ts";
import { stack } from "./charts_stack.ts"; ← from charts.stack ^1.0.0 · built alongside by fune
import { niceTicks } from "./charts_ticks_nice_ticks.ts";
import { tickStep } from "./charts_ticks_tick_step.ts";
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
// Fixed layout constants; the README explains each.
const CHAR_WIDTH = 7;
const SWATCH = 10;
const LEGEND_GAP = 16;
const LEGEND_ROW = 18;
const EDGE = 8;
const TICK_SIZE = 6;
const LABEL_PADDING = 3;
const TOP_WITHOUT_LEGEND = 12;
const RIGHT = 20;
const BOTTOM = 30;
const CATEGORY_PADDING_INNER = 0.2;
const CATEGORY_PADDING_OUTER = 0.1;
const SERIES_PADDING = 0.05;
/**
* A whole bar chart as geometry, grouped or stacked. Every layout decision is
* a fixed rule (listed in the README), so the same data gives the same chart
* in every language and every renderer.
*/
export function barChart(spec: BarChartSpec): BarChart {
const { width, height, categories, series } = spec;
if (spec.mode !== "grouped" && spec.mode !== "stacked") {
throw new RangeError(`mode must be grouped or stacked, received ${spec.mode}`);
}
if (categories.length === 0) throw new RangeError("a bar chart needs at least one category");
if (series.length === 0) throw new RangeError("a bar chart needs at least one series");
for (const s of series) {
if (s.values.length !== categories.length) {
throw new RangeError(`series "${s.name}" has ${s.values.length} values but there are ${categories.length} categories`);
}
}
const names = series.map((s) => s.name);
let legend: LegendItem[] = [];
let top = TOP_WITHOUT_LEGEND;
if (series.length > 1) {
const rows = legendRows(names, width - 2 * EDGE, CHAR_WIDTH, SWATCH, LEGEND_GAP, LEGEND_ROW);
legend = rows.map((it) => ({
...it,
x: roundFloat(it.x + EDGE, 2),
y: roundFloat(it.y + EDGE, 2),
textX: roundFloat(it.textX + EDGE, 2),
}));
top = EDGE + (rows[rows.length - 1].row + 1) * LEGEND_ROW + EDGE;
}
// Bars grow from zero, so zero is always in the y domain. Stacked bars
// stack positives up and negatives down (a diverging stack).
const stacked = spec.mode === "stacked" ? stack(series.map((s) => s.values), "diverging") : null;
let lo = 0;
let hi = 0;
series.forEach((s, i) => {
s.values.forEach((v, j) => {
const ends = stacked === null ? [v] : [stacked[i][j].y0, stacked[i][j].y1];
for (const e of ends) {
if (e < lo) lo = e;
if (e > hi) hi = e;
}
});
});
if (lo === hi) hi = 1;
const yCount = Math.max(2, Math.floor((height - top - BOTTOM) / 50));
const yDomain = niceDomain([lo, hi], yCount).domain;
const yStep = tickStep(yDomain[0], yDomain[1], yCount);
let widest = 0;
for (const v of niceTicks(yDomain[0], yDomain[1], yCount)) {
widest = Math.max(widest, [...formatTick(v, yStep)].length);
}
const left = TICK_SIZE + LABEL_PADDING + widest * CHAR_WIDTH + EDGE;
const plot = plotArea(width, height, { top, right: RIGHT, bottom: BOTTOM, left });
const bottomY = plot.y + plot.height;
const yRange = [bottomY, plot.y];
const y = (v: number) => linearScale(yDomain, yRange, v, false);
const yAxis = numberAxis(yDomain, yRange, yCount, "left", plot.x, TICK_SIZE);
const xRange = [plot.x, plot.x + plot.width];
const xAxis = bandAxis(categories, xRange, CATEGORY_PADDING_INNER, CATEGORY_PADDING_OUTER, "bottom", bottomY, TICK_SIZE);
const colors = palette("okabe-ito", series.length, true);
const specs: BarSpec[] = [];
const meta: { series: string; category: string; value: number; color: string }[] = [];
series.forEach((s, i) => {
categories.forEach((c, j) => {
const outer = bandScale(categories, xRange, c, CATEGORY_PADDING_INNER, CATEGORY_PADDING_OUTER, 0.5);
if (stacked !== null) {
specs.push({ band: outer.start, thickness: outer.width, base: y(stacked[i][j].y0), value: y(stacked[i][j].y1) });
} else if (series.length === 1) {
specs.push({ band: outer.start, thickness: outer.width, base: y(0), value: y(s.values[j]) });
} else {
const inner = bandScale(names, [outer.start, outer.start + outer.width], s.name, SERIES_PADDING, 0, 0.5);
specs.push({ band: inner.start, thickness: inner.width, base: y(0), value: y(s.values[j]) });
}
meta.push({ series: s.name, category: c, value: s.values[j], color: colors[i] });
});
});
const rects = barRects(specs, "vertical");
const bars: ChartBar[] = meta.map((m, k) => ({ ...m, rect: rects[k] }));
return {
width: roundFloat(width, 2),
height: roundFloat(height, 2),
plot,
xAxis,
yAxis,
gridLines: gridLines(yAxis, plot.width),
bars,
colors,
legend,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 9 dependencies, 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.bar-chart
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./charts.bar-chart-1.0.0-typescript.fune, or fetch it from a terminal with fune pull charts.bar-chart@1.0.0:typescript.
The whole function, every language, is one file too: charts.bar-chart-1.0.0.fune, 38,104 bytes, sha256 f2f19f88e0020d7eca1278bfe4cc959f8ef94dad942d2107c08b3fe086f020f3. 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.bar-chart
after — your function gets the result and the arguments, and returns the final result.
// fune: after charts.bar-chart
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 charts.axis in charts.bar-chart
// fune: replace charts.format in charts.bar-chart
// fune: replace charts.layout in charts.bar-chart
// fune: replace charts.palette in charts.bar-chart
// fune: replace charts.scale in charts.bar-chart
// fune: replace charts.shape in charts.bar-chart
// fune: replace charts.stack in charts.bar-chart
// fune: replace charts.ticks in charts.bar-chart
// fune: replace math.round-float in charts.bar-chart
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.bar-chart --steps.
// fune: step charts.bar-chart 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 | |
|---|---|---|---|
| grouped: two series side by side in each category | width 300, height 200, categories Q1, Q2, series ×2, mode grouped | → | width 300, height 200, plot …, x axis …, y axis …, grid lines ×3, bars ×4, colors #e69f00, #56b4e9, legend ×2 |
| stacked: positives stack up from zero and a negative hangs below it | width 300, height 200, categories x, y, series ×2, mode stacked | → | width 300, height 200, plot …, x axis …, y axis …, grid lines ×4, bars ×4, colors #e69f00, #56b4e9, legend ×2 |
| one series: full-width bands, no legend; widths are differences of rounded edges (37.87, 37.86) | width 200, height 150, categories a, b, c, series ×1, mode grouped | → | width 200, height 150, plot …, x axis …, y axis …, grid lines ×3, bars ×3, colors #e69f00, legend |
| all zero: the y axis still has a height, 0 to 1 | width 200, height 150, categories a, series ×1, mode grouped | → | width 200, height 150, plot …, x axis …, y axis …, grid lines ×3, bars ×1, colors #e69f00, legend |
| no categories is an error | width 300, height 200, categories , series ×1, mode grouped | → | error: a bar chart needs at least one category |
| no series is an error | width 300, height 200, categories a, series , mode grouped | → | error: a bar chart needs at least one series |
| a series of the wrong length is an error | width 300, height 200, categories a, b, series ×1, mode grouped | → | error: series "A" has 1 values but there are 2 categories |
| a repeated category is an error | width 300, height 200, categories a, a, series ×1, mode grouped | → | error: domain has a repeated value |
| two series with one name is an error | width 300, height 200, categories a, series ×2, mode grouped | → | error: domain has a repeated value |
| an unknown mode is an error | width 300, height 200, categories a, series ×1, mode overlapped | → | error: mode must be grouped or stacked |
Show the other 1 test
| Case | Arguments | Expected | |
|---|---|---|---|
| a chart too small for its margins is an error | width 40, height 40, categories a, series ×1, mode grouped | → | error: margins leave no room to plot |
More from the author
## Modes
- `grouped`: the series sit side by side in each category. Categories are bands of the x axis with 20% inner and 10% outer padding; inside a category each series gets a sub-band with 5% padding between them. A single series uses the whole category band. - `stacked`: the series are stacked in each category with a diverging stack (`charts.stack`): positive values stack upwards from zero, negative values downwards, so a negative value never hides inside a positive stack.
Bars always grow from zero, so zero is always on the y axis. The domain is widened to nice numbers with about one tick per 50 pixels; if every value is zero the axis runs from 0 to 1 so it still has a height.
## Rectangles
Bar edges are rounded to 2 decimal places first and the widths and heights are the differences of the rounded edges (`charts.shape` barRects). That is why neighbouring bars can be 37.87 and 37.86 wide: each edge is where it should be to the hundredth of a pixel, and bars that share an edge share it exactly, with no hairline gaps.
## Layout, colours, legend
The same fixed rules as `charts.line-chart`: 7 pixels per character, a legend only for more than one series (at the top, 18-pixel rows), margins top 12 or the legend's height plus 16, right 20, bottom 30, left sized to the widest y label; 6-pixel ticks; Okabe-Ito colours in series order, given once per series in `colors` and again on every bar. `bars` lists series by series, each in category order.
Category names and series names must each be unique. No categories, no series, a series of the wrong length or a chart too small for its margins is an error.
Files
| Path | Bytes |
|---|---|
| README.md | 2,161 |
| impl/python.py | 5,228 |
| impl/rust.rs | 8,488 |
| impl/typescript.ts | 5,497 |
| vectors.json | 9,793 |