measurable 1.1.1 → 3.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 (50) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +271 -53
  3. package/dist/dimensions/angle.js +13 -6
  4. package/dist/dimensions/area.d.ts +14 -0
  5. package/dist/dimensions/area.js +46 -0
  6. package/dist/dimensions/data.d.ts +10 -0
  7. package/dist/dimensions/data.js +39 -0
  8. package/dist/dimensions/energy.d.ts +14 -0
  9. package/dist/dimensions/energy.js +29 -0
  10. package/dist/dimensions/force.js +6 -6
  11. package/dist/dimensions/frequency.d.ts +7 -0
  12. package/dist/dimensions/frequency.js +11 -0
  13. package/dist/dimensions/illuminance.d.ts +9 -0
  14. package/dist/dimensions/illuminance.js +16 -0
  15. package/dist/dimensions/index.d.ts +9 -0
  16. package/dist/dimensions/index.js +9 -0
  17. package/dist/dimensions/length.js +7 -7
  18. package/dist/dimensions/luminance.d.ts +7 -0
  19. package/dist/dimensions/luminance.js +13 -0
  20. package/dist/dimensions/luminousIntensity.d.ts +9 -0
  21. package/dist/dimensions/luminousIntensity.js +16 -0
  22. package/dist/dimensions/mass.js +13 -9
  23. package/dist/dimensions/power.d.ts +9 -0
  24. package/dist/dimensions/power.js +13 -0
  25. package/dist/dimensions/pressure.d.ts +14 -0
  26. package/dist/dimensions/pressure.js +21 -0
  27. package/dist/dimensions/temperature.js +9 -3
  28. package/dist/dimensions/time.js +19 -7
  29. package/dist/dimensions/volume.js +57 -24
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.js +2 -1
  32. package/dist/lib/Dimension.d.ts +49 -14
  33. package/dist/lib/Dimension.js +58 -21
  34. package/dist/lib/MeasurementSystem.js +2 -2
  35. package/dist/lib/Quantity.d.ts +94 -10
  36. package/dist/lib/Quantity.js +92 -37
  37. package/dist/lib/Rational.d.ts +58 -0
  38. package/dist/lib/Rational.js +174 -0
  39. package/dist/lib/Unit.d.ts +52 -11
  40. package/dist/lib/Unit.js +41 -8
  41. package/dist/lib/prefixes.d.ts +2 -2
  42. package/dist/lib/prefixes.js +1 -1
  43. package/dist/systems/imperial.js +19 -1
  44. package/dist/systems/metric.js +25 -1
  45. package/dist/systems/usCustomary.js +19 -1
  46. package/dist/utils/definePrefixed.d.ts +28 -0
  47. package/dist/utils/definePrefixed.js +72 -0
  48. package/dist/utils/scaleOf.d.ts +3 -0
  49. package/dist/utils/scaleOf.js +6 -0
  50. package/package.json +13 -4
@@ -1,27 +1,89 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Quantity = void 0;
4
+ const assert_never_1 = require("assert-never");
4
5
  const AmbiguousUnitError_1 = require("../errors/AmbiguousUnitError");
5
6
  const UnknownUnitError_1 = require("../errors/UnknownUnitError");
6
- const scale_1 = require("./scale");
7
+ const scaleOf_1 = require("../utils/scaleOf");
8
+ const Rational_1 = require("./Rational");
7
9
  /** A magnitude paired with a unit (e.g. `5` `kilometer`). */
