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.
- formatTick (value: float, step: float) -> string
- formatSi (value: float, significant: int) -> string
- formatPercent (value: float, decimals: int) -> string
- formatDate (iso: date, label: DateLabel) -> string
- formatMoneyTick (minor: float, currency: string) -> string
The type it declares, generated into your project
export type DateLabel = "day" | "week" | "month" | "quarter" | "year";
Once installed, your code imports each one from the group's module.
formatTick throws on bad input 12 tests
export function formatTick(value: number, step: number): 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
formatTick(1,000, 200)→ 1,000 whole-number step, grouped thousandsformatTick(0, 0.5)→ 0.0 a half step gives every label one place, zero includedformatTick(2.5, 0.5)→ 2.5 a half step
import { formatTick } from "#fune/charts.format@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { formatDecimal } from "./text_format_decimal.ts"; ← from text.format-decimal ^1.0.0 · built alongside by fune
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
/** The fewest decimal places (0 to 12) that write x exactly; 12 if none do. */
export function placesFor(x: number): number {
const a = Math.abs(x);
for (let d = 0; d < 12; d++) {
if (roundFloat(a, d) === a) return d;
}
return 12;
}
/**
* A tick label, with as many decimal places as the tick step needs, 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". String(0.1 + 0.2) would print
* 0.30000000000000004; this prints "0.3". Thousands are grouped with commas.
* A step of 0 (a single tick) uses the places the value itself needs.
*/
export function formatTick(value: number, step: number): string {
if (typeof step !== "number" || !Number.isFinite(step)) {
throw new RangeError(`step must be a finite number, received ${step}`);
}
const places = step === 0 ? placesFor(value) : placesFor(step);
return formatDecimal(value, places, false, ",");
}formatSi throws on bad input 18 tests
export function formatSi(value: number, significant: number): 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
formatSi(1,200, 2)→ 1.2k thousandsformatSi(3,400,000, 2)→ 3.4M millionsformatSi(1,000, 3)→ 1k trailing zeros are dropped
import { formatSi } from "#fune/charts.format@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { formatDecimal } from "./text_format_decimal.ts"; ← from text.format-decimal ^1.0.0 · built alongside by fune
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
// yocto to yotta, as d3-format writes them (micro is U+00B5).
const PREFIXES = ["y", "z", "a", "f", "p", "n", "µ", "m", "", "k", "M", "G", "T", "P", "E", "Z", "Y"];
const THOUSANDS = [1, 1e3, 1e6, 1e9, 1e12, 1e15, 1e18, 1e21, 1e24];
const TENS = [1, 10, 100];
/**
* A number with an SI prefix and a given number of significant digits:
* 1200 is "1.2k", 3400000 is "3.4M", 0.0015 is "1.5m". Trailing zeros are
* dropped, so 1000 is "1k". The exponent is found by comparing against exact
* powers of ten rather than log10, and a value that rounds up into the next
* prefix (999.96 to 3 digits) moves to it: "1k", not "1000".
*/
export function formatSi(value: number, significant: number): string {
if (!Number.isInteger(significant) || significant < 1 || significant > 15) {
throw new RangeError(`significant must be a whole number from 1 to 15, received ${significant}`);
}
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new RangeError(`value must be a finite number, received ${value}`);
}
if (value === 0) return "0";
const a = Math.abs(value);
let exponent = 0;
if (a >= 1) {
let p = 1;
while (p * 10 <= a) {
p *= 10;
exponent += 1;
}
} else {
let q = 1;
while (a * q < 1) {
q *= 10;
exponent -= 1;
}
}
let group = Math.floor(exponent / 3);
if (group < -8) group = -8;
if (group > 8) group = 8;
let rounded = scaleAndRound(value, group, significant - 1 - (exponent - 3 * group));
if (Math.abs(rounded.value) >= 1000 && group < 8) {
group += 1;
rounded = scaleAndRound(value, group, significant - 1);
}
return formatDecimal(rounded.value, rounded.places, true, "") + PREFIXES[group + 8];
}
function scaleAndRound(value: number, group: number, places: number): { value: number; places: number } {
const scaled = group >= 0 ? value / THOUSANDS[group] : value * THOUSANDS[-group];
if (places < 0) {
// Fewer significant digits than the whole part has: round to tens or hundreds.
const unit = TENS[-places];
return { value: roundFloat(scaled / unit, 0) * unit, places: 0 };
}
const p = places > 12 ? 12 : places;
return { value: roundFloat(scaled, p), places: p };
}formatPercent throws on bad input 11 tests
export function formatPercent(value: number, decimals: number): 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
formatPercent(0.123, 1)→ 12.3% one placeformatPercent(0.145, 0)→ 15% 0.145 is 15%: 0.145 * 100 is 14.4999..., which a plain round makes 14%formatPercent(0.07, 0)→ 7% 0.07 * 100 is 7.000000000000001, printed 7%
import { formatPercent } from "#fune/charts.format@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { formatDecimal } from "./text_format_decimal.ts"; ← from text.format-decimal ^1.0.0 · built alongside by fune
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
/**
* A fraction as a percentage with fixed places: 0.123 to 1 place is "12.3%".
* Multiplying by 100 adds binary noise (0.145 * 100 is 14.499999999999998),
* so the product is first cleaned at 12 places and only then rounded to the
* places asked for: 0.145 is "15%", as anyone reading it expects, not "14%".
*/
export function formatPercent(value: number, decimals: number): string {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new RangeError(`value must be a finite number, received ${value}`);
}
const percent = roundFloat(value * 100, 12);
return formatDecimal(percent, decimals, false, "") + "%";
}formatDate throws on bad input 11 tests
export function formatDate(iso: string, label: DateLabel): 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
formatDate(2026-09-23, day)→ 23 Sep a day tickformatDate(2026-09-03, day)→ 3 Sep no leading zero on the dayformatDate(2026-09-21, week)→ 21 Sep a week tick reads like a day
import { formatDate } from "#fune/charts.format@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { parseIsoDate } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { type DateLabel } from "./charts_format_types.ts";
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/**
* A date as a tick label for the interval it stands for: a day or week tick
* "23 Sep", a month "Sep 2026", a quarter "Q3 2026", a year "2026". English
* month abbreviations, no locale lookup, so every language agrees.
*/
export function formatDate(iso: string, label: DateLabel): string {
const date = parseIsoDate(iso);
const year = iso.slice(0, 4);
const month = MONTHS[date.month - 1];
if (label === "day" || label === "week") return `${date.day} ${month}`;
if (label === "month") return `${month} ${year}`;
if (label === "quarter") return `Q${Math.floor((date.month - 1) / 3) + 1} ${year}`;
if (label === "year") return year;
throw new RangeError(`unknown date label "${label}"; use day, week, month, quarter or year`);
}formatMoneyTick throws on bad input 9 tests
export function formatMoneyTick(minor: number, currency: string): 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
formatMoneyTick(50,000, GBP)→ £500.00 pounds from penceformatMoneyTick(123,456.5, GBP)→ £1,234.57 a tick between pence rounds half away from zeroformatMoneyTick(-0.5, GBP)→ -£0.01 a negative half rounds away from zero too
import { formatMoneyTick } from "#fune/charts.format@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { formatMoney } from "./money_format.ts"; ← from money.format ^1.0.0 · built alongside by fune
import { money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
/**
* A tick on an axis of money, where the chart's data are integer minor units
* but a tick can land between them. The tick is rounded half away from zero
* to a whole minor unit and printed by money.format, with the currency's own
* digits and symbol.
*/
export function formatMoneyTick(minor: number, currency: string): string {
if (typeof minor !== "number" || !Number.isFinite(minor)) {
throw new RangeError(`minor must be a finite number, received ${minor}`);
}
return formatMoney(money(roundFloat(minor, 0), currency));
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 5 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add charts.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 TypeScript implementation. Install it without the registry with fune add ./charts.format-1.0.0-typescript.fune, or fetch it from a terminal with fune pull charts.format@1.0.0:typescript.
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).