Functional Weave
Code in Rust

charts.axis

Axis geometry as data: tick positions, tick marks, label anchors and gridlines for number, time and category axes.

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

Pinned by 31 tests, run in TypeScript, Python and Rust · fewer than the registry now requires. 1.0.1 adds them.axisFromTicks 10 · numberAxis 7 · timeAxis 4 · bandAxis 4 · gridLines 6

What it does

An axis as data rather than drawing: the axis line, and for each tick its position, its mark, its label and where and how to anchor that label. Any renderer (SVG, canvas, a PDF library, a native chart view) can draw it without doing any arithmetic of its own, so a chart built in Python on the server and one built in TypeScript in the browser put every tick in the same place.

This is a group, because an axis is useless without its scale and ticks:

The functions

A group: 5 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. axis_from_ticks (positions: float[], labels: string[], range: float[], orient: AxisOrient, position: float, tickSize: float) -> Axis
  2. number_axis (domain: float[], range: float[], count: int, orient: AxisOrient, position: float, tickSize: float) -> Axis
  3. time_axis (domain: date[], range: float[], count: int, orient: AxisOrient, position: float, tickSize: float) -> Axis
  4. band_axis (domain: string[], range: float[], paddingInner: float, paddingOuter: float, orient: AxisOrient, position: float, tickSize: float) -> Axis
  5. grid_lines (axis: Axis, length: float) -> Line[]

The types it declares, generated into your project

// AxisOrient is a string in Rust, one of: "bottom", "top", "left", "right".
// Parameters take it as &str and results hold it as String.

// TextAnchor is a string in Rust, one of: "start", "middle", "end".
// Parameters take it as &str and results hold it as String.

// TextBaseline is a string in Rust, one of: "hanging", "middle", "alphabetic".
// Parameters take it as &str and results hold it as String.

/// A straight line segment in pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Line {
    pub x1: f64,
    pub y1: f64,
    pub x2: f64,
    pub y2: f64,
}

/// One tick: where it is, its mark, and where and how to draw its label.
#[derive(Debug, Clone, PartialEq)]
pub struct AxisTick {
    /// along the axis, in pixels
    pub position: f64,
    pub label: String,
    /// the tick mark, from the axis line outwards
    pub tick: Line,
    pub label_x: f64,
    pub label_y: f64,
    /// SVG text-anchor for the label
    pub anchor: String,
    /// SVG dominant-baseline for the label
    pub baseline: String,
}

/// Everything needed to draw one axis; all pixel values rounded to 2 decimal places.
#[derive(Debug, Clone, PartialEq)]
pub struct Axis {
    pub orient: String,
    /// the axis line's fixed coordinate
    pub position: f64,
    /// the axis line itself, along the range
    pub line: Line,
    pub ticks: Vec<AxisTick>,
}

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

axis_from_ticks throws on bad input 10 tests

pub fn axis_from_ticks(positions: &[f64], labels: &[String], range: &[f64], orient: &str, position: f64, tick_size: f64) -> Axis
positionsfloat[]where each tick falls along the axis, in pixels, from any scale
labelsstring[]one label per position
rangefloat[]the axis line's [from, to] in pixels
orientAxisOrient
positionfloatthe axis line's fixed coordinate: y for bottom and top, x for left and right
tick_sizefloatlength of each tick mark; d3's default is 6
returnsAxis

For example

  • axis_from_ticks(0, 50, 100, 0, 5, 10, 0, 100, bottom, 200, 6) → orient bottom, position 200, line …, ticks ×3 a bottom axis: marks point down, labels hang below them
  • axis_from_ticks(10, 90, a, b, 0, 100, top, 20, 6) → orient top, position 20, line …, ticks ×2 a top axis: marks point up, labels sit on their baseline above
  • axis_from_ticks(300, 150, 0, 0, 50, 100, 300, 0, left, 40, 6) → orient left, position 40, line …, ticks ×3 a left axis: marks point left, labels right-aligned beside them
fune!(charts.axis@^1);  // then call axis_from_ticks(…)
impl/rust/axis_from_ticks.rs · 158 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

// d3-axis's default gap between the end of a tick mark and its label.
const LABEL_PADDING: f64 = 3.0;

/// A pixel coordinate rounded to 2 places, the precision every axis output uses.
pub fn axis_pixel(value: f64) -> f64 {
    round_float(value, 2)
}

pub fn axis_line(x1: f64, y1: f64, x2: f64, y2: f64) -> Line {
    Line { x1: axis_pixel(x1), y1: axis_pixel(y1), x2: axis_pixel(x2), y2: axis_pixel(y2) }
}

