@onrail-xyz/amount 1.0.0

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.
Files changed (47) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +617 -0
  3. package/dist/aggregating.d.ts +31 -0
  4. package/dist/aggregating.d.ts.map +1 -0
  5. package/dist/aggregating.js +31 -0
  6. package/dist/allocating.d.ts +9 -0
  7. package/dist/allocating.d.ts.map +1 -0
  8. package/dist/allocating.js +31 -0
  9. package/dist/amount.d.ts +68 -0
  10. package/dist/amount.d.ts.map +1 -0
  11. package/dist/amount.js +184 -0
  12. package/dist/format.d.ts +15 -0
  13. package/dist/format.d.ts.map +1 -0
  14. package/dist/format.js +226 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/jsonCodecs.d.ts +9 -0
  19. package/dist/jsonCodecs.d.ts.map +1 -0
  20. package/dist/jsonCodecs.js +46 -0
  21. package/dist/kind.d.ts +126 -0
  22. package/dist/kind.d.ts.map +1 -0
  23. package/dist/kind.js +118 -0
  24. package/dist/rate.d.ts +58 -0
  25. package/dist/rate.d.ts.map +1 -0
  26. package/dist/rate.js +178 -0
  27. package/dist/rational.d.ts +49 -0
  28. package/dist/rational.d.ts.map +1 -0
  29. package/dist/rational.js +378 -0
  30. package/dist/segmenting.d.ts +15 -0
  31. package/dist/segmenting.d.ts.map +1 -0
  32. package/dist/segmenting.js +97 -0
  33. package/dist/unitSpecs.d.ts +47 -0
  34. package/dist/unitSpecs.d.ts.map +1 -0
  35. package/dist/unitSpecs.js +8 -0
  36. package/package.json +56 -0
  37. package/src/aggregating.ts +160 -0
  38. package/src/allocating.ts +70 -0
  39. package/src/amount.ts +291 -0
  40. package/src/format.ts +330 -0
  41. package/src/index.ts +8 -0
  42. package/src/jsonCodecs.ts +63 -0
  43. package/src/kind.ts +442 -0
  44. package/src/rate.ts +316 -0
  45. package/src/rational.ts +471 -0
  46. package/src/segmenting.ts +154 -0
  47. package/src/unitSpecs.ts +37 -0
