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.1 · published 2026-10-03 by charlie · Anterra

Pinned by 49 tests, run in TypeScript, Python and Rust.axisFromTicks 10 · numberAxis 11 · timeAxis 10 · bandAxis 10 · gridLines 8

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 11 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 10 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 10 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 8 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.1-rust.fune · 54,822 bytes sha256 0c3ad3cfdb80710a0ce8caa3cfc29a801090ddff0a954da565c40c4856f17dfd

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

The whole function, every language, is one file too: charts.axis-1.0.1.fune, 69,750 bytes, sha256 4aec0b33d90de1c1c687dff41d21141c1182ad88190729c54bf4fc3fbd7c0d4d. 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.

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 11 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
a right axis: labels start 3 pixels past the marks 0, 10, 0, 100, 2, right, 0, 6 → orient right, position 0, line …, ticks ×3
a domain given backwards puts its ticks backwards 10, 0, 0, 100, 2, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×3
a step of 0.05 labels every tick with two places 0, 0.1, 0, 100, 2, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×3
Show the other 1 test
CaseArgumentsExpected
an unknown orient is an error 0, 10, 0, 100, 5, centre, 0, 6 → error: orient must be bottom, top, left or right

timeAxis 10 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
leap years make yearly ticks uneven: 2021 is 366 days along, not 365 2020-01-01, 2025-01-01, 0, 500, 5, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×6
in a leap year March starts on day 60 2024-01-01, 2024-04-01, 0, 91, 3, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×4
weekly ticks fall on Mondays, not on the first date 2026-09-01, 2026-09-29, 0, 280, 4, left, 50, 6 → orient left, position 50, line …, ticks ×4
a domain needs two ends 2026-01-01, 0, 100, 4, bottom, 0, 6 → error: domain must have exactly 2 values
count below 1 is an error 2026-01-01, 2026-12-31, 0, 364, 0, bottom, 0, 6 → error: count must be a whole number of at least 1
an unknown orient is an error 2026-01-01, 2026-12-31, 0, 364, 4, middle, 0, 6 → error: orient must be bottom, top, left or right

bandAxis 10 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
one category fills the range and sits in the middle only, 0, 100, 0, 0, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×1
outer padding on a top axis: a third of a step each side a, b, 0, 100, 0, 0.5, top, 10, 6 → orient top, position 10, line …, ticks ×2
a reversed range puts the first category at the far end a, b, c, 300, 0, 0, 0, bottom, 0, 6 → orient bottom, position 0, line …, ticks ×3
no categories is just the axis line , 0, 100, 0.1, 0.1, bottom, 0, 6 → orient bottom, position 0, line …, ticks
inner padding above 1 is an error a, b, 0, 100, 1.5, 0, bottom, 0, 6 → error: paddingInner must be between 0 and 1
an unknown orient is an error a, b, 0, 100, 0, 0, centre, 0, 6 → error: orient must be bottom, top, left or right

gridLines 8 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
a zero length draws each line as a point on the axis orient bottom, position 200, line …, ticks ×1, 0 → ×1
any negative length is refused, however small orient bottom, position 200, line …, ticks ×1, -0.01 → 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.

1.0.1 adds tests; behaviour unchanged.

Files

PathBytes
README.md2,454
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.json27,260