Functional Weave
Code in TypeScript

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.

  1. plotArea (width: float, height: float, margins: Margins) -> PlotArea
  2. legendRows (labels: string[], maxWidth: float, charWidth: float, swatchSize: float, gap: float, rowHeight: float) -> LegendItem[]
  3. avoidCollisions (boxes: LabelBox[], padding: float) -> bool[]

The types it declares, generated into your project

/** Space around the plot, in pixels. */
export interface Margins {
  readonly top: number;
  readonly right: number;
  readonly bottom: number;
  readonly left: number;
}

/** Where the data is drawn, in chart pixels. */
export interface PlotArea {
  readonly x: number;
  readonly y: number;
  readonly width: number;
  readonly height: number;
}

/** One legend entry placed in its row. */
export interface LegendItem {
  readonly label: string;
  /** 0 for the first row */
  readonly row: number;
  /** where the swatch starts */
  readonly x: number;
  /** the row's top */
  readonly y: number;
  /** swatch, 4 pixels and the estimated text width */
  readonly width: number;
  /** where the text starts */
  readonly textX: number;
}

/** A label's bounding box and how much it matters. */
export interface LabelBox {
  readonly x: number;
  readonly y: number;
  readonly width: number;
  readonly height: number;
  /** higher wins; equal priorities go in input order */
  readonly priority: number;
}

Once installed, your code imports each one from the group's module.

plotArea throws on bad input 12 tests

export function plotArea(width: number, height: number, margins: Margins): PlotArea
widthfloatthe whole chart, in pixels
heightfloat
marginsMargins
returnsPlotArearounded to 2 places

For example

  • plotArea(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
  • plotArea(300, 200, top 0, right 0, bottom 0, left 0) → x 0, y 0, width 300, height 200 no margins is the whole chart
  • plotArea(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
import { plotArea } from "#fune/charts.layout@^1";
impl/typescript/plot_area.ts · 17 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 { roundFloat } from "./math_round_float.ts";  ← from math.round-float ^1.0.0 · built alongside by fune
import type { Margins, PlotArea } from "./charts_layout_types.ts";

/** The rectangle left for data once the margins are taken off the chart. */
export function plotArea(width: number, height: number, margins: Margins): PlotArea {
  const { top, right, bottom, left } = margins;
  if (!(top >= 0 && right >= 0 && bottom >= 0 && left >= 0)) {
    throw new RangeError("margins must not be negative");
  }
  const w = width - left - right;
  const h = height - top - bottom;
  // A zero or negative plot would draw nothing, or draw everything flipped.
  if (!(w > 0 && h > 0)) {
    throw new RangeError(`margins leave no room to plot: ${width} x ${height} less margins is ${w} x ${h}`);
  }
  return { x: roundFloat(left, 2), y: roundFloat(top, 2), width: roundFloat(w, 2), height: roundFloat(h, 2) };
}

legendRows throws on bad input 9 tests

export function legendRows(labels: readonly string[], maxWidth: number, charWidth: number, swatchSize: number, gap: number, rowHeight: number): readonly LegendItem[]
labelsstring[]
maxWidthfloatthe width available; items wrap onto a new row past it
charWidthfloatthe average width of one character in the legend's font, e.g. 7 for 12px sans-serif
swatchSizefloatthe colour square's side; the text starts 4 pixels after it
gapfloatspace between one item and the next on a row
rowHeightfloatdistance from one row's top to the next
returnsLegendItem[]

For example

  • legendRows(Revenue, Costs, Profit, 200, 7, 10, 16, 18) → ×3 three items on one row; the last ends exactly at maxWidth and does not wrap
  • legendRows(Revenue, Costs, Profit, 150, 7, 10, 16, 18) → ×3 the item that would pass maxWidth starts a new row
  • legendRows(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
import { legendRows } from "#fune/charts.layout@^1";
impl/typescript/legend_rows.ts · 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.

import { roundFloat } from "./math_round_float.ts";  ← from math.round-float ^1.0.0 · built alongside by fune
import type { LegendItem } from "./charts_layout_types.ts";

// Space between the swatch and its text.
const SWATCH_PADDING = 4;

/**
 * Legend items laid out left to right, wrapping onto a new row when the next
 * item would pass maxWidth. Text width is estimated from the number of
 * characters (Unicode code points) times charWidth: no font is measured, so
 * the answer is the same everywhere, and a caller with real metrics can pass
 * a charWidth to suit.
 */
export function legendRows(
  labels: readonly string[],
  maxWidth: number,
  charWidth: number,
  swatchSize: number,
  gap: number,
  rowHeight: number,
): readonly LegendItem[] {
  if (!(maxWidth > 0)) throw new RangeError(`maxWidth must be greater than zero, received ${maxWidth}`);
  if (!(charWidth >= 0 && swatchSize >= 0 && gap >= 0 && rowHeight >= 0)) {
    throw new RangeError("legend sizes must not be negative");
  }
  const items: LegendItem[] = [];
  let row = 0;
  let x = 0;
  let onRow = 0;
  for (const label of labels) {
    const width = swatchSize + SWATCH_PADDING + [...label].length * charWidth;
    // An item wider than the whole legend still gets a row of its own rather
    // than an empty row before it.
    if (onRow > 0 && x + width > maxWidth) {
      row += 1;
      x = 0;
      onRow = 0;
    }
    items.push({
      label,
      row,
      x: roundFloat(x, 2),
      y: roundFloat(row * rowHeight, 2),
      width: roundFloat(width, 2),
      textX: roundFloat(x + swatchSize + SWATCH_PADDING, 2),
    });
    x = x + width + gap;
    onRow += 1;
  }
  return items;
}

avoidCollisions throws on bad input 12 tests

export function avoidCollisions(boxes: readonly LabelBox[], padding: number): readonly boolean[]
boxesLabelBox[]
paddingfloatthe clear space required between two shown labels
returnsbool[]one flag per box, in input order: true to show it

For example

  • avoidCollisions(boxes ×2, 0) → true, false of two overlapping labels the first is kept
  • avoidCollisions(boxes ×2, 0) → false, true a higher priority wins over input order
  • avoidCollisions(boxes ×2, 0) → true, true touching is not overlapping
import { avoidCollisions } from "#fune/charts.layout@^1";
impl/typescript/avoid_collisions.ts · 32 lines · open · raw
import type { LabelBox } from "./charts_layout_types.ts";

/**
 * Which labels to show so that none overlap: greedily, most important first
 * (higher priority, then earlier in the list), keeping a label only if it
 * clears every label already kept by at least `padding`. Touching is not
 * overlapping. The result is in input order.
 */
export function avoidCollisions(boxes: readonly LabelBox[], padding: number): readonly boolean[] {
  if (!(padding >= 0)) throw new RangeError(`padding must not be negative, received ${padding}`);
  for (const b of boxes) {
    if (!(b.width >= 0 && b.height >= 0)) throw new RangeError("label boxes must not have negative size");
  }
  const order = boxes.map((_, i) => i).sort((a, b) => boxes[b].priority - boxes[a].priority || a - b);
  const shown = boxes.map(() => false);
  const kept: LabelBox[] = [];
  for (const i of order) {
    const a = boxes[i];
    const clash = kept.some(
      (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;
    }
  }
  return shown;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, 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.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 TypeScript charts.layout-1.0.1-typescript.fune · 21,351 bytes sha256 09cf8ea378612399f78ee662617779777cf6f364eb819e763b07c13a57c08e3b

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

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

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

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.

1.0.1 adds tests; behaviour unchanged.

Files

PathBytes
README.md2,345
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.json7,475