8
10
  class Quantity {
9
11
  constructor(magnitude, unit) {
10
- this.magnitude = magnitude;
11
12
  this.unit = unit;
13
+ this.rational = Rational_1.Rational.from(magnitude);
14
+ }
15
+ /** The magnitude as a `number`, derived from {@link rational}. */
16
+ get magnitude() {
17
+ return this.rational.toNumber();
12
18
  }
13
19
  /** Return an equivalent quantity expressed in `target`. */
14
20
  to(target) {
15
- return new Quantity(this.in(target), target);
21
+ return new Quantity(this.inRational(target), target);
16
22
  }
17
23
  /** Return this quantity's raw magnitude expressed in `target`. */
18
24
  in(target) {
19
- return this.unit.dimension.convert(this.magnitude, this.unit, target);
25
+ return this.inRational(target).toNumber();
26
+ }
27
+ /** This quantity's exact magnitude expressed in `target`. */
28
+ inRational(target) {
29
+ return this.unit.dimension.convertRational(this.rational, this.unit, target);
20
30
  }
21
31
  /** Render as `"<magnitude> <unit name>"`, e.g. `"5 kilometer"`. */
22
32
  toString() {
23
33
  return `${this.magnitude} ${this.unit.name}`;
24
34
  }
35
+ /**
36
+ * Render as `"<magnitude> <label>"`, choosing the label per `options.unit`
37
+ * (default `"auto"`: magnitude-aware singular/plural). Unlike {@link toString},
38
+ * this can use the unit's symbol or plural — e.g. `"5 grams"`, `"5 g"`,
39
+ * `"1 gram"`. Pass `locale` / `numberFormat` to localize the magnitude via
40
+ * `toLocaleString` — e.g. `format({ locale: "de-DE" })` → `"1.234,5 meters"`,
41
+ * or `format({ numberFormat: { maximumFractionDigits: 2 } })` for precision.
42
+ *
43
+ * For non-string output (e.g. JSX), use {@link formatParts} and assemble the
44
+ * pieces yourself.
45
+ */
46
+ format(options = {}) {
47
+ const { magnitude, unit } = this.formatParts(options);
48
+ return `${magnitude} ${unit}`;
49
+ }
50
+ /**
51
+ * Like {@link format}, but returns the rendered magnitude and label as
52
+ * separate strings instead of joining them, so the caller controls the
53
+ * assembly. Useful when a single string won't do — e.g. styling the magnitude
54
+ * in a React component:
55
+ *
56
+ * ```tsx
57
+ * const { magnitude, unit } = q.formatParts({ locale: "de-DE" });
58
+ * return <><b>{magnitude}</b> {unit}</>;
59
+ * ```
60
+ */
61
+ formatParts(options = {}) {
62
+ return { magnitude: this.formatMagnitude(options), unit: this.formatLabel(options) };
63
+ }
64
+ /**
65
+ * Render the magnitude via `toLocaleString`. With no `locale`/`numberFormat`
66
+ * it uses the runtime's default locale; supply either for locale- and
67
+ * precision-aware formatting.
68
+ */
69
+ formatMagnitude({ locale, numberFormat }) {
70
+ return this.magnitude.toLocaleString(locale, numberFormat);
71
+ }
72
+ formatLabel({ unit = "auto" }) {
73
+ const { name, symbol, plural } = this.unit;
74
+ switch (unit) {
75
+ case "symbol":
76
+ return symbol ?? name;
77
+ case "name":
78
+ return name;
79
+ case "plural":
80
+ return plural ?? name;
81
+ case "auto":
82
+ return Math.abs(this.magnitude) === 1 ? name : (plural ?? name);
83
+ default:
84
+ return (0, assert_never_1.assertNever)(unit);
85
+ }
86
+ }
25
87
  /**
26
88
  * Add another quantity, returned in *this* quantity's unit. The other operand
27
89
  * is converted into this unit first, so the two may use different units of the
@@ -33,19 +95,19 @@ class Quantity {
33
95
  * difference.
34
96
  */
35
97
  plus(other) {
36
- return new Quantity(this.magnitude + other.in(this.unit), this.unit);
98
+ return new Quantity(this.rational.plus(other.inRational(this.unit)), this.unit);
37
99
  }
38
100
  /** Subtract another quantity, returned in this quantity's unit. */
39
101
  minus(other) {
40
- return new Quantity(this.magnitude - other.in(this.unit), this.unit);
102
+ return new Quantity(this.rational.minus(other.inRational(this.unit)), this.unit);
41
103
  }
42
104
  /** Scale this quantity by a dimensionless factor. */
43
105
  times(factor) {
44
- return new Quantity(this.magnitude * factor, this.unit);
106
+ return new Quantity(this.rational.times(Rational_1.Rational.from(factor)), this.unit);
45
107
  }
46
108
  /** Divide this quantity by a dimensionless divisor. */
47
109
  dividedBy(divisor) {
48
- return new Quantity(this.magnitude / divisor, this.unit);
110
+ return new Quantity(this.rational.dividedBy(Rational_1.Rational.from(divisor)), this.unit);
49
111
  }
50
112
  /**
51
113
  * Divide this quantity by `other` of the same dimension, yielding the
@@ -54,15 +116,15 @@ class Quantity {
54
116
  * Throws {@link InvalidConversionError} across dimensions.
55
117
  */
56
118
  ratioTo(other) {
57
- return this.magnitude / other.in(this.unit);
119
+ return this.rational.dividedBy(other.inRational(this.unit)).toNumber();
58
120
  }
59
121
  /** Return this quantity with its magnitude negated. */
60
122
  negate() {
61
- return new Quantity(-this.magnitude, this.unit);
123
+ return new Quantity(this.rational.negate(), this.unit);
62
124
  }
63
125
  /** Return this quantity with a non-negative magnitude. */
64
126
  abs() {
65
- return new Quantity(Math.abs(this.magnitude), this.unit);
127
+ return new Quantity(this.rational.abs(), this.unit);
66
128
  }
67
129
  /** Clamp this quantity to the range [`lower`, `upper`], returned in this unit. */
68
130
  clamp(lower, upper) {
@@ -98,11 +160,12 @@ class Quantity {
98
160
  /**
99
161
  * Whether this quantity equals `other`, compared in this quantity's unit.
100
162
  * Throws {@link InvalidConversionError} if the operands belong to different
101
- * dimensions. Comparison is exact, so values that differ only by
102
- * floating-point rounding from a conversion may compare unequal.
163
+ * dimensions. Comparison is exact rational equality, so quantities that are
164
+ * mathematically equal compare equal even if reaching them involved a
165
+ * conversion that would have drifted in floating point.
103
166
  */
104
167
  equals(other) {
105
- return this.magnitude === other.in(this.unit);
168
+ return this.rational.equals(other.inRational(this.unit));
106
169
  }
107
170
  /** Whether this quantity does not equal `other`. */
108
171
  notEquals(other) {
@@ -110,19 +173,19 @@ class Quantity {
110
173
  }
111
174
  /** Whether this quantity is less than `other`. */
112
175
  lessThan(other) {
113
- return this.magnitude < other.in(this.unit);
176
+ return this.rational.compare(other.inRational(this.unit)) < 0;
114
177
  }
115
178
  /** Whether this quantity is greater than `other`. */
116
179
  greaterThan(other) {
117
- return this.magnitude > other.in(this.unit);
180
+ return this.rational.compare(other.inRational(this.unit)) > 0;
118
181
  }
119
182
  /** Whether this quantity is less than or equal to `other`. */
120
183
  lessThanOrEqual(other) {
121
- return this.magnitude <= other.in(this.unit);
184
+ return this.rational.compare(other.inRational(this.unit)) <= 0;
122
185
  }
123
186
  /** Whether this quantity is greater than or equal to `other`. */
124
187
  greaterThanOrEqual(other) {
125
- return this.magnitude >= other.in(this.unit);
188
+ return this.rational.compare(other.inRational(this.unit)) >= 0;
126
189
  }
127
190
  /** Alias for {@link equals}. */
128
191
  eq(other) {
@@ -153,26 +216,19 @@ class Quantity {
153
216
  * if larger, `0` if equal. Suitable as an `Array#sort` comparator.
154
217
  */
155
218
  compareTo(other) {
156
- const value = other.in(this.unit);
157
- if (this.magnitude < value) {
158
- return -1;
159
- }
160
- if (this.magnitude > value) {
161
- return 1;
162
- }
163
- return 0;
219
+ return this.rational.compare(other.inRational(this.unit));
164
220
  }
165
221
  /** Whether this quantity's magnitude is exactly zero. */
166
222
  isZero() {
167
- return this.magnitude === 0;
223
+ return this.rational.sign() === 0;
168
224
  }
169
225
  /** Whether this quantity's magnitude is greater than zero. */
170
226
  isPositive() {
171
- return this.magnitude > 0;
227
+ return this.rational.sign() > 0;
172
228
  }
173
229
  /** Whether this quantity's magnitude is less than zero. */
174
230
  isNegative() {
175
- return this.magnitude < 0;
231
+ return this.rational.sign() < 0;
176
232
  }
177
233
  /**
178
234
  * Parse a string into a `Quantity` using a dimension's known units and aliases.
@@ -181,7 +237,7 @@ class Quantity {
181
237
  * - `"5 hr"` -> `Quantity(5, hour)`
182
238
  * - `"5hr 20min"` -> `Quantity(320, minute)`
183
239
  *
184
- * Compound inputs are summed in base units and returned in the *finest*
240
+ * Compound inputs are summed (exactly) and returned in the *finest*
185
241
  * (smallest-scale) unit present, so `"5hr 20min"` collapses to `320 minute`.
186
242
  *
187
243
  * When a token is a shared alias (e.g. `"ton"` → short ton & long ton), pass
@@ -189,22 +245,21 @@ class Quantity {
189
245
  */
190
246
  static parse(str, dimension, options = {}) {
191
247
  const pattern = /(-?\d+(?:\.\d+)?)\s*([^\d\s]+)/g;
192
- let total = 0; // accumulated in base units
248
+ let total;
193
249
  let finest;
194
- let count = 0;
195
250
  for (let match = pattern.exec(str); match !== null; match = pattern.exec(str)) {
196
251
  const value = Number.parseFloat(match[1]);
197
252
  const unit = resolve(match[2], dimension, options.prefer);
198
- total += unit.toBase(value);
199
- if (!finest || (0, scale_1.scaleOf)(unit) < (0, scale_1.scaleOf)(finest)) {
253
+ const quantity = new Quantity(value, unit);
254
+ total = total ? total.plus(quantity) : quantity;
255
+ if (!finest || (0, scaleOf_1.scaleOf)(unit) < (0, scaleOf_1.scaleOf)(finest)) {
200
256
  finest = unit;
201
257
  }
202
- count += 1;
203
258
  }
204
- if (count === 0 || !finest) {
259
+ if (!total || !finest) {
205
260
  throw new Error(`Could not parse a quantity from "${str}"`);
206
261
  }
207
- return new Quantity(finest.fromBase(total), finest);
262
+ return total.to(finest);
208
263
  }
209
264
  /** The smallest of the given quantities (by value); requires at least one. */
210
265
  static min(first, ...rest) {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * An exact rational number (`n / d`) used to make conversions between linear
3
+ * and affine units lossless. Such conversions are inherently rational — a foot
4
+ * is exactly `3048/10000` m and an inch exactly `254/10000` m, so foot → inch is
5
+ * exactly `12` — but baking those ratios into binary `number` scales rounds the
6
+ * result (`12.000000000000002`). Doing the arithmetic over integer numerator /
7
+ * denominator pairs and collapsing to a float only at the end avoids the drift.
8
+ *
9
+ * Instances are immutable and always stored in lowest terms with a positive
10
+ * denominator. `bigint` is used so cross-multiplying large scales (e.g. a
11
+ * mile's `1609344`) cannot overflow.
12
+ */
13
+ export declare class Rational {
14
+ readonly n: bigint;
15
+ readonly d: bigint;
16
+ /**
17
+ * Build a rational from integer numerator and denominator. Pass exact ratios
18
+ * the literal way — `new Rational(5, 9)` — using `bigint` or integer `number`.
19
+ * For a decimal value, use {@link Rational.from} instead.
20
+ */
21
+ constructor(numerator: bigint | number, denominator?: bigint | number);
22
+ /** Coerce a `number | Rational` to a `Rational`, parsing numbers as decimals. */
23
+ static from(value: number | Rational): Rational;
24
+ /**
25
+ * Convert a `number` to the exact rational the author *wrote*, by reading its
26
+ * shortest round-tripping decimal (`(0.0254).toString() === "0.0254"` →
27
+ * `254/10000`). This recovers the intended terminating decimal. A value that
28
+ * was never terminating in source (e.g. Fahrenheit's `5 / 9`) is captured as
29
+ * the exact rational of its nearest double — no worse than a raw float, and
30
+ * the conversion is still done in one rounding instead of several. To avoid
31
+ * that, pass an exact {@link Rational} (e.g. `new Rational(5, 9)`) instead.
32
+ */
33
+ private static fromNumber;
34
+ plus(other: Rational): Rational;
35
+ minus(other: Rational): Rational;
36
+ times(other: Rational): Rational;
37
+ dividedBy(other: Rational): Rational;
38
+ negate(): Rational;
39
+ abs(): Rational;
40
+ /** Whether this equals `other` exactly (both are stored in canonical form). */
41
+ equals(other: Rational): boolean;
42
+ /** Alias for {@link plus}. */
43
+ add(other: Rational): Rational;
44
+ /** Alias for {@link minus}. */
45
+ sub(other: Rational): Rational;
46
+ /** Alias for {@link times}. */
47
+ mul(other: Rational): Rational;
48
+ /** Alias for {@link dividedBy}. */
49
+ div(other: Rational): Rational;
50
+ /** Alias for {@link equals}. */
51
+ eq(other: Rational): boolean;
52
+ /** `-1` if this is smaller than `other`, `1` if larger, `0` if equal. */
53
+ compare(other: Rational): number;
54
+ /** Sign of the value: `-1`, `0`, or `1`. */
55
+ sign(): number;
56
+ /** Collapse to the nearest `number`. */
57
+ toNumber(): number;
58
+ }
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Rational = void 0;
4
+ /**
5
+ * An exact rational number (`n / d`) used to make conversions between linear
6
+ * and affine units lossless. Such conversions are inherently rational — a foot
7
+ * is exactly `3048/10000` m and an inch exactly `254/10000` m, so foot → inch is
8
+ * exactly `12` — but baking those ratios into binary `number` scales rounds the
9
+ * result (`12.000000000000002`). Doing the arithmetic over integer numerator /
10
+ * denominator pairs and collapsing to a float only at the end avoids the drift.
11
+ *
12
+ * Instances are immutable and always stored in lowest terms with a positive
13
+ * denominator. `bigint` is used so cross-multiplying large scales (e.g. a
14
+ * mile's `1609344`) cannot overflow.
15
+ */
16
+ class Rational {
17
+ /**
18
+ * Build a rational from integer numerator and denominator. Pass exact ratios
19
+ * the literal way — `new Rational(5, 9)` — using `bigint` or integer `number`.
20
+ * For a decimal value, use {@link Rational.from} instead.
21
+ */
22
+ constructor(numerator, denominator = 1n) {
23
+ let n = toBigInt(numerator);
24
+ let d = toBigInt(denominator);
25
+ if (d === 0n) {
26
+ throw new Error("Rational denominator cannot be zero");
27
+ }
28
+ if (d < 0n) {
29
+ n = -n;
30
+ d = -d;
31
+ }
32
+ const g = gcd(n, d) || 1n;
33
+ this.n = n / g;
34
+ this.d = d / g;
35
+ }
36
+ /** Coerce a `number | Rational` to a `Rational`, parsing numbers as decimals. */
37
+ static from(value) {
38
+ return value instanceof Rational ? new this(value.n, value.d) : Rational.fromNumber(value);
39
+ }
40
+ /**
41
+ * Convert a `number` to the exact rational the author *wrote*, by reading its
42
+ * shortest round-tripping decimal (`(0.0254).toString() === "0.0254"` →
43
+ * `254/10000`). This recovers the intended terminating decimal. A value that
44
+ * was never terminating in source (e.g. Fahrenheit's `5 / 9`) is captured as
45
+ * the exact rational of its nearest double — no worse than a raw float, and
46
+ * the conversion is still done in one rounding instead of several. To avoid
47
+ * that, pass an exact {@link Rational} (e.g. `new Rational(5, 9)`) instead.
48
+ */
49
+ static fromNumber(value) {
50
+ if (!Number.isFinite(value)) {
51
+ throw new Error(`Cannot derive a rational from ${value}`);
52
+ }
53
+ const match = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(value.toString());
54
+ if (!match) {
55
+ throw new Error(`Cannot derive a rational from ${value}`);
56
+ }
57
+ const [, sign, intPart, fracPart = "", expPart] = match;
58
+ let n = BigInt(intPart + fracPart);
59
+ let d = 10n ** BigInt(fracPart.length);
60
+ const exp = expPart ? Number(expPart) : 0;
61
+ if (exp > 0) {
62
+ n *= 10n ** BigInt(exp);
63
+ }
64
+ else if (exp < 0) {
65
+ d *= 10n ** BigInt(-exp);
66
+ }
67
+ return new Rational(sign === "-" ? -n : n, d);
68
+ }
69
+ plus(other) {
70
+ return new Rational(this.n * other.d + other.n * this.d, this.d * other.d);
71
+ }
72
+ minus(other) {
73
+ return new Rational(this.n * other.d - other.n * this.d, this.d * other.d);
74
+ }
75
+ times(other) {
76
+ return new Rational(this.n * other.n, this.d * other.d);
77
+ }
78
+ dividedBy(other) {
79
+ return new Rational(this.n * other.d, this.d * other.n);
80
+ }
81
+ negate() {
82
+ return new Rational(-this.n, this.d);
83
+ }
84
+ abs() {
85
+ return new Rational(this.n < 0n ? -this.n : this.n, this.d);
86
+ }
87
+ /** Whether this equals `other` exactly (both are stored in canonical form). */
88
+ equals(other) {
89
+ return this.n === other.n && this.d === other.d;
90
+ }
91
+ /** Alias for {@link plus}. */
92
+ add(other) {
93
+ return this.plus(other);
94
+ }
95
+ /** Alias for {@link minus}. */
96
+ sub(other) {
97
+ return this.minus(other);
98
+ }
99
+ /** Alias for {@link times}. */
100
+ mul(other) {
101
+ return this.times(other);
102
+ }
103
+ /** Alias for {@link dividedBy}. */
104
+ div(other) {
105
+ return this.dividedBy(other);
106
+ }
107
+ /** Alias for {@link equals}. */
108
+ eq(other) {
109
+ return this.equals(other);
110
+ }
111
+ /** `-1` if this is smaller than `other`, `1` if larger, `0` if equal. */
112
+ compare(other) {
113
+ // Denominators are always positive, so cross-multiplication preserves order.
114
+ const lhs = this.n * other.d;
115
+ const rhs = other.n * this.d;
116
+ return lhs < rhs ? -1 : lhs > rhs ? 1 : 0;
117
+ }
118
+ /** Sign of the value: `-1`, `0`, or `1`. */
119
+ sign() {
120
+ return this.n < 0n ? -1 : this.n > 0n ? 1 : 0;
121
+ }
122
+ /** Collapse to the nearest `number`. */
123
+ toNumber() {
124
+ const { n, d } = this;
125
+ if (n === 0n) {
126
+ return 0;
127
+ }
128
+ // Fast path: both operands are exactly representable as doubles, so a single
129
+ // IEEE division is correctly rounded.
130
+ if (n >= -MAX_EXACT_INT && n <= MAX_EXACT_INT && d <= MAX_EXACT_INT) {
131
+ return Number(n) / Number(d);
132
+ }
133
+ // Otherwise converting each operand to a double first would round it before
134
+ // dividing and lose precision (e.g. denominators like 10^24). Long-divide to
135
+ // SIG significant digits and let Number()'s correctly-rounded decimal parser
136
+ // produce the nearest double, independent of magnitude.
137
+ const SIG = 20;
138
+ const negative = n < 0n;
139
+ const num = negative ? -n : n;
140
+ // floor(log10(num / d)), accurate to within 1 — enough to capture SIG digits.
141
+ const exponent = num.toString().length - d.toString().length;
142
+ const shift = SIG - 1 - exponent;
143
+ const scaledNum = shift >= 0 ? num * 10n ** BigInt(shift) : num;
144
+ const scaledDen = shift >= 0 ? d : d * 10n ** BigInt(-shift);
145
+ let digits = scaledNum / scaledDen;
146
+ if (2n * (scaledNum % scaledDen) >= scaledDen) {
147
+ digits += 1n; // round half up on the last significant digit
148
+ }
149
+ return Number(`${negative ? "-" : ""}${digits}e${exponent - (SIG - 1)}`);
150
+ }
151
+ }
152
+ exports.Rational = Rational;
153
+ /**
154
+ * One past `Number.MAX_SAFE_INTEGER` (i.e. 2^53): every integer with magnitude
155
+ * at most this is exactly representable as a double, so `Number(n)` is lossless.
156
+ */
157
+ const MAX_EXACT_INT = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
158
+ function gcd(a, b) {
159
+ let x = a < 0n ? -a : a;
160
+ let y = b < 0n ? -b : b;
161
+ while (y) {
162
+ [x, y] = [y, x % y];
163
+ }
164
+ return x;
165
+ }
166
+ function toBigInt(value) {
167
+ if (typeof value === "bigint") {
168
+ return value;
169
+ }
170
+ if (!Number.isInteger(value)) {
171
+ throw new Error(`Rational numerator and denominator must be integers; got ${value}`);
172
+ }
173
+ return BigInt(value);
174
+ }
@@ -1,26 +1,55 @@
1
1
  import type { Dimension } from "./Dimension";
2
- interface UnitOptions {
2
+ import type { Rational } from "./Rational";
3
+ /**
4
+ * An affine transform to the base unit, `base = value * scale + offset`, with
5
+ * both terms held as exact rationals. Covers the base unit (`1·x + 0`), plain
6
+ * linear units (`scale·x + 0`), and offset units like Celsius (`1·x + 273.15`).
7
+ */
8
+ export interface LinearTransform {
9
+ scale: Rational;
10
+ offset: Rational;
11
+ }
12
+ export interface BaseUnitOptions {
3
13
  name: string;
4
14
  dimension: Dimension;
15
+ /** Canonical symbol, e.g. `"g"`, `"km"`, `"°C"` (optional). */
16
+ symbol?: string;
17
+ /** Plural name, e.g. `"grams"`, `"kilometers"` (optional). */
18
+ plural?: string;
19
+ }
20
+ export type UnitConversionOptions = {
21
+ /** Exact transform for linear / affine units (the common case). */
22
+ linear: LinearTransform;
23
+ } | {
24
+ /** Hand-written transform for non-linear units; mutually exclusive with `linear`. */
5
25
  toBase: (value: number) => number;
6
26
  fromBase: (value: number) => number;
7
- }
27
+ };
28
+ export type UnitOptions = BaseUnitOptions & UnitConversionOptions;
8
29
  /**
9
30
  * A single unit of measurement (e.g. "meter", "celsius").
10
31
  *
11
32
  * A `Unit` is a passive handle: it knows its name, its home {@link Dimension},
12
33
  * and how to transform a value to and from that dimension's canonical base
13
34
  * unit. It does NOT know about other units or store pairwise conversions — all
14
- * conversion math lives in {@link Dimension}, derived from these two transforms.
15
- * Because the reverse direction (`fromBase`) is the mathematical inverse of the
16
- * forward direction (`toBase`), the two can never fall out of sync.
35
+ * conversion math lives in {@link Dimension}, derived from this transform.
36
+ *
37
+ * Almost every unit relates to the base by an affine map (`value * scale +
38
+ * offset`), so the transform is normally a {@link LinearTransform} of exact
39
+ * rationals — a single source of truth from which {@link toBase} / {@link
40
+ * fromBase} are derived, and which lets {@link Dimension.convert} stay exact.
41
+ * Only genuinely non-linear units (e.g. logarithmic scales, defined via
42
+ * `Dimension.custom`) fall back to a hand-written `toBase` / `fromBase` pair,
43
+ * since no `scale`/`offset` can express a curve like `10^x`.
17
44
  *
18
45
  * A unit belongs to exactly one dimension, but may belong to many
19
46
  * {@link MeasurementSystem}s (metric/imperial/…); that membership lives on the
20
47
  * measurement systems, not here, so a `Unit` stays a lean descriptor.
21
48
  *
22
- * Names and aliases live solely in the dimension's lookup index; they are
23
- * declared once when the unit is defined.
49
+ * Parsing aliases live in the dimension's lookup index. A unit additionally
50
+ * carries its canonical {@link symbol} and {@link plural} as first-class data
51
+ * so callers can choose how to render it (see {@link Quantity.format}); both are
52
+ * optional and English-centric — real localization is left to the consumer.
24
53
  *
25
54
  * Units are normally created through a {@link Dimension}'s builder methods
26
55
  * (`base`, `unit`, `affine`, `custom`) rather than constructed directly.
@@ -28,8 +57,20 @@ interface UnitOptions {
28
57
  export declare class Unit {
29
58
  readonly name: string;
30
59
  readonly dimension: Dimension;
31
- readonly toBase: (value: number) => number;
32
- readonly fromBase: (value: number) => number;
33
- constructor({ name, dimension, toBase, fromBase }: UnitOptions);
60
+ /** Canonical symbol, e.g. `"g"`, `"km"`, `"°C"` (optional). */
61
+ readonly symbol?: string;
62
+ /** Plural name, e.g. `"grams"`, `"kilometers"` (optional). */
63
+ readonly plural?: string;
64
+ private readonly conversion;
65
+ constructor({ name, dimension, symbol, plural, ...conversionOptions }: UnitOptions);
66
+ /**
67
+ * Exact affine transform to the base unit, present for all but non-linear
68
+ * units. When both ends of a conversion have one, {@link Dimension.convert}
69
+ * routes through exact rational arithmetic instead of lossy float scaling.
70
+ */
71
+ get linear(): LinearTransform | undefined;
72
+ /** Convert a value in this unit to the dimension's base unit. */
73
+ toBase(value: number): number;
74
+ /** Convert a value in the dimension's base unit to this unit. */
75
+ fromBase(value: number): number;
34
76
  }
35
- export {};
package/dist/lib/Unit.js CHANGED
@@ -7,26 +7,59 @@ exports.Unit = void 0;
7
7
  * A `Unit` is a passive handle: it knows its name, its home {@link Dimension},
8
8
  * and how to transform a value to and from that dimension's canonical base
9
9
  * unit. It does NOT know about other units or store pairwise conversions — all
10
- * conversion math lives in {@link Dimension}, derived from these two transforms.
11
- * Because the reverse direction (`fromBase`) is the mathematical inverse of the
12
- * forward direction (`toBase`), the two can never fall out of sync.
10
+ * conversion math lives in {@link Dimension}, derived from this transform.
11
+ *
12
+ * Almost every unit relates to the base by an affine map (`value * scale +
13
+ * offset`), so the transform is normally a {@link LinearTransform} of exact
14
+ * rationals — a single source of truth from which {@link toBase} / {@link
15
+ * fromBase} are derived, and which lets {@link Dimension.convert} stay exact.
16
+ * Only genuinely non-linear units (e.g. logarithmic scales, defined via
17
+ * `Dimension.custom`) fall back to a hand-written `toBase` / `fromBase` pair,
18
+ * since no `scale`/`offset` can express a curve like `10^x`.
13
19
  *
14
20
  * A unit belongs to exactly one dimension, but may belong to many
15
21
  * {@link MeasurementSystem}s (metric/imperial/…); that membership lives on the
16
22
  * measurement systems, not here, so a `Unit` stays a lean descriptor.
17
23
  *
18
- * Names and aliases live solely in the dimension's lookup index; they are
19
- * declared once when the unit is defined.
24
+ * Parsing aliases live in the dimension's lookup index. A unit additionally
25
+ * carries its canonical {@link symbol} and {@link plural} as first-class data
26
+ * so callers can choose how to render it (see {@link Quantity.format}); both are
27
+ * optional and English-centric — real localization is left to the consumer.
20
28
  *
21
29
  * Units are normally created through a {@link Dimension}'s builder methods
22
30
  * (`base`, `unit`, `affine`, `custom`) rather than constructed directly.
23
31
  */
24
32
  class Unit {
25
- constructor({ name, dimension, toBase, fromBase }) {
33
+ constructor({ name, dimension, symbol, plural, ...conversionOptions }) {
26
34
  this.name = name;
27
35
  this.dimension = dimension;
28
- this.toBase = toBase;
29
- this.fromBase = fromBase;
36
+ this.symbol = symbol;
37
+ this.plural = plural;
38
+ this.conversion = conversionOptions;
39
+ }
40
+ /**
41
+ * Exact affine transform to the base unit, present for all but non-linear
42
+ * units. When both ends of a conversion have one, {@link Dimension.convert}
43
+ * routes through exact rational arithmetic instead of lossy float scaling.
44
+ */
45
+ get linear() {
46
+ return "linear" in this.conversion ? this.conversion.linear : undefined;
47
+ }
48
+ /** Convert a value in this unit to the dimension's base unit. */
49
+ toBase(value) {
50
+ if ("linear" in this.conversion) {
51
+ const linear = this.conversion.linear;
52
+ return value * linear.scale.toNumber() + linear.offset.toNumber();
53
+ }
54
+ return this.conversion.toBase(value);
55
+ }
56
+ /** Convert a value in the dimension's base unit to this unit. */
57
+ fromBase(value) {
58
+ if ("linear" in this.conversion) {
59
+ const linear = this.conversion.linear;
60
+ return (value - linear.offset.toNumber()) / linear.scale.toNumber();
61
+ }
62
+ return this.conversion.fromBase(value);
30
63
  }
31
64
  }
32
65
  exports.Unit = Unit;
@@ -16,8 +16,8 @@ export interface PrefixReference {
16
16
  name: string;
17
17
  /** Primary symbol, e.g. "m" → "km", "mm", … */
18
18
  symbol: string;
19
- /** Scale of the reference relative to its dimension's base (meter → 1, gram → 0.001). */
20
- scale: number;
19
+ /** Scale of the reference relative to its dimension's base (meter → 1, gram → 0.001) (default 1). */
20
+ scale?: number;
21
21
  }
22
22
  /**
23
23
  * Define metric-prefixed variants of a reference unit on a dimension. Each
@@ -50,7 +50,7 @@ function definePrefixed(dimension, reference, prefixes = exports.SI_PREFIXES) {
50
50
  if (prefix.name === "micro") {
51
51
  aliases.push(`u${reference.symbol}`);
52
52
  }
53
- units[name] = dimension.unit(name, reference.scale * prefix.factor, aliases);
53
+ units[name] = dimension.unit(name, (reference.scale ?? 1) * prefix.factor, aliases);
54
54
  }
55
55
  return units;
56
56
  }