@weasel-js/quantity 1.9.3 → 1.10.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.
package/dist/index.d.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  * `UnitSystem` at the API boundary. Internals never see units.
9
9
  *
10
10
  * An entry is affine — `base = value * factor + offset` — so a scale that
11
- * puts zero somewhere else (degC against K) is expressible. No per-axis
12
- * units. No mixed-unit arithmetic.
11
+ * puts zero somewhere else (degC against K) is expressible. Arithmetic
12
+ * across units is `resolveUnitExpr`; a plane whose axes measure different
13
+ * things resolves through `resolveAxes`.
13
14
  */
14
15
  /** A unit name (e.g. `'in'`, `'ft'`, `'mm'`). Looked up in a `UnitSystem`. */
15
16
  type Unit = string;
@@ -56,6 +57,25 @@ declare function formatUnit(baseValue: number, displayUnit: Unit, unitSystem: Un
56
57
  precision?: number;
57
58
  suffix?: boolean;
58
59
  }): string;
60
+ /** One of something for each axis of a plane. */
61
+ interface PerAxis<T> {
62
+ x: T;
63
+ y: T;
64
+ }
65
+ /** A plane's units: one system for both axes, or one each — a chart whose x
66
+ * is time and whose y is a value. */
67
+ type PlaneUnits = UnitSystem | PerAxis<UnitSystem>;
68
+ /** A measure on a plane: one value for both axes, or one each. */
69
+ type PlaneValue = UnitValue | Quantity | PerAxis<UnitValue | Quantity>;
70
+ /** The unit system `units` gives one axis. */
71
+ declare function axisUnits(units: PlaneUnits | undefined, axis: 'x' | 'y'): UnitSystem | undefined;
72
+ /** `v` in base units on each axis, every axis resolved against its own system. */
73
+ declare function resolveAxes(v: PlaneValue, units?: PlaneUnits): PerAxis<number>;
74
+ /** A base-unit point as text, each axis in its own display unit. */
75
+ declare function formatAxes(base: PerAxis<number>, display: Unit | PerAxis<Unit>, units: PlaneUnits, opts?: {
76
+ precision?: number;
77
+ suffix?: boolean;
78
+ }): PerAxis<string>;
59
79
  /** Imperial unit system with base 'in'. */
60
80
  declare const IMPERIAL_INCHES: UnitSystem;
61
81
  /** Metric unit system with base 'mm'. */
@@ -321,6 +341,7 @@ declare const CONSTANTS: {
321
341
  readonly spoken: "e";
322
342
  };
323
343
  };
344
+ /** How a `fraction` display picks and writes its fraction. */
324
345
  type FractionOptions = {
325
346
  /** Default 64. */
326
347
  maxDenominator?: number;
@@ -389,14 +410,10 @@ declare function zoom(): ZoomDisplay;
389
410
  declare const zoomKind: DisplayKind<ZoomDisplay>;
390
411
 
391
412
  /**
392
- * Display-formatter for numbers. Use this anywhere a number is shown to
393
- * a user. The whole point: negative values get prefixed with the real
394
- * MINUS SIGN (U+2212) instead of the ASCII HYPHEN-MINUS (U+002D) that
395
- * `toLocaleString` and template literals produce by default.
396
- *
397
- * U+2212 is the same visual width as `+` and reads as a sign rather
398
- * than a hyphen — columns of signed numbers align cleanly and the
399
- * glyph doesn't get confused with a bullet or list dash.
413
+ * The real MINUS SIGN (U+2212), which display formatting uses instead of the
414
+ * ASCII HYPHEN-MINUS (U+002D) that `toLocaleString` and template literals
415
+ * produce. It is the same visual width as `+`, so columns of signed numbers
416
+ * align.
400
417
  */
401
418
  declare const MINUS_SIGN = "\u2212";
402
419
  /**
@@ -525,4 +542,116 @@ declare function ordinal(): OrdinalDisplay;
525
542
  /** The built-in `ordinal` kind, to spread into a `registerDisplayKind` replacement. */
526
543
  declare const ordinalKind: DisplayKind<OrdinalDisplay>;
527
544
 
528
- export { ANGLE_RADIANS, type BytesDisplay, CONSTANTS, type CompactDisplay, type CurrencyDisplay, type DecimalDisplay, type Display, type DisplayKind, type DurationDisplay, type FormatContext, type FractionDisplay, IMPERIAL_INCHES, type InfinityText, type IntegerDisplay, METRIC_MM, MINUS_SIGN, type MultiplierDisplay, type NamedConstant, type OrdinalDisplay, PIXELS, type Part, type PercentDisplay, type PresentOptions, type Presentation, type Quantity, QuantityView, type RatioDisplay, type RomanDisplay, type Tagged, type Unit, type UnitDisplay, type UnitEntry, type UnitScale, type UnitSystem, type UnitTable, type UnitValue, type ZoomDisplay, amount, bytes, bytesKind, compact, compactKind, currency, currencyKind, decimal, decimalKind, displayKindOf, displayOf, duration, durationKind, endless, entryScale, formatCompact, formatNumber, formatUnit, fraction, fractionKind, fromRoman, integer, integerKind, intlParts, multiplier, multiplierKind, nearestFraction, numberPart, ordinal, ordinalKind, parseAs, parseNumber, parseSignedNumber, partsOf, partsToHtml, percent, percentKind, plural, present, qty, ratio, ratioKind, registerDisplayKind, resolveUnit, retag, roman, romanKind, signed, spokenSign, tag, textOf, toRoman, unit, unitKind, unitOf, unitScale, zoom, zoomKind };
545
+ /**
546
+ * Arithmetic over values in different units, as plain data: `50% + 2ft`
547
+ * parses once and resolves against whatever context it is handed, so a stored
548
+ * expression follows a parent that resizes. `value` nodes are the leaves.
549
+ */
550
+ type UnitExpr = {
551
+ op: 'value';
552
+ value: number;
553
+ unit?: Unit;
554
+ } | {
555
+ op: 'neg';
556
+ of: UnitExpr;
557
+ } | {
558
+ op: 'add' | 'sub' | 'mul' | 'div';
559
+ left: UnitExpr;
560
+ right: UnitExpr;
561
+ };
562
+ /** What a {@link UnitExpr} resolves against. */
563
+ interface UnitExprContext {
564
+ unitSystem?: UnitSystem;
565
+ /** What `100%` is, in base units. A `%` term throws without it, unless the
566
+ * unit system carries a `%` unit of its own. */
567
+ percentOf?: number;
568
+ }
569
+ /**
570
+ * Parse arithmetic over unit-tagged numbers: `+ - * /` (also `− × ÷`),
571
+ * parentheses, and adjacent terms read as one compound value (`5ft 3in`),
572
+ * with a leading sign carried across all of them. Throws on text it cannot
573
+ * read. Unit names are not checked here — resolving does that.
574
+ */
575
+ declare function parseUnitExpr(text: string): UnitExpr;
576
+ /**
577
+ * An expression's value in base units. A bare number beside `+` or `-` is
578
+ * read as base units, as it is everywhere a `UnitValue` goes, and beside `*`
579
+ * or `/` as a scalar. A measure over a measure is a scalar; a product of two
580
+ * measures, or a scalar over a measure, throws.
581
+ */
582
+ declare function resolveUnitExpr(expr: UnitExpr | string, ctx?: UnitExprContext): number;
583
+
584
+ /**
585
+ * A unit system nested in another: a sub-assembly drawn in millimeters inside
586
+ * a scene measured in inches. Values inside are read against `system`, and
587
+ * `scale` converts at the boundary.
588
+ */
589
+ interface UnitScope {
590
+ system: UnitSystem;
591
+ /** What one of this scope's base units is worth in the enclosing scope's
592
+ * base units. Absent is 1, which is also what the outermost scope has. */
593
+ scale?: UnitEntry;
594
+ }
595
+ /** A measure in the innermost scope's base units, in the outermost's. `scopes` runs outermost first. */
596
+ declare function toRoot(base: number, scopes: readonly UnitScope[]): number;
597
+ /** The inverse of {@link toRoot}: an outermost-scope measure in the innermost scope's base units. */
598
+ declare function fromRoot(rootBase: number, scopes: readonly UnitScope[]): number;
599
+ /**
600
+ * Resolve `v` where it was written — against the innermost scope's unit
601
+ * system, so `ft` inside a metric sub-assembly is an error rather than a
602
+ * silent inch measure — and return it in the outermost scope's base units.
603
+ */
604
+ declare function resolveScoped(v: UnitValue | Quantity, scopes: readonly UnitScope[]): number;
605
+ /** A measure in the outermost scope's base units, as text in a unit of the innermost scope. */
606
+ declare function formatScoped(rootBase: number, displayUnit: Unit, scopes: readonly UnitScope[], opts?: {
607
+ precision?: number;
608
+ suffix?: boolean;
609
+ }): string;
610
+ /** How {@link scopesOf} reads a tree it knows nothing else about. */
611
+ interface ScopeTree<Id> {
612
+ parentOf(id: Id): Id | null | undefined;
613
+ /** The scope `id` declares for what it holds, when it declares one. */
614
+ scopeOf(id: Id): UnitScope | undefined;
615
+ /** Count `id`'s own scope too — for a value written inside it rather than
616
+ * one that places it in its parent. Default false. */
617
+ inclusive?: boolean;
618
+ }
619
+ /**
620
+ * The scopes enclosing `id`, outermost first, under `root`: what
621
+ * {@link resolveScoped} takes for a value that positions or sizes `id`. A
622
+ * node's own scope covers its children, not itself, unless `inclusive`.
623
+ */
624
+ declare function scopesOf<Id>(id: Id, root: UnitScope, tree: ScopeTree<Id>): UnitScope[];
625
+
626
+ /** Options for presenting a range. */
627
+ interface RangeOptions extends PresentOptions {
628
+ /** What goes between the ends. Default an en dash, spaced when an end is negative. */
629
+ separator?: string;
630
+ /** The separator read aloud. Default ` to `. */
631
+ spokenSeparator?: string;
632
+ }
633
+ /** Options for presenting a vector. */
634
+ interface VectorOptions extends PresentOptions {
635
+ /** What goes between members. Default `, `. */
636
+ separator?: string;
637
+ /** A name before each member, by position: `['x', 'y']`. */
638
+ labels?: readonly string[];
639
+ }
640
+ /**
641
+ * A range as one run of parts: both ends under their own display, else
642
+ * `fallback`, around a `separator` part. A unit or percent sign both ends
643
+ * share is written once, after the second: `2–5ft`.
644
+ */
645
+ declare function rangeParts(from: Quantity, to: Quantity, fallback?: Display, options?: RangeOptions): Part[];
646
+ /** A range as text, spoken text, and HTML: a `data-composite="range"` span
647
+ * holding a `<data value>` for each end and a `separator` part between. */
648
+ declare function presentRange(from: Quantity, to: Quantity, fallback?: Display, options?: RangeOptions): Presentation;
649
+ /** A vector as one run of parts: every member under its own display, else
650
+ * `fallback`, with a `separator` part between and a `label` part before each
651
+ * member that has one. */
652
+ declare function vectorParts(members: readonly Quantity[], fallback?: Display, options?: VectorOptions): Part[];
653
+ /** A vector as text, spoken text, and HTML: a `data-composite="vector"` span
654
+ * holding a `<data value>` for each member. */
655
+ declare function presentVector(members: readonly Quantity[], fallback?: Display, options?: VectorOptions): Presentation;
656
+
657
+ export { ANGLE_RADIANS, type BytesDisplay, CONSTANTS, type CompactDisplay, type CurrencyDisplay, type DecimalDisplay, type Display, type DisplayKind, type DurationDisplay, type FormatContext, type FractionDisplay, type FractionOptions, IMPERIAL_INCHES, type InfinityText, type IntegerDisplay, METRIC_MM, MINUS_SIGN, type MultiplierDisplay, type NamedConstant, type OrdinalDisplay, PIXELS, type Part, type PerAxis, type PercentDisplay, type PlaneUnits, type PlaneValue, type PresentOptions, type Presentation, type Quantity, QuantityView, type RangeOptions, type RatioDisplay, type RomanDisplay, type ScopeTree, type Tagged, type Unit, type UnitDisplay, type UnitEntry, type UnitExpr, type UnitExprContext, type UnitScale, type UnitScope, type UnitSystem, type UnitTable, type UnitValue, type VectorOptions, type ZoomDisplay, amount, axisUnits, bytes, bytesKind, compact, compactKind, currency, currencyKind, decimal, decimalKind, displayKindOf, displayOf, duration, durationKind, endless, entryScale, formatAxes, formatCompact, formatNumber, formatScoped, formatUnit, fraction, fractionKind, fromRoman, fromRoot, integer, integerKind, intlParts, multiplier, multiplierKind, nearestFraction, numberPart, ordinal, ordinalKind, parseAs, parseNumber, parseSignedNumber, parseUnitExpr, partsOf, partsToHtml, percent, percentKind, plural, present, presentRange, presentVector, qty, rangeParts, ratio, ratioKind, registerDisplayKind, resolveAxes, resolveScoped, resolveUnit, resolveUnitExpr, retag, roman, romanKind, scopesOf, signed, spokenSign, tag, textOf, toRoman, toRoot, unit, unitKind, unitOf, unitScale, vectorParts, zoom, zoomKind };
package/dist/index.js CHANGED
@@ -52,11 +52,30 @@ function formatUnit(baseValue, displayUnit, unitSystem, opts) {
52
52
  const suffix = opts?.suffix ?? true;
53
53
  const display = (baseValue - offset) / factor;
54
54
  let s = display.toFixed(precision);
55
+ if (Number(s) === 0) s = s.replace(/^-/, "");
55
56
  if (s.includes(".")) {
56
57
  s = s.replace(/0+$/, "").replace(/\.$/, "");
57
58
  }
58
59
  return suffix ? `${s}${displayUnit}` : s;
59
60
  }
61
+ function axisUnits(units, axis) {
62
+ return units === void 0 || "base" in units ? units : units[axis];
63
+ }
64
+ function resolveAxes(v, units) {
65
+ const perAxis = typeof v === "object" && "x" in v;
66
+ const on = (axis) => {
67
+ try {
68
+ return resolveUnit(perAxis ? v[axis] : v, axisUnits(units, axis));
69
+ } catch (e) {
70
+ throw new Error(`${axis} axis: ${e instanceof Error ? e.message : String(e)}`);
71
+ }
72
+ };
73
+ return { x: on("x"), y: on("y") };
74
+ }
75
+ function formatAxes(base, display, units, opts) {
76
+ const on = (axis) => formatUnit(base[axis], typeof display === "string" ? display : display[axis], axisUnits(units, axis), opts);
77
+ return { x: on("x"), y: on("y") };
78
+ }
60
79
  function scaleOrThrow(caller, unitSystem, unit2) {
61
80
  try {
62
81
  return unitScale(unitSystem, unit2);
@@ -84,18 +103,23 @@ var PIXELS = {
84
103
  // src/number.ts
85
104
  var MINUS_SIGN = "\u2212";
86
105
  function formatNumber(value, options) {
87
- const formatted = Number.isFinite(value) ? value.toLocaleString(void 0, options) : String(value);
106
+ const formatted = Number.isFinite(value) ? unsignedZero(value, (n) => n.toLocaleString(void 0, options)) : String(value);
88
107
  return formatted.replace(/^-/, MINUS_SIGN);
89
108
  }
109
+ function unsignedZero(value, format) {
110
+ const shown = format(value);
111
+ return value <= 0 && shown === format(-0) ? format(0) : shown;
112
+ }
90
113
  function parseSignedNumber(text) {
91
114
  return Number(text.replace(MINUS_SIGN, "-"));
92
115
  }
93
116
  var TERM_NUMBER = String.raw`\d+(?:\.\d+)?|\.\d+`;
117
+ var DECIMAL_NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i;
94
118
  var EXPONENTS = { k: 3, m: 6, b: 9, t: 12 };
95
119
  function formatCompact(value, decimals = 0) {
96
120
  if (!Number.isFinite(value)) return formatNumber(value);
97
- const options = Math.abs(value) < 1e3 ? { useGrouping: false, minimumFractionDigits: decimals, maximumFractionDigits: decimals } : { notation: "compact", minimumSignificantDigits: 3, maximumSignificantDigits: 3 };
98
- return value.toLocaleString("en-US", options).replace(/^-/, MINUS_SIGN);
121
+ const options = Number(Math.abs(value).toFixed(decimals)) < 1e3 ? { useGrouping: false, minimumFractionDigits: decimals, maximumFractionDigits: decimals } : { notation: "compact", minimumSignificantDigits: 3, maximumSignificantDigits: 3 };
122
+ return unsignedZero(value, (n) => n.toLocaleString("en-US", options)).replace(/^-/, MINUS_SIGN);
99
123
  }
100
124
  function parseNumber(text, units, locale = "en-US") {
101
125
  const n = readNumber(delocalized(text, locale), units);
@@ -116,8 +140,9 @@ function readNumber(text, units) {
116
140
  let t = text.trim().replace(MINUS_SIGN, "-");
117
141
  const exponent = EXPONENTS[t.slice(-1).toLowerCase()];
118
142
  if (exponent !== void 0) t = t.slice(0, -1).trimEnd();
119
- if (!/\d/.test(t)) return Number.NaN;
120
- return Number(exponent === void 0 ? t : `${t}e${exponent}`);
143
+ if (!DECIMAL_NUMBER.test(t)) return Number.NaN;
144
+ const n = Number(exponent === void 0 ? t : `${t}e${exponent}`);
145
+ return Number.isFinite(n) ? n : Number.NaN;
121
146
  }
122
147
  var shapes = /* @__PURE__ */ new Map();
123
148
  function shapeOf(locale) {
@@ -158,6 +183,14 @@ function latinDigits(text, locale) {
158
183
  return digits ? t.replace(digits.pattern, (c) => digits.latin.get(c)) : t;
159
184
  }
160
185
  var BIDI_MARKS = /[\u061c\u200e\u200f]/g;
186
+ function numberWithin(text, locale) {
187
+ const t = latinDigits(text, locale);
188
+ const m = new RegExp(shapeOf(locale).run.source).exec(t);
189
+ if (!m) return "";
190
+ const around = t.slice(0, m.index) + t.slice(m.index + m[0].length);
191
+ const suffix = /^[kmbt](?!\p{L})/iu.exec(t.slice(m.index + m[0].length))?.[0] ?? "";
192
+ return `${/[-\u2212]/.test(around) ? "-" : ""}${m[0]}${suffix}`;
193
+ }
161
194
  var decimalOf = (locale) => shapeOf(locale).decimal;
162
195
  var SPELLINGS = [
163
196
  ["in", '"', "\u2033", "\u201D"],
@@ -213,11 +246,15 @@ function unitSuffixOf(text, units) {
213
246
  }
214
247
 
215
248
  // src/kind.ts
249
+ var ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
250
+ function escapeMarkup(s) {
251
+ return s.replace(/[&<>"]/g, (c) => ESCAPES[c]);
252
+ }
216
253
  function textOf(parts) {
217
254
  return parts.map((p) => p.value).join("");
218
255
  }
219
256
  function numberPart(value, locale, options) {
220
- return { type: "number", value: signed(value.toLocaleString(locale, options)) };
257
+ return { type: "number", value: signed(unsignedZero(value, (n) => n.toLocaleString(locale, options))) };
221
258
  }
222
259
  function signed(text) {
223
260
  return text.replace(/^-/, MINUS_SIGN);
@@ -230,7 +267,9 @@ function plural(value, locale, singular, pluralForm) {
230
267
  }
231
268
  function intlParts(value, locale, options) {
232
269
  const out = [];
233
- for (const p of new Intl.NumberFormat(locale, options).formatToParts(value)) {
270
+ const nf = new Intl.NumberFormat(locale, options);
271
+ const shown = value <= 0 && nf.format(value) === nf.format(-0) ? 0 : value;
272
+ for (const p of nf.formatToParts(shown)) {
234
273
  const type = DIGIT_RUNS.has(p.type) ? "number" : p.type;
235
274
  const v = p.type === "minusSign" ? MINUS_SIGN : p.value;
236
275
  const last = out[out.length - 1];
@@ -306,8 +345,12 @@ var percentKind = {
306
345
  })
307
346
  )
308
347
  ),
309
- parse: (text, _d, ctx) => parseNumber(text.replace(/^\s*%|%\s*$/, ""), void 0, ctx.locale) / 100
348
+ parse: (text, _d, ctx) => parseNumber(withoutPercentSign(text, ctx.locale), void 0, ctx.locale) / 100
310
349
  };
350
+ function withoutPercentSign(text, locale) {
351
+ const m = /^\s*([-+\u2212]?)\s*[%٪]?\s*(.*?)\s*[%٪]?\s*$/su.exec(latinDigits(text, locale));
352
+ return m ? m[1] + m[2] : text;
353
+ }
311
354
  function nearestFraction(x, maxDenominator) {
312
355
  if (!Number.isFinite(x)) return [x, 1];
313
356
  const sign = x < 0 ? -1 : 1;
@@ -421,7 +464,7 @@ var fractionKind = {
421
464
  const { negative, whole, num, den, constant } = fractionPieces(value, d);
422
465
  const counted = (n) => {
423
466
  if (!constant) return `<mn>${n}</mn>`;
424
- const mi = `<mi>${constant.symbol}</mi>`;
467
+ const mi = `<mi>${escapeMarkup(constant.symbol)}</mi>`;
425
468
  return n === 1 ? mi : `<mn>${n}</mn>${mi}`;
426
469
  };
427
470
  const body = [
@@ -466,14 +509,20 @@ var multiplierKind = {
466
509
  { type: "symbol", value: d.symbol ?? "\xD7" }
467
510
  ],
468
511
  speak: (value, d, ctx) => spokenSign(`${numberPart(value, ctx.locale, { maximumFractionDigits: d.places ?? 2 }).value} times`),
469
- parse: (text, _d, ctx) => parseNumber(text.replace(/\s*[x×X]\s*$/, ""), void 0, ctx.locale)
512
+ parse: (text, d, ctx) => {
513
+ const own = d.symbol !== void 0 && text.trimEnd().endsWith(d.symbol) ? text.trimEnd().slice(0, -d.symbol.length) : text.replace(/\s*[x×X]\s*$/, "");
514
+ return parseNumber(own, void 0, ctx.locale);
515
+ }
470
516
  };
471
517
  function zoom() {
472
518
  return { kind: "zoom" };
473
519
  }
474
520
  function zoomParts(z) {
475
521
  if (!Number.isFinite(z)) return [{ type: "number", value: String(z) }];
476
- const n = (v, grouping = false) => ({ type: "number", value: v.toLocaleString("en-US", { useGrouping: grouping, maximumFractionDigits: 1 }).replace(/^-/, MINUS_SIGN) });
522
+ const n = (v, grouping = false) => ({
523
+ type: "number",
524
+ value: unsignedZero(v, (x) => x.toLocaleString("en-US", { useGrouping: grouping, maximumFractionDigits: 1 })).replace(/^-/, MINUS_SIGN)
525
+ });
477
526
  if (z <= 2) return [n(Math.round(z * 100)), { type: "percentSign", value: "%" }];
478
527
  if (z >= 100) return [n(Math.round(z), true), { type: "symbol", value: "x" }];
479
528
  return [n(Math.round(z * 10) / 10), { type: "symbol", value: "x" }];
@@ -570,7 +619,7 @@ var currencyKind = {
570
619
  kind: "currency",
571
620
  format: (value, d, ctx) => intlParts(value, ctx.locale, currencyOptions(d)),
572
621
  speak: (value, d, ctx) => spokenSign(textOf(intlParts(value, ctx.locale, { ...currencyOptions(d), currencyDisplay: "name" }))),
573
- parse: (text, _d, ctx) => parseNumber(text.replace(/[^\d.,'’+\-−]/g, ""), void 0, ctx.locale)
622
+ parse: (text, _d, ctx) => parseNumber(numberWithin(text, ctx.locale), void 0, ctx.locale)
574
623
  };
575
624
  function duration(options = {}) {
576
625
  return { kind: "duration", ...options };
@@ -638,14 +687,21 @@ var BINARY_NAMES = ["byte", "kibibyte", "mebibyte", "gibibyte", "tebibyte", "peb
638
687
  function bytes(options = {}) {
639
688
  return { kind: "bytes", ...options };
640
689
  }
641
- function byteScale(value, base) {
690
+ function byteScale(d, value) {
691
+ const base = d.base ?? 1e3;
692
+ const last = DECIMAL_BYTES.length - 1;
693
+ const placesAt = (step2) => step2 === 0 ? 0 : d.places ?? 1;
642
694
  let step = 0;
643
695
  let scaled2 = value;
644
- while (Math.abs(scaled2) >= base && step < DECIMAL_BYTES.length - 1) {
696
+ while (Math.abs(scaled2) >= base && step < last) {
697
+ scaled2 /= base;
698
+ step++;
699
+ }
700
+ if (step < last && Number(Math.abs(scaled2).toFixed(placesAt(step))) >= base) {
645
701
  scaled2 /= base;
646
702
  step++;
647
703
  }
648
- return { scaled: scaled2, step };
704
+ return { scaled: scaled2, step, places: placesAt(step) };
649
705
  }
650
706
  var BYTE_UNITS = Object.fromEntries([
651
707
  ...DECIMAL_BYTES.map((u, i) => [u, 1e3 ** i]),
@@ -655,20 +711,18 @@ var BYTE_UNITS = Object.fromEntries([
655
711
  var bytesKind = {
656
712
  kind: "bytes",
657
713
  format: (value, d, ctx) => {
658
- const base = d.base ?? 1e3;
659
- const { scaled: scaled2, step } = byteScale(value, base);
660
- const units = base === 1024 ? BINARY_BYTES : DECIMAL_BYTES;
714
+ const { scaled: scaled2, step, places } = byteScale(d, value);
715
+ const units = d.base === 1024 ? BINARY_BYTES : DECIMAL_BYTES;
661
716
  return [
662
- numberPart(scaled2, ctx.locale, { maximumFractionDigits: step === 0 ? 0 : d.places ?? 1 }),
717
+ numberPart(scaled2, ctx.locale, { maximumFractionDigits: places }),
663
718
  { type: "literal", value: " " },
664
719
  { type: "unit", value: units[step] }
665
720
  ];
666
721
  },
667
722
  speak: (value, d, ctx) => {
668
- const base = d.base ?? 1e3;
669
- const { scaled: scaled2, step } = byteScale(value, base);
670
- const name = (base === 1024 ? BINARY_NAMES : DECIMAL_NAMES)[step];
671
- const number = numberPart(scaled2, ctx.locale, { maximumFractionDigits: step === 0 ? 0 : d.places ?? 1 }).value;
723
+ const { scaled: scaled2, step, places } = byteScale(d, value);
724
+ const name = (d.base === 1024 ? BINARY_NAMES : DECIMAL_NAMES)[step];
725
+ const number = numberPart(scaled2, ctx.locale, { maximumFractionDigits: places }).value;
672
726
  return spokenSign(`${number} ${plural(Math.abs(parseNumber(number, void 0, ctx.locale)), ctx.locale, name, `${name}s`)}`);
673
727
  },
674
728
  parse: (text, _d, ctx) => parseNumber(text, BYTE_UNITS, ctx.locale)
@@ -809,13 +863,9 @@ function contextOf(q, options) {
809
863
  const unit2 = unitOf(q);
810
864
  return unit2 === void 0 ? { locale: options?.locale ?? "en-US" } : { locale: options?.locale ?? "en-US", unit: unit2 };
811
865
  }
812
- var ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
813
- function escape2(s) {
814
- return s.replace(/[&<>"]/g, (c) => ESCAPES[c]);
815
- }
816
866
  function partsToHtml(value, parts) {
817
- const body = parts.map((p) => p.type === "literal" ? escape2(p.value) : `<span data-part="${escape2(p.type)}">${escape2(p.value)}</span>`).join("");
818
- return `<data value="${escape2(String(value))}">${body}</data>`;
867
+ const body = parts.map((p) => p.type === "literal" ? escapeMarkup(p.value) : `<span data-part="${escapeMarkup(p.type)}">${escapeMarkup(p.value)}</span>`).join("");
868
+ return `<data value="${escapeMarkup(String(value))}">${body}</data>`;
819
869
  }
820
870
  function partsOf(q, fallback, options) {
821
871
  const display = displayOf(q, fallback) ?? DEFAULT_DISPLAY;
@@ -915,6 +965,231 @@ function qty(q, fallback, options) {
915
965
  return new QuantityView(q, fallback, options);
916
966
  }
917
967
 
918
- export { ANGLE_RADIANS, CONSTANTS, IMPERIAL_INCHES, METRIC_MM, MINUS_SIGN, PIXELS, QuantityView, amount, bytes, bytesKind, compact, compactKind, currency, currencyKind, decimal, decimalKind, displayKindOf, displayOf, duration, durationKind, endless, entryScale, formatCompact, formatNumber, formatUnit, fraction, fractionKind, fromRoman, integer, integerKind, intlParts, multiplier, multiplierKind, nearestFraction, numberPart, ordinal, ordinalKind, parseAs, parseNumber, parseSignedNumber, partsOf, partsToHtml, percent, percentKind, plural, present, qty, ratio, ratioKind, registerDisplayKind, resolveUnit, retag, roman, romanKind, signed, spokenSign, tag, textOf, toRoman, unit, unitKind, unitOf, unitScale, zoom, zoomKind };
968
+ // src/unitExpr.ts
969
+ var TOKEN = /\s*(?:(\d+(?:\.\d+)?|\.\d+)\s*([^\s\d.+\-−*×/÷()]+)?|([+\-−*×/÷()]))/y;
970
+ var OPS = { [MINUS_SIGN]: "-", "\xD7": "*", "\xF7": "/" };
971
+ function tokenize(text) {
972
+ const tokens = [];
973
+ let at = 0;
974
+ while (text.slice(at).trim() !== "") {
975
+ TOKEN.lastIndex = at;
976
+ const m = TOKEN.exec(text);
977
+ if (!m) throw new Error(`unitExpr: cannot read '${text.slice(at).trim()}' in '${text}'`);
978
+ at = TOKEN.lastIndex;
979
+ if (m[1] === void 0) tokens.push({ kind: "op", op: OPS[m[3]] ?? m[3] });
980
+ else if (m[2] === void 0) tokens.push({ kind: "value", value: Number(m[1]) });
981
+ else tokens.push({ kind: "value", value: Number(m[1]), unit: m[2] });
982
+ }
983
+ return tokens;
984
+ }
985
+ function parseUnitExpr(text) {
986
+ const tokens = tokenize(text);
987
+ let i = 0;
988
+ const isOp = (op) => {
989
+ const t = tokens[i];
990
+ return t !== void 0 && t.kind === "op" && t.op === op;
991
+ };
992
+ const fail = () => {
993
+ throw new Error(`unitExpr: '${text}' is not a complete expression`);
994
+ };
995
+ const sum = () => {
996
+ let left = product();
997
+ while (isOp("+") || isOp("-")) {
998
+ const op = isOp("+") ? "add" : "sub";
999
+ i++;
1000
+ left = { op, left, right: product() };
1001
+ }
1002
+ return left;
1003
+ };
1004
+ const product = () => {
1005
+ let left = unary();
1006
+ while (isOp("*") || isOp("/")) {
1007
+ const op = isOp("*") ? "mul" : "div";
1008
+ i++;
1009
+ left = { op, left, right: unary() };
1010
+ }
1011
+ return left;
1012
+ };
1013
+ const unary = () => {
1014
+ if (isOp("+")) {
1015
+ i++;
1016
+ return unary();
1017
+ }
1018
+ if (!isOp("-")) return primary();
1019
+ i++;
1020
+ const of = unary();
1021
+ return of.op === "value" ? { ...of, value: -of.value } : { op: "neg", of };
1022
+ };
1023
+ const primary = () => {
1024
+ if (isOp("(")) {
1025
+ i++;
1026
+ const inner = sum();
1027
+ if (!isOp(")")) fail();
1028
+ i++;
1029
+ return inner;
1030
+ }
1031
+ let left = literal();
1032
+ while (tokens[i]?.kind === "value") left = { op: "add", left, right: literal() };
1033
+ return left;
1034
+ };
1035
+ const literal = () => {
1036
+ const t = tokens[i];
1037
+ if (t === void 0 || t.kind !== "value") return fail();
1038
+ i++;
1039
+ return t.unit === void 0 ? { op: "value", value: t.value } : { op: "value", value: t.value, unit: t.unit };
1040
+ };
1041
+ const expr = sum();
1042
+ if (i !== tokens.length) fail();
1043
+ return expr;
1044
+ }
1045
+ function resolveNode(expr, ctx) {
1046
+ if (expr.op === "value") return resolveValue(expr.value, expr.unit, ctx);
1047
+ if (expr.op === "neg") {
1048
+ const of = amountOf(resolveNode(expr.of, ctx));
1049
+ return { ...of, n: -of.n };
1050
+ }
1051
+ const l = amountOf(resolveNode(expr.left, ctx));
1052
+ const r = amountOf(resolveNode(expr.right, ctx));
1053
+ switch (expr.op) {
1054
+ case "add":
1055
+ return { n: l.n + r.n, measure: l.measure || r.measure, offset: false };
1056
+ case "sub":
1057
+ return { n: l.n - r.n, measure: l.measure || r.measure, offset: false };
1058
+ case "mul":
1059
+ if (l.measure && r.measure) throw new Error("unitExpr: cannot multiply two measures");
1060
+ return { n: l.n * r.n, measure: l.measure || r.measure, offset: false };
1061
+ case "div":
1062
+ if (r.measure && !l.measure) throw new Error("unitExpr: cannot divide by a measure");
1063
+ return { n: l.n / r.n, measure: l.measure && !r.measure, offset: false };
1064
+ }
1065
+ }
1066
+ function amountOf(r) {
1067
+ if (r.offset) {
1068
+ throw new Error("unitExpr: a unit with an offset names a point, and has no place in arithmetic");
1069
+ }
1070
+ return r;
1071
+ }
1072
+ function resolveValue(value, unit2, ctx) {
1073
+ if (unit2 === void 0) return { n: value, measure: false, offset: false };
1074
+ const units = ctx.unitSystem ? withSpellings(ctx.unitSystem.units) : void 0;
1075
+ if (unit2 === "%" && units?.["%"] === void 0) {
1076
+ if (ctx.percentOf === void 0) {
1077
+ throw new Error(`unitExpr: ${value}% needs a percentOf to resolve against`);
1078
+ }
1079
+ return { n: value / 100 * ctx.percentOf, measure: true, offset: false };
1080
+ }
1081
+ if (!ctx.unitSystem || !units) throw new Error(`unitExpr: ${value}${unit2} requires a UnitSystem`);
1082
+ let scale;
1083
+ try {
1084
+ scale = unitScale({ base: ctx.unitSystem.base, units }, unit2);
1085
+ } catch (e) {
1086
+ throw new Error(`unitExpr: ${e instanceof Error ? e.message : String(e)}`);
1087
+ }
1088
+ return { n: value * scale.factor + scale.offset, measure: true, offset: scale.offset !== 0 };
1089
+ }
1090
+ function resolveUnitExpr(expr, ctx = {}) {
1091
+ return resolveNode(typeof expr === "string" ? parseUnitExpr(expr) : expr, ctx).n;
1092
+ }
1093
+
1094
+ // src/scope.ts
1095
+ function toRoot(base, scopes) {
1096
+ let n = base;
1097
+ for (let i = scopes.length - 1; i >= 0; i--) {
1098
+ const { factor, offset } = entryScale(scopes[i].scale ?? 1);
1099
+ n = n * factor + offset;
1100
+ }
1101
+ return n;
1102
+ }
1103
+ function fromRoot(rootBase, scopes) {
1104
+ let n = rootBase;
1105
+ for (const scope of scopes) {
1106
+ const { factor, offset } = entryScale(scope.scale ?? 1);
1107
+ n = (n - offset) / factor;
1108
+ }
1109
+ return n;
1110
+ }
1111
+ function resolveScoped(v, scopes) {
1112
+ return toRoot(resolveUnit(v, scopes[scopes.length - 1]?.system), scopes);
1113
+ }
1114
+ function formatScoped(rootBase, displayUnit, scopes, opts) {
1115
+ return formatUnit(fromRoot(rootBase, scopes), displayUnit, scopes[scopes.length - 1].system, opts);
1116
+ }
1117
+ function scopesOf(id, root, tree) {
1118
+ const inner = [];
1119
+ let at = tree.inclusive ? id : tree.parentOf(id);
1120
+ while (at !== null && at !== void 0) {
1121
+ const scope = tree.scopeOf(at);
1122
+ if (scope) inner.push(scope);
1123
+ at = tree.parentOf(at);
1124
+ }
1125
+ return [root, ...inner.reverse()];
1126
+ }
1127
+
1128
+ // src/composite.ts
1129
+ var SEPARATOR = "separator";
1130
+ var MEASURE = /* @__PURE__ */ new Set(["unit", "percentSign"]);
1131
+ function measureTail(parts) {
1132
+ let at = parts.length;
1133
+ while (at > 0 && (MEASURE.has(parts[at - 1].type) || parts[at - 1].type === "literal")) at--;
1134
+ const tail = parts.slice(at);
1135
+ return tail.some((p) => MEASURE.has(p.type)) ? tail : [];
1136
+ }
1137
+ function rangeEnds(from, to, fallback, options) {
1138
+ const a = partsOf(from, fallback, options);
1139
+ const b = partsOf(to, fallback, options);
1140
+ const tail = measureTail(a);
1141
+ const shared = tail.length > 0 && textOf(tail) === textOf(measureTail(b));
1142
+ return [shared ? a.slice(0, a.length - tail.length) : a, b];
1143
+ }
1144
+ function rangeSeparator(from, to, options) {
1145
+ return options?.separator ?? (amount(from) < 0 || amount(to) < 0 ? " \u2013 " : "\u2013");
1146
+ }
1147
+ function rangeParts(from, to, fallback, options) {
1148
+ const [a, b] = rangeEnds(from, to, fallback, options);
1149
+ return [...a, { type: SEPARATOR, value: rangeSeparator(from, to, options) }, ...b];
1150
+ }
1151
+ function presentRange(from, to, fallback, options) {
1152
+ const [a, b] = rangeEnds(from, to, fallback, options);
1153
+ const separator = rangeSeparator(from, to, options);
1154
+ const spoken = [from, to].map((q) => present(q, fallback, options).spoken);
1155
+ return {
1156
+ text: `${textOf(a)}${separator}${textOf(b)}`,
1157
+ spoken: spoken.join(options?.spokenSeparator ?? " to "),
1158
+ html: compositeHtml("range", [partsToHtml(amount(from), a), partsToHtml(amount(to), b)], separator)
1159
+ };
1160
+ }
1161
+ function vectorParts(members, fallback, options) {
1162
+ const separator = options?.separator ?? ", ";
1163
+ return members.flatMap((q, i) => [
1164
+ ...i > 0 ? [{ type: SEPARATOR, value: separator }] : [],
1165
+ ...labelParts(options?.labels?.[i]),
1166
+ ...partsOf(q, fallback, options)
1167
+ ]);
1168
+ }
1169
+ function presentVector(members, fallback, options) {
1170
+ const separator = options?.separator ?? ", ";
1171
+ const label = (i) => textOf(labelParts(options?.labels?.[i]));
1172
+ return {
1173
+ text: textOf(vectorParts(members, fallback, options)),
1174
+ spoken: members.map((q, i) => `${label(i)}${present(q, fallback, options).spoken}`).join(separator),
1175
+ html: compositeHtml(
1176
+ "vector",
1177
+ members.map((q, i) => `${partsHtml(labelParts(options?.labels?.[i]))}${present(q, fallback, options).html}`),
1178
+ separator
1179
+ )
1180
+ };
1181
+ }
1182
+ function labelParts(label) {
1183
+ return label === void 0 ? [] : [{ type: "label", value: label }, { type: "literal", value: " " }];
1184
+ }
1185
+ function partsHtml(parts) {
1186
+ return parts.map((p) => p.type === "literal" ? escapeMarkup(p.value) : `<span data-part="${escapeMarkup(p.type)}">${escapeMarkup(p.value)}</span>`).join("");
1187
+ }
1188
+ function compositeHtml(kind, members, separator) {
1189
+ const between = partsHtml([{ type: SEPARATOR, value: separator }]);
1190
+ return `<span data-composite="${kind}">${members.join(between)}</span>`;
1191
+ }
1192
+
1193
+ export { ANGLE_RADIANS, CONSTANTS, IMPERIAL_INCHES, METRIC_MM, MINUS_SIGN, PIXELS, QuantityView, amount, axisUnits, bytes, bytesKind, compact, compactKind, currency, currencyKind, decimal, decimalKind, displayKindOf, displayOf, duration, durationKind, endless, entryScale, formatAxes, formatCompact, formatNumber, formatScoped, formatUnit, fraction, fractionKind, fromRoman, fromRoot, integer, integerKind, intlParts, multiplier, multiplierKind, nearestFraction, numberPart, ordinal, ordinalKind, parseAs, parseNumber, parseSignedNumber, parseUnitExpr, partsOf, partsToHtml, percent, percentKind, plural, present, presentRange, presentVector, qty, rangeParts, ratio, ratioKind, registerDisplayKind, resolveAxes, resolveScoped, resolveUnit, resolveUnitExpr, retag, roman, romanKind, scopesOf, signed, spokenSign, tag, textOf, toRoman, toRoot, unit, unitKind, unitOf, unitScale, vectorParts, zoom, zoomKind };
919
1194
  //# sourceMappingURL=index.js.map
920
1195
  //# sourceMappingURL=index.js.map