@cfasim-ui/shared 0.4.7 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/src/formatNumber.test.ts +135 -0
- package/src/formatNumber.ts +146 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfasim-ui/shared",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared utilities for cfasim-ui",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,8 +22,12 @@
|
|
|
22
22
|
"vue": "^3.5.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
+
"@types/sprintf-js": "^1.1.4",
|
|
25
26
|
"@vue/test-utils": "^2.4.6",
|
|
26
27
|
"happy-dom": "^20.8.9",
|
|
27
28
|
"vitest": "^4.1.0"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"sprintf-js": "^1.1.3"
|
|
28
32
|
}
|
|
29
33
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatNumber, isNumberFormat } from "./formatNumber.js";
|
|
3
|
+
|
|
4
|
+
describe("formatNumber", () => {
|
|
5
|
+
it("returns String(value) when no format is given", () => {
|
|
6
|
+
expect(formatNumber(42)).toBe("42");
|
|
7
|
+
expect(formatNumber(1.5)).toBe("1.5");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("passes non-finite values through unchanged", () => {
|
|
11
|
+
expect(formatNumber(NaN, "percent")).toBe("NaN");
|
|
12
|
+
expect(formatNumber(Infinity, "%.2f")).toBe("Infinity");
|
|
13
|
+
expect(formatNumber(-Infinity, (v) => `x=${v}`)).toBe("-Infinity");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("invokes a custom function formatter", () => {
|
|
17
|
+
expect(formatNumber(3, (v) => `n=${v * 2}`)).toBe("n=6");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("presets", () => {
|
|
21
|
+
it("plain returns String(value)", () => {
|
|
22
|
+
expect(formatNumber(1234.5, "plain")).toBe("1234.5");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("localized groups thousands", () => {
|
|
26
|
+
// Locale-dependent grouping char; just check there's a separator.
|
|
27
|
+
const out = formatNumber(1234567, "localized");
|
|
28
|
+
expect(out).toMatch(/1.234.567/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("percent multiplies by 100 and appends %", () => {
|
|
32
|
+
expect(formatNumber(0.5, "percent")).toBe("50%");
|
|
33
|
+
expect(formatNumber(0.1234, "percent")).toBe("12.34%");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("presets preserve raw precision when no :N is given", () => {
|
|
37
|
+
// Without a digit suffix, these used to round at Intl defaults (~3
|
|
38
|
+
// significant digits). Now they preserve the raw value.
|
|
39
|
+
expect(formatNumber(1234.5678, "localized")).toMatch(/1.234[.,]5678$/);
|
|
40
|
+
expect(formatNumber(0.123456, "percent")).toBe("12.3456%");
|
|
41
|
+
expect(formatNumber(12345, "scientific")).toBe("1.2345E4");
|
|
42
|
+
expect(formatNumber(12345, "engineering")).toBe("12.345E3");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("compact uses short forms", () => {
|
|
46
|
+
expect(formatNumber(1500, "compact")).toBe("1.5K");
|
|
47
|
+
expect(formatNumber(2_500_000, "compact")).toBe("2.5M");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("scientific uses scientific notation", () => {
|
|
51
|
+
expect(formatNumber(12345, "scientific")).toMatch(/^1\.\d+E4$/);
|
|
52
|
+
expect(formatNumber(0.001, "scientific")).toBe("1E-3");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("engineering uses powers of 1000", () => {
|
|
56
|
+
expect(formatNumber(12345, "engineering")).toMatch(/^12\.\d+E3$/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("preset:digits suffix", () => {
|
|
61
|
+
it("plain:N rounds to N decimal places", () => {
|
|
62
|
+
expect(formatNumber(1.2345, "plain:2")).toBe("1.23");
|
|
63
|
+
expect(formatNumber(1, "plain:0")).toBe("1");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("localized:N pads/truncates fractional digits", () => {
|
|
67
|
+
expect(formatNumber(1234.5, "localized:2")).toBe("1,234.50");
|
|
68
|
+
expect(formatNumber(1234.567, "localized:0")).toBe("1,235");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("percent:N controls fraction digits", () => {
|
|
72
|
+
expect(formatNumber(0.1234, "percent:1")).toBe("12.3%");
|
|
73
|
+
expect(formatNumber(0.5, "percent:0")).toBe("50%");
|
|
74
|
+
expect(formatNumber(0.5, "percent:2")).toBe("50.00%");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("compact:N controls fraction digits", () => {
|
|
78
|
+
expect(formatNumber(1500, "compact:2")).toBe("1.50K");
|
|
79
|
+
expect(formatNumber(1500, "compact:0")).toBe("2K");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("scientific:N controls fraction digits", () => {
|
|
83
|
+
expect(formatNumber(12345, "scientific:2")).toBe("1.23E4");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("engineering:N controls fraction digits", () => {
|
|
87
|
+
expect(formatNumber(12345, "engineering:1")).toBe("12.3E3");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("throws on malformed :suffix", () => {
|
|
91
|
+
expect(() => formatNumber(42, "percent:abc")).toThrow(/invalid format/);
|
|
92
|
+
expect(() => formatNumber(42, "compact:")).toThrow(/invalid format/);
|
|
93
|
+
expect(() => formatNumber(42, "percent:1.5")).toThrow(/invalid format/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("throws on unknown preset names", () => {
|
|
97
|
+
expect(() => formatNumber(42, "bogus")).toThrow(/invalid format/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("printf strings", () => {
|
|
102
|
+
it("formats with %f", () => {
|
|
103
|
+
expect(formatNumber(3.14159, "%.2f")).toBe("3.14");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("formats with %d", () => {
|
|
107
|
+
expect(formatNumber(42, "%05d")).toBe("00042");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("supports compound printf strings", () => {
|
|
111
|
+
expect(formatNumber(0.42, "rate: %.1f")).toBe("rate: 0.4");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("treats any string with % as printf (skips preset parsing)", () => {
|
|
115
|
+
// Even though "percent" is a preset name, a `%` placeholder anywhere
|
|
116
|
+
// routes through sprintf instead.
|
|
117
|
+
expect(formatNumber(42, "percent %d")).toBe("percent 42");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("isNumberFormat", () => {
|
|
123
|
+
it("accepts strings and functions", () => {
|
|
124
|
+
expect(isNumberFormat("percent")).toBe(true);
|
|
125
|
+
expect(isNumberFormat("%.2f")).toBe(true);
|
|
126
|
+
expect(isNumberFormat((v: number) => String(v))).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rejects other types", () => {
|
|
130
|
+
expect(isNumberFormat(undefined)).toBe(false);
|
|
131
|
+
expect(isNumberFormat(null)).toBe(false);
|
|
132
|
+
expect(isNumberFormat(42)).toBe(false);
|
|
133
|
+
expect(isNumberFormat({})).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { sprintf } from "sprintf-js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Named number-format presets, modelled on Streamlit's number column formats.
|
|
5
|
+
* - `plain` — `String(value)` (no grouping)
|
|
6
|
+
* - `localized` — `Intl.NumberFormat()` with the user's locale
|
|
7
|
+
* - `percent` — formats as a percent (value `0.5` → `"50%"`)
|
|
8
|
+
* - `compact` — short form (`1.2K`, `3.4M`)
|
|
9
|
+
* - `scientific` — scientific notation (`1.23E4`)
|
|
10
|
+
* - `engineering` — engineering notation (powers of 1000)
|
|
11
|
+
*
|
|
12
|
+
* Presets preserve the raw value's precision by default (no rounding).
|
|
13
|
+
* Append `:N` (a digit) to fix the number of fractional digits, e.g.
|
|
14
|
+
* `"percent:1"` → `"12.3%"`, `"localized:2"` → `"1,234.50"`.
|
|
15
|
+
*/
|
|
16
|
+
export type NumberFormatPreset =
|
|
17
|
+
| "plain"
|
|
18
|
+
| "localized"
|
|
19
|
+
| "percent"
|
|
20
|
+
| "compact"
|
|
21
|
+
| "scientific"
|
|
22
|
+
| "engineering";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A number format specifier:
|
|
26
|
+
* - A {@link NumberFormatPreset} name, optionally with `:N` digits suffix
|
|
27
|
+
* (e.g. `"percent:1"`, `"compact:2"`)
|
|
28
|
+
* - A printf-style format string (must contain a `%` placeholder, parsed
|
|
29
|
+
* by sprintf-js — e.g. `"%.2f"`, `"%05d"`)
|
|
30
|
+
* - A function `(value) => string` for full control
|
|
31
|
+
*
|
|
32
|
+
* Strings that contain no `%` and don't match a preset throw at format
|
|
33
|
+
* time, so typos surface immediately instead of silently rendering wrong.
|
|
34
|
+
*/
|
|
35
|
+
export type NumberFormat =
|
|
36
|
+
| NumberFormatPreset
|
|
37
|
+
| string
|
|
38
|
+
| ((value: number) => string);
|
|
39
|
+
|
|
40
|
+
const PRESET_NAMES = new Set<NumberFormatPreset>([
|
|
41
|
+
"plain",
|
|
42
|
+
"localized",
|
|
43
|
+
"percent",
|
|
44
|
+
"compact",
|
|
45
|
+
"scientific",
|
|
46
|
+
"engineering",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function isPreset(s: string): s is NumberFormatPreset {
|
|
50
|
+
return PRESET_NAMES.has(s as NumberFormatPreset);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse `"preset"` or `"preset:N"` into a name and optional digit count.
|
|
55
|
+
* Returns null if the string isn't a recognized preset.
|
|
56
|
+
*/
|
|
57
|
+
function parsePreset(
|
|
58
|
+
s: string,
|
|
59
|
+
): { preset: NumberFormatPreset; digits: number | undefined } | null {
|
|
60
|
+
const colon = s.indexOf(":");
|
|
61
|
+
if (colon === -1) {
|
|
62
|
+
return isPreset(s) ? { preset: s, digits: undefined } : null;
|
|
63
|
+
}
|
|
64
|
+
const name = s.slice(0, colon);
|
|
65
|
+
const rest = s.slice(colon + 1);
|
|
66
|
+
if (!isPreset(name)) return null;
|
|
67
|
+
// Digits must be a non-negative integer (matches Intl's 0..100 range).
|
|
68
|
+
if (!/^\d+$/.test(rest)) return null;
|
|
69
|
+
const digits = Number(rest);
|
|
70
|
+
if (digits > 100) return null;
|
|
71
|
+
return { preset: name, digits };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Covers JS double-precision (≤17 significant digits). When the caller
|
|
75
|
+
// hasn't asked for a specific digit count, we use this to preserve the
|
|
76
|
+
// raw value rather than letting Intl round to its default precision.
|
|
77
|
+
const RAW_PRECISION_DIGITS = 20;
|
|
78
|
+
|
|
79
|
+
function formatPreset(
|
|
80
|
+
value: number,
|
|
81
|
+
preset: NumberFormatPreset,
|
|
82
|
+
digits: number | undefined,
|
|
83
|
+
): string {
|
|
84
|
+
const fractionOpts: Intl.NumberFormatOptions =
|
|
85
|
+
digits !== undefined
|
|
86
|
+
? { minimumFractionDigits: digits, maximumFractionDigits: digits }
|
|
87
|
+
: { maximumFractionDigits: RAW_PRECISION_DIGITS };
|
|
88
|
+
switch (preset) {
|
|
89
|
+
case "plain":
|
|
90
|
+
return digits !== undefined ? value.toFixed(digits) : String(value);
|
|
91
|
+
case "localized":
|
|
92
|
+
return new Intl.NumberFormat(undefined, fractionOpts).format(value);
|
|
93
|
+
case "percent":
|
|
94
|
+
return new Intl.NumberFormat(undefined, {
|
|
95
|
+
style: "percent",
|
|
96
|
+
...fractionOpts,
|
|
97
|
+
}).format(value);
|
|
98
|
+
case "compact":
|
|
99
|
+
return new Intl.NumberFormat(undefined, {
|
|
100
|
+
notation: "compact",
|
|
101
|
+
...fractionOpts,
|
|
102
|
+
}).format(value);
|
|
103
|
+
case "scientific":
|
|
104
|
+
return new Intl.NumberFormat(undefined, {
|
|
105
|
+
notation: "scientific",
|
|
106
|
+
...fractionOpts,
|
|
107
|
+
}).format(value);
|
|
108
|
+
case "engineering":
|
|
109
|
+
return new Intl.NumberFormat(undefined, {
|
|
110
|
+
notation: "engineering",
|
|
111
|
+
...fractionOpts,
|
|
112
|
+
}).format(value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Format a number using a preset name, a printf-style format string, or a
|
|
118
|
+
* custom function. Non-finite values (NaN, ±Infinity) are returned as
|
|
119
|
+
* `String(value)`; if `format` is omitted, falls back to `String(value)`.
|
|
120
|
+
*
|
|
121
|
+
* Throws if `format` is a string that's neither a recognized preset (with
|
|
122
|
+
* an optional `:N` digit suffix) nor a printf string containing `%`.
|
|
123
|
+
*/
|
|
124
|
+
export function formatNumber(value: number, format?: NumberFormat): string {
|
|
125
|
+
if (!Number.isFinite(value)) return String(value);
|
|
126
|
+
if (format === undefined) return String(value);
|
|
127
|
+
if (typeof format === "function") return format(value);
|
|
128
|
+
// printf strings always contain a `%` placeholder; everything else must
|
|
129
|
+
// be a recognized preset (with an optional `:N` digits suffix).
|
|
130
|
+
if (format.includes("%")) return sprintf(format, value);
|
|
131
|
+
const parsed = parsePreset(format);
|
|
132
|
+
if (!parsed) {
|
|
133
|
+
const names = [...PRESET_NAMES].join(", ");
|
|
134
|
+
throw new Error(
|
|
135
|
+
`formatNumber: invalid format ${JSON.stringify(format)}. ` +
|
|
136
|
+
`Expected one of ${names} (optionally with ":N" digits), ` +
|
|
137
|
+
`a printf format string containing "%", or a function.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return formatPreset(value, parsed.preset, parsed.digits);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True if `f` is a recognized {@link NumberFormat} value. */
|
|
144
|
+
export function isNumberFormat(f: unknown): f is NumberFormat {
|
|
145
|
+
return typeof f === "string" || typeof f === "function";
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ export type {
|
|
|
14
14
|
ModelOutputsWire,
|
|
15
15
|
} from "./ModelOutput.js";
|
|
16
16
|
export { modelOutputToCSV } from "./csv.js";
|
|
17
|
+
export { formatNumber, isNumberFormat } from "./formatNumber.js";
|
|
18
|
+
export type { NumberFormat, NumberFormatPreset } from "./formatNumber.js";
|
|
17
19
|
export {
|
|
18
20
|
useUrlParams,
|
|
19
21
|
serialize,
|