Functional Weave
Code in Rust

charts.format

Axis and label text for charts: tick numbers, SI prefixes (1.2k, 3.4M), percentages, dates and money.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 61 tests, run in TypeScript, Python and Rust.formatTick 12 · formatSi 18 · formatPercent 11 · formatDate 11 · formatMoneyTick 9

What it does

The text on a chart: tick labels for numbers, dates and money, SI-prefixed values for compact labels, and percentages. A group, because each is one way of labelling an axis and `charts.axis` picks between them.

Every number goes through `text.format-decimal`, so there is never an exponent (`1e-7`), never binary noise (`0.30000000000000004`) and never `-0`, and all three languages print the same text. Rounding is half away from zero on the exact double (`math.round-float`). The minus sign is the ASCII hyphen, not d3-format's U+2212.

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. format_tick (value: float, step: float) -> string
  2. format_si (value: float, significant: int) -> string
  3. format_percent (value: float, decimals: int) -> string
  4. format_date (iso: date, label: DateLabel) -> string
  5. format_money_tick (minor: float, currency: string) -> string

The type it declares, generated into your project

// DateLabel is a string in Rust, one of: "day", "week", "month", "quarter", "year".
// Parameters take it as &str and results hold it as String.

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

format_tick throws on bad input 12 tests

pub fn format_tick(value: f64, step: f64) -> String
valuefloata tick value
stepfloatthe distance between ticks; decides the decimal places, so 0.5 and 1.0 print alike
returnsstringcomma-grouped, e.g. "1,500" or "0.25"

For example

  • format_tick(1,000, 200) → 1,000 whole-number step, grouped thousands
  • format_tick(0, 0.5) → 0.0 a half step gives every label one place, zero included
  • format_tick(2.5, 0.5) → 2.5 a half step
fune!(charts.format@^1);  // then call format_tick(…)
impl/rust/format_tick.rs · 30 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
use super::text_format_decimal::format_decimal;  ← from text.format-decimal ^1.0.0 · built alongside by fune

/// The fewest decimal places (0 to 12) that write x exactly; 12 if none do.
pub fn places_for(x: f64) -> i64 {
    let a = x.abs();
    for d in 0..12 {
        if round_float(a, d) == a {
            return d;
        }
    }
    12
}

/// A tick label with as many decimal places as the step needs, comma-grouped.
///
/// # Panics
/// Panics if the step is not finite or the value is too large to format.
pub fn format_tick(value: f64, step: f64) -> String {
    if !step.is_finite() {
        panic!("step must be a finite number, received {}", step);
    }
    let places = if step == 0.0 { places_for(value) } else { places_for(step) };
    format_decimal(value, places, false, ",")
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::str(&format_tick(args[0].as_f64(), args[1].as_f64()))
}

format_si throws on bad input 18 tests

pub fn format_si(value: f64, significant: i64) -> String
valuefloat
significantint1 to 15 significant digits; trailing zeros are dropped
returnsstringe.g. "1.2k", "3.4M", "1.5m", "0"

For example

  • format_si(1,200, 2) → 1.2k thousands
  • format_si(3,400,000, 2) → 3.4M millions
  • format_si(1,000, 3) → 1k trailing zeros are dropped
fune!(charts.format@^1);  // then call format_si(…)
impl/rust/format_si.rs · 64 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
use super::text_format_decimal::format_decimal;  ← from text.format-decimal ^1.0.0 · built alongside by fune

// yocto to yotta, as d3-format writes them (micro is U+00B5).
const PREFIXES: [&str; 17] = ["y", "z", "a", "f", "p", "n", "\u{b5}", "m", "", "k", "M", "G", "T", "P", "E", "Z", "Y"];
const THOUSANDS: [f64; 9] = [1.0, 1e3, 1e6, 1e9, 1e12, 1e15, 1e18, 1e21, 1e24];
const TENS: [f64; 3] = [1.0, 10.0, 100.0];

fn scale_and_round(value: f64, group: i64, places: i64) -> (f64, i64) {
    let scaled = if group >= 0 { value / THOUSANDS[group as usize] } else { value * THOUSANDS[(-group) as usize] };
    if places < 0 {
        let unit = TENS[(-places) as usize];
        return (round_float(scaled / unit, 0) * unit, 0);
    }
    let p = if places > 12 { 12 } else { places };
    (round_float(scaled, p), p)
}

/// A number with an SI prefix and `significant` significant digits, trailing
/// zeros dropped: 1200 is "1.2k", 0.0015 is "1.5m".
///
/// # Panics
/// Panics if `significant` is outside 1..=15 or the value is not finite.
pub fn format_si(value: f64, significant: i64) -> String {
    if !(1..=15).contains(&significant) {
        panic!("significant must be a whole number from 1 to 15, received {}", significant);
    }
    if !value.is_finite() {
        panic!("value must be a finite number, received {}", value);
    }
    if value == 0.0 {
        return "0".to_string();
    }
    let a = value.abs();
    let mut exponent: i64 = 0;
    if a >= 1.0 {
        let mut p = 1.0;
        while p * 10.0 <= a {
            p *= 10.0;
            exponent += 1;
        }
    } else {
        let mut q = 1.0;
        while a * q < 1.0 {
            q *= 10.0;
            exponent -= 1;
        }
    }
    let group = exponent.div_euclid(3).clamp(-8, 8);
    let (mut rounded, mut places) = scale_and_round(value, group, significant - 1 - (exponent - 3 * group));
    let mut group = group;
    if rounded.abs() >= 1000.0 && group < 8 {
        group += 1;
        let next = scale_and_round(value, group, significant - 1);
        rounded = next.0;
        places = next.1;
    }
    format!("{}{}", format_decimal(rounded, places, true, ""), PREFIXES[(group + 8) as usize])
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::str(&format_si(args[0].as_f64(), args[1].as_i64()))
}

format_percent throws on bad input 11 tests

pub fn format_percent(value: f64, decimals: i64) -> String
valuefloata fraction: 0.123 is 12.3%
decimalsint0 to 12 places after the point, kept even when zero
returnsstringe.g. "12.3%"

For example

  • format_percent(0.123, 1) → 12.3% one place
  • format_percent(0.145, 0) → 15% 0.145 is 15%: 0.145 * 100 is 14.4999..., which a plain round makes 14%
  • format_percent(0.07, 0) → 7% 0.07 * 100 is 7.000000000000001, printed 7%
fune!(charts.format@^1);  // then call format_percent(…)
impl/rust/format_percent.rs · 20 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
use super::text_format_decimal::format_decimal;  ← from text.format-decimal ^1.0.0 · built alongside by fune

/// A fraction as a percentage with fixed places; the product is cleaned of
/// binary noise at 12 places first, so 0.145 is "15%", not "14%".
///
/// # Panics
/// Panics if the value is not finite or `decimals` is outside 0..=12.
pub fn format_percent(value: f64, decimals: i64) -> String {
    if !value.is_finite() {
        panic!("value must be a finite number, received {}", value);
    }
    let percent = round_float(value * 100.0, 12);
    format!("{}%", format_decimal(percent, decimals, false, ""))
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::str(&format_percent(args[0].as_f64(), args[1].as_i64()))
}

format_date throws on bad input 11 tests

pub fn format_date(iso: &str, label: &str) -> String
isodate
labelDateLabelthe tick interval the date stands for
returnsstringday and week "23 Sep", month "Sep 2026", quarter "Q3 2026", year "2026"

For example

  • format_date(2026-09-23, day) → 23 Sep a day tick
  • format_date(2026-09-03, day) → 3 Sep no leading zero on the day
  • format_date(2026-09-21, week) → 21 Sep a week tick reads like a day
fune!(charts.format@^1);  // then call format_date(…)
impl/rust/format_date.rs · 25 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::dates_add_days::parse_iso_date;  ← from dates.add-days ^1.0.0 · built alongside by fune

const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

/// A date as a tick label for the interval it stands for.
///
/// # Panics
/// Panics on an invalid date or an unknown label.
pub fn format_date(iso: &str, label: &str) -> String {
    let date = parse_iso_date(iso);
    let year = &iso[0..4];
    let month = MONTHS[(date.month - 1) as usize];
    match label {
        "day" | "week" => format!("{} {}", date.day, month),
        "month" => format!("{} {}", month, year),
        "quarter" => format!("Q{} {}", (date.month - 1) / 3 + 1, year),
        "year" => year.to_string(),
        other => panic!("unknown date label \"{}\"; use day, week, month, quarter or year", other),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::str(&format_date(args[0].as_str(), args[1].as_str()))
}

format_money_tick throws on bad input 9 tests

pub fn format_money_tick(minor: f64, currency: &str) -> String
minorfloata tick on an axis of minor units (pence, cents); rounded to a whole unit
currencystringISO 4217 code that money.format knows
returnsstringe.g. "£500.00"

For example

  • format_money_tick(50,000, GBP) → £500.00 pounds from pence
  • format_money_tick(123,456.5, GBP) → £1,234.57 a tick between pence rounds half away from zero
  • format_money_tick(-0.5, GBP) → -£0.01 a negative half rounds away from zero too
fune!(charts.format@^1);  // then call format_money_tick(…)
impl/rust/format_money_tick.rs · 20 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
use super::money_amount::money;  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_format::format_money;  ← from money.format ^1.0.0 · built alongside by fune

/// A money-axis tick: rounded half away from zero to a whole minor unit, then
/// printed by money.format.
///
/// # Panics
/// Panics if `minor` is not finite or the currency is unknown.
pub fn format_money_tick(minor: f64, currency: &str) -> String {
    if !minor.is_finite() {
        panic!("minor must be a finite number, received {}", minor);
    }
    format_money(&money(round_float(minor, 0) as i64, currency))
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::str(&format_money_tick(args[0].as_f64(), args[1].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 5 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.format

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

fune add charts.format --only formatTick
Download for Rust charts.format-1.0.0-rust.fune · 22,637 bytes sha256 c5a5a7b8f62a1e0233440694464f5607f28b8861ba39f041f52c1c70a438ad35

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

The whole function, every language, is one file too: charts.format-1.0.0.fune, 34,031 bytes, sha256 90a9db7883d51cbff8d67baa2bc4c41feb39fc5e9c4820bafa27f68ded7fcadd. 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.format.formatTick
// fune: before charts.format.formatSi
// fune: before charts.format.formatPercent
// fune: before charts.format.formatDate
// fune: before charts.format.formatMoneyTick

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

// fune: after charts.format.formatTick
// fune: after charts.format.formatSi
// fune: after charts.format.formatPercent
// fune: after charts.format.formatDate
// fune: after charts.format.formatMoneyTick

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 dates.add-days in charts.format
// fune: replace math.round-float in charts.format
// fune: replace money.amount in charts.format
// fune: replace money.format in charts.format
// fune: replace text.format-decimal in charts.format

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

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

formatTick 12 tests

CaseArgumentsExpected
whole-number step, grouped thousands 1,000, 200 → 1,000
a half step gives every label one place, zero included 0, 0.5 → 0.0
a half step 2.5, 0.5 → 2.5
a quarter step needs two places 1.5, 0.25 → 1.50
binary noise is not printed (String gives 0.30000000000000004) 0.3, 0.1 → 0.3
a negative tick -2,000, 500 → -2,000
millions 1,234,567, 1,000,000 → 1,234,567
a tiny step is written out, never 1e-5 0, 0 → 0.00003
a step of two tenths needs one place 0.7, 0.2 → 0.7
a descending axis has a negative step 5, -1 → 5
Show the other 2 tests
CaseArgumentsExpected
a zero step (one tick) uses the value's own places 12.5, 0 → 12.5
a value too large to write exactly is an error 1,000,000,000,000,000, 0.5 → error: value is too large to format

formatSi 18 tests

CaseArgumentsExpected
thousands 1,200, 2 → 1.2k
millions 3,400,000, 2 → 3.4M
trailing zeros are dropped 1,000, 3 → 1k
rounding up into the next prefix gives 1k, not 1000 999.96, 3 → 1k
one significant digit rounds up into the next prefix too 950, 1 → 1k
milli 0.002, 2 → 1.5m
micro uses the micro sign 0, 3 → 1.23µ
micro, whole 0, 2 → 25µ
a half is 500m, as d3 writes it 0.5, 2 → 500m
negative -45,600, 3 → -45.6k
Show the other 8 tests
CaseArgumentsExpected
fewer digits than the whole part rounds to tens 12, 1 → 10
and to hundreds, half away from zero 150, 1 → 200
four significant digits of a large number 123,456,789, 4 → 123.5M
yotta is the largest prefix 50,000,000,000,000,000,000,000,000, 2 → 50Y
no prefix between 1 and 999 42, 2 → 42
zero 0, 3 → 0
zero significant digits is an error 1,200, 0 → error: significant must be a whole number from 1 to 15
sixteen significant digits is an error 1,200, 16 → error: significant must be a whole number from 1 to 15

formatPercent 11 tests

CaseArgumentsExpected
one place 0.123, 1 → 12.3%
0.145 is 15%: 0.145 * 100 is 14.4999..., which a plain round makes 14% 0.145, 0 → 15%
0.07 * 100 is 7.000000000000001, printed 7% 0.07, 0 → 7%
all of it 1, 0 → 100%
fixed places are kept 0.5, 2 → 50.00%
negative -0.05, 1 → -5.0%
zero 0, 0 → 0%
more than 100%, no grouping 12.5, 0 → 1250%
two places of a four-place fraction 1.235, 2 → 123.45%
a tiny share rounds up to the last place 0, 2 → 0.01%
Show the other 1 test
CaseArgumentsExpected
more than 12 places is an error 0.5, 13 → error: decimals must be a whole number from 0 to 12

formatDate 11 tests

CaseArgumentsExpected
a day tick 2026-09-23, day → 23 Sep
no leading zero on the day 2026-09-03, day → 3 Sep
a week tick reads like a day 2026-09-21, week → 21 Sep
a month tick 2026-09-01, month → Sep 2026
a quarter tick 2026-07-01, quarter → Q3 2026
the first day of the year is Q1 2026-01-01, quarter → Q1 2026
the last day of the year is Q4 2026-12-31, quarter → Q4 2026
a year tick 2026-01-01, year → 2026
a leap day 2024-02-29, day → 29 Feb
a day that never existed is an error 2026-02-30, day → error: is not a real calendar date
Show the other 1 test
CaseArgumentsExpected
an unknown label is an error 2026-09-23, hour → error: unknown date label

formatMoneyTick 9 tests

CaseArgumentsExpected
pounds from pence 50,000, GBP → £500.00
a tick between pence rounds half away from zero 123,456.5, GBP → £1,234.57
a negative half rounds away from zero too -0.5, GBP → -£0.01
euros below zero -2,500, EUR → -€25.00
yen have no minor unit 1,500, JPY → ¥1,500
three-digit currency 1,234, KWD → KD 1.234
zero 0, GBP → £0.00
an unknown currency is an error 100, XYZ → error: no formatting rules
a lower-case code is an error 100, gbp → error: is not an uppercase ISO 4217 currency code

More from the author

- **formatTick(value, step)** uses the fewest decimal places (0 to 12) that write the tick *step* exactly, so every label on an axis has the same number of places: with a step of 0.5 the ticks read `0.0`, `0.5`, `1.0` (d3's `precisionFixed`). Thousands are grouped with commas: `1,000`. A step of 0, as for a single tick, uses the places the value needs. A step that no 12-place decimal writes exactly (a third) gets 12 places. - **formatSi(value, significant)** writes the value with an SI prefix and that many significant digits, then drops trailing zeros: 1200 is `1.2k`, 3,400,000 is `3.4M`, 1000 is `1k`, 0.0015 is `1.5m`, 0.5 is `500m`. Prefixes run from y (1e-24) to Y (1e24), with µ (U+00B5) for micro, as d3-format writes them. The prefix is chosen from the exponent, found by comparing against exact powers of ten, not `log10`; a value that rounds up across a prefix boundary moves to the next prefix (999.96 to 3 digits is `1k`, not `1000`). With fewer significant digits than the whole part has, the value rounds to tens or hundreds (12 to 1 digit is `10`). "G" is giga, as SI says; finance's "B" for billions is not used. - **formatPercent(value, decimals)** takes a fraction (0.123 is 12.3%) and keeps fixed places: `50.00%`. Multiplying by 100 adds binary noise (0.145 x 100 is 14.499999999999998), so the product is cleaned at 12 decimal places before being rounded to the places asked for: 0.145 is `15%`, which is what anyone reading 0.145 expects, where rounding the raw product says 14%. No thousands grouping. - **formatDate(iso, label)** labels a date for the tick interval it stands for: day and week ticks `23 Sep` (no leading zero), month `Sep 2026`, quarter `Q3 2026` (calendar quarters), year `2026`. English abbreviations and no locale lookup, so every language agrees. The date is checked strictly by `dates.add-days`. - **formatMoneyTick(minor, currency)** labels a tick on an axis whose data are integer minor units. A tick can fall between them, so it is rounded half away from zero to a whole minor unit and printed by `money.format` with the currency's own digits and symbol: 50000 GBP is `£500.00`, 1500 JPY is `¥1,500`.

Sources: d3-format (Mike Bostock), `precisionFixed` and the `s` type (https://github.com/d3/d3-format); BIPM, The International System of Units (SI), 9th edition, 2019, table 7 (prefixes).

Files

PathBytes
README.md2,960
impl/python/format_date.py757
impl/python/format_money_tick.py551
impl/python/format_percent.py603
impl/python/format_si.py1,991
impl/python/format_tick.py770
impl/rust/format_date.rs941
impl/rust/format_money_tick.rs686
impl/rust/format_percent.rs748
impl/rust/format_si.rs2,323
impl/rust/format_tick.rs924
impl/typescript/format_date.ts965
impl/typescript/format_money_tick.ts690
impl/typescript/format_percent.ts755
impl/typescript/format_si.ts2,325
impl/typescript/format_tick.ts1,057
vectors.json6,862