/*
* This file belongs to Hoist, an application development toolkit
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
*
* Copyright © 2026 Extremely Heavy Industries Inc.
*/
import {span} from '@xh/hoist/cmp/layout';
import {
defaults,
isBoolean,
isFinite,
isFunction,
isInteger,
isNil,
isNumber,
isString,
isUndefined,
round
} from 'lodash';
import Numbro from 'numbro';
import numbro from 'numbro';
import {CSSProperties, ReactNode} from 'react';
import {IntRange} from 'type-fest';
import {fmtSpan, FormatOptions} from './FormatMisc';
import {createRenderer} from './FormatUtils';
import {saveOriginal} from './impl/Utils';
const THOUSAND = 1000,
MILLION = 1000000,
BILLION = 1000000000,
MAX_NUMERIC_PRECISION = 12;
const UP_TICK = '▴',
DOWN_TICK = '▾',
LEDGER_ALIGN_PLACEHOLDER = ')',
LEDGER_ALIGN_PLACEHOLDER_EL = span({style: {visibility: 'hidden'}, item: ')'}),
DEFAULT_COLOR_SPEC: ColorSpec = {
pos: 'xh-pos-val',
neg: 'xh-neg-val',
neutral: 'xh-neutral-val'
};
export type NumericPrecision = IntRange<0, 13>;
export type Precision = NumericPrecision | 'auto';
export type ZeroPad = NumericPrecision | boolean;
export interface NumberFormatOptions extends Omit, 'tooltip'> {
/**
* Color output based on the sign of the value. True to use red/green/grey defaults, or provide
* an object with alternate CSS classes or properties.
*/
colorSpec?: boolean | ColorSpec;
/**
* True to add placeholder after positive ledgers to ensure columns of mixed positive and
* negative numbers vertically align their digits, avoiding shift due to ")" on negative values.
*/
forceLedgerAlign?: boolean;
/** A valid numbro format object or string. */
formatConfig?: Numbro.Format | string;
/**
* Label to append to value, or true to append a default label for the formatter -
* e.g. 'm' for fmtMillions.
*/
label?: string | boolean;
/** CSS class of label span. */
labelCls?: string;
/** True to use ledger format. */
ledger?: boolean;
/**
* Set to true to omit thousands-separator comma if value is to be formatted as a whole number
* with exactly 4 digits (e.g. 1,500).
*/
omitFourDigitComma?: boolean;
/**
* Desired number of decimal places, 'auto' (default) to adjust the displayed precision
* automatically based on the scale of the value, or null for full, unrestricted precision
* (capped at the max supported precision, with trailing zeros trimmed).
*/
precision?: Precision;
/** Prefix to prepend to value (between the number and its sign). */
prefix?: string;
/**
* If set to false, small numbers that would show only digits of zero due to precision will be
* formatted as exactly zero. In particular, if a zeroDisplay is specified it will be used and
* sign-based glyphs, '+/-' characters, and colors will not be shown. Default true.
*/
strictZero?: boolean;
/**
* True to enable default tooltip with minimally formatted original value, or a function to
* generate a custom tooltip string.
*/
tooltip?: boolean | ((v: number) => string);
/** True to include comma delimiters. */
withCommas?: boolean;
/** True to prepend positive numbers with a '+'. */
withPlusSign?: boolean;
/** True to prepend an up / down arrow. */
withSignGlyph?: boolean;
/** Optional display value for the input value 0. */
zeroDisplay?: ReactNode;
/**
* True to pad with trailing zeros out to precision, false to skip adding any trailing zeroes.
* Can also be a number (lte precision) to specify a minimum number of trailing zeroes to add,
* without extending zero padding all the way out to full precision.
*
* e.g. `{precision:4, zeroPad:2}` will format `1.2` → "1.20" and `1.234` → "1.234"
*
* Default is true if a fixed precision is set, false if precision is 'auto' or null (full).
*/
zeroPad?: ZeroPad;
}
export interface QuantityFormatOptions extends NumberFormatOptions {
/** True to compact values \>= 1 million into units of millions (m). Default true. */
useMillions?: boolean;
/** True to compact values \>= 1 billion into units of billions (b). Default true. */
useBillions?: boolean;
/**
* True to compact to m/b units only when no precision is lost, else render the full value.
* Default false.
*/
lossless?: boolean;
}
/** Config for pos/neg/neutral color classes. */
export interface ColorSpec {
/** CSS color class or CSS Style Properties to apply to positive values */
pos?: string | CSSProperties;
/** CSS color class or CSS Style Properties to apply to negative values */
neg?: string | CSSProperties;
/** CSS color class or CSS Style Properties to apply to zero values. */
neutral?: string | CSSProperties;
}
/**
* Standard number formatting for Hoist
*
* This method delegates to numbro, @see http://numbrojs.com for more details.
*
* Hierarchy of params is by specificity: formatPattern, precision.
* If no options are given, a heuristic based auto-rounding will occur.
*
* @returns a ReactNode. For an HTML string use `asHtml = true`.
*/
export function fmtNumber(v: number, opts?: NumberFormatOptions): ReactNode {
let {
nullDisplay = '',
zeroDisplay = null,
formatConfig = null,
precision,
zeroPad,
ledger = false,
forceLedgerAlign = true,
withPlusSign = false,
strictZero = true,
withSignGlyph = false,
withCommas = true,
omitFourDigitComma = false,
prefix = null,
label = null,
labelCls = 'xh-units-label',
colorSpec = false,
tooltip = null,
asHtml = false,
originalValue = v
} = opts ?? ({} as NumberFormatOptions);
if (isInvalidInput(v)) return nullDisplay;
// Resolve precision: null means full precision, other non-integers (e.g. undefined) mean 'auto'.
const fullPrecision = precision === null;
if (fullPrecision) {
precision = MAX_NUMERIC_PRECISION;
} else if (!isInteger(precision)) {
precision = 'auto';
}
// Default zeroPad to pad only for a fixed precision - 'auto' and full precision trim zeros.
if (isNil(zeroPad)) zeroPad = precision != 'auto' && !fullPrecision;
formatConfig =
formatConfig || buildFormatConfig(v, precision, zeroPad, withCommas, omitFourDigitComma);
const str = numbro(v).format(formatConfig).replace('-', '');
let sign = null;
// Tests for zero strings at various precisions
if (!strictZero && /^0+.?0*$/.test(str)) {
// Treat rounded zeros as a true zero, for sign checks
v = 0;
}
if (v === 0 && zeroDisplay != null) {
return zeroDisplay;
}
if (v > 0 && withPlusSign) {
sign = '+';
} else if (v < 0 && !ledger) {
sign = '-';
}
// As an optimization, return the string form if we do not *need* to wrap in markup.
const delOpts = {
ledger,
forceLedgerAlign,
withSignGlyph,
prefix,
label,
labelCls,
colorSpec,
tooltip,
originalValue
},
asString =
!withSignGlyph &&
!colorSpec &&
!tooltip &&
(!ledger || !forceLedgerAlign) &&
(!label || !labelCls);
return asHtml || asString
? fmtNumberString(v, str, sign, delOpts)
: fmtNumberElement(v, str, sign, delOpts);
}
/**
* Render number in thousands.
*/
export function fmtThousands(v: number, opts?: NumberFormatOptions): ReactNode {
opts = {...opts};
saveOriginal(v, opts);
if (isInvalidInput(v)) return fmtNumber(v, opts);
v = v / THOUSAND;
if (opts.label === true) opts.label = 'k';
return fmtNumber(v, opts);
}
/**
* Render number in millions.
*/
export function fmtMillions(v: number, opts?: NumberFormatOptions): ReactNode {
opts = {...opts};
saveOriginal(v, opts);
if (isInvalidInput(v)) return fmtNumber(v, opts);
v = v / MILLION;
if (opts.label === true) opts.label = 'm';
return fmtNumber(v, opts);
}
/**
* Render number in billions.
*/
export function fmtBillions(v: number, opts?: NumberFormatOptions): ReactNode {
opts = {...opts};
saveOriginal(v, opts);
if (isInvalidInput(v)) return fmtNumber(v, opts);
v = v / BILLION;
if (opts.label === true) opts.label = 'b';
return fmtNumber(v, opts);
}
/**
* Render a quantity value, handling highly variable amounts by using units of millions (m) and
* billions (b) as needed.'
*/
export function fmtQuantity(v: number, opts?: QuantityFormatOptions) {
opts = {...opts};
saveOriginal(v, opts);
if (isInvalidInput(v)) return fmtNumber(v, opts);
defaults(opts, {
ledger: true,
label: true,
useMillions: true,
useBillions: true,
lossless: false
});
const absV = Math.abs(v),
lessM = absV < MILLION,
lessB = absV < BILLION,
targetPrecision = opts.precision ?? (lessM ? 0 : 2);
// Compute scaling, if any (Lossless flag may preclude).
let scale = !lessB && opts.useBillions ? BILLION : !lessM && opts.useMillions ? MILLION : null;
if (scale && opts.lossless) {
const precision = parsePrecision(absV / scale, targetPrecision),
lossy = v % (scale / 10 ** precision) !== 0;
if (lossy) scale = null;
}
// Resolve render precision (unless the caller set one).
if (isUndefined(opts.precision)) {
opts.precision = opts.lossless ? null : targetPrecision;
}
switch (scale) {
case BILLION:
return fmtBillions(v, opts);
case MILLION:
return fmtMillions(v, opts);
default:
return fmtNumber(v, opts);
}
}
/**
* Render market price.
*/
export function fmtPrice(v: number, opts?: NumberFormatOptions): ReactNode {
opts = {...opts};
saveOriginal(v, opts);
if (isInvalidInput(v)) return fmtNumber(v, opts);
if (opts.precision === undefined) {
const absVal = Math.abs(v);
opts.precision = absVal < 1000 && absVal !== 0 ? 2 : 0;
}
return fmtNumber(v, opts);
}
/**
* Render a number as a percent. Value will be multiplied by 100 to calculated the percentage.
* This behavior purposefully matches Microsoft Excel's percentage formatting.
*/
export function fmtPercent(v: number, opts?: NumberFormatOptions): ReactNode {
opts = {...opts};
saveOriginal(v, opts);
if (isInvalidInput(v)) return fmtNumber(v, opts);
defaults(opts, {precision: 2, label: '%', labelCls: null});
return fmtNumber(v * 100, opts);
}
/**
* Render a minimally formatted, full precision number, suitable for use in tooltips.
* Only ledger opt is supported.
*
* @param v - value to format.
* @param opts - set key 'ledger' to true to use ledger format, default false
*/
export function fmtNumberTooltip(v: number, opts?: {ledger?: boolean}): string {
return fmtNumber(v, {
ledger: opts?.ledger,
forceLedgerAlign: false,
precision: MAX_NUMERIC_PRECISION,
zeroPad: false,
asHtml: true
}) as string;
}
//---------------
// Implementation
//---------------
function fmtNumberElement(v: number, str: string, sign: '+' | '-', opts?: NumberFormatOptions) {
const {ledger, forceLedgerAlign, withSignGlyph, prefix, label, labelCls, colorSpec, tooltip} =
opts ?? {};
// CSS classes
const cls = [];
if (colorSpec) cls.push(calcClassFromColorSpec(v, colorSpec));
if (tooltip) cls.push('xh-title-tip');
// Compile child items
const items = [];
if (withSignGlyph) {
items.push(signGlyph(v));
} else if (sign) {
items.push(sign);
}
if (isString(prefix)) {
items.push(prefix);
}
items.push(str);
if (isString(label)) {
items.push(labelCls ? fmtSpan(label, {className: labelCls}) : label);
}
if (ledger) {
if (v < 0) {
items.unshift('(');
items.push(')');
} else if (forceLedgerAlign) {
items.push(LEDGER_ALIGN_PLACEHOLDER_EL);
}
}
return span({
className: cls.join(' '),
style: calcStyleFromColorSpec(v, colorSpec),
title: processToolTip(tooltip, opts),
items: items
});
}
function fmtNumberString(
v: number,
str: string,
sign: '+' | '-',
opts?: NumberFormatOptions
): string {
const {ledger, forceLedgerAlign, withSignGlyph, label, labelCls, colorSpec, tooltip, prefix} =
opts,
asHtml = true;
let ret = '';
if (withSignGlyph) {
ret += signGlyph(v, asHtml);
} else if (sign) {
ret += sign;
}
if (isString(prefix) && prefix.trim()) {
ret += prefix;
}
ret += str;
if (isString(label) && label.trim()) {
if (labelCls) {
ret += fmtSpan(label, {className: labelCls, asHtml});
} else {
ret += label;
}
}
if (ledger) {
if (v < 0) {
ret = '(' + ret + ')';
} else if (forceLedgerAlign) {
ret += LEDGER_ALIGN_PLACEHOLDER;
}
}
if (colorSpec) {
ret = fmtSpan(ret, {
className: calcClassFromColorSpec(v, colorSpec),
style: calcStyleFromColorSpec(v, colorSpec),
asHtml
}) as string;
}
if (tooltip) {
ret = fmtSpan(ret, {
className: 'xh-title-tip',
title: processToolTip(tooltip, opts),
asHtml
}) as string;
}
return ret;
}
function signGlyph(v: number, asHtml: boolean = false) {
if (!isFinite(v)) return '';
return v === 0
? fmtSpan(UP_TICK, {className: 'xh-transparent', asHtml})
: v > 0
? UP_TICK
: DOWN_TICK;
}
function calcClassFromColorSpec(v: number, colorSpec: ColorSpec | boolean): string {
if (colorSpec === true) colorSpec = DEFAULT_COLOR_SPEC;
if (!isFinite(v) || !colorSpec) return '';
const possibleClassName = v < 0 ? colorSpec.neg : v > 0 ? colorSpec.pos : colorSpec.neutral;
return isString(possibleClassName) ? possibleClassName : '';
}
function calcStyleFromColorSpec(v: number, colorSpec: ColorSpec | boolean): CSSProperties {
if (!isFinite(v) || isBoolean(colorSpec) || !colorSpec) return {};
const possibleStyles = v < 0 ? colorSpec.neg : v > 0 ? colorSpec.pos : colorSpec.neutral;
return !isString(possibleStyles) ? possibleStyles : {};
}
/**
* Resolve a precisionSpec to a concrete number of decimal places. A fixed precision is used as-is
* (capped at max), while 'auto' is derived from the scale of the value.
*/
function parsePrecision(v: number, precisionSpec: Precision): number {
// Fixed precision - use requested, capped at max.
if (precisionSpec !== 'auto') return Math.min(precisionSpec, MAX_NUMERIC_PRECISION);
// 'auto' - derive from the scale of the value.
const absVal = Math.abs(v);
if (absVal === 0) return 2;
if (absVal < 0.01) return 6;
if (absVal < 100) return 4;
if (absVal < 10000) return 2;
return 0;
}
function buildFormatConfig(
v: number,
precisionSpec: Precision,
zeroPad: ZeroPad,
withCommas: boolean,
omitFourDigitComma: boolean
): Numbro.Format {
const absVal = Math.abs(v),
config: Numbro.Format = {};
const precision = parsePrecision(v, precisionSpec);
// If zeroPad gte precision, treat as `true` to pad out to (but not beyond) full precision.
// We don't support applying some precision (rounding) then padding out zeroes after that.
if (isNumber(zeroPad) && zeroPad >= precision) {
zeroPad = true;
}
// Calculate numbro mantissa and trimMantissa options based on precision and zeroPad settings.
if (isNumber(zeroPad)) {
// Specific zeroPad set - we want to show at least this much precision, but not more unless
// the value actually has more precision. Note, we round to requested *max* precision first,
// then determine the value's actual precision. This avoids issues where values resulting
// from floating point operations have spurious precision that would defeat this routine.
const requiredPrecision = countDecimalPlaces(round(absVal, precision));
// Then set mantissa to higher of required precision vs. requested zeroPad.
// Ensures we display all of the requested/available precision + extra zeros if needed.
config.mantissa = Math.max(requiredPrecision, zeroPad);
config.trimMantissa = false;
} else {
// Without a specific (numeric) zeroPad set, we can set mantissa to precision then
// optionally enable trimMantissa option to remove trailing zeroes if requested.
// No need to measure actual precision of number.
config.mantissa = precision;
config.trimMantissa = !zeroPad && precision != 0;
}
// Apply comma-separation unless contradicted by omitFourDigitComma, which should apply only to
// whole number values between 1000 and 9999, where we are not displaying any decimal places.
config.thousandSeparated =
withCommas &&
!(
omitFourDigitComma &&
absVal < 10000 &&
(config.mantissa == 0 || (!zeroPad && Number.isInteger(absVal)))
);
return config;
}
function countDecimalPlaces(number: number): number {
const numStr = number.toString(),
dpIdx = numStr.indexOf('.');
return dpIdx === -1 ? 0 : numStr.length - dpIdx - 1;
}
function isInvalidInput(v) {
return v == null || v === '';
}
function processToolTip(tooltip, opts) {
if (tooltip === true) return fmtNumberTooltip(opts.originalValue, opts);
if (isFunction(tooltip)) return tooltip(opts.originalValue);
return null;
}
export const numberRenderer = createRenderer(fmtNumber),
thousandsRenderer = createRenderer(fmtThousands),
millionsRenderer = createRenderer(fmtMillions),
billionsRenderer = createRenderer(fmtBillions),
quantityRenderer = createRenderer(fmtQuantity),
priceRenderer = createRenderer(fmtPrice),
percentRenderer = createRenderer(fmtPercent);
const shorthandValidator = /((\.\d+)|(\d+(\.\d+)?))([kmb])\b/i;
/**
* @param value - A value that represents a shorthand numerical value
* @returns The number represented by the shorthand string, or NaN
*/
export function parseNumber(value: any): number {
if (isNil(value) || value === '') return null;
value = value.toString();
value = value.replace(/,/g, '');
if (shorthandValidator.test(value)) {
const num = +value.substring(0, value.length - 1),
lastChar = value.charAt(value.length - 1).toLowerCase();
switch (lastChar) {
case 'k':
return num * 1000;
case 'm':
return num * 1000000;
case 'b':
return num * 1000000000;
default:
return NaN;
}
}
return parseFloat(value);
}