Created
July 10, 2024 09:02
-
-
Save eliask/f70434e047f4286a17552ad9e6d7256b to your computer and use it in GitHub Desktop.
nformat - significant digits and maximum precision aware number formatting in Typescript
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* nformat - significant digits and maximum precision aware number formatting with SI prefixes + null value handling | |
* | |
* Examples: | |
* nformat({na: "", maxPrecision: 5, significantDigits: 4, si: "si", unit: 'J', value: 1231.2278346237846e12}) | |
* -> "1.2313 PJ" | |
* nformat({na: "", maxPrecision: 1, significantDigits: 9, si: "english", unit: ' kg', value: 1231.2278346237846e4}) | |
* -> "12.3122783M kg" | |
* nformat({na: "", maxPrecision: 2, significantDigits: 3, unit: ' s', value: 1231.2278346237846e-3}) | |
* -> "0.12 s" | |
* nformat({na: "No data", maxPrecision: 2, significantDigits: 3, unit: ' s', value: null}) | |
* -> "No data" | |
*/ | |
import { Decimal } from "decimal.js"; | |
type DecimalInput = Decimal | number | string; | |
type DecimalInputNA = DecimalInput | undefined | null; | |
const SI_PREFIXES = "kMGTPEZYRQ"; | |
/** Scale the number to [1, 1000) and return SI prefix like T for 1e12 etc. Only for positive magnitudes. */ | |
export const getSIfiedNumber = (amount: DecimalInput) => { | |
let value = new Decimal(amount).toNumber(); | |
let prefix = ""; | |
let i = 0; | |
while (value >= 1000 && i < SI_PREFIXES.length) { | |
value /= 1000; | |
prefix = SI_PREFIXES[i++]; | |
} | |
return { value, prefix, magnitude: Math.pow(1000, i) }; | |
}; | |
/** | |
* Return English number abbreviations thousands through trillions, KMBT. | |
* Larger numbers are just e.g. 1 321 435 T. | |
*/ | |
export const getEnglishLargeNumberAbbreviation = (amount: DecimalInput) => { | |
let value = new Decimal(amount).toNumber(); | |
let abbrev = ""; | |
let i = 0; | |
const ENGLISH_PREFIXES = "KMBT"; | |
while (value >= 1000 && i < ENGLISH_PREFIXES.length) { | |
value /= 1000; | |
abbrev = ENGLISH_PREFIXES[i++]; | |
} | |
return { value, abbrev, magnitude: Math.pow(1000, i) }; | |
}; | |
/** | |
* If provided with options, format using Intl.NumberFormat e.g. 123456.7 -> 123,456.7 | |
* Otherwise, format verbatim e.g. 123456 -> 123456.7 | |
* | |
* NB: Also formats 11 -> "11.00" if significantDigits = 4 | |
*/ | |
function formatWithNumberFormat(value: number | string, minFrac: number, options?: Intl.NumberFormatOptions) { | |
// NB: \u2009 is a thin space, as recommended by SI | |
// See: https://en.m.wikipedia.org/wiki/Decimal_separator | |
// NB: default maximumFractionDigits is mere 3. | |
const formatter = new Intl.NumberFormat("en-US", { ...options, maximumFractionDigits: 18, minimumFractionDigits: minFrac }); | |
return formatter | |
.formatToParts(+value) | |
.map(({ type, value }) => (type === "group" ? "\u2009" : value)) | |
.join(""); | |
} | |
/** | |
* Format 1234.321 with significantDigits=100 and maxPrecision=2 as "1234.32" | |
* Format 0.011111 with significantDigits=2 and maxPrecision=100 as "0.011" | |
* NB: maxPrecision >= 0, significantDigits >= 1 | |
* NB: maxPrecision denotes the maximum number of decimal places to show | |
*/ | |
type NFormatInput = { | |
na: string | null; | |
maxPrecision: number; | |
significantDigits: number; | |
value: DecimalInputNA; | |
/** NB: English = K, M, B, T */ | |
si?: "si" | "english"; | |
unit?: string; | |
formatOptions?: Intl.NumberFormatOptions; | |
}; | |
export function nformat({ na, maxPrecision, significantDigits, value, si, unit, formatOptions }: NFormatInput): string { | |
if (maxPrecision < 0) throw new Error("maxPrecision must be >= 0"); | |
if (significantDigits < 1) throw new Error("significantDigits must be >= 1"); | |
const isNa = value === undefined || value === null; | |
if (na === null) { | |
if (isNa) throw new Error("na must be provided if value is null or undefined"); | |
} else { | |
if (isNa) { | |
return na; | |
} | |
} | |
const decimal = new Decimal(value); | |
if (decimal.isNaN()) throw new Error("value must be a number"); | |
const magnitude = decimal.eq(0) ? 0 : 1 + Math.floor(Math.log10(decimal.abs().toNumber())); | |
const minFrac = Math.max(0, Math.min(maxPrecision, significantDigits - magnitude)); | |
const numStr = decimal.toSignificantDigits(significantDigits).toFixed(minFrac); | |
if (si === "si") { | |
const { value, prefix, magnitude } = getSIfiedNumber(numStr); | |
const rescaledMag = decimal.eq(0) ? 0 : 1 + Math.floor(Math.log10(Math.abs(value))); | |
// 43e15 J -> 43.00 PJ - initial significantDigits = 4, magnitude = 16 | |
// rescaled: magnitude 1, maxPrecision += 15, minFrac = 4 - (1 + 1) | |
const minFrac = Math.max(0, Math.min(maxPrecision + Math.log10(magnitude), significantDigits - rescaledMag)); | |
const combinedUnit = prefix === "" ? (unit ?? "").trimStart() : `${prefix}${unit ?? ""}`; | |
return `${formatWithNumberFormat(value, minFrac, formatOptions)} ${combinedUnit}`; | |
} else if (si === "english") { | |
const { value, abbrev, magnitude } = getEnglishLargeNumberAbbreviation(numStr); | |
const rescaledMag = decimal.eq(0) ? 0 : 1 + Math.floor(Math.log10(Math.abs(value))); | |
const minFrac = Math.max(0, Math.min(maxPrecision + magnitude, significantDigits - rescaledMag)); | |
return `${formatWithNumberFormat(value, minFrac, formatOptions)}${abbrev}${unit ? ` ${unit.trimStart()}` : ""}`; | |
} else { | |
return `${formatWithNumberFormat(numStr, minFrac, formatOptions)}${unit ?? ""}`; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment