Functional Weave
Code in TypeScript

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. formatTick (value: float, step: float) -> string
  2. formatSi (value: float, significant: int) -> string
  3. formatPercent (value: float, decimals: int) -> string
  4. formatDate (iso: date, label: DateLabel) -> string
  5. 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
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

  • formatTick(1,000, 200) → 1,000 whole-number step, grouped thousands
  • formatTick(0, 0.5) → 0.0 a half step gives every label one place, zero included
  • formatTick(2.5, 0.5) → 2.5 a half step
import { formatTick } from "#fune/charts.format@^1";
impl/typescript/format_tick.ts · 26 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.

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
valuefloat
significantint1 to 15 significant digits; trailing zeros are dropped
returnsstringe.g. "1.2k", "3.4M", "1.5m", "0"

For example

  • formatSi(1,200, 2) → 1.2k thousands
  • formatSi(3,400,000, 2) → 3.4M millions
  • formatSi(1,000, 3) → 1k trailing zeros are dropped
import { formatSi } from "#fune/charts.format@^1";
impl/typescript/format_si.ts · 59 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.

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
valuefloata fraction: 0.123 is 12.3%
decimalsint0 to 12 places after the point, kept even when zero
returnsstringe.g. "12.3%"

For example

  • formatPercent(0.123, 1) → 12.3% one place
  • formatPercent(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";
impl/typescript/format_percent.ts · 16 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.

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
isodate
labelDateLabelthe tick interval the date stands for
returnsstringday and week "23 Sep", month "Sep 2026", quarter "Q3 2026", year "2026"

For example

  • formatDate(2026-09-23, day) → 23 Sep a day tick
  • formatDate(2026-09-03, day) → 3 Sep no leading zero on the day
  • formatDate(2026-09-21, week) → 21 Sep a week tick reads like a day
import { formatDate } from "#fune/charts.format@^1";
impl/typescript/format_date.ts · 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.

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
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

  • formatMoneyTick(50,000, GBP) → £500.00 pounds from pence
  • formatMoneyTick(123,456.5, GBP) → £1,234.57 a tick between pence rounds half away from zero
  • formatMoneyTick(-0.5, GBP) → -£0.01 a negative half rounds away from zero too
import { formatMoneyTick } from "#fune/charts.format@^1";
impl/typescript/format_money_tick.ts · 16 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.

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
Download for TypeScript charts.format-1.0.0-typescript.fune · 22,854 bytes sha256 a009d2d1d9d4cdd39ef7db2dc1c1a73783886d8859c8283a428d10f066be5119

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

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