Pantrix UI

Formatting numbers

Why, in Compose Multiplatform, an amount (1234567.891) does not come out the same on the screen on every platform, what the library gives for it, and how it is tested. The problem is not Pantrix's own: it shows the same way in every Compose Multiplatform project that shows a sum, a measure or a percentage in its common code.

Which of what is said here is held by a test, which is known from the platforms' documentation, and which was not verified, is written out in section 8.

1. The problem

Symptom

Cause

Kotlin's common library has no number formatter. Each platform has a formatter of its own, and they can be wired with expect/actual: java.text.NumberFormat on the JVM and Android, NSNumberFormatter on iOS, Intl.NumberFormat in a browser. But they do not give the same text for the same number:

DifferenceWhat happens
Rounding a halfDecimalFormat and NSNumberFormatter round a half to the even one by default (2.5 → 2), Intl.NumberFormat away from nought (2.5 → 3)
The short form (1.2K, 2.5M)It exists in Java since 12, on Android with android.icu since API 24, in a browser with notation: "compact"; NSNumberFormatter has none
The language's dataThe signs and the abbreviations come from the language data the operating system or the browser carries: the same app may write differently for the same language on an old Android and in a new browser; a place without the data falls back to English
TestsScreenshot tests and text checks need an expectation for each platform

For a design system the last one decides: if a chart's axis writes 1,2 B on Android and 1.234 on iOS, the same screen is two different screens.

The 1.005 problem, though, comes not from the platform but from how the number is held: a Double holds 1.005 as a number a little smaller than it (1.00499999999999989…). Multiplied by a hundred it gives 100.49999999999999, and stays below the half.

2. The solution

The number is written in common code, and the platform is not asked. Three parts:

  1. The language's signs stand in the language's pack. The decimal sign, the group sign, the digits, the percentage's pattern and the short form's four patterns: eight values for a language. The app's own pack gives them for its own language.

  2. Rounding is one rule: a half away from nought. 2.5 → 3, -2.5 → -3. The rule taught at school, and Intl.NumberFormat's default.

  3. What is rounded is the number as it is written. The scaled number is pushed up by as much as a Double's own uncertainty (two steps of its last digit), and then the half is looked at. So 1.005 → 1.01 and 0.615 → 0.62; what really stays below the half (1.0049) stays below it. The push never passes a hundredth of the digit being rounded: anything larger than that is no slip of the Double but the number's own digit.

A Double holds fifteen reliable digits. Decimal places asked for past that are not written (1.0 / 3 is 0.333333333333333, even where twenty places are asked for); of a number longer than fifteen digits, fifteen digits are written and the rest are noughts (1.2345e30 → 1,234,500,000,000,000,000,000,000,000,000).

3. With Pantrix

Three functions, in the theme's language pack (com.pantrixui.foundation.l10n.PantrixStrings):

public fun decimal(value: Number, fractionDigits: Int = 2, minFractionDigits: Int = 0, grouped: Boolean = true): String
public fun compact(value: Number, fractionDigits: Int = 1): String
public fun percent(share: Number, fractionDigits: Int = 0): String
val strings = PantrixTheme.strings
PantrixText(text = strings.decimal(order.total))          // 1,234,567.89
PantrixText(text = strings.compact(channel.followers))    // 1.2M
PantrixText(text = strings.percent(task.done))            // 26%
CallEnglishTurkishArabic
decimal(1234567.891)1,234,567.891.234.567,89١٬٢٣٤٬٥٦٧٫٨٩
decimal(2.5, minFractionDigits = 2)2.502,50٢٫٥٠
decimal(1234567.5, grouped = false)1234567.51234567,5١٢٣٤٥٦٧٫٥
compact(1234)1.2K1,2 B١٫٢ ألف
compact(2_500_000)2.5M2,5 Mn٢٫٥ مليون
compact(999_950)1M1 Mn١ مليون
percent(0.256)26%%26٢٦٪
percent(-0.05)-5%-%5-٥٪

What is not a number is written NaN, and what has no end ∞ and -∞. A negative number rounded to nought has no minus (-0.004 → 0).

Your own language

For a language with no pack, the app gives the signs; a field not given stays English:

val german = PantrixStrings(
    decimalSeparator = ",",
    groupSeparator = ".",
    percentTemplate = "{number} %",
    thousandsTemplate = "{number} Tsd.",
    millionsTemplate = "{number} Mio.",
    billionsTemplate = "{number} Mrd.",
    trillionsTemplate = "{number} Bio."
)
PantrixTheme(strings = german) { … }

A pack that could not be read is turned down as it is built: where the decimal sign is empty or the same as the group sign, or where a pattern has no {number}. A language that writes its digits in one piece gives groupSeparator = "".

In a chart

PantrixChart writes the values of a chart whose axis gives no format with these functions:

An axis that wants a writing of its own calls the same functions; format gives the same writing beside the axis, in the tooltip and to a screen reader:

val strings = PantrixTheme.strings
PantrixChart(
    series = series,
    yAxis = ChartAxis(format = { strings.percent(it) })    // 0.256 → 26%
)

The tooltip can have a writing of its own as well (ChartTooltip.format): a chart that wants to write short beside the axis and with its unit in the tooltip, or one that draws no axis at all (yAxis = null), gives it. Where it is given, it holds in the tooltip and to a screen reader; the writing beside the axis stays the axis's:

