@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.
- package/ARCHITECTURE.md +229 -26
- package/README.md +64 -10
- package/dist/types/array.d.ts +13 -0
- package/dist/types/cache.d.ts +166 -0
- package/dist/types/chunk.d.ts +102 -0
- package/dist/types/color.d.ts +64 -0
- package/dist/types/convert/convert.d.ts +33 -0
- package/dist/types/convert/currency.d.ts +47 -0
- package/dist/types/convert/index.d.ts +9 -0
- package/dist/types/convert/registry.d.ts +55 -0
- package/dist/types/dates/civil.d.ts +146 -0
- package/dist/types/dates/duration.d.ts +83 -0
- package/dist/types/dates/format.d.ts +59 -0
- package/dist/types/dates/index.d.ts +4 -0
- package/dist/types/{dates.d.ts → dates/rfc3339.d.ts} +57 -3
- package/dist/types/errors.d.ts +77 -0
- package/dist/types/finance/amortization.d.ts +51 -0
- package/dist/types/finance/bond.d.ts +45 -0
- package/dist/types/finance/cashflow.d.ts +44 -0
- package/dist/types/finance/depreciation.d.ts +26 -0
- package/dist/types/finance/index.d.ts +16 -0
- package/dist/types/finance/indicators.d.ts +76 -0
- package/dist/types/finance/interest.d.ts +44 -0
- package/dist/types/finance/returns.d.ts +43 -0
- package/dist/types/finance/tvm.d.ts +50 -0
- package/dist/types/geo/angle.d.ts +4 -0
- package/dist/types/geo/bbox.d.ts +32 -0
- package/dist/types/geo/distance.d.ts +67 -0
- package/dist/types/geo/geohash.d.ts +63 -0
- package/dist/types/geo/geojson.d.ts +108 -0
- package/dist/types/geo/index-tree.d.ts +52 -0
- package/dist/types/geo/index.d.ts +11 -0
- package/dist/types/geo/mercator.d.ts +57 -0
- package/dist/types/geo/predicates.d.ts +44 -0
- package/dist/types/geo/ring.d.ts +80 -0
- package/dist/types/geo/simplify.d.ts +31 -0
- package/dist/types/geo/valid.d.ts +24 -0
- package/dist/types/geo/wkt.d.ts +16 -0
- package/dist/types/math/float64.d.ts +126 -3
- package/dist/types/math/format.d.ts +58 -0
- package/dist/types/math/index.d.ts +5 -0
- package/dist/types/math/mat4.d.ts +60 -0
- package/dist/types/math/project.d.ts +54 -0
- package/dist/types/math/solve.d.ts +67 -0
- package/dist/types/math/word.d.ts +82 -0
- package/dist/types/message.d.ts +52 -0
- package/dist/types/object.d.ts +151 -0
- package/dist/types/scan.d.ts +30 -0
- package/dist/types/schema.d.ts +24 -0
- package/dist/types/string.d.ts +96 -0
- package/dist/types/text/base64.d.ts +6 -4
- package/dist/types/text/email.d.ts +0 -1
- package/dist/types/text/host.d.ts +39 -8
- package/dist/types/text/i18n.d.ts +13 -1
- package/dist/types/text/index.d.ts +1 -0
- package/dist/types/text/misc.d.ts +15 -1
- package/dist/types/text/punycode.d.ts +45 -85
- package/docs/CONVERT.md +45 -0
- package/docs/DATES.md +78 -0
- package/docs/FINANCE.md +59 -0
- package/docs/GEO.md +114 -0
- package/docs/MATH.md +75 -0
- package/package.json +57 -4
- package/src/array.js +16 -0
- package/src/cache.js +206 -0
- package/src/chunk.js +159 -0
- package/src/color.js +125 -0
- package/src/convert/convert.js +59 -0
- package/src/convert/currency.js +74 -0
- package/src/convert/index.js +11 -0
- package/src/convert/registry.js +213 -0
- package/src/dates/civil.js +360 -0
- package/src/dates/duration.js +225 -0
- package/src/dates/format.js +238 -0
- package/src/dates/index.js +30 -0
- package/src/dates/rfc3339.js +621 -0
- package/src/errors.js +102 -0
- package/src/finance/amortization.js +50 -0
- package/src/finance/bond.js +100 -0
- package/src/finance/cashflow.js +122 -0
- package/src/finance/depreciation.js +54 -0
- package/src/finance/index.js +27 -0
- package/src/finance/indicators.js +207 -0
- package/src/finance/interest.js +63 -0
- package/src/finance/returns.js +90 -0
- package/src/finance/tvm.js +94 -0
- package/src/function.js +0 -2
- package/src/geo/angle.js +12 -0
- package/src/geo/bbox.js +80 -0
- package/src/geo/distance.js +155 -0
- package/src/geo/geohash.js +224 -0
- package/src/geo/geojson.js +361 -0
- package/src/geo/index-tree.js +294 -0
- package/src/geo/index.js +52 -0
- package/src/geo/mercator.js +124 -0
- package/src/geo/predicates.js +313 -0
- package/src/geo/ring.js +182 -0
- package/src/geo/simplify.js +130 -0
- package/src/geo/valid.js +142 -0
- package/src/geo/wkt.js +262 -0
- package/src/math/float64.js +228 -11
- package/src/math/format.js +157 -0
- package/src/math/index.js +5 -0
- package/src/math/mat4.js +131 -0
- package/src/math/project.js +49 -0
- package/src/math/solve.js +112 -0
- package/src/math/word.js +177 -0
- package/src/message.js +119 -0
- package/src/object.js +329 -0
- package/src/scan.js +42 -0
- package/src/schema.js +37 -0
- package/src/string.js +189 -0
- package/src/text/base64.js +15 -52
- package/src/text/email.js +0 -5
- package/src/text/host.js +515 -140
- package/src/text/i18n.js +13 -6
- package/src/text/identifiers.js +1 -1
- package/src/text/index.js +1 -0
- package/src/text/misc.js +81 -5
- package/src/text/punycode.js +235 -323
- 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
|
+
}
|