Functional Weave
Code in Rust

charts.layout

Chart layout: the plot area inside margins, legend items wrapped into rows, and greedy label collision avoidance.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 28 tests, run in TypeScript, Python and Rust · fewer than the registry now requires. 1.0.1 adds them.plotArea 7 · 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.

  1. plot_area (width: float, height: float, margins: Margins) -> PlotArea
  2. legend_rows (labels: string[], maxWidth: float, charWidth: float, swatchSize: float, gap: float, rowHeight: float) -> LegendItem[]
  3. 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 7 tests

pub fn plot_area(width: f64, height: f64, margins: &Margins) -> PlotArea
widthfloatthe whole chart, in pixels
heightfloat
marginsMargins
returnsPlotArearounded 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 chart
  • plot_area(300, 200, top 0, right 0, bottom 0, left 0) → x 0, y 0, width 300, height 200 no margins is the whole chart
  • plot_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(…)
impl/rust/plot_area.rs · 42 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.

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>
labelsstring[]
max_widthfloatthe width available; items wrap onto a new row past it
char_widthfloatthe average width of one character in the legend's font, e.g. 7 for 12px sans-serif
swatch_sizefloatthe colour square's side; the text starts 4 pixels after it
gapfloatspace between one item and the next on a row
row_heightfloatdistance from one row's top to the next
returnsLegendItem[]

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 wrap
  • legend_rows(Revenue, Costs, Profit, 150, 7, 10, 16, 18) → ×3 the item that would pass maxWidth starts a new row
  • legend_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(…)
impl/rust/legend_rows.rs · 74 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.

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>
boxesLabelBox[]
paddingfloatthe clear space required between two shown labels
returnsbool[]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 kept
  • avoid_collisions(boxes ×2, 0) → false, true a higher priority wins over input order
  • avoid_collisions(boxes ×2, 0) → true, true touching is not overlapping
fune!(charts.layout@^1);  // then call avoid_collisions(…)
impl/rust/avoid_collisions.rs · 51 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.

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
Download for Rust charts.layout-1.0.0-rust.fune · 22,231 bytes sha256 6fda36889b897d772099eb63b453cf2d33368cdc2b7d9022abbc1ce438bda589

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charts.layout-1.0.0-rust.fune, or fetch it from a terminal with fune pull charts.layout@1.0.0:rust.

The whole function, every language, is one file too: charts.layout-1.0.0.fune, 29,997 bytes, sha256 c8c9572f8a23db8a6df85292ba342b8fac84dbca2803c988edc9e393933e8057. 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.

This version has fewer tests than the registry now requires. It was published before every function had to have 8. 1.0.1 meets it, and a project on ^1.0.0 installs that or newer.

  • plotArea has 7 tests; every function needs at least 8. Add 1 more to vectors.json ("fn": "plotArea"): the ordinary case, the boundaries (zero, negative, the largest values), the rounding edge and every error it documents

plotArea 7 tests

CaseArgumentsExpected
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

legendRows 9 tests

CaseArgumentsExpected
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

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

Files

PathBytes
README.md2,305
impl/python/avoid_collisions.py1,154
impl/python/legend_rows.py1,559
impl/python/plot_area.py906
impl/rust/avoid_collisions.rs1,788
impl/rust/legend_rows.rs2,310
impl/rust/plot_area.rs1,546
impl/typescript/avoid_collisions.ts1,201
impl/typescript/legend_rows.ts1,627
impl/typescript/plot_area.ts855
vectors.json6,486