charts.layout
Chart layout: the plot area inside margins, legend items wrapped into rows, and greedy label collision avoidance.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 33 tests, run in TypeScript, Python and Rust.plotArea 12 · legendRows 9 · avoidCollisions 12
What it does
Three pieces of chart layout that have nothing to do with the data: where the plot goes, where the legend items go, and which labels to leave out so none overlap. Pixels in, pixels out, rounded to 2 decimal places by `math.round-float`, the same in every language.
This is a group; install only what you use, e.g. `require charts.layout ^1.0.0 only=plotArea`.
The functions
A group: 3 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.
- plot_area (width: float, height: float, margins: Margins) -> PlotArea
- legend_rows (labels: string[], maxWidth: float, charWidth: float, swatchSize: float, gap: float, rowHeight: float) -> LegendItem[]
- avoid_collisions (boxes: LabelBox[], padding: float) -> bool[]
The types it declares, generated into your project
/// Space around the plot, in pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Margins {
pub top: f64,
pub right: f64,
pub bottom: f64,
pub left: f64,
}
/// Where the data is drawn, in chart pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlotArea {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
/// One legend entry placed in its row.
#[derive(Debug, Clone, PartialEq)]
pub struct LegendItem {
pub label: String,
/// 0 for the first row
pub row: i64,
/// where the swatch starts
pub x: f64,
/// the row's top
pub y: f64,
/// swatch, 4 pixels and the estimated text width
pub width: f64,
/// where the text starts
pub text_x: f64,
}
/// A label's bounding box and how much it matters.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LabelBox {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
/// higher wins; equal priorities go in input order
pub priority: i64,
}
Once installed, your code imports each one from the group's module.
plot_area throws on bad input 12 tests
pub fn plot_area(width: f64, height: f64, margins: &Margins) -> PlotArea
| width | float | the whole chart, in pixels |
| height | float | |
| margins | Margins | |
| returns | PlotArea | rounded to 2 places |
For example
plot_area(600, 400, top 20, right 20, bottom 30, left 40)→ x 40, y 20, width 540, height 350 d3's usual margins on a 600 x 400 chartplot_area(300, 200, top 0, right 0, bottom 0, left 0)→ x 0, y 0, width 300, height 200 no margins is the whole chartplot_area(300.5, 200, top 10.25, right 5, bottom 10, left 12.125)→ x 12.13, y 10.25, width 283.38, height 179.75 fractional sizes round to 2 places; 12.125 is an exact tie and rounds up
fune!(charts.layout@^1); // then call plot_area(…)
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::math_round_float::round_float; ← from math.round-float ^1.0.0 · built alongside by fune
/// The rectangle left for data once the margins are taken off the chart.
///
/// # Panics
/// Panics on a negative margin or margins that leave no room.
pub fn plot_area(width: f64, height: f64, margins: &Margins) -> PlotArea {
let (top, right, bottom, left) = (margins.top, margins.right, margins.bottom, margins.left);
if !(top >= 0.0 && right >= 0.0 && bottom >= 0.0 && left >= 0.0) {
panic!("margins must not be negative");
}
let w = width - left - right;
let h = height - top - bottom;
// A zero or negative plot would draw nothing, or draw everything flipped.
if !(w > 0.0 && h > 0.0) {
panic!("margins leave no room to plot: {} x {} less margins is {} x {}", width, height, w, h);
}
PlotArea { x: round_float(left, 2), y: round_float(top, 2), width: round_float(w, 2), height: round_float(h, 2) }
}
pub fn plot_area_to_value(p: &PlotArea) -> Value {
Value::obj(vec![
("x", Value::Float(p.x)),
("y", Value::Float(p.y)),
("width", Value::Float(p.width)),
("height", Value::Float(p.height)),
])
}
pub fn margins_from_value(v: &Value) -> Margins {
Margins {
top: v.get("top").as_f64(),
right: v.get("right").as_f64(),
bottom: v.get("bottom").as_f64(),
left: v.get("left").as_f64(),
}
}
pub fn fune_vector(args: &[Value]) -> Value {
plot_area_to_value(&plot_area(args[0].as_f64(), args[1].as_f64(), &margins_from_value(&args[2])))
}legend_rows throws on bad input 9 tests
pub fn legend_rows(labels: &[String], max_width: f64, char_width: f64, swatch_size: f64, gap: f64, row_height: f64) -> Vec<LegendItem>
| labels | string[] | |
| max_width | float | the width available; items wrap onto a new row past it |
| char_width | float | the average width of one character in the legend's font, e.g. 7 for 12px sans-serif |
| swatch_size | float | the colour square's side; the text starts 4 pixels after it |
| gap | float | space between one item and the next on a row |
| row_height | float | distance from one row's top to the next |
| returns | LegendItem[] |
For example
legend_rows(Revenue, Costs, Profit, 200, 7, 10, 16, 18)→ ×3 three items on one row; the last ends exactly at maxWidth and does not wraplegend_rows(Revenue, Costs, Profit, 150, 7, 10, 16, 18)→ ×3 the item that would pass maxWidth starts a new rowlegend_rows(A very long label, B, 50, 7, 10, 16, 18)→ ×2 an item wider than the legend gets its own row, not an empty row before it
fune!(charts.layout@^1); // then call legend_rows(…)
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::math_round_float::round_float; ← from math.round-float ^1.0.0 · built alongside by fune
// Space between the swatch and its text.
const SWATCH_PADDING: f64 = 4.0;
/// Legend items left to right, wrapping when the next would pass max_width.
/// Text width is estimated as code points times char_width; no font is
/// measured, so the answer is the same in every language.
///
/// # Panics
/// Panics if max_width is not positive or any other size is negative.
pub fn legend_rows(
labels: &[String],
max_width: f64,
char_width: f64,
swatch_size: f64,
gap: f64,
row_height: f64,
) -> Vec<LegendItem> {
if !(max_width > 0.0) {
panic!("maxWidth must be greater than zero, received {}", max_width);
}
if !(char_width >= 0.0 && swatch_size >= 0.0 && gap >= 0.0 && row_height >= 0.0) {
panic!("legend sizes must not be negative");
}
let mut items = Vec::new();
let mut row: i64 = 0;
let mut x = 0.0;
let mut on_row = 0;
for label in labels {
let width = swatch_size + SWATCH_PADDING + label.chars().count() as f64 * char_width;
if on_row > 0 && x + width > max_width {
row += 1;
x = 0.0;
on_row = 0;
}
items.push(LegendItem {
label: label.clone(),
row,
x: round_float(x, 2),
y: round_float(row as f64 * row_height, 2),
width: round_float(width, 2),
text_x: round_float(x + swatch_size + SWATCH_PADDING, 2),
});
x = x + width + gap;
on_row += 1;
}
items
}
pub fn legend_item_to_value(item: &LegendItem) -> Value {
Value::obj(vec![
("label", Value::str(&item.label)),
("row", Value::Int(item.row)),
("x", Value::Float(item.x)),
("y", Value::Float(item.y)),
("width", Value::Float(item.width)),
("textX", Value::Float(item.text_x)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let labels: Vec<String> = args[0].as_arr().iter().map(|v| v.as_str().to_string()).collect();
let items = legend_rows(
&labels,
args[1].as_f64(),
args[2].as_f64(),
args[3].as_f64(),
args[4].as_f64(),
args[5].as_f64(),
);
Value::Arr(items.iter().map(legend_item_to_value).collect())
}avoid_collisions throws on bad input 12 tests
pub fn avoid_collisions(boxes: &[LabelBox], padding: f64) -> Vec<bool>
| boxes | LabelBox[] | |
| padding | float | the clear space required between two shown labels |
| returns | bool[] | one flag per box, in input order: true to show it |
For example
avoid_collisions(boxes ×2, 0)→ true, false of two overlapping labels the first is keptavoid_collisions(boxes ×2, 0)→ false, true a higher priority wins over input orderavoid_collisions(boxes ×2, 0)→ true, true touching is not overlapping
fune!(charts.layout@^1); // then call avoid_collisions(…)
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
/// Which labels to show so none overlap: greedily, highest priority first
/// (then input order), keeping a label only if it clears every kept label by
/// at least padding. Touching is not overlapping. Result in input order.
///
/// # Panics
/// Panics on a negative padding or a box with negative size.
pub fn avoid_collisions(boxes: &[LabelBox], padding: f64) -> Vec<bool> {
if !(padding >= 0.0) {
panic!("padding must not be negative, received {}", padding);
}
for b in boxes {
if !(b.width >= 0.0 && b.height >= 0.0) {
panic!("label boxes must not have negative size");
}
}
let mut order: Vec<usize> = (0..boxes.len()).collect();
order.sort_by(|&a, &b| boxes[b].priority.cmp(&boxes[a].priority).then(a.cmp(&b)));
let mut shown = vec![false; boxes.len()];
let mut kept: Vec<&LabelBox> = Vec::new();
for i in order {
let a = &boxes[i];
let clash = kept.iter().any(|k| {
a.x < k.x + k.width + padding
&& k.x < a.x + a.width + padding
&& a.y < k.y + k.height + padding
&& k.y < a.y + a.height + padding
});
if !clash {
kept.push(a);
shown[i] = true;
}
}
shown
}
pub fn fune_vector(args: &[Value]) -> Value {
let boxes: Vec<LabelBox> = args[0]
.as_arr()
.iter()
.map(|b| LabelBox {
x: b.get("x").as_f64(),
y: b.get("y").as_f64(),
width: b.get("width").as_f64(),
height: b.get("height").as_f64(),
priority: b.get("priority").as_i64(),
})
.collect();
Value::Arr(avoid_collisions(&boxes, args[1].as_f64()).into_iter().map(Value::Bool).collect())
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, 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.layout
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add charts.layout --only plotArea
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charts.layout-1.0.1-rust.fune, or fetch it from a terminal with fune pull charts.layout@1.0.1:rust.
The whole function, every language, is one file too: charts.layout-1.0.1.fune, 31,155 bytes, sha256 362c12dd048eb65cd08ece4f6dad012fe7c294ba2598e17ae72a5f0cc38e2ab5. 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.layout.plotArea
// fune: before charts.layout.legendRows
// fune: before charts.layout.avoidCollisions
after — your function gets the result and the arguments, and returns the final result.
// fune: after charts.layout.plotArea
// fune: after charts.layout.legendRows
// fune: after charts.layout.avoidCollisions
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.layout
step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.layout --steps.
// fune: step charts.layout.<fn> 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.
plotArea 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| d3's usual margins on a 600 x 400 chart | 600, 400, top 20, right 20, bottom 30, left 40 | → | x 40, y 20, width 540, height 350 |
| no margins is the whole chart | 300, 200, top 0, right 0, bottom 0, left 0 | → | x 0, y 0, width 300, height 200 |
| fractional sizes round to 2 places; 12.125 is an exact tie and rounds up | 300.5, 200, top 10.25, right 5, bottom 10, left 12.125 | → | x 12.13, y 10.25, width 283.38, height 179.75 |
| a very small plot is still a plot | 1, 1, top 0, right 0, bottom 0, left 0.5 | → | x 0.5, y 0, width 0.5, height 1 |
| margins that use up the whole width are an error | 100, 100, top 0, right 50, bottom 0, left 50 | → | error: margins leave no room to plot |
| margins wider than the chart are an error | 100, 100, top 60, right 0, bottom 60, left 0 | → | error: margins leave no room to plot |
| a negative margin is an error | 100, 100, top 0, right -5, bottom 0, left 0 | → | error: margins must not be negative |
| height is what the top and bottom leave, width what the left and right leave | 640, 480, top 40, right 10, bottom 60, left 50 | → | x 50, y 40, width 580, height 380 |
| margins that leave exactly one pixel | 101, 100, top 0, right 50, bottom 0, left 50 | → | x 50, y 0, width 1, height 100 |
| a negative top margin is an error | 100, 100, top -1, right 0, bottom 0, left 0 | → | error: margins must not be negative |
Show the other 2 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a chart with no width is an error | 0, 100, top 0, right 0, bottom 0, left 0 | → | error: margins leave no room to plot |
| a chart of negative height is an error | 100, -10, top 0, right 0, bottom 0, left 0 | → | error: margins leave no room to plot |
legendRows 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| three items on one row; the last ends exactly at maxWidth and does not wrap | Revenue, Costs, Profit, 200, 7, 10, 16, 18 | → | ×3 |
| the item that would pass maxWidth starts a new row | Revenue, Costs, Profit, 150, 7, 10, 16, 18 | → | ×3 |
| an item wider than the legend gets its own row, not an empty row before it | A very long label, B, 50, 7, 10, 16, 18 | → | ×2 |
| characters are counted as code points, not UTF-8 bytes | Café, €, 200, 7, 10, 16, 18 | → | ×2 |
| an empty label is just its swatch | , 100, 7, 10, 16, 18 | → | ×1 |
| fractional character width | ab, cd, 100, 6.5, 10, 5.25, 20 | → | ×2 |
| no labels | , 100, 7, 10, 16, 18 | → | |
| maxWidth must be positive | A, 0, 7, 10, 16, 18 | → | error: maxWidth must be greater than zero |
| a negative size is an error | A, 100, -1, 10, 16, 18 | → | error: legend sizes must not be negative |
avoidCollisions 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| of two overlapping labels the first is kept | boxes ×2, 0 | → | true, false |
| a higher priority wins over input order | boxes ×2, 0 | → | false, true |
| touching is not overlapping | boxes ×2, 0 | → | true, true |
| padding turns touching into a clash | boxes ×2, 2 | → | true, false |
| a gap exactly equal to the padding is clear | boxes ×2, 2 | → | true, true |
| a gap smaller than the padding is not | boxes ×2, 3 | → | true, false |
| greedy: hiding the middle label frees the third (hiding every overlapping label would hide all three) | boxes ×3, 0 | → | true, false, true |
| equal top priorities go in input order | boxes ×3, 0 | → | false, true, false |
| zero-size boxes never clash | boxes ×2, 0 | → | true, true |
| no boxes | , 0 | → |
Show the other 2 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| negative padding is an error | boxes ×1, -1 | → | error: padding must not be negative |
| a box with negative size is an error | boxes ×1, 0 | → | error: label boxes must not have negative size |
More from the author
## plotArea
The d3 margin convention: the chart is `width` by `height`, the margins are taken off each side, and what is left is where the data is drawn. `x` and `y` are its top-left corner (the left and top margins). Margins that leave no width or height are an error, since a zero or negative plot area either draws nothing or draws the data mirrored; negative margins are an error too.
## legendRows
Places legend entries left to right, each a colour swatch, 4 pixels, then the label, with `gap` between entries, wrapping to a new row when the next entry would pass `maxWidth`. An entry that ends exactly at `maxWidth` stays on the row. An entry wider than the whole legend is placed on a row of its own rather than leaving an empty row before it. Row `r` has its top at `r x rowHeight`; offset the whole legend wherever it belongs.
Nothing here can measure text, so a label's width is estimated as its number of characters (Unicode code points, so "Café" is 4, not its 5 UTF-8 bytes) times `charWidth`. About 0.55-0.6 of the font size suits common sans-serif fonts (7 for 12px); a renderer with real metrics can pass its own average. The estimate is deliberately simple so every language places the legend the same.
## avoidCollisions
Given label boxes, decides which to show so that no two overlap, greedily: the most important label first (higher `priority`, then earlier in the list), and each further label only if it is at least `padding` clear of every label already shown. Boxes that merely touch do not overlap. The answer is one flag per box, in input order.
Greedy is not optimal (it can show fewer labels than the best possible choice), but it is predictable, fast and stable as data changes, and it always keeps the labels you rank highest, such as the first and last tick or the largest value. It does not move labels; to try alternative positions, pass each candidate as a box with a lower priority.
1.0.1 adds tests; behaviour unchanged.