pub fn check_orient(orient: &str) {
    if !matches!(orient, "bottom" | "top" | "left" | "right") {
        panic!("orient must be bottom, top, left or right, received \"{}\"", orient);
    }
}

/// Axis geometry from tick positions and labels, for any scale (d3-axis layout).
///
/// # Panics
/// Panics on an unknown orient, positions and labels of different lengths, or
/// a range without exactly two values.
pub fn axis_from_ticks(positions: &[f64], labels: &[String], range: &[f64], orient: &str, position: f64, tick_size: f64) -> Axis {
    check_orient(orient);
    if positions.len() != labels.len() {
        panic!(
            "positions and labels must be the same length, received {} and {}",
            positions.len(),
            labels.len()
        );
    }
    if range.len() != 2 {
        panic!("range must have exactly 2 values, [from, to]; got {}", range.len());
    }
    let horizontal = orient == "bottom" || orient == "top";
    let sign = if orient == "bottom" || orient == "right" { 1.0 } else { -1.0 };
    let mark_end = position + sign * tick_size;
    let label_at = position + sign * (tick_size + LABEL_PADDING);
    let ticks = positions
        .iter()
        .zip(labels)
        .map(|(&p, label)| {
            if horizontal {
                AxisTick {
                    position: axis_pixel(p),
                    label: label.clone(),
                    tick: axis_line(p, position, p, mark_end),
                    label_x: axis_pixel(p),
                    label_y: axis_pixel(label_at),
                    anchor: "middle".to_string(),
                    baseline: if orient == "bottom" { "hanging" } else { "alphabetic" }.to_string(),
                }
            } else {
                AxisTick {
                    position: axis_pixel(p),
                    label: label.clone(),
                    tick: axis_line(position, p, mark_end, p),
                    label_x: axis_pixel(label_at),
                    label_y: axis_pixel(p),
                    anchor: if orient == "left" { "end" } else { "start" }.to_string(),
                    baseline: "middle".to_string(),
                }
            }
        })
        .collect();
    let line = if horizontal {
        axis_line(range[0], position, range[1], position)
    } else {
        axis_line(position, range[0], position, range[1])
    };
    Axis { orient: orient.to_string(), position: axis_pixel(position), line, ticks }
}

pub fn line_to_value(l: &Line) -> Value {
    Value::obj(vec![
        ("x1", Value::Float(l.x1)),
        ("y1", Value::Float(l.y1)),
        ("x2", Value::Float(l.x2)),
        ("y2", Value::Float(l.y2)),
    ])
}

pub fn line_from_value(v: &Value) -> Line {
    Line { x1: v.get("x1").as_f64(), y1: v.get("y1").as_f64(), x2: v.get("x2").as_f64(), y2: v.get("y2").as_f64() }
}

pub fn axis_to_value(a: &Axis) -> Value {
    Value::obj(vec![
        ("orient", Value::str(&a.orient)),
        ("position", Value::Float(a.position)),
        ("line", line_to_value(&a.line)),
        (
            "ticks",
            Value::Arr(
                a.ticks
                    .iter()
                    .map(|t| {
                        Value::obj(vec![
                            ("position", Value::Float(t.position)),
                            ("label", Value::str(&t.label)),
                            ("tick", line_to_value(&t.tick)),
                            ("labelX", Value::Float(t.label_x)),
                            ("labelY", Value::Float(t.label_y)),
                            ("anchor", Value::str(&t.anchor)),
                            ("baseline", Value::str(&t.baseline)),
                        ])
                    })
                    .collect(),
            ),
        ),
    ])
}

pub fn axis_from_value(v: &Value) -> Axis {
    Axis {
        orient: v.get("orient").as_str().to_string(),
        position: v.get("position").as_f64(),
        line: line_from_value(v.get("line")),
        ticks: v
            .get("ticks")
            .as_arr()
            .iter()
            .map(|t| AxisTick {
                position: t.get("position").as_f64(),
                label: t.get("label").as_str().to_string(),
                tick: line_from_value(t.get("tick")),
                label_x: t.get("labelX").as_f64(),
                label_y: t.get("labelY").as_f64(),
                anchor: t.get("anchor").as_str().to_string(),
                baseline: t.get("baseline").as_str().to_string(),
            })
            .collect(),
    }
}

/// A JSON list of numbers or strings, for the vector adapters.
pub fn axis_floats(v: &Value) -> Vec<f64> {
    v.as_arr().iter().map(|x| x.as_f64()).collect()
}

pub fn axis_strings(v: &Value) -> Vec<String> {
    v.as_arr().iter().map(|x| x.as_str().to_string()).collect()
}

pub fn fune_vector(args: &[Value]) -> Value {
    axis_to_value(&axis_from_ticks(
        &axis_floats(&args[0]),
        &axis_strings(&args[1]),
        &axis_floats(&args[2]),
        args[3].as_str(),
        args[4].as_f64(),
        args[5].as_f64(),
    ))
}

number_axis throws on bad input 7 tests

pub fn number_axis(domain: &[f64], range: &[f64], count: i64, orient: &str, position: f64, tick_size: f64) -> Axis
domainfloat[]
rangefloat[]
countintroughly how many ticks; nice values from charts.ticks
orientAxisOrient
positionfloat
tick_sizefloat
returnsAxis

For example

  • number_axis(0, 100, 0, 500, 5, bottom, 300, 6) → orient bottom, position 300, line …, ticks ×6 0 to 100 in steps of 20 along 500 pixels
  • number_axis(0, 1, 300, 0, 5, left, 40, 6) → orient left, position 40, line …, ticks ×6 a y axis drawn upwards, labelled to one place because the step is 0.2
  • number_axis(0, 5,000, 0, 100, 2, bottom, 0, 6) → orient bottom, position 0, line …, ticks ×3 thousands are grouped
fune!(charts.axis@^1);  // then call number_axis(…)
impl/rust/number_axis.rs · 35 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::charts_axis_axis_from_ticks::{axis_floats, axis_from_ticks, axis_to_value};  ← axisFromTicks, another function of this group · built into the same file, even by a slim install
use super::charts_format_format_tick::format_tick;
use super::charts_scale_linear_scale::linear_scale;
use super::charts_ticks_nice_ticks::nice_ticks;
use super::charts_ticks_tick_step::tick_step;

/// A linear number axis: nice ticks, placed by linear_scale, labelled for the step.
///
/// # Panics
/// Panics on a domain without two different ends, a count below 1, or an
/// unknown orient.
pub fn number_axis(domain: &[f64], range: &[f64], count: i64, orient: &str, position: f64, tick_size: f64) -> Axis {
    if domain.len() != 2 {
        panic!("domain must have exactly 2 values, [from, to]; got {}", domain.len());
    }
    let lo = domain[0].min(domain[1]);
    let hi = domain[0].max(domain[1]);
    let values = nice_ticks(domain[0], domain[1], count);
    let step = tick_step(lo, hi, count);
    let positions: Vec<f64> = values.iter().map(|v| linear_scale(domain, range, *v, false)).collect();
    let labels: Vec<String> = values.iter().map(|v| format_tick(*v, step)).collect();
    axis_from_ticks(&positions, &labels, range, orient, position, tick_size)
}

pub fn fune_vector(args: &[Value]) -> Value {
    axis_to_value(&number_axis(
        &axis_floats(&args[0]),
        &axis_floats(&args[1]),
        args[2].as_i64(),
        args[3].as_str(),
        args[4].as_f64(),
        args[5].as_f64(),
    ))
}

time_axis throws on bad input 4 tests

pub fn time_axis(domain: &[String], range: &[f64], count: i64, orient: &str, position: f64, tick_size: f64) -> Axis
domaindate[]
rangefloat[]
countintroughly how many ticks; the interval (day to year) is chosen to fit
orientAxisOrient
positionfloat
tick_sizefloat
returnsAxis

For example

  • time_axis(2026-01-01, 2026-12-31, 0, 364, 4, bottom, 200, 6) → orient bottom, position 200, line …, ticks ×4 a year in quarters
  • time_axis(2026-09-01, 2026-09-15, 0, 140, 7, bottom, 0, 6) → orient bottom, position 0, line …, ticks ×8 a fortnight in days every 2 days, starting on the 1st of the month's odd days
  • time_axis(2026-12-31, 2026-01-01, 0, 364, 4, bottom, 0, 6) → orient bottom, position 0, line …, ticks ×4 a date domain given backwards is placed backwards
fune!(charts.axis@^1);  // then call time_axis(…)
impl/rust/time_axis.rs · 34 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::charts_axis_axis_from_ticks::{axis_floats, axis_from_ticks, axis_strings, axis_to_value};  ← axisFromTicks, another function of this group · built into the same file, even by a slim install
use super::charts_format_format_date::format_date;
use super::charts_scale_time_scale::time_scale;
use super::charts_ticks_time_tick_interval::time_tick_interval;
use super::charts_ticks_time_ticks::time_ticks;