PantrixChart(
    series = series,
    yAxis = ChartAxis(format = { strings.compact(it) }),                    // on the axis 12K
    tooltip = ChartTooltip(format = { "${strings.decimal(it)} visits" })    // in the tooltip 12,340 visits
)

In a chart with two axes of values, each value is written by its own axis's format: a series that is a number as a number, one that is a rate as a percentage. A series that writes its values beside their marks (ChartDataLabels) uses the writing of the axis it is read by too; where the axis says nothing, a value is written short from ten thousand on and in full below. A label's own writing is given with ChartDataLabels(format = …).

PantrixChart(
    series = listOf(
        BarSeries("orders", "Orders", months, x = { it.name }, y = { it.orders }, labels = ChartDataLabels()),
        LineSeries("rate", "Return rate", months, x = { it.name }, y = { it.rate }, axis = ChartYAxis.Second)
    ),
    secondYAxis = ChartAxis(format = { strings.percent(it, fractionDigits = 1) })   // 0.042 → 4.2%
)

The places' names are the app's to give (ChartAxis.label); a place whose name is a whole number (2026) is written in the theme's digits, with no sign in between.

4. Without Pantrix

What follows can be copied as it is into a project's commonMain. There is no platform code.

import kotlin.math.abs
import kotlin.math.floor

/** The signs a language writes numbers with. */
data class NumberMarks(
    val decimal: String = ".",
    val group: String = ",",
    val digits: String = "0123456789"
)

/** [value]: rounded to [maxFraction] decimal places at the most, with its thousands parted. */
fun formatDecimal(
    value: Double,
    marks: NumberMarks = NumberMarks(),
    maxFraction: Int = 2,
    minFraction: Int = 0
): String {
    if (value.isNaN()) return "NaN"
    if (value.isInfinite()) return if (value < 0) "-∞" else "∞"
    val magnitude = abs(value)
    // A Double has fifteen reliable digits: what lies past them is not written, even where it is asked for.
    var places = minOf(maxFraction, 15)
    while (places > 0 && magnitude * POWERS[places] >= 1e15) places--
    var head = magnitude * POWERS[places]
    var noughts = 0
    while (head >= 1e15) {
        head /= 10.0
        noughts++
    }
    // A half goes up by the number as it is written: 1.005 stands in a Double as 1.00499….
    val seen = head + minOf(head * 4.440892098500626e-16, 0.01)
    val below = floor(seen)
    val rounded = if (seen - below >= 0.5) below + 1.0 else below
    val all = (rounded.toLong().toString() + "0".repeat(noughts)).padStart(places + 1, '0')
    val whole = all.dropLast(places)
    val fraction = all.takeLast(places).trimEnd('0').padEnd(minFraction, '0')
    val first = whole.length % 3
    val groups = listOfNotNull(whole.take(first).ifEmpty { null }) + whole.drop(first).chunked(3)
    val text = groups.joinToString(marks.group) + if (fraction.isEmpty()) "" else marks.decimal + fraction
    val written = text.map { it.digitToIntOrNull()?.let(marks.digits::get) ?: it }.joinToString("")
    val nought = whole.all { it == '0' } && fraction.all { it == '0' }
    return if (value < 0 && !nought) "-$written" else written
}

private val POWERS = listOf(1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15)
val turkish = NumberMarks(decimal = ",", group = ".")
formatDecimal(1234567.891, turkish)   // 1.234.567,89
formatDecimal(1.005)                  // 1.01

A percentage is the share multiplied by a hundred and put into the language's pattern ("%" + formatDecimal(share * 100, marks, 0)). The short form is dividing the number by a thousand until its rounded form stays below a thousand; this is looked at after rounding, or 999 950 gives 1000K.

4.440892098500626e-16 is two to the power of minus fifty-one: two steps of a Double's last digit, as a share of the number itself.

5. What the helper does not do

6. Testing

The formatter is a pure function: it needs no theme, no Compose and no platform.

@Test
fun a_total_is_written_as_turkish_writes_it() {
    assertEquals("1.234.567,89", PantrixStrings.Turkish.decimal(1234567.891))
    assertEquals("1,2 Mn", PantrixStrings.Turkish.compact(1_234_567))
}

A screen's test says itself which language comes: PantrixTheme(strings = PantrixStrings.Turkish). The same test passes on all five targets with the same expectation; no expectation for each platform is needed.

7. Pitfalls

8. Verification

Tests

PantrixNumbersTest (commonTest) runs with the same expectations on five targets: the JVM, Android (host), the iOS simulator, JS and Wasm in a browser. What they hold:

The arithmetic of the rounding was also tried, before it went into the code, on ten million hundredths and two million halves (in Python, with the same Doubles): none came out wrong.

Known from the documentation

The platforms' differences in section 1 (the default rounding rules, which platform has the short form) are what those platforms' own documentation says. They were not run side by side and compared in this repository.

Not verified

9. Where it is in this repository

WhatWhere
The three functions and the language's signsui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/l10n/PantrixStrings.kt
Writing with the language's signsNumberWriting.kt in the same package
Rounding and digitsWrittenDigits.kt in the same package
TestsPantrixNumbersTest (ui/foundation/src/commonTest/.../l10n)
The component that uses itPantrixChart: ui/components/src/commonMain/kotlin/com/pantrixui/components/chart/cartesian/ (ValueScale.kt, ChartReading.kt)