@jarenjs/core 0.9.2 → 0.34.2

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 (121) hide show
  1. package/ARCHITECTURE.md +229 -26
  2. package/README.md +64 -10
  3. package/dist/types/array.d.ts +13 -0
  4. package/dist/types/cache.d.ts +166 -0
  5. package/dist/types/chunk.d.ts +102 -0
  6. package/dist/types/color.d.ts +64 -0
  7. package/dist/types/convert/convert.d.ts +33 -0
  8. package/dist/types/convert/currency.d.ts +47 -0
  9. package/dist/types/convert/index.d.ts +9 -0
  10. package/dist/types/convert/registry.d.ts +55 -0
  11. package/dist/types/dates/civil.d.ts +146 -0
  12. package/dist/types/dates/duration.d.ts +83 -0
  13. package/dist/types/dates/format.d.ts +59 -0
  14. package/dist/types/dates/index.d.ts +4 -0
  15. package/dist/types/{dates.d.ts → dates/rfc3339.d.ts} +57 -3
  16. package/dist/types/errors.d.ts +77 -0
  17. package/dist/types/finance/amortization.d.ts +51 -0
  18. package/dist/types/finance/bond.d.ts +45 -0
  19. package/dist/types/finance/cashflow.d.ts +44 -0
  20. package/dist/types/finance/depreciation.d.ts +26 -0
  21. package/dist/types/finance/index.d.ts +16 -0
  22. package/dist/types/finance/indicators.d.ts +76 -0
  23. package/dist/types/finance/interest.d.ts +44 -0
  24. package/dist/types/finance/returns.d.ts +43 -0
  25. package/dist/types/finance/tvm.d.ts +50 -0
  26. package/dist/types/geo/angle.d.ts +4 -0
  27. package/dist/types/geo/bbox.d.ts +32 -0
  28. package/dist/types/geo/distance.d.ts +67 -0
  29. package/dist/types/geo/geohash.d.ts +63 -0
  30. package/dist/types/geo/geojson.d.ts +108 -0
  31. package/dist/types/geo/index-tree.d.ts +52 -0
  32. package/dist/types/geo/index.d.ts +11 -0
  33. package/dist/types/geo/mercator.d.ts +57 -0
  34. package/dist/types/geo/predicates.d.ts +44 -0
  35. package/dist/types/geo/ring.d.ts +80 -0
  36. package/dist/types/geo/simplify.d.ts +31 -0
  37. package/dist/types/geo/valid.d.ts +24 -0
  38. package/dist/types/geo/wkt.d.ts +16 -0
  39. package/dist/types/math/float64.d.ts +126 -3
  40. package/dist/types/math/format.d.ts +58 -0
  41. package/dist/types/math/index.d.ts +5 -0
  42. package/dist/types/math/mat4.d.ts +60 -0
  43. package/dist/types/math/project.d.ts +54 -0
  44. package/dist/types/math/solve.d.ts +67 -0
  45. package/dist/types/math/word.d.ts +82 -0
  46. package/dist/types/message.d.ts +52 -0
  47. package/dist/types/object.d.ts +151 -0
  48. package/dist/types/scan.d.ts +30 -0
  49. package/dist/types/schema.d.ts +24 -0
  50. package/dist/types/string.d.ts +96 -0
  51. package/dist/types/text/base64.d.ts +6 -4
  52. package/dist/types/text/email.d.ts +0 -1
  53. package/dist/types/text/host.d.ts +39 -8
  54. package/dist/types/text/i18n.d.ts +13 -1
  55. package/dist/types/text/index.d.ts +1 -0
  56. package/dist/types/text/misc.d.ts +15 -1
  57. package/dist/types/text/punycode.d.ts +45 -85
  58. package/docs/CONVERT.md +45 -0
  59. package/docs/DATES.md +78 -0
  60. package/docs/FINANCE.md +59 -0
  61. package/docs/GEO.md +114 -0
  62. package/docs/MATH.md +75 -0
  63. package/package.json +57 -4
  64. package/src/array.js +16 -0
  65. package/src/cache.js +206 -0
  66. package/src/chunk.js +159 -0
  67. package/src/color.js +125 -0
  68. package/src/convert/convert.js +59 -0
  69. package/src/convert/currency.js +74 -0
  70. package/src/convert/index.js +11 -0
  71. package/src/convert/registry.js +213 -0
  72. package/src/dates/civil.js +360 -0
  73. package/src/dates/duration.js +225 -0
  74. package/src/dates/format.js +238 -0
  75. package/src/dates/index.js +30 -0
  76. package/src/dates/rfc3339.js +621 -0
  77. package/src/errors.js +102 -0
  78. package/src/finance/amortization.js +50 -0
  79. package/src/finance/bond.js +100 -0
  80. package/src/finance/cashflow.js +122 -0
  81. package/src/finance/depreciation.js +54 -0
  82. package/src/finance/index.js +27 -0
  83. package/src/finance/indicators.js +207 -0
  84. package/src/finance/interest.js +63 -0
  85. package/src/finance/returns.js +90 -0
  86. package/src/finance/tvm.js +94 -0
  87. package/src/function.js +0 -2
  88. package/src/geo/angle.js +12 -0
  89. package/src/geo/bbox.js +80 -0
  90. package/src/geo/distance.js +155 -0
  91. package/src/geo/geohash.js +224 -0
  92. package/src/geo/geojson.js +361 -0
  93. package/src/geo/index-tree.js +294 -0
  94. package/src/geo/index.js +52 -0
  95. package/src/geo/mercator.js +124 -0
  96. package/src/geo/predicates.js +313 -0
  97. package/src/geo/ring.js +182 -0
  98. package/src/geo/simplify.js +130 -0
  99. package/src/geo/valid.js +142 -0
  100. package/src/geo/wkt.js +262 -0
  101. package/src/math/float64.js +228 -11
  102. package/src/math/format.js +157 -0
  103. package/src/math/index.js +5 -0
  104. package/src/math/mat4.js +131 -0
  105. package/src/math/project.js +49 -0
  106. package/src/math/solve.js +112 -0
  107. package/src/math/word.js +177 -0
  108. package/src/message.js +119 -0
  109. package/src/object.js +329 -0
  110. package/src/scan.js +42 -0
  111. package/src/schema.js +37 -0
  112. package/src/string.js +189 -0
  113. package/src/text/base64.js +15 -52
  114. package/src/text/email.js +0 -5
  115. package/src/text/host.js +515 -140
  116. package/src/text/i18n.js +13 -6
  117. package/src/text/identifiers.js +1 -1
  118. package/src/text/index.js +1 -0
  119. package/src/text/misc.js +81 -5
  120. package/src/text/punycode.js +235 -323
  121. package/src/dates.js +0 -371
package/src/chunk.js ADDED
@@ -0,0 +1,159 @@
1
+ //@ts-check
2
+ /**
3
+ * Cutting text down to size: the four operations every consumer of a
4
+ * too-large value needs, in one place.
5
+ *
6
+ * They live together because they are one decision made four ways —
7
+ * *how much of this do I carry?* `sizeOf` answers what it costs,
8
+ * `excerpt` shows a line of it, `truncate` cuts it at a boundary and
9
+ * says so, and `chunkText` splits it into pieces that can each be
10
+ * carried, addressed and fetched on their own.
11
+ *
12
+ * Two rules hold across all four:
13
+ *
14
+ * - **Characters, not bytes and not tokens.** Tokens are
15
+ * provider-private and bytes depend on an encoding nobody has agreed
16
+ * on at this layer; characters are deterministic, and a budget stated
17
+ * in them holds exactly.
18
+ * - **A cut is always visible.** `excerpt` appends an ellipsis and
19
+ * `truncate` a named marker, so nothing downstream — a reader, a
20
+ * model, a diff — mistakes a cut tail for the end of the content.
21
+ *
22
+ * Nothing here allocates a copy it does not return: `chunkText` slices,
23
+ * and a caller streaming a corpus can write each piece and drop it.
24
+ */
25
+
26
+ /**
27
+ * The size of a value as the characters it will occupy in a request: a
28
+ * string is its own length, anything else is the length of its JSON
29
+ * encoding. That is the one size rule the suite budgets against — an
30
+ * agent's history budget, a slot's metadata, a digest's cap — and it is
31
+ * exact rather than an estimate, which is what makes those budgets hold
32
+ * to the character instead of approximately.
33
+ * @param {any} value
34
+ * @returns {number}
35
+ */
36
+ export function sizeOf(value) {
37
+ return typeof value === 'string' ? value.length : JSON.stringify(value ?? null).length;
38
+ }
39
+
40
+ /**
41
+ * A one-line excerpt: whitespace collapsed to single spaces, trimmed,
42
+ * hard-capped, with an ellipsis when anything was dropped. Total over
43
+ * `null`/`undefined`/non-strings, because it is called on content a
44
+ * provider or a store may have left absent.
45
+ * @param {any} text
46
+ * @param {number} max - characters kept, before the ellipsis
47
+ * @returns {string}
48
+ */
49
+ export function excerpt(text, max) {
50
+ const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
51
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
52
+ }
53
+
54
+ /**
55
+ * A hard cut with a named suffix, preserving newlines — what an
56
+ * oversized result or an over-long generated block is reduced to. The
57
+ * suffix is part of the contract: a cut that looked like an ending would
58
+ * be read as one.
59
+ * @param {string} text
60
+ * @param {number} max
61
+ * @param {string} [marker] - the suffix appended when a cut happened
62
+ * @returns {string}
63
+ */
64
+ export function truncate(text, max, marker = '… [truncated]') {
65
+ return text.length > max ? `${text.slice(0, max)}${marker}` : text;
66
+ }
67
+
68
+ /**
69
+ * How a text is cut into pieces.
70
+ *
71
+ * - `size` — fixed character windows. The only strategy that will split
72
+ * a word, and the only one whose piece count is exactly predictable.
73
+ * - `line` — whole lines, grouped up to `size`. A line longer than
74
+ * `size` becomes its own oversized piece rather than being split:
75
+ * breaking a line is the one thing a line strategy may not do.
76
+ * - `separator` — units delimited by `separator` (paragraphs, records),
77
+ * grouped up to `size`, separators preserved between units in a
78
+ * piece. Same rule for an oversized unit.
79
+ *
80
+ * @typedef {'size' | 'line' | 'separator'} ChunkStrategy
81
+ */
82
+
83
+ /**
84
+ * Split a text into addressable pieces.
85
+ *
86
+ * Deterministic and total: the same text and the same options always
87
+ * produce the same pieces, with the same offsets, which is what lets a
88
+ * caller name a piece by its index and re-derive that name later instead
89
+ * of storing a mapping. Empty text produces no pieces at all — an empty
90
+ * piece is not a thing anyone wants an address for.
91
+ *
92
+ * `overlap` (character strategy only) repeats the tail of each piece at
93
+ * the head of the next, so a match spanning a cut is still found whole
94
+ * in one piece. It costs storage proportional to `overlap / size` and is
95
+ * off by default.
96
+ *
97
+ * @param {string} text
98
+ * @param {{ strategy?: ChunkStrategy, size?: number, overlap?: number,
99
+ * separator?: string }} [options]
100
+ * @returns {Array<{ index: number, start: number, end: number, text: string }>}
101
+ * `start`/`end` are offsets into the original text, so a piece can be
102
+ * located in the source it came from.
103
+ */
104
+ export function chunkText(text, options = {}) {
105
+ const source = String(text ?? '');
106
+ const size = Math.max(1, Math.floor(options.size ?? 4000));
107
+ const strategy = options.strategy ?? 'size';
108
+ if (source.length === 0) return [];
109
+
110
+ if (strategy === 'size') {
111
+ const overlap = Math.max(0, Math.min(Math.floor(options.overlap ?? 0), size - 1));
112
+ /** @type {Array<{ index: number, start: number, end: number, text: string }>} */
113
+ const pieces = [];
114
+ const step = size - overlap;
115
+ for (let start = 0; start < source.length; start += step) {
116
+ const end = Math.min(source.length, start + size);
117
+ pieces.push({ index: pieces.length, start, end, text: source.slice(start, end) });
118
+ if (end === source.length) break;
119
+ }
120
+ return pieces;
121
+ }
122
+
123
+ // line and separator differ only in what a unit is and what joins two
124
+ // of them back together — one grouping loop, two vocabularies
125
+ const separator = strategy === 'line' ? '\n' : (options.separator ?? '\n\n');
126
+ return groupUnits(source, separator, size);
127
+ }
128
+
129
+ /**
130
+ * Group separator-delimited units into pieces of at most `size`
131
+ * characters, never splitting a unit. A unit longer than `size` is its
132
+ * own piece and is reported oversized by its length rather than being
133
+ * cut — the caller asked for units, and a cut unit is not one.
134
+ * @param {string} source
135
+ * @param {string} separator
136
+ * @param {number} size
137
+ */
138
+ function groupUnits(source, separator, size) {
139
+ /** @type {Array<{ index: number, start: number, end: number, text: string }>} */
140
+ const pieces = [];
141
+ const step = separator.length;
142
+ let start = 0;
143
+ let cursor = 0;
144
+ while (cursor < source.length) {
145
+ const found = source.indexOf(separator, cursor);
146
+ const unitEnd = found === -1 ? source.length : found + step;
147
+ // close the current piece when this unit would overflow it, unless
148
+ // the piece is still empty (which is the oversized-unit case)
149
+ if (unitEnd - start > size && cursor > start) {
150
+ pieces.push({ index: pieces.length, start, end: cursor, text: source.slice(start, cursor) });
151
+ start = cursor;
152
+ }
153
+ cursor = unitEnd;
154
+ }
155
+ if (cursor > start) {
156
+ pieces.push({ index: pieces.length, start, end: cursor, text: source.slice(start, cursor) });
157
+ }
158
+ return pieces;
159
+ }
package/src/color.js ADDED
@@ -0,0 +1,125 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Pure color helpers — dependency-free numeric math over color
4
+ * strings, with no view or DOM knowledge. The one home for the suite's
5
+ * color primitives (currently the hex `#rrggbb` interpolation used by the
6
+ * calc surface shader).
7
+ */
8
+
9
+ /**
10
+ * Linear-interpolate two `#rrggbb` hex colors channel-by-channel and
11
+ * return the result as `#rrggbb`. `t` is the blend factor (0 → `a`,
12
+ * 1 → `b`); values outside `[0, 1]` extrapolate.
13
+ *
14
+ * @param {string} a a `#rrggbb` hex color
15
+ * @param {string} b a `#rrggbb` hex color
16
+ * @param {number} t blend factor
17
+ * @returns {string} the interpolated `#rrggbb` color
18
+ */
19
+ export function lerpColor(a, b, t) {
20
+ const pa = parseInt(a.slice(1), 16);
21
+ const pb = parseInt(b.slice(1), 16);
22
+ const ar = (pa >> 16) & 255, ag = (pa >> 8) & 255, ab = pa & 255;
23
+ const br = (pb >> 16) & 255, bg = (pb >> 8) & 255, bb = pb & 255;
24
+ const r = Math.round(ar + (br - ar) * t);
25
+ const g = Math.round(ag + (bg - ag) * t);
26
+ const bl = Math.round(ab + (bb - ab) * t);
27
+ return '#' + ((1 << 24) | (r << 16) | (g << 8) | bl).toString(16).slice(1);
28
+ }
29
+
30
+ /**
31
+ * WCAG relative luminance of a `#rrggbb` hex color: 0 for black, 1 for
32
+ * white. The sRGB channels are linearized (the 2.4-exponent transfer
33
+ * curve with its linear toe) and weighted per ITU-R BT.709. Use it to
34
+ * pick a legible ink over an arbitrary fill (light ink below ~0.4,
35
+ * dark ink above).
36
+ *
37
+ * @param {string} hex a `#rrggbb` hex color
38
+ * @returns {number} relative luminance in [0, 1]
39
+ */
40
+ export function relativeLuminance(hex) {
41
+ const p = parseInt(hex.slice(1), 16);
42
+ const r = channel(((p >> 16) & 255) / 255);
43
+ const g = channel(((p >> 8) & 255) / 255);
44
+ const b = channel((p & 255) / 255);
45
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
46
+ }
47
+
48
+ /**
49
+ * Linearize one sRGB channel value in [0, 1].
50
+ * @param {number} c
51
+ * @returns {number}
52
+ */
53
+ function channel(c) {
54
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
55
+ }
56
+
57
+ /** The designed inks, and the WCAG AA threshold for body text. */
58
+ const INK_DARK = '#1f2020';
59
+ const INK_LIGHT = '#ffffff';
60
+ const AA_CONTRAST = 4.5;
61
+
62
+ /**
63
+ * A legible ink for text set INSIDE a concrete fill: near-black on light
64
+ * fills, white on dark ones.
65
+ *
66
+ * Keyed off the fill's luminance, **not** the theme. That distinction is the
67
+ * whole point: a fill the author named is a constant, so it does not follow
68
+ * light/dark — and if the ink does, a light fill under a dark theme ends up
69
+ * with light text on it and the label disappears.
70
+ *
71
+ * The choice is made by comparing the two candidates' actual WCAG contrast
72
+ * rather than by a luminance threshold. A threshold is subtly wrong in the
73
+ * mid-tones: at 0.4 a fill like `#87b496` took dark ink and landed at 2.33:1,
74
+ * well under AA, because the crossover between the two inks is near 0.179 and
75
+ * not where a round number puts it. Picking the better of two is optimal by
76
+ * construction, so the worst case over the whole RGB cube is ~4.6:1 — which
77
+ * clears AA with nothing to tune.
78
+ * @param {string} fillHex - The `#rgb` or `#rrggbb` fill under the text
79
+ * @returns {string} `#1f2020` or `#ffffff`
80
+ */
81
+ export function inkFor(fillHex) {
82
+ const fill = relativeLuminance(expandHex(fillHex));
83
+ const preferred = contrastOf(fill, relativeLuminance(INK_DARK))
84
+ >= contrastOf(fill, relativeLuminance(INK_LIGHT))
85
+ ? INK_DARK
86
+ : INK_LIGHT;
87
+ if (contrastOf(fill, relativeLuminance(preferred)) >= AA_CONTRAST)
88
+ return preferred;
89
+ // The designed near-black is softer than true black, and that softness
90
+ // costs contrast: against a mid-tone it bottoms out near 4.06:1, just under
91
+ // AA. Falling through to pure black or white for exactly those fills keeps
92
+ // the guarantee without making every other diagram harsher than it needs.
93
+ return fill > 0.1791 ? '#000000' : '#ffffff';
94
+ }
95
+
96
+ /**
97
+ * The WCAG contrast ratio between two relative luminances, from 1 (identical)
98
+ * to 21 (black on white).
99
+ * @param {number} a
100
+ * @param {number} b
101
+ * @returns {number}
102
+ */
103
+ export function contrastOf(a, b) {
104
+ const hi = a > b ? a : b;
105
+ const lo = a > b ? b : a;
106
+ return (hi + 0.05) / (lo + 0.05);
107
+ }
108
+
109
+ /**
110
+ * Whether a CSS color value is a concrete hex this module can reason about.
111
+ * A `var(...)`, a named color or a function is not: it may resolve to
112
+ * anything at paint time, so the caller must fall back to its theme.
113
+ * @param {string} value
114
+ * @returns {boolean}
115
+ */
116
+ export function isHexColor(value) {
117
+ return typeof value === 'string' && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value.trim());
118
+ }
119
+
120
+ /** Expand `#abc` to `#aabbcc`; pass a 6-digit hex through. */
121
+ function expandHex(hex) {
122
+ const h = hex.trim();
123
+ if (h.length !== 4) return h;
124
+ return '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
125
+ }
@@ -0,0 +1,59 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The static conversion engine. Pure, single-pass,
4
+ * no per-call allocation. Rejects cross-dimension conversions with a
5
+ * clear error.
6
+ */
7
+
8
+ import { DIMENSIONS, UNIT_INDEX } from './registry.js';
9
+
10
+ /**
11
+ * Convert `value` from unit `fromId` to unit `toId` (same dimension).
12
+ * Affine through the dimension base: `base = value*factor + offset`,
13
+ * `target = (base - offset')/factor'`.
14
+ *
15
+ * @param {number} value
16
+ * @param {string} fromId
17
+ * @param {string} toId
18
+ * @returns {number}
19
+ */
20
+ export function convert(value, fromId, toId) {
21
+ const from = UNIT_INDEX.get(fromId);
22
+ const to = UNIT_INDEX.get(toId);
23
+ if (from === undefined) throw new RangeError(`convert: unknown unit '${fromId}'`);
24
+ if (to === undefined) throw new RangeError(`convert: unknown unit '${toId}'`);
25
+ if (from.dimension !== to.dimension) {
26
+ throw new RangeError(
27
+ `convert: cannot convert '${fromId}' (${from.dimension}) to '${toId}' (${to.dimension})`);
28
+ }
29
+ const base = value * from.unit.factor + (from.unit.offset ?? 0);
30
+ return (base - (to.unit.offset ?? 0)) / to.unit.factor;
31
+ }
32
+
33
+ /**
34
+ * The unit list for a dimension (in registry order).
35
+ * @param {string} dimension
36
+ * @returns {import('./registry.js').Unit[]}
37
+ */
38
+ export function unitsOf(dimension) {
39
+ const def = DIMENSIONS[dimension];
40
+ if (def === undefined) throw new RangeError(`convert: unknown dimension '${dimension}'`);
41
+ return def.units;
42
+ }
43
+
44
+ /**
45
+ * All dimension names.
46
+ * @returns {string[]}
47
+ */
48
+ export function dimensions() {
49
+ return Object.keys(DIMENSIONS);
50
+ }
51
+
52
+ /**
53
+ * Look up the dimension a unit belongs to (or `null`).
54
+ * @param {string} unitId
55
+ * @returns {string|null}
56
+ */
57
+ export function dimensionOf(unitId) {
58
+ return UNIT_INDEX.get(unitId)?.dimension ?? null;
59
+ }
@@ -0,0 +1,74 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The pure currency-conversion primitive.
4
+ * `convertCurrency` is deterministic and side-effect
5
+ * free: the caller supplies the `{ code: rate }` table; core NEVER
6
+ * fetches it. This lives in core because the dividing line is **purity**,
7
+ * not constant-vs-variable factors — this function is exactly as pure as
8
+ * the fixed-factor path, it just receives its factors as an argument.
9
+ * Fetching the table is the impure half and stays in the app component.
10
+ *
11
+ * Rate convention: `rateTable[code]` is the value of ONE unit of `code`
12
+ * expressed in the table's base currency (so the base currency has rate
13
+ * 1). To convert `value` from → to:
14
+ *
15
+ * result = value * rate[from] / rate[to]
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} RateTable
20
+ * @property {string} [base] the base currency code (informational)
21
+ * @property {Record<string, number>} rates code → value-in-base
22
+ * @property {number|string} [at] timestamp of the table (informational)
23
+ */
24
+
25
+ /**
26
+ * Resolve a rate for `code` from a table that may be either the
27
+ * `{ rates: {...} }` envelope or a bare `{ code: rate }` map.
28
+ * @param {RateTable | Record<string, number>} rateTable
29
+ * @param {string} code
30
+ * @returns {number|undefined}
31
+ */
32
+ function rateFor(rateTable, code) {
33
+ if (rateTable && typeof rateTable === 'object' && 'rates' in rateTable
34
+ && rateTable.rates && typeof rateTable.rates === 'object') {
35
+ return /** @type {any} */ (rateTable).rates[code];
36
+ }
37
+ return /** @type {any} */ (rateTable)?.[code];
38
+ }
39
+
40
+ /**
41
+ * Convert an amount between currencies using a supplied rate table.
42
+ * Throws a `RangeError` when either code is missing from the table (the
43
+ * component decides how to fall back — a static table, a last-good cache).
44
+ *
45
+ * @param {number} value
46
+ * @param {string} from
47
+ * @param {string} to
48
+ * @param {RateTable | Record<string, number>} rateTable
49
+ * @returns {number}
50
+ */
51
+ export function convertCurrency(value, from, to, rateTable) {
52
+ if (from === to) return value;
53
+ const rFrom = rateFor(rateTable, from);
54
+ const rTo = rateFor(rateTable, to);
55
+ if (typeof rFrom !== 'number' || !isFinite(rFrom)) {
56
+ throw new RangeError(`convertCurrency: no rate for '${from}'`);
57
+ }
58
+ if (typeof rTo !== 'number' || !isFinite(rTo) || rTo === 0) {
59
+ throw new RangeError(`convertCurrency: no rate for '${to}'`);
60
+ }
61
+ return (value * rFrom) / rTo;
62
+ }
63
+
64
+ /**
65
+ * The currency codes available in a rate table.
66
+ * @param {RateTable | Record<string, number>} rateTable
67
+ * @returns {string[]}
68
+ */
69
+ export function currenciesOf(rateTable) {
70
+ const rates = (rateTable && typeof rateTable === 'object' && 'rates' in rateTable)
71
+ ? /** @type {any} */ (rateTable).rates
72
+ : rateTable;
73
+ return rates ? Object.keys(rates) : [];
74
+ }
@@ -0,0 +1,11 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `@jarenjs/core/convert` — pure, deterministic quantity
4
+ * conversion. Fixed-factor dimensional conversion (`convert`) plus the
5
+ * pure rate-table primitive `convertCurrency` (the caller supplies the
6
+ * table; core never fetches). Zero runtime dependencies.
7
+ */
8
+
9
+ export { DIMENSIONS, UNIT_INDEX } from './registry.js';
10
+ export { convert, unitsOf, dimensions, dimensionOf } from './convert.js';
11
+ export { convertCurrency, currenciesOf } from './currency.js';
@@ -0,0 +1,213 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Unit registry. Fixed-factor unit definitions per
4
+ * **dimension**. Conversion is affine through the dimension's base unit:
5
+ *
6
+ * base = value * factor + offset
7
+ * target = (base - offset') / factor'
8
+ *
9
+ * Most dimensions are linear (`offset` absent = 0); **temperature** is
10
+ * affine (°C/°F/K/°R). Digital storage ships both binary (KiB/MiB, 1024)
11
+ * and decimal (KB/MB, 1000) prefixes.
12
+ *
13
+ * Number-base conversion (HEX/DEC/OCT/BIN) is intentionally NOT here — it
14
+ * is `@jarenjs/core/math/word.js` (`toBase`/`fromBase`), which the
15
+ * converter delegates to. Fuel-economy (mpg ↔ L/100km) is non-affine and
16
+ * likewise out of this data-driven model.
17
+ */
18
+
19
+ const F = 5 / 9; // °F/°R scale relative to Kelvin
20
+
21
+ /**
22
+ * @typedef {object} Unit
23
+ * @property {string} id unique unit id
24
+ * @property {string} symbol display symbol
25
+ * @property {number} factor multiply to reach the base unit
26
+ * @property {number} [offset] affine offset toward the base unit
27
+ */
28
+
29
+ /** @type {Record<string, { base: string, units: Unit[] }>} */
30
+ export const DIMENSIONS = {
31
+ length: {
32
+ base: 'm',
33
+ units: [
34
+ { id: 'nm', symbol: 'nm', factor: 1e-9 },
35
+ { id: 'um', symbol: 'µm', factor: 1e-6 },
36
+ { id: 'mm', symbol: 'mm', factor: 0.001 },
37
+ { id: 'cm', symbol: 'cm', factor: 0.01 },
38
+ { id: 'm', symbol: 'm', factor: 1 },
39
+ { id: 'km', symbol: 'km', factor: 1000 },
40
+ { id: 'in', symbol: 'in', factor: 0.0254 },
41
+ { id: 'ft', symbol: 'ft', factor: 0.3048 },
42
+ { id: 'yd', symbol: 'yd', factor: 0.9144 },
43
+ { id: 'mi', symbol: 'mi', factor: 1609.344 },
44
+ { id: 'nmi', symbol: 'nmi', factor: 1852 },
45
+ ],
46
+ },
47
+ area: {
48
+ base: 'm2',
49
+ units: [
50
+ { id: 'mm2', symbol: 'mm²', factor: 1e-6 },
51
+ { id: 'cm2', symbol: 'cm²', factor: 1e-4 },
52
+ { id: 'm2', symbol: 'm²', factor: 1 },
53
+ { id: 'ha', symbol: 'ha', factor: 10000 },
54
+ { id: 'km2', symbol: 'km²', factor: 1e6 },
55
+ { id: 'in2', symbol: 'in²', factor: 0.00064516 },
56
+ { id: 'ft2', symbol: 'ft²', factor: 0.09290304 },
57
+ { id: 'acre', symbol: 'acre', factor: 4046.8564224 },
58
+ { id: 'mi2', symbol: 'mi²', factor: 2589988.110336 },
59
+ ],
60
+ },
61
+ volume: {
62
+ base: 'l',
63
+ units: [
64
+ { id: 'ml', symbol: 'mL', factor: 0.001 },
65
+ { id: 'l', symbol: 'L', factor: 1 },
66
+ { id: 'm3', symbol: 'm³', factor: 1000 },
67
+ { id: 'tsp', symbol: 'tsp', factor: 0.00492892159 },
68
+ { id: 'tbsp', symbol: 'tbsp', factor: 0.0147867648 },
69
+ { id: 'floz', symbol: 'fl oz', factor: 0.0295735296 },
70
+ { id: 'cup', symbol: 'cup', factor: 0.2365882365 },
71
+ { id: 'pt', symbol: 'pt', factor: 0.473176473 },
72
+ { id: 'qt', symbol: 'qt', factor: 0.946352946 },
73
+ { id: 'gal', symbol: 'gal', factor: 3.785411784 },
74
+ ],
75
+ },
76
+ mass: {
77
+ base: 'kg',
78
+ units: [
79
+ { id: 'mg', symbol: 'mg', factor: 1e-6 },
80
+ { id: 'g', symbol: 'g', factor: 0.001 },
81
+ { id: 'kg', symbol: 'kg', factor: 1 },
82
+ { id: 't', symbol: 't', factor: 1000 },
83
+ { id: 'oz', symbol: 'oz', factor: 0.028349523125 },
84
+ { id: 'lb', symbol: 'lb', factor: 0.45359237 },
85
+ { id: 'st', symbol: 'st', factor: 6.35029318 },
86
+ ],
87
+ },
88
+ temperature: {
89
+ base: 'K',
90
+ units: [
91
+ { id: 'K', symbol: 'K', factor: 1, offset: 0 },
92
+ { id: 'C', symbol: '°C', factor: 1, offset: 273.15 },
93
+ { id: 'F', symbol: '°F', factor: F, offset: 273.15 - 32 * F },
94
+ { id: 'R', symbol: '°R', factor: F, offset: 0 },
95
+ ],
96
+ },
97
+ time: {
98
+ base: 's',
99
+ units: [
100
+ { id: 'ns', symbol: 'ns', factor: 1e-9 },
101
+ { id: 'us', symbol: 'µs', factor: 1e-6 },
102
+ { id: 'ms', symbol: 'ms', factor: 0.001 },
103
+ { id: 's', symbol: 's', factor: 1 },
104
+ { id: 'min', symbol: 'min', factor: 60 },
105
+ { id: 'h', symbol: 'h', factor: 3600 },
106
+ { id: 'day', symbol: 'day', factor: 86400 },
107
+ { id: 'week', symbol: 'week', factor: 604800 },
108
+ { id: 'year', symbol: 'year', factor: 31557600 },
109
+ ],
110
+ },
111
+ speed: {
112
+ base: 'mps',
113
+ units: [
114
+ { id: 'mps', symbol: 'm/s', factor: 1 },
115
+ { id: 'kmh', symbol: 'km/h', factor: 1 / 3.6 },
116
+ { id: 'mph', symbol: 'mph', factor: 0.44704 },
117
+ { id: 'fps', symbol: 'ft/s', factor: 0.3048 },
118
+ { id: 'knot', symbol: 'kn', factor: 0.514444444 },
119
+ ],
120
+ },
121
+ pressure: {
122
+ base: 'pa',
123
+ units: [
124
+ { id: 'pa', symbol: 'Pa', factor: 1 },
125
+ { id: 'kpa', symbol: 'kPa', factor: 1000 },
126
+ { id: 'bar', symbol: 'bar', factor: 100000 },
127
+ { id: 'atm', symbol: 'atm', factor: 101325 },
128
+ { id: 'psi', symbol: 'psi', factor: 6894.757293168 },
129
+ { id: 'mmhg', symbol: 'mmHg', factor: 133.322387415 },
130
+ { id: 'torr', symbol: 'Torr', factor: 101325 / 760 },
131
+ ],
132
+ },
133
+ energy: {
134
+ base: 'j',
135
+ units: [
136
+ { id: 'j', symbol: 'J', factor: 1 },
137
+ { id: 'kj', symbol: 'kJ', factor: 1000 },
138
+ { id: 'cal', symbol: 'cal', factor: 4.184 },
139
+ { id: 'kcal', symbol: 'kcal', factor: 4184 },
140
+ { id: 'wh', symbol: 'Wh', factor: 3600 },
141
+ { id: 'kwh', symbol: 'kWh', factor: 3600000 },
142
+ { id: 'btu', symbol: 'BTU', factor: 1055.05585262 },
143
+ { id: 'ev', symbol: 'eV', factor: 1.602176634e-19 },
144
+ ],
145
+ },
146
+ power: {
147
+ base: 'w',
148
+ units: [
149
+ { id: 'w', symbol: 'W', factor: 1 },
150
+ { id: 'kw', symbol: 'kW', factor: 1000 },
151
+ { id: 'mw', symbol: 'MW', factor: 1e6 },
152
+ { id: 'hp', symbol: 'hp', factor: 745.699871582 },
153
+ { id: 'ps', symbol: 'PS', factor: 735.49875 },
154
+ ],
155
+ },
156
+ data: {
157
+ base: 'B',
158
+ units: [
159
+ { id: 'bit', symbol: 'bit', factor: 0.125 },
160
+ { id: 'B', symbol: 'B', factor: 1 },
161
+ { id: 'KB', symbol: 'KB', factor: 1e3 },
162
+ { id: 'MB', symbol: 'MB', factor: 1e6 },
163
+ { id: 'GB', symbol: 'GB', factor: 1e9 },
164
+ { id: 'TB', symbol: 'TB', factor: 1e12 },
165
+ { id: 'KiB', symbol: 'KiB', factor: 1024 },
166
+ { id: 'MiB', symbol: 'MiB', factor: 1048576 },
167
+ { id: 'GiB', symbol: 'GiB', factor: 1073741824 },
168
+ { id: 'TiB', symbol: 'TiB', factor: 1099511627776 },
169
+ ],
170
+ },
171
+ datarate: {
172
+ base: 'bps',
173
+ units: [
174
+ { id: 'bps', symbol: 'bit/s', factor: 1 },
175
+ { id: 'kbps', symbol: 'kbit/s', factor: 1e3 },
176
+ { id: 'mbps', symbol: 'Mbit/s', factor: 1e6 },
177
+ { id: 'gbps', symbol: 'Gbit/s', factor: 1e9 },
178
+ { id: 'Bps', symbol: 'B/s', factor: 8 },
179
+ { id: 'KBps', symbol: 'KB/s', factor: 8e3 },
180
+ { id: 'MBps', symbol: 'MB/s', factor: 8e6 },
181
+ ],
182
+ },
183
+ frequency: {
184
+ base: 'hz',
185
+ units: [
186
+ { id: 'hz', symbol: 'Hz', factor: 1 },
187
+ { id: 'khz', symbol: 'kHz', factor: 1e3 },
188
+ { id: 'mhz', symbol: 'MHz', factor: 1e6 },
189
+ { id: 'ghz', symbol: 'GHz', factor: 1e9 },
190
+ ],
191
+ },
192
+ angle: {
193
+ base: 'rad',
194
+ units: [
195
+ { id: 'rad', symbol: 'rad', factor: 1 },
196
+ { id: 'deg', symbol: '°', factor: Math.PI / 180 },
197
+ { id: 'grad', symbol: 'grad', factor: Math.PI / 200 },
198
+ { id: 'turn', symbol: 'turn', factor: 2 * Math.PI },
199
+ ],
200
+ },
201
+ };
202
+
203
+ /**
204
+ * Reverse index: unit id → `{ dimension, unit }`. Built once at module
205
+ * load; ids are unique across dimensions.
206
+ * @type {Map<string, { dimension: string, unit: Unit }>}
207
+ */
208
+ export const UNIT_INDEX = new Map();
209
+ for (const [dimension, def] of Object.entries(DIMENSIONS)) {
210
+ for (const unit of def.units) {
211
+ UNIT_INDEX.set(unit.id, { dimension, unit });
212
+ }
213
+ }