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
bar_chart(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 categorybar_chart(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 itbar_chart(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.
pub fn bar_chart(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
// BarMode is a string in Rust, one of: "grouped", "stacked".
// Parameters take it as &str and results hold it as String.
/// One series: a value per category.
#[derive(Debug, Clone, PartialEq)]
pub struct BarSeries {
pub name: String,
/// one per category, in the same order
pub values: Vec<f64>,
}
/// What to draw.
#[derive(Debug, Clone, PartialEq)]
pub struct BarChartSpec {
pub width: f64,
pub height: f64,
/// the x axis, left to right; no repeats
pub categories: Vec<String>,
/// no two with the same name
pub series: Vec<BarSeries>,
/// grouped side by side, or stacked (positives up, negatives down from zero)
pub mode: String,
}
/// One bar, ready to draw.
#[derive(Debug, Clone, PartialEq)]
pub struct ChartBar {
pub series: String,
pub category: String,
/// the data value, for a tooltip
pub value: f64,
/// "#rrggbb", the series' colour
pub color: String,
pub rect: Rect,
}
/// The whole chart; legend[i] and colors[i] are series i.
#[derive(Debug, Clone, PartialEq)]
pub struct BarChart {
pub width: f64,
pub height: f64,
pub plot: PlotArea,
pub x_axis: Axis,
pub y_axis: Axis,
/// horizontal, one per y tick
pub grid_lines: Vec<Line>,
/// series by series, each in category order
pub bars: Vec<ChartBar>,
/// one per series, from the Okabe-Ito palette
pub colors: Vec<String>,
/// empty for a single series
pub legend: Vec<LegendItem>,
}
Your code names it in one line, in the file that uses it
fune!(charts.bar-chart@^1); // then call bar_chart(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_axis_axis_from_ticks::{axis_to_value, line_to_value};
use super::charts_axis_band_axis::band_axis;
use super::charts_axis_grid_lines::grid_lines;
use super::charts_axis_number_axis::number_axis;
use super::charts_format_format_tick::format_tick;
use super::charts_layout_legend_rows::{legend_item_to_value, legend_rows};
use super::charts_layout_plot_area::{plot_area, plot_area_to_value};
use super::charts_layout_types::{LegendItem, Margins};
use super::charts_palette::palette; ← from charts.palette ^1.0.0 · built alongside by fune
use super::charts_scale_band_scale::band_scale;
use super::charts_scale_linear_scale::linear_scale;
use super::charts_scale_nice_domain::nice_domain;
use super::charts_shape_bar_rects::{bar_rects, rect_to_value};
use super::charts_shape_types::BarSpec;
use super::charts_stack::stack; ← from charts.stack ^1.0.0 · built alongside by fune
use super::charts_ticks_nice_ticks::nice_ticks;
use super::charts_ticks_tick_step::tick_step;
use super::math_round_float::round_float; ← from math.round-float ^1.0.0 · built alongside by fune
// Fixed layout constants; the README explains each.
const CHAR_WIDTH: f64 = 7.0;
const SWATCH: f64 = 10.0;
const LEGEND_GAP: f64 = 16.0;
const LEGEND_ROW: f64 = 18.0;
const EDGE: f64 = 8.0;
const TICK_SIZE: f64 = 6.0;
const LABEL_PADDING: f64 = 3.0;
const TOP_WITHOUT_LEGEND: f64 = 12.0;
const RIGHT: f64 = 20.0;
const BOTTOM: f64 = 30.0;
const CATEGORY_PADDING_INNER: f64 = 0.2;
const CATEGORY_PADDING_OUTER: f64 = 0.1;
const SERIES_PADDING: f64 = 0.05;
/// A whole bar chart as geometry, grouped or stacked, by the fixed rules in
/// the README.
///
/// # Panics
/// Panics on an unknown mode, no categories or series, a series of the wrong
/// length, repeated categories or series names, or a chart too small for its
/// margins.
pub fn bar_chart(spec: &BarChartSpec) -> BarChart {
let (width, height, categories, series) = (spec.width, spec.height, &spec.categories, &spec.series);
if spec.mode != "grouped" && spec.mode != "stacked" {
panic!("mode must be grouped or stacked, received {}", spec.mode);
}
if categories.is_empty() {
panic!("a bar chart needs at least one category");
}
if series.is_empty() {
panic!("a bar chart needs at least one series");
}
for s in series {
if s.values.len() != categories.len() {
panic!(
"series \"{}\" has {} values but there are {} categories",
s.name,
s.values.len(),
categories.len()
);
}
}
let names: Vec<String> = series.iter().map(|s| s.name.clone()).collect();
let mut legend: Vec<LegendItem> = Vec::new();
let mut top = TOP_WITHOUT_LEGEND;
if series.len() > 1 {
let rows = legend_rows(&names, width - 2.0 * EDGE, CHAR_WIDTH, SWATCH, LEGEND_GAP, LEGEND_ROW);
legend = rows
.iter()
.map(|it| LegendItem {
label: it.label.clone(),
row: it.row,
x: round_float(it.x + EDGE, 2),
y: round_float(it.y + EDGE, 2),
width: it.width,
text_x: round_float(it.text_x + EDGE, 2),
})
.collect();
top = EDGE + (rows[rows.len() - 1].row + 1) as f64 * LEGEND_ROW + EDGE;
}
let stacked = if spec.mode == "stacked" {
let values: Vec<Vec<f64>> = series.iter().map(|s| s.values.clone()).collect();
Some(stack(&values, "diverging"))
} else {
None
};
let (mut lo, mut hi) = (0.0f64, 0.0f64);
for (i, s) in series.iter().enumerate() {
for (j, v) in s.values.iter().enumerate() {
let ends = match &stacked {
None => vec![*v],
Some(st) => vec![st[i][j].y0, st[i][j].y1],
};
for e in ends {
if e < lo {
lo = e;
}
if e > hi {
hi = e;
}
}
}
}
if lo == hi {
hi = 1.0;
}
let y_count = (((height - top - BOTTOM) / 50.0).floor() as i64).max(2);
let y_domain = nice_domain(&[lo, hi], y_count).domain;
let y_step = tick_step(y_domain[0], y_domain[1], y_count);
let widest = nice_ticks(y_domain[0], y_domain[1], y_count)
.iter()
.map(|v| format_tick(*v, y_step).chars().count())
.max()
.unwrap_or(0);
let left = TICK_SIZE + LABEL_PADDING + widest as f64 * CHAR_WIDTH + EDGE;
let plot = plot_area(width, height, &Margins { top, right: RIGHT, bottom: BOTTOM, left });
let bottom_y = plot.y + plot.height;
let y_range = vec![bottom_y, plot.y];
let y = |v: f64| linear_scale(&y_domain, &y_range, v, false);
let y_axis = number_axis(&y_domain, &y_range, y_count, "left", plot.x, TICK_SIZE);
let x_range = vec![plot.x, plot.x + plot.width];
let x_axis = band_axis(categories, &x_range, CATEGORY_PADDING_INNER, CATEGORY_PADDING_OUTER, "bottom", bottom_y, TICK_SIZE);
let colors = palette("okabe-ito", series.len() as i64, true);
let mut specs: Vec<BarSpec> = Vec::new();
let mut meta: Vec<(String, String, f64, String)> = Vec::new();
for (i, s) in series.iter().enumerate() {
for (j, c) in categories.iter().enumerate() {
let outer = band_scale(categories, &x_range, c, CATEGORY_PADDING_INNER, CATEGORY_PADDING_OUTER, 0.5);
let bar = match &stacked {
Some(st) => BarSpec { band: outer.start, thickness: outer.width, base: y(st[i][j].y0), value: y(st[i][j].y1) },
None if series.len() == 1 => {
BarSpec { band: outer.start, thickness: outer.width, base: y(0.0), value: y(s.values[j]) }
}
None => {
let inner = band_scale(&names, &[outer.start, outer.start + outer.width], &s.name, SERIES_PADDING, 0.0, 0.5);
BarSpec { band: inner.start, thickness: inner.width, base: y(0.0), value: y(s.values[j]) }
}
};
specs.push(bar);
meta.push((s.name.clone(), c.clone(), s.values[j], colors[i].clone()));
}
}
let rects = bar_rects(&specs, "vertical");
let bars: Vec<ChartBar> = meta
.into_iter()
.zip(rects)
.map(|((series, category, value, color), rect)| ChartBar { series, category, value, color, rect })
.collect();
BarChart {
width: round_float(width, 2),
height: round_float(height, 2),
grid_lines: grid_lines(&y_axis, plot.width),
plot,
x_axis,
y_axis,
bars,
colors,
legend,
}
}
pub fn bar_chart_spec_from_value(v: &Value) -> BarChartSpec {
BarChartSpec {
width: v.get("width").as_f64(),
height: v.get("height").as_f64(),
categories: v.get("categories").as_arr().iter().map(|c| c.as_str().to_string()).collect(),
series: v
.get("series")
.as_arr()
.iter()
.map(|s| BarSeries {
name: s.get("name").as_str().to_string(),
values: s.get("values").as_arr().iter().map(|x| x.as_f64()).collect(),
})
.collect(),
mode: v.get("mode").as_str().to_string(),
}
}
pub fn bar_chart_to_value(c: &BarChart) -> Value {
Value::obj(vec![
("width", Value::Float(c.width)),
("height", Value::Float(c.height)),
("plot", plot_area_to_value(&c.plot)),
("xAxis", axis_to_value(&c.x_axis)),
("yAxis", axis_to_value(&c.y_axis)),
("gridLines", Value::Arr(c.grid_lines.iter().map(line_to_value).collect())),
(
"bars",
Value::Arr(
c.bars
.iter()
.map(|b| {
Value::obj(vec![
("series", Value::str(&b.series)),
("category", Value::str(&b.category)),
("value", Value::Float(b.value)),
("color", Value::str(&b.color)),
("rect", rect_to_value(&b.rect)),
])
})
.collect(),
),
),
("colors", Value::Arr(c.colors.iter().map(|s| Value::str(s)).collect())),
("legend", Value::Arr(c.legend.iter().map(legend_item_to_value).collect())),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
bar_chart_to_value(&bar_chart(&bar_chart_spec_from_value(&args[0])))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 9 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./charts.bar-chart-1.0.0-rust.fune, or fetch it from a terminal with fune pull charts.bar-chart@1.0.0:rust.
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 |