Functional Weave
Code in Python

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 = Literal["day", "week", "month", "quarter", "year"]

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

format_tick throws on bad input 12 tests

def format_tick(value: float, step: float) -> str
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
from fune.charts.format import format_tick  # charts.format@^1
impl/python/format_tick.py · 21 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 math

from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune
from .text_format_decimal import format_decimal  ← from text.format-decimal ^1.0.0 · built alongside by fune


def places_for(x: float) -> int:
    """The fewest decimal places (0 to 12) that write x exactly; 12 if none do."""
    a = abs(float(x))
    for d in range(12):
        if round_float(a, d) == a:
            return d
    return 12


def format_tick(value: float, step: float) -> str:
    """A tick label with as many decimal places as the step needs, comma-grouped."""
    if isinstance(step, bool) or not isinstance(step, (int, float)) or not math.isfinite(step):
        raise ValueError("step must be a finite number, received %r" % (step,))
    places = places_for(value) if step == 0 else places_for(step)
    return format_decimal(value, places, False, ",")

format_si throws on bad input 18 tests

def format_si(value: float, significant: int) -> str
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
from fune.charts.format import format_si  # charts.format@^1
impl/python/format_si.py · 50 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 math
from typing import Tuple

from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune
from .text_format_decimal import 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).
_PREFIXES = ["y", "z", "a", "f", "p", "n", "µ", "m", "", "k", "M", "G", "T", "P", "E", "Z", "Y"]
_THOUSANDS = [1.0, 1e3, 1e6, 1e9, 1e12, 1e15, 1e18, 1e21, 1e24]
_TENS = [1.0, 10.0, 100.0]


def _scale_and_round(value: float, group: int, places: int) -> Tuple[float, int]:
    scaled = value / _THOUSANDS[group] if group >= 0 else value * _THOUSANDS[-group]
    if places < 0:
        unit = _TENS[-places]
        return round_float(scaled / unit, 0) * unit, 0
    p = 12 if places > 12 else places
    return round_float(scaled, p), p


def format_si(value: float, significant: int) -> str:
    """A number with an SI prefix and ``significant`` significant digits,
    trailing zeros dropped: 1200 is "1.2k", 0.0015 is "1.5m"."""
    if isinstance(significant, bool) or not isinstance(significant, int) or significant < 1 or significant > 15:
        raise ValueError("significant must be a whole number from 1 to 15, received %r" % (significant,))
    if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
        raise ValueError("value must be a finite number, received %r" % (value,))
    value = float(value)
    if value == 0:
        return "0"
    a = abs(value)
    exponent = 0
    if a >= 1:
        p = 1.0
        while p * 10 <= a:
            p *= 10
            exponent += 1
    else:
        q = 1.0
        while a * q < 1:
            q *= 10
            exponent -= 1
    group = exponent // 3
    group = max(-8, min(8, group))
    rounded, places = _scale_and_round(value, group, significant - 1 - (exponent - 3 * group))
    if abs(rounded) >= 1000 and group < 8:
        group += 1
        rounded, places = _scale_and_round(value, group, significant - 1)
    return format_decimal(rounded, places, True, "") + _PREFIXES[group + 8]

format_percent throws on bad input 11 tests

def format_percent(value: float, decimals: int) -> str
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%
from fune.charts.format import format_percent  # charts.format@^1
impl/python/format_percent.py · 13 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 math

from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune
from .text_format_decimal import format_decimal  ← from text.format-decimal ^1.0.0 · built alongside by fune


def format_percent(value: float, decimals: int) -> str:
    """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%"."""
    if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
        raise ValueError("value must be a finite number, received %r" % (value,))
    percent = round_float(float(value) * 100, 12)
    return format_decimal(percent, decimals, False, "") + "%"

format_date throws on bad input 11 tests

def format_date(iso: str, label: DateLabel) -> str
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
from fune.charts.format import format_date  # charts.format@^1
impl/python/format_date.py · 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.

from .charts_format_types import DateLabel
from .dates_add_days import parse_iso_date  ← from dates.add-days ^1.0.0 · built alongside by fune

_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]


def format_date(iso: str, label: DateLabel) -> str:
    """A date as a tick label for the interval it stands for."""
    date = parse_iso_date(iso)
    year = iso[0:4]
    month = _MONTHS[date.month - 1]
    if label in ("day", "week"):
        return "%d %s" % (date.day, month)
    if label == "month":
        return "%s %s" % (month, year)
    if label == "quarter":
        return "Q%d %s" % ((date.month - 1) // 3 + 1, year)
    if label == "year":
        return year
    raise ValueError('unknown date label "%s"; use day, week, month, quarter or year' % (label,))

format_money_tick throws on bad input 9 tests

def format_money_tick(minor: float, currency: str) -> str
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
from fune.charts.format import format_money_tick  # charts.format@^1
impl/python/format_money_tick.py · 13 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 math

from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune
from .money_amount import money  ← from money.amount ^1.0.0 · built alongside by fune
from .money_format import format_money  ← from money.format ^1.0.0 · built alongside by fune


def format_money_tick(minor: float, currency: str) -> str:
    """A money-axis tick: rounded half away from zero to a whole minor unit,
    then printed by money.format."""
    if isinstance(minor, bool) or not isinstance(minor, (int, float)) or not math.isfinite(minor):
        raise ValueError("minor must be a finite number, received %r" % (minor,))
    return format_money(money(int(round_float(minor, 0)), currency))

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 5 dependencies, pins them in fune.lock, downloads only the Python 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 Python charts.format-1.0.0-python.fune · 21,686 bytes sha256 c78ff97c30d38ba939417ea930d362cc0b31de5ecad3af6833ead003ce8650c5

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

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