@cfasim-ui/shared 0.4.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/shared",
3
- "version": "0.4.6",
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,