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.
- format_tick (value: float, step: float) -> string
- format_si (value: float, significant: int) -> string
- format_percent (value: float, decimals: int) -> string
- format_date (iso: date, label: DateLabel) -> string
- 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
| value | float | a tick value |
| step | float | the distance between ticks; decides the decimal places, so 0.5 and 1.0 print alike |
| returns | string | comma-grouped, e.g. "1,500" or "0.25" |
For example
format_tick(1,000, 200)→ 1,000 whole-number step, grouped thousandsformat_tick(0, 0.5)→ 0.0 a half step gives every label one place, zero includedformat_tick(2.5, 0.5)→ 2.5 a half step
fune!(charts.format@^1); // then call format_tick(…)
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
| value | float | |
| significant | int | 1 to 15 significant digits; trailing zeros are dropped |
| returns | string | e.g. "1.2k", "3.4M", "1.5m", "0" |
For example
format_si(1,200, 2)→ 1.2k thousandsformat_si(3,400,000, 2)→ 3.4M millionsformat_si(1,000, 3)→ 1k trailing zeros are dropped
fune!(charts.format@^1); // then call format_si(…)
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
| value | float | a fraction: 0.123 is 12.3% |
| decimals | int | 0 to 12 places after the point, kept even when zero |
| returns | string | e.g. "12.3%" |
For example
format_percent(0.123, 1)→ 12.3% one placeformat_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(…)
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
| iso | date | |
| label | DateLabel | the tick interval the date stands for |
| returns | string | day and week "23 Sep", month "Sep 2026", quarter "Q3 2026", year "2026" |
For example
format_date(2026-09-23, day)→ 23 Sep a day tickformat_date(2026-09-03, day)→ 3 Sep no leading zero on the dayformat_date(2026-09-21, week)→ 21 Sep a week tick reads like a day
fune!(charts.format@^1); // then call format_date(…)
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
| minor | float | a tick on an axis of minor units (pence, cents); rounded to a whole unit |
| currency | string | ISO 4217 code that money.format knows |
| returns | string | e.g. "£500.00" |
For example
format_money_tick(50,000, GBP)→ £500.00 pounds from penceformat_money_tick(123,456.5, GBP)→ £1,234.57 a tick between pence rounds half away from zeroformat_money_tick(-0.5, GBP)→ -£0.01 a negative half rounds away from zero too
fune!(charts.format@^1); // then call format_money_tick(…)
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
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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| more than 12 places is an error | 0.5, 13 | → | error: decimals must be a whole number from 0 to 12 |
formatDate 11 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| an unknown label is an error | 2026-09-23, hour | → | error: unknown date label |
formatMoneyTick 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 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).