/// A date axis: a calendar interval for about `count` ticks, labelled for that interval.
///
/// # Panics
/// Panics on a domain without two different dates, a count below 1, or an
/// unknown orient.
pub fn time_axis(domain: &[String], range: &[f64], count: i64, orient: &str, position: f64, tick_size: f64) -> Axis {
    if domain.len() != 2 {
        panic!("domain must have exactly 2 values, [from, to]; got {}", domain.len());
    }
    let (lo, hi) = if domain[0] < domain[1] { (&domain[0], &domain[1]) } else { (&domain[1], &domain[0]) };
    let chosen = time_tick_interval(lo, hi, count);
    let values = time_ticks(lo, hi, &chosen.interval, chosen.step);
    let positions: Vec<f64> = values.iter().map(|v| time_scale(domain, range, v, false)).collect();
    let labels: Vec<String> = values.iter().map(|v| format_date(v, &chosen.interval)).collect();
    axis_from_ticks(&positions, &labels, range, orient, position, tick_size)
}

pub fn fune_vector(args: &[Value]) -> Value {
    axis_to_value(&time_axis(
        &axis_strings(&args[0]),
        &axis_floats(&args[1]),
        args[2].as_i64(),
        args[3].as_str(),
        args[4].as_f64(),
        args[5].as_f64(),
    ))
}

band_axis throws on bad input 4 tests

pub fn band_axis(domain: &[String], range: &[f64], padding_inner: f64, padding_outer: f64, orient: &str, position: f64, tick_size: f64) -> Axis
domainstring[]the categories, one tick at the centre of each band
rangefloat[]
padding_innerfloat
padding_outerfloat
orientAxisOrient
positionfloat
tick_sizefloat
returnsAxis

For example

  • band_axis(a, b, c, 0, 300, 0.2, 0.1, bottom, 100, 6) → orient bottom, position 100, line …, ticks ×3 three categories, ticks at band centres
  • band_axis(x, y, 0, 100, 0.5, 0, bottom, 0, 6) → orient bottom, position 0, line …, ticks ×2 centres are rounded to 2 places
  • band_axis(north, south, 200, 0, 0, 0, left, 50, 6) → orient left, position 50, line …, ticks ×2 a category axis on the left
fune!(charts.axis@^1);  // then call band_axis(…)
impl/rust/band_axis.rs · 36 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::charts_axis_axis_from_ticks::{axis_floats, axis_from_ticks, axis_strings, axis_to_value};  ← axisFromTicks, another function of this group · built into the same file, even by a slim install
use super::charts_scale_band_scale::band_scale;

/// A category axis: one tick at the centre of each band, labelled with the category.
///
/// # Panics
/// Panics on an empty or duplicated domain, padding outside 0..=1, or an
/// unknown orient.
pub fn band_axis(
    domain: &[String],
    range: &[f64],
    padding_inner: f64,
    padding_outer: f64,
    orient: &str,
    position: f64,
    tick_size: f64,
) -> Axis {
    let positions: Vec<f64> = domain
        .iter()
        .map(|v| band_scale(domain, range, v, padding_inner, padding_outer, 0.5).center)
        .collect();
    axis_from_ticks(&positions, domain, range, orient, position, tick_size)
}

pub fn fune_vector(args: &[Value]) -> Value {
    axis_to_value(&band_axis(
        &axis_strings(&args[0]),
        &axis_floats(&args[1]),
        args[2].as_f64(),
        args[3].as_f64(),
        args[4].as_str(),
        args[5].as_f64(),
        args[6].as_f64(),
    ))
}

grid_lines throws on bad input 6 tests

pub fn grid_lines(axis: &Axis, length: f64) -> Vec<Line>
axisAxis
lengthfloathow far across the plot each line runs, usually the plot's height or width
returnsLine[]

For example

  • grid_lines(orient bottom, position 200, line …, ticks ×3, 180) → ×3 from a bottom axis the lines run up across the plot
  • grid_lines(orient top, position 20, line …, ticks ×1, 100) → ×1 from a top axis they run down
  • grid_lines(orient left, position 40, line …, ticks ×3, 460) → ×3 from a left axis they run right
fune!(charts.axis@^1);  // then call grid_lines(…)
impl/rust/grid_lines.rs · 31 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::charts_axis_axis_from_ticks::{axis_from_value, axis_line, check_orient, line_to_value};  ← axisFromTicks, another function of this group · built into the same file, even by a slim install

/// One gridline per tick, from the axis line across the plot: up from a bottom
/// axis, down from a top one, right from a left axis and left from a right one.
///
/// # Panics
/// Panics on an unknown orient or a negative or non-finite length.
pub fn grid_lines(axis: &Axis, length: f64) -> Vec<Line> {
    check_orient(&axis.orient);
    if !length.is_finite() || length < 0.0 {
        panic!("length must be a finite number of zero or more, received {}", length);
    }
    let pos = axis.position;
    axis.ticks
        .iter()
        .map(|t| {
            let p = t.position;
            match axis.orient.as_str() {
                "bottom" => axis_line(p, pos, p, pos - length),
                "top" => axis_line(p, pos, p, pos + length),
                "left" => axis_line(pos, p, pos + length, p),
                _ => axis_line(pos, p, pos - length, p),
            }
        })
        .collect()
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::Arr(grid_lines(&axis_from_value(&args[0]), args[1].as_f64()).iter().map(line_to_value).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 4 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.axis

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add charts.axis --only axisFromTicks
Download for Rust charts.axis-1.0.0-rust.fune · 44,455 bytes sha256 298f076b455ec3e5053a6025b07e64f8c72092464048c82e65268b921747bcee

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

The whole function, every language, is one file too: charts.axis-1.0.0.fune, 59,383 bytes, sha256 6268c1533bbe811a1db6266d76348046d8dcdf69ce89c3b86d059779b3efa45f. 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.axis.axisFromTicks
// fune: before charts.axis.numberAxis
// fune: before charts.axis.timeAxis
// fune: before charts.axis.bandAxis
// fune: before charts.axis.gridLines

after — your function gets the result and the arguments, and returns the final result.

// fune: after charts.axis.axisFromTicks
// fune: after charts.axis.numberAxis
// fune: after charts.axis.timeAxis
// fune: after charts.axis.bandAxis
// fune: after charts.axis.gridLines

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.format in charts.axis
// fune: replace charts.scale in charts.axis
// fune: replace charts.ticks in charts.axis
// fune: replace math.round-float in charts.axis

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.axis --steps.

// fune: step charts.axis.<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.

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

axisFromTicks 10 tests

CaseArgumentsExpected
a bottom axis: marks point down, labels hang below them 0, 50, 100, 0, 5, 10, 0, 100, bottom, 200, 6 → orient bottom, position 200, line …, ticks ×3
a top axis: marks point up, labels sit on their baseline above 10, 90, a, b, 0, 100, top, 20, 6 → orient top, position 20, line …, ticks ×2
a left axis: marks point left, labels right-aligned beside them 300, 150, 0, 0, 50, 100, 300, 0, left, 40, 6 → orient left, position 40, line …, ticks ×3
a right axis: labels start after the marks 0, 100, low, high, 0, 100, right, 500, 4 → orient right, position 500, line …, ticks ×2
pixels are rounded to 2 places, half away from zero 12.345, 12.345, x, y, 0.004, 99.996, bottom, 100.125, 6 → orient bottom, position 100.13, line …, ticks ×2
zero-length marks put labels 3 pixels from the line 5, 5, 0, 10, left, 0, 0 → orient left, position 0, line …, ticks ×1
an axis with no ticks is just its line , , 0, 300, bottom, 150, 6 → orient bottom, position 150, line …, ticks
a label per position is required 0, 1, 0, 0, 1, bottom, 0, 6 → error: positions and labels must be the same length
an unknown orient is an error, not a left axis 0, 0, 0, 1, middle, 0, 6 → error: orient must be bottom, top, left or right
a range needs two ends 0, 0, 0, bottom, 0, 6 → error: range must have exactly 2 values

numberAxis 7 tests

CaseArgumentsExpected
0 to 100 in steps of 20 along 500 pixels 0, 100, 0, 500, 5, bottom, 300, 6 → orient bottom, position 300, line …, ticks ×6
a y axis drawn upwards, labelled to one place because the step is 0.2 0, 1, 300, 0, 5, left, 40, 6 → orient left, position 40, line …, ticks ×6
thousands are grouped 0, 5,000, 0, 100, 2, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×3
a domain that is not nice only gets the ticks inside it 3, 97, 0, 94, 4, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×4
negative to positive -10, 10, 0, 200, 4, top, 0, 6 → orient top, position 0, line …, ticks ×5
count below 1 is an error 0, 1, 0, 1, 0, bottom, 0, 6 → error: count must be a whole number of at least 1
a domain needs two ends 0, 0, 1, 5, bottom, 0, 6 → error: domain must have exactly 2 values

timeAxis 4 tests

CaseArgumentsExpected
a year in quarters 2026-01-01, 2026-12-31, 0, 364, 4, bottom, 200, 6 → orient bottom, position 200, line …, ticks ×4
a fortnight in days every 2 days, starting on the 1st of the month's odd days 2026-09-01, 2026-09-15, 0, 140, 7, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×8
a date domain given backwards is placed backwards 2026-12-31, 2026-01-01, 0, 364, 4, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×4
an impossible date is an error 2026-02-30, 2026-12-31, 0, 1, 4, bottom, 0, 6 → error: is not a real calendar date

bandAxis 4 tests

CaseArgumentsExpected
three categories, ticks at band centres a, b, c, 0, 300, 0.2, 0.1, bottom, 100, 6 → orient bottom, position 100, line …, ticks ×3
centres are rounded to 2 places x, y, 0, 100, 0.5, 0, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×2
a category axis on the left north, south, 200, 0, 0, 0, left, 50, 6 → orient left, position 50, line …, ticks ×2
a repeated category is an error a, a, 0, 100, 0, 0, bottom, 0, 6 → error: domain has a repeated value

gridLines 6 tests

CaseArgumentsExpected
from a bottom axis the lines run up across the plot orient bottom, position 200, line …, ticks ×3, 180 → ×3
from a top axis they run down orient top, position 20, line …, ticks ×1, 100 → ×1
from a left axis they run right orient left, position 40, line …, ticks ×3, 460 → ×3
from a right axis they run left orient right, position 500, line …, ticks ×1, 460.004 → ×1
no ticks, no gridlines orient bottom, position 0, line …, ticks , 50 →
a negative length is an error orient bottom, position 200, line …, ticks ×3, -1 → error: length must be a finite number of zero or more

More from the author

- `numberAxis` for a linear number scale: nice tick values inside the domain (`charts.ticks` niceTicks), placed by `charts.scale` linearScale, labelled by `charts.format` formatTick with just enough decimals for the step (0.1, 0.2, 0.3, never 0.30000000000000004). - `timeAxis` for dates: `charts.ticks` picks the calendar interval (days, weeks, months, quarters, years) that gives about `count` ticks, puts ticks on its boundaries, and `charts.format` formatDate labels them for that interval. - `bandAxis` for categories: a tick at the centre of each band of `charts.scale` bandScale with the same padding as the bars. - `axisFromTicks` builds the geometry from any positions and labels, for scales this group does not cover (a log scale with hand-picked ticks). - `gridLines` runs a line from each tick across the plot.

## Layout (as d3-axis)

The mark points away from the plot: down from a bottom axis, up from a top one, left from a left axis, right from a right one, `tickSize` pixels long. The label sits 3 pixels past the end of the mark (d3's default tick padding). Under a bottom axis it is centred with `dominant-baseline: hanging`; above a top axis centred on its alphabetic baseline; beside a left axis right-aligned (`text-anchor: end`) and vertically centred; beside a right axis left-aligned. `position` is the axis line's fixed coordinate: its y for a bottom or top axis, its x for a left or right one.

Gridlines start on the axis line and run `length` pixels across the plot, the opposite way to the marks.

Every pixel value is rounded to 2 decimal places by `math.round-float`, half away from zero, so the numbers agree to the digit in every language. Labels are not measured here: how wide text is depends on the font, which only the renderer knows; `charts.layout` estimates widths when a layout needs them.

Source: d3-axis (github.com/d3/d3-axis), the conventions for mark direction, padding and anchors.

Files

PathBytes
README.md2,414
impl/python/axis_from_ticks.py2,791
impl/python/band_axis.py713
impl/python/grid_lines.py975
impl/python/number_axis.py1,122
impl/python/time_axis.py1,164
impl/rust/axis_from_ticks.rs5,577
impl/rust/band_axis.rs1,073
impl/rust/grid_lines.rs1,203
impl/rust/number_axis.rs1,447
impl/rust/time_axis.rs1,540
impl/typescript/axis_from_ticks.ts2,855
impl/typescript/band_axis.ts777
impl/typescript/grid_lines.ts944
impl/typescript/number_axis.ts1,338
impl/typescript/time_axis.ts1,358
vectors.json18,349