Skip to content

Instantly share code, notes, and snippets.

@eliask
Created July 10, 2024 09:02
Show Gist options
  • Save eliask/f70434e047f4286a17552ad9e6d7256b to your computer and use it in GitHub Desktop.
Save eliask/f70434e047f4286a17552ad9e6d7256b to your computer and use it in GitHub Desktop.
nformat - significant digits and maximum precision aware number formatting in Typescript
/**
* 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