Functional Weave
Code in TypeScript

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 category
  • barChart(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 it
  • barChart(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
specBarChartSpec
returnsBarChartevery 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";
impl/typescript.ts · 129 lines · open · raw

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
Download for TypeScript charts.bar-chart-1.0.0-typescript.fune · 23,917 bytes sha256 5310a2625443b73851df12449ff1dd17f8f61c38f9d372869e02d1c229b33a1d

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md2,161
impl/python.py5,228
impl/rust.rs8,488
impl/typescript.ts5,497
vectors.json9,793