package/src/format.ts ADDED
@@ -0,0 +1,330 @@
1
+ import type { Opts, RoArray } from "@onrail-xyz/utils";
2
+ import { type ToFixedOptions, Rational } from "./rational.js";
3
+ import type { Kind, Unit, KindUnitSymbols } from "./kind.js";
4
+ import { type SegmentPair, findUnit, segment } from "./segmenting.js";
5
+
6
+ const approxDigits = 3;
7
+
8
+ export function inUnit<K extends Kind>(
9
+ kind: K,
10
+ stdVal: Rational,
11
+ symbol: KindUnitSymbols<K>,
12
+ precision: number | KindUnitSymbols<K> = 0,
13
+ opts?: Opts<ToFixedOptions>,
14
+ ): string {
15
+ const unit = kind.units[symbol]!;
16
+ const value = stdVal.div(unit.scale);
17
+ const decimals = typeof precision === "number"
18
+ ? precision
19
+ : unit.oom! - kind.units[precision]!.oom!;
20
+
21
+ if (decimals < 0)
22
+ throw new Error("Precision must be non-negative");
23
+
24
+ return formatWithSymbol(value, decimals, unit, opts);
25
+ }
26
+
27
+ export function inUnitApprox<K extends Kind>(
28
+ kind: K,
29
+ stdVal: Rational,
30
+ symbol: KindUnitSymbols<K>,
31
+ opts?: Opts<ToFixedOptions>,
32
+ ): string {
33
+ const unit = kind.units[symbol]!;
34
+ const value = stdVal.div(unit.scale);
35
+
36
+ return formatWithSymbol(value, sigDigitPrec(value), unit, opts);
37
+ }
38
+
39
+ export function approximate(
40
+ kind: Kind,
41
+ stdVal: Rational,
42
+ opts?: Opts<ToFixedOptions & { system: string }>,
43
+ ): string {
44
+ const sortedByScale = getSystemUnits(kind, opts?.system);
45
+ if (!isDecimal(kind, opts?.system))
46
+ return compound(kind, sortedByScale, stdVal, true, opts);
47
+
48
+ if (stdVal.eq(0))
49
+ return formatZero(kind, sortedByScale, opts);
50
+
51
+ const reading = approxReading(stdVal, sortedByScale);
52
+ //a reading that rounds up into the next unit's range renders there instead: $0.9996 reads $1,
53
+ // not 100c
54
+ const rounded = roundTo(reading.value, reading.precision).mul(reading.unit.scale);
55
+ const next = sortedByScale[reading.index - 1];
56
+ const final = reading.index > 0 && next !== undefined && rounded.abs().ge(next.scale)
57
+ ? approxReading(rounded, sortedByScale)
58
+ : reading;
59
+ return formatWithSymbol(final.value, final.precision, final.unit, opts);
60
+ }
61
+
62
+ const approxReading = (stdVal: Rational, sortedByScale: RoArray<Unit>) => {
63
+ const { index, unit, value, promoted } = displayUnitInfo(stdVal, sortedByScale);
64
+ const precision = promoted ? approxDigits : getApproxPrec(index, value);
65
+ return { index, unit, value, precision };
66
+ };
67
+
68
+ //the value as a given number of decimals renders it (half away from zero, as toFixed does)
69
+ const roundTo = (value: Rational, decimals: number): Rational => {
70
+ const scale = Rational.powerOfTen(decimals);
71
+ return Rational.from(value.mul(scale).round()).div(scale);
72
+ };
73
+
74
+ export function exact(
75
+ kind: Kind,
76
+ stdVal: Rational,
77
+ opts?: Opts<ToFixedOptions & { system: string }>,
78
+ ): string {
79
+ const sortedByScale = getSystemUnits(kind, opts?.system);
80
+ if (!isDecimal(kind, opts?.system))
81
+ return compound(kind, sortedByScale, stdVal, false, opts);
82
+
83
+ if (stdVal.eq(0))
84
+ return formatZero(kind, sortedByScale, opts);
85
+
86
+ const { unit, value } = displayUnitInfo(stdVal, sortedByScale);
87
+ return renderExact(value, unit, opts);
88
+ }
89
+
90
+ export function parse(kind: Kind, str: string): Rational {
91
+ const processPair = (pair: SegmentPair) => {
92
+ const value = Rational.from(str.substring(pair.value[0], pair.value[1]));
93
+ const symbol = str.substring(pair.symbol[0], pair.symbol[1]);
94
+ const unit = findUnit(kind.units, symbol);
95
+ if (!unit)
96
+ throw new Error(`Unknown unit: ${symbol}`);
97
+
98
+ return { value, unit };
99
+ };
100
+
101
+ const { negative, pairs } = segment(str);
102
+
103
+ if (pairs.length === 1) {
104
+ const { value, unit } = processPair(pairs[0]!);
105
+ const absVal = value.mul(unit.scale);
106
+ return negative ? absVal.neg() : absVal;
107
+ }
108
+
109
+ let total = Rational.from(0n);
110
+ let lastScale: Rational | undefined = undefined;
111
+ for (let i = 0; i < pairs.length; ++i) {
112
+ const { value, unit } = processPair(pairs[i]!);
113
+
114
+ if (i !== pairs.length - 1 && !value.isInteger())
115
+ throw new Error("Decimal only allowed in final unit");
116
+
117
+ if (lastScale && lastScale.le(unit.scale))
118
+ throw new Error("Units must be in descending order");
119
+
120
+ total = total.add(value.mul(unit.scale));
121
+ lastScale = unit.scale;
122
+ }
123
+
124
+ return negative ? total.neg() : total;
125
+ }
126
+
127
+ export function exactRate(
128
+ num: Kind,
129
+ den: Kind,
130
+ ratio: Rational,
131
+ opts?: Opts<ToFixedOptions>,
132
+ ): string {
133
+ const denUnit = bestDenUnit(num, den, ratio);
134
+ return `${exact(num, ratio.mul(denUnit.scale), opts)}/${denUnit.symbol}`;
135
+ }
136
+
137
+ export const bestDenUnit = (num: Kind, den: Kind, ratio: Rational): Unit => {
138
+ if (den.human !== undefined || ratio.eq(0))
139
+ return den.units[den.human ?? den.standard.unit]!;
140
+
141
+ let best: { mixed: boolean; size: number; unit: Unit } | undefined;
142
+ for (const unit of getSystemUnits(den)) {
143
+ const rendered = exact(num, ratio.mul(unit.scale), { thousandsSep: "" });
144
+ //symbols cannot contain "/", so its presence marks the mixed-number form
145
+ const mixed = rendered.includes("/");
146
+ const size = rendered.length + unit.symbol.length;
147
+ if (!best || (mixed === best.mixed ? size < best.size : best.mixed))
148
+ best = { mixed, size, unit };
149
+ }
150
+ return best!.unit;
151
+ };
152
+
153
+ const renderExact = (value: Rational, unit: Unit, opts?: Opts<ToFixedOptions>): string => {
154
+ const valStr = value.toString({ thousandsSep: opts?.thousandsSep ?? "," });
155
+ return attachSymbol(valStr, unit, valStr === "1" || valStr === "-1");
156
+ };
157
+
158
+ const compound = (
159
+ kind: Kind,
160
+ sortedByScale: Unit[],
161
+ stdVal: Rational,
162
+ approx: boolean,
163
+ opts?: Opts<ToFixedOptions>,
164
+ ): string => {
165
+ if (stdVal.eq(0))
166
+ return formatZero(kind, sortedByScale, opts);
167
+
168
+ const sign = stdVal.lt(0) ? "-" : "";
169
+ const smallest = sortedByScale.length - 1;
170
+ let absStdVal = stdVal.abs();
171
+
172
+ if (approx) {
173
+ //a value the smallest unit carries on its own renders in it, to significant digits - unless
174
+ // those round up to the next unit, in which case it spans units after all
175
+ const { lbIndex, lbUnit, lbValue } = lowerBoundInfo(absStdVal, sortedByScale);
176
+ if (lbIndex === -1 || lbIndex === smallest) {
177
+ const precision = getApproxPrec(lbIndex, lbValue);
178
+ const rounded = roundTo(lbValue, precision).mul(lbUnit.scale);
179
+ if (smallest === 0 || lbIndex === -1 || rounded.lt(sortedByScale[smallest - 1]!.scale))
180
+ return sign + formatWithSymbol(lbValue, precision, lbUnit, opts);
181
+
182
+ absStdVal = rounded;
183
+ }
184
+ //rounded to whole smallest units first and decomposed exactly after, so no part can round
185
+ // up into the next unit's range (no "1 minute 60 seconds")
186
+ const smallestScale = sortedByScale[smallest]!.scale;
187
+ absStdVal = Rational.from(absStdVal.div(smallestScale).round()).mul(smallestScale);
188
+ }
189
+
190
+ const parts: string[] = [];
191
+ const tolerance = approx ? absStdVal.div(Rational.powerOfTen(approxDigits)) : null;
192
+ let remainder = absStdVal;
193
+ let startFrom = 0;
194
+
195
+ while (remainder.ne(0)) {
196
+ const { lbIndex, lbUnit, lbValue } = lowerBoundInfo(remainder, sortedByScale, startFrom);
197
+ const atSmallest = lbIndex === -1 || lbIndex === smallest;
198
+
199
+ if (atSmallest && !approx)
200
+ parts.push(renderExact(lbValue, lbUnit, opts));
201
+ else
202
+ parts.push(formatWithSymbol(Rational.from(lbValue.floor()), 0, lbUnit, opts));
203
+
204
+ if (atSmallest)
205
+ break;
206
+
207
+ const nextRemainder = remainder.mod(lbUnit.scale);
208
+ if (tolerance !== null && nextRemainder.lt(tolerance))
209
+ break;
210
+
211
+ remainder = nextRemainder;
212
+ startFrom = lbIndex + 1;
213
+ }
214
+
215
+ return sign + parts.join(" ");
216
+ };
217
+
218
+ const formatZero = (
219
+ kind: Kind,
220
+ sortedByScale: RoArray<Unit>,
221
+ opts?: Opts<ToFixedOptions>,
222
+ ): string => {
223
+ const preferred = kind.units[kind.human ?? kind.standard.unit]!;
224
+ const unit = sortedByScale.find(u => u.scale.eq(preferred.scale)) ?? sortedByScale.at(-1)!;
225
+ return formatWithSymbol(Rational.from(0n), 0, unit, opts);
226
+ };
227
+
228
+ function formatWithSymbol(
229
+ value: Rational,
230
+ decimals: number,
231
+ unit: Unit,
232
+ opts?: Opts<ToFixedOptions>,
233
+ ): string {
234
+ const thousandsSep = opts?.thousandsSep ?? ",";
235
+ const trimZeros = opts?.trimZeros ?? true;
236
+
237
+ const valStr = value.toFixed(decimals, { thousandsSep, trimZeros });
238
+ //the plural follows the displayed magnitude, not the exact one (0.9999 at 2 decimals shows as 1)
239
+ const oneStr = Rational.from(1n).toFixed(decimals, { thousandsSep, trimZeros });
240
+ return attachSymbol(valStr, unit, valStr === oneStr || valStr === `-${oneStr}`);
241
+ }
242
+
243
+ const attachSymbol = (valStr: string, unit: Unit, singular: boolean): string => {
244
+ const sym = unit.plural && !singular ? unit.plural : unit.symbol;
245
+ const space = (unit.spacing ?? "spaced") === "spaced" ? " " : "";
246
+
247
+ if ((unit.position ?? "postfix") === "postfix")
248
+ return valStr + space + sym;
249
+
250
+ //the sign stays outside the symbol: parsing only takes a "-" that leads the whole pair, so an
251
+ // interior one ("$-100") would be read as part of the symbol
252
+ const [sign, digits] = valStr.startsWith("-") ? ["-", valStr.slice(1)] : ["", valStr];
253
+ return sign + sym + space + digits;
254
+ };
255
+
256
+ const getSystem = (kind: Kind, system?: string) => {
257
+ const name = system ?? kind.standard.system;
258
+ const sys = kind.systems[name];
259
+ if (!sys)
260
+ throw new Error(`Unknown system: ${name}`);
261
+
262
+ return sys;
263
+ };
264
+
265
+ const getSystemUnits = (kind: Kind, system?: string): Unit[] => {
266
+ const units: Unit[] = [];
267
+ for (const symbol of getSystem(kind, system).symbols) {
268
+ const unit = kind.units[symbol]!;
269
+ if (!units.some(u => u.scale.eq(unit.scale)))
270
+ units.push(unit);
271
+ }
272
+ return units;
273
+ };
274
+
275
+ const isDecimal = (kind: Kind, system?: string): boolean =>
276
+ getSystem(kind, system).decimal;
277
+
278
+ //number of decimals that yields approxDigits significant digits (0 for values >= 10^approxDigits)
279
+ const sigDigitPrec = (value: Rational): number => {
280
+ const absVal = value.abs();
281
+ let precision = approxDigits;
282
+ while (precision > 0 && absVal.ge(Rational.powerOfTen(approxDigits - precision)))
283
+ --precision;
284
+
285
+ while (absVal.ne(0) && absVal.lt(Rational.powerOfTen(approxDigits - precision - 1)))
286
+ ++precision;
287
+
288
+ return precision;
289
+ };
290
+
291
+ //below the smallest unit of the kind the precision is capped instead of following the value
292
+ const getApproxPrec = (lbIndex: number, lbValue: Rational): number =>
293
+ lbIndex === -1 ? approxDigits : sigDigitPrec(lbValue);
294
+
295
+ //the unit both display modes render in: the largest unit the value reaches, promoted to the next
296
+ // one up when the ladder gap is wide enough for the reading to run long (six orders — ¢/$ never
297
+ // promotes) and the promoted reading has at least a thousandth of it: 15,000 Gwei stays put,
298
+ // 0.5 ETH is not 500,000,000 Gwei
299
+ const displayUnitInfo = (stdVal: Rational, sortedByScale: RoArray<Unit>) => {
300
+ const { lbIndex, lbUnit, lbValue } = lowerBoundInfo(stdVal, sortedByScale);
301
+ const lb = { index: lbIndex, unit: lbUnit, value: lbValue, promoted: false } as const;
302
+ if (lbIndex <= 0)
303
+ return lb;
304
+
305
+ const unit = sortedByScale[lbIndex - 1]!;
306
+ const value = stdVal.div(unit.scale);
307
+ return unit.oom! - lbUnit.oom! >= 2 * approxDigits
308
+ && value.abs().ge(Rational.powerOfTen(-approxDigits))
309
+ ? { index: lbIndex - 1, unit, value, promoted: true } as const
310
+ : lb;
311
+ };
312
+
313
+ const lowerBoundInfo = (
314
+ stdVal: Rational,
315
+ sortedByScale: RoArray<Unit>,
316
+ startIndex: number = 0,
317
+ ) => {
318
+ const absStdVal = stdVal.abs();
319
+ let lbIndex = startIndex;
320
+ for (; lbIndex < sortedByScale.length; ++lbIndex)
321
+ if (absStdVal.ge(sortedByScale[lbIndex]!.scale))
322
+ break;
323
+
324
+ if (lbIndex === sortedByScale.length)
325
+ lbIndex = -1;
326
+
327
+ const lbUnit = sortedByScale.at(lbIndex)!;
328
+ const lbValue = stdVal.div(lbUnit.scale);
329
+ return { lbIndex, lbUnit, lbValue } as const;
330
+ };
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./rational.js";
2
+ export * from "./kind.js";
3
+ export * from "./unitSpecs.js";
4
+ export * from "./amount.js";
5
+ export * from "./rate.js";
6
+ export * from "./aggregating.js";
7
+ export * from "./allocating.js";
8
+ export * from "./jsonCodecs.js";
@@ -0,0 +1,63 @@
1
+ import type { JsonCodec, MaybeArray, RoArray, StrRecord } from "@onrail-xyz/utils";
2
+ import { isArray, invalidPayload, expectString } from "@onrail-xyz/utils";
3
+ import { Rational } from "./rational.js";
4
+ import type { Kind } from "./kind.js";
5
+ import { Amount, isAmount } from "./amount.js";
6
+ import { Rate, isRate } from "./rate.js";
7
+
8
+ const expectFields = <const F extends RoArray<string>>(
9
+ encoded: unknown,
10
+ tag: string,
11
+ fields: F,
12
+ ): { [N in F[number]]: string } => {
13
+ if (typeof encoded !== "object" || encoded === null || isArray(encoded))
14
+ throw invalidPayload(encoded, tag);
15
+
16
+ const out: StrRecord<string> = {};
17
+ for (const field of fields) {
18
+ const value = (encoded as StrRecord)[field];
19
+ if (typeof value !== "string")
20
+ throw invalidPayload(encoded, tag);
21
+
22
+ out[field] = value;
23
+ }
24
+ return out as { [N in F[number]]: string };
25
+ };
26
+
27
+ const namedKind = <K extends Kind>(candidates: MaybeArray<K>, name: string, tag: string): K => {
28
+ const kind = (isArray(candidates) ? candidates : [candidates]).find(k => k.name === name);
29
+ if (!kind)
30
+ throw new Error(`No candidate kind named "${name}" for ${tag}`);
31
+
32
+ return kind;
33
+ };
34
+
35
+ export const rationalCodec: JsonCodec<Rational> = {
36
+ tag: "Rational",
37
+ test: (value): value is Rational => value instanceof Rational,
38
+ encode: value => value.toString(),
39
+ decode: encoded => Rational.from(expectString(encoded, "Rational")),
40
+ };
41
+
42
+ export const amountCodec = (kinds: MaybeArray<Kind>): JsonCodec<Amount<Kind>> => ({
43
+ tag: "Amount",
44
+ test: isAmount,
45
+ encode: value => ({ kind: value.kind.name, value: value.toJSON() }),
46
+ decode: encoded => {
47
+ const { kind, value } = expectFields(encoded, "Amount", ["kind", "value"]);
48
+ return Amount.parse(value, namedKind(kinds, kind, "Amount"));
49
+ },
50
+ });
51
+
52
+ export const rateCodec = (
53
+ numKinds: MaybeArray<Kind>,
54
+ denKinds: MaybeArray<Kind>,
55
+ ): JsonCodec<Rate<Kind, Kind>> => ({
56
+ tag: "Rate",
57
+ test: isRate,
58
+ encode: value => ({ num: value.num.name, den: value.den.name, value: value.toJSON() }),
59
+ decode: encoded => {
60
+ const { num, den, value } = expectFields(encoded, "Rate", ["num", "den", "value"]);
61
+ return Rate.parse(value, namedKind(numKinds, num, "Rate"), namedKind(denKinds, den, "Rate"));
62
+ },
63
+ });