dsh-math-calculator 2.1.1 → 2.2.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/CHANGELOG.md +921 -0
- package/README.md +64 -50
- package/dsh.plugin.json +2 -2
- package/lib/__wtest.txt +1 -0
- package/lib/awareness.js +69 -20
- package/lib/format.js +522 -13
- package/lib/formula-library-em.js +428 -0
- package/lib/formulas.js +28 -1
- package/lib/guards.js +653 -0
- package/lib/help.js +509 -0
- package/lib/index.js +1357 -212
- package/lib/ops/shared.js +547 -6
- package/lib/ops/units-op.js +116 -10
- package/lib/ops/workspace-op.js +10 -0
- package/lib/playbook.js +32 -7
- package/lib/types/format.d.ts +31 -1
- package/lib/types/formula-library-em.d.ts +28 -0
- package/lib/types/formulas.d.ts +2 -0
- package/lib/types/guards.d.ts +255 -0
- package/lib/types/help.d.ts +70 -0
- package/lib/types/index.d.ts +34 -1
- package/lib/types/ops/shared.d.ts +87 -3
- package/lib/types/playbook.d.ts +28 -0
- package/lib/types/units-data.d.ts +253 -0
- package/lib/types/units.d.ts +54 -4
- package/lib/types/workspace.d.ts +50 -0
- package/lib/units-data.js +1037 -0
- package/lib/units.js +82 -117
- package/lib/workspace.js +54 -0
- package/package.json +1 -1
- package/src/awareness.ts +69 -20
- package/src/format.ts +520 -12
- package/src/formula-library-em.ts +435 -0
- package/src/formulas.ts +35 -4
- package/src/guards.ts +673 -0
- package/src/help.ts +543 -0
- package/src/index.ts +1492 -213
- package/src/ops/shared.ts +589 -9
- package/src/ops/units-op.ts +125 -9
- package/src/ops/workspace-op.ts +10 -0
- package/src/playbook.ts +32 -7
- package/src/units-data.ts +1128 -0
- package/src/units.ts +149 -130
- package/src/workspace.ts +65 -0
package/lib/ops/shared.js
CHANGED
|
@@ -12,7 +12,16 @@
|
|
|
12
12
|
* @module dsh-math-calculator/ops/shared
|
|
13
13
|
*/
|
|
14
14
|
import { create, all } from 'mathjs';
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* 单位数据表:中文单位归一化要用它派生出展开表。
|
|
17
|
+
*
|
|
18
|
+
* `units-data.ts` 是**纯数据模块**(不 import 任何本项目模块),
|
|
19
|
+
* 所以这里引用它不会造成循环依赖 —— 这一点很关键,
|
|
20
|
+
* 因为 `units.ts` 的逻辑层会 import 本模块。
|
|
21
|
+
*/
|
|
22
|
+
import { ALL_UNITS } from '../units-data.js';
|
|
23
|
+
import { BASE_UNIT } from '../units.js';
|
|
24
|
+
import { PRECISION_LOSS_THRESHOLD, isDisplayPrecisionExplicit, reportPrecisionLoss, } from '../precision-report.js';
|
|
16
25
|
// ────────────────────────────────────────────────────────────────────────────
|
|
17
26
|
// 错误
|
|
18
27
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -47,6 +56,141 @@ export class CalcError extends Error {
|
|
|
47
56
|
* **不能**删 `typed` / `parse` / `evaluate` —— derivative、simplify 依赖它们。
|
|
48
57
|
*/
|
|
49
58
|
import { factorizeInt, makeNsolve, nullspaceOf, rankOf, singularValuesOf } from '../numeric.js';
|
|
59
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
// 把 units 的单位表注入 evaluate 引擎
|
|
61
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
/**
|
|
63
|
+
* 两套单位表长期没打通:`operation:"units"` 认 462 条,`evaluate` 只认 mathjs 自带的那批。
|
|
64
|
+
*
|
|
65
|
+
* ## 实测的落差(1107 报告问题 7)
|
|
66
|
+
*
|
|
67
|
+
*```
|
|
68
|
+
* 1 mph to m/s units=OK evaluate=Undefined symbol mph
|
|
69
|
+
* 1 kn to m/s units=OK evaluate=Undefined symbol kn
|
|
70
|
+
* 1 rpm to Hz units=OK evaluate=Undefined symbol rpm
|
|
71
|
+
* 1 inHg to Pa units=OK evaluate=Undefined symbol inHg
|
|
72
|
+
* 1 cal to J units=OK evaluate=Undefined symbol cal
|
|
73
|
+
*```
|
|
74
|
+
*
|
|
75
|
+
* 报告作者为此手填系数(`10*745.7` 而不是 `10 hp`),而**手填系数正是他那
|
|
76
|
+
* 四起千倍级事故的共同源头**。换句话说:这张表不通,就是在逼人回到出错的老路。
|
|
77
|
+
*
|
|
78
|
+
* ## 为什么现在是安全的
|
|
79
|
+
*
|
|
80
|
+
* 早先刻意保留了 `createUnit` 的缺口,理由是「表达式求值不许往引擎注入定义」。
|
|
81
|
+
* 那个顾虑仍然成立,做法是**在构建期注入、注入完再删掉方法**:
|
|
82
|
+
*
|
|
83
|
+
* · 注入发生在 `buildMath` 里,由我们的代码调用,不经过用户表达式
|
|
84
|
+
* · 注入完成后照旧 `delete instance.createUnit`,用户表达式里仍然拿不到它
|
|
85
|
+
*
|
|
86
|
+
* 所以安全边界**一点没动**,只是把「我们自己能定义单位」这件事用上了。
|
|
87
|
+
*
|
|
88
|
+
* ## 三条必须遵守的取舍
|
|
89
|
+
*
|
|
90
|
+
* 1. **不覆盖既有单位**。已认识的(`m`/`kg`/`hp`/`psi`…)一律跳过 ——
|
|
91
|
+
* mathjs 自己的定义比我们的 `factor` 更精确,改它只会制造回归。
|
|
92
|
+
* 2. **不收单字母单位**。`c`(光速)与 `G`(万有引力常数)在 units-data 里是常数,
|
|
93
|
+
* 进了单位表就会把变量 `c`/`G` 吃掉 —— 正是 `guardBareUnitSymbols` 一直防的事。
|
|
94
|
+
* 3. **不覆盖函数名**。`min`/`max`/`sum` 这类若被注册成单位,
|
|
95
|
+
* `1 s to min` 会从「可诊断的报错」变成「静默取错单位」。
|
|
96
|
+
*
|
|
97
|
+
* 成本实测:构建一次 40ms(另有 plain 引擎 1ms)。引擎按精度缓存,
|
|
98
|
+
* 单次调用只付一次,换来 174 个单位可直接写进表达式。
|
|
99
|
+
*
|
|
100
|
+
* @param instance - 刚创建好、尚未删除 createUnit 的 mathjs 实例
|
|
101
|
+
* @returns 实际注入的单位名数量(供测试断言用)
|
|
102
|
+
*/
|
|
103
|
+
function injectUnitTable(instance) {
|
|
104
|
+
if (!instance || typeof instance.createUnit !== 'function')
|
|
105
|
+
return 0;
|
|
106
|
+
/**
|
|
107
|
+
* units-data 的基准单位写法与 mathjs 略有出入:`m²`/`m³` 上标、`Ω` 希腊字母。
|
|
108
|
+
* 直接拿去做 definition 会被解析器拒(`Unexpected "²"`),所以映射一次。
|
|
109
|
+
*/
|
|
110
|
+
const baseOf = (base) => {
|
|
111
|
+
if (base === 'm²')
|
|
112
|
+
return 'm^2';
|
|
113
|
+
if (base === 'm³')
|
|
114
|
+
return 'm^3';
|
|
115
|
+
if (base === 'Ω')
|
|
116
|
+
return 'ohm';
|
|
117
|
+
return base;
|
|
118
|
+
};
|
|
119
|
+
/** mathjs 是否已经认识这个名字(单位、常量或函数都算)。 */
|
|
120
|
+
const known = (name) => {
|
|
121
|
+
try {
|
|
122
|
+
const value = instance.evaluate(name);
|
|
123
|
+
return Boolean(value && (value.isUnit || typeof value === 'function' || value === true));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
let added = 0;
|
|
130
|
+
for (const unit of ALL_UNITS) {
|
|
131
|
+
const base = baseOf(BASE_UNIT[unit.dimension] ?? '');
|
|
132
|
+
if (base === '')
|
|
133
|
+
continue;
|
|
134
|
+
for (const rawName of [unit.name, ...(unit.aliases ?? [])]) {
|
|
135
|
+
const name = String(rawName ?? '');
|
|
136
|
+
/** 只收纯字母:上标、希腊字母、中文都进不了 mathjs 的单位命名空间。 */
|
|
137
|
+
if (name === '' || /[^A-Za-z]/.test(name))
|
|
138
|
+
continue;
|
|
139
|
+
/** 取舍 2:单字母一律不收(会在变量名域里引起冲突)。 */
|
|
140
|
+
if (name.length === 1)
|
|
141
|
+
continue;
|
|
142
|
+
/** 取舍 1:已认识的一律不动。 */
|
|
143
|
+
if (known(name))
|
|
144
|
+
continue;
|
|
145
|
+
/** 别把基准单位名定义成它自己。 */
|
|
146
|
+
if (name === base)
|
|
147
|
+
continue;
|
|
148
|
+
/** 取舍 3:函数名不能被单位覆盖。 */
|
|
149
|
+
try {
|
|
150
|
+
const asFunction = instance.evaluate(name + '(1)');
|
|
151
|
+
if (typeof asFunction !== 'undefined')
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* 不是函数才往下走 */
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* ## 注入会顺带改写 mathjs 的「显示基准」,这里必须重置回来
|
|
159
|
+
*
|
|
160
|
+
* 这条是踩出来的,代价很具体:注入 `katha`(定义 `66.89 m^2`)之后,
|
|
161
|
+
*
|
|
162
|
+
* ```
|
|
163
|
+
* 1 m^2 → 0.0149499177754522 katha ← 面积被改写成了卡塔
|
|
164
|
+
* 1 m^3 → 1086.95 gantang ← 体积被改写成了甘唐
|
|
165
|
+
* ```
|
|
166
|
+
*
|
|
167
|
+
* 根因在 mathjs 的显示层:`Unit.toString()` 会调 `simplify()`,
|
|
168
|
+
* 而 `simplify()` 从 `currentUnitSystem` 里挑**第一个**同维度的单位。
|
|
169
|
+
* `createUnit` 会把新单位登记进 `currentUnitSystem`,于是刚注册的
|
|
170
|
+
* 冷门单位(卡塔、甘唐)就顶掉了 `m^2`/`m^3`。
|
|
171
|
+
*
|
|
172
|
+
* 这个坑最阴的地方:注入的用例全绿(`1 katha to m^2` 算得对),
|
|
173
|
+
* 坏的是**别的**老用例(`5 亩 + 100 m²`、`1 m^2`)。单测新增断言看不见,
|
|
174
|
+
* 只有跑全量回归才会暴露。
|
|
175
|
+
*
|
|
176
|
+
* ## 为什么重置成 `si` 而不是 `auto`
|
|
177
|
+
*
|
|
178
|
+
* 实测两者的输出**原本完全一致**(`1 m^2` 都是 `1 m^2`),
|
|
179
|
+
* 而 `si` 是只读的固定表,注入改不动它;`auto` 是可写的那个。
|
|
180
|
+
* 重置成 `si` 之后,注入的单位照样能用(`1 mph to m/s` 仍是 0.44704),
|
|
181
|
+
* 只是显示基准不再被它们干扰。
|
|
182
|
+
*/
|
|
183
|
+
try {
|
|
184
|
+
instance.createUnit(name, { definition: String(unit.factor) + ' ' + base });
|
|
185
|
+
added += 1;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* 个别名字 mathjs 拒收(如已有同义前缀),跳过即可 */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return added;
|
|
193
|
+
}
|
|
50
194
|
/**
|
|
51
195
|
* 创建一个 mathjs 实例。
|
|
52
196
|
*
|
|
@@ -58,6 +202,9 @@ import { factorizeInt, makeNsolve, nullspaceOf, rankOf, singularValuesOf } from
|
|
|
58
202
|
* 安全:删除 import / createUnit,防止表达式注入任意 JS 或改写单位表。
|
|
59
203
|
* **不能**删 typed/parse/evaluate —— derivative/simplify 依赖它们。
|
|
60
204
|
* 自定义函数(rank/nsolve…)在删除 import **之前**注册,顺序不能反。
|
|
205
|
+
*
|
|
206
|
+
* 单位注入(`injectUnitTable`)同理:也在删除 createUnit **之前**做,
|
|
207
|
+
* 注入完方法即被删除,所以表达式求值仍然拿不到它。
|
|
61
208
|
*/
|
|
62
209
|
export function buildMath(numberMode, precision) {
|
|
63
210
|
const instance = create(all, {
|
|
@@ -84,6 +231,43 @@ export function buildMath(numberMode, precision) {
|
|
|
84
231
|
catch {
|
|
85
232
|
/* 注册失败不影响核心运算 */
|
|
86
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* 把 units 的单位表注入进来 —— **必须在删除 createUnit 之前**。
|
|
236
|
+
*
|
|
237
|
+
* 注入完照旧删掉方法,所以表达式求值仍然无法注入定义。
|
|
238
|
+
* 三条取舍(不覆盖既有、不收单字母、不覆盖函数名)见 `injectUnitTable`。
|
|
239
|
+
*/
|
|
240
|
+
try {
|
|
241
|
+
injectUnitTable(instance);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* 注入失败退化为 mathjs 自带单位,不影响核心运算 */
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* ## 重置显示基准(必须紧跟在注入之后)
|
|
248
|
+
*
|
|
249
|
+
* `createUnit` 会把新单位登记进全局的 `currentUnitSystem`,而
|
|
250
|
+
* `Unit.toString()` 的 `simplify()` 从那里挑**第一个**同维度单位 ——
|
|
251
|
+
* 于是刚注册的冷门单位(卡塔 66.89 m²、甘唐)顶掉了 `m^2`/`m^3`:
|
|
252
|
+
*
|
|
253
|
+
* ```
|
|
254
|
+
* 1 m^2 → 0.0149499177754522 katha
|
|
255
|
+
* 1 m^3 → 1086.95 gantang
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* 重置成 `si` 而不是 `auto`:两者输出**原本完全一致**,
|
|
259
|
+
* 而 `si` 是只读固定表、注入改不动它。注入的单位照样能用。
|
|
260
|
+
*
|
|
261
|
+
* 注意 `currentUnitSystem` 在 mathjs 里是**模块级全局**,
|
|
262
|
+
* 所以每个实例都必须重置一次;否则先构建的实例会污染后构建的。
|
|
263
|
+
*/
|
|
264
|
+
try {
|
|
265
|
+
if (typeof instance.Unit?.setUnitSystem === 'function')
|
|
266
|
+
instance.Unit.setUnitSystem('si');
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
/* 重置失败就退回 auto —— 显示可能被新单位干扰,但计算不受影响 */
|
|
270
|
+
}
|
|
87
271
|
for (const key of ['import', 'createUnit']) {
|
|
88
272
|
try {
|
|
89
273
|
delete instance[key];
|
|
@@ -121,6 +305,32 @@ export function fast() {
|
|
|
121
305
|
fastCache = buildMath('number', 15);
|
|
122
306
|
return fastCache;
|
|
123
307
|
}
|
|
308
|
+
/** 有理数引擎缓存。 */
|
|
309
|
+
let rationalCache;
|
|
310
|
+
/**
|
|
311
|
+
* 取**有理数**引擎(Fraction 模式)。
|
|
312
|
+
*
|
|
313
|
+
* ## 为什么需要它
|
|
314
|
+
*
|
|
315
|
+
* 定积分要按 Newton-Leibniz 代入上下限。用 BigNumber 引擎会出这种事:
|
|
316
|
+
*
|
|
317
|
+
* 原函数 1/3·x³,在 x=3 处
|
|
318
|
+
* BigNumber → 8.99999999999999999999999999999999999999…
|
|
319
|
+
* Fraction → 9
|
|
320
|
+
*
|
|
321
|
+
* 因为 BigNumber 的 `1/3` 是**截断的小数**,乘 27 后回不到整数。
|
|
322
|
+
* 而 Fraction 保留精确分数,代入后能约分成整数 —— 结果 9 就是 9。
|
|
323
|
+
*
|
|
324
|
+
* 三角函数等无理性结果 Fraction 会退化成浮点,这是可接受的:
|
|
325
|
+
* 那些结果本来就不是有理数。
|
|
326
|
+
*
|
|
327
|
+
* @returns mathjs 实例(Fraction 模式)
|
|
328
|
+
*/
|
|
329
|
+
export function rational() {
|
|
330
|
+
if (!rationalCache)
|
|
331
|
+
rationalCache = buildMath('Fraction', 64);
|
|
332
|
+
return rationalCache;
|
|
333
|
+
}
|
|
124
334
|
// ────────────────────────────────────────────────────────────────────────────
|
|
125
335
|
// 输入处理 / 数字格式化
|
|
126
336
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -150,9 +360,329 @@ export function normalizeSource(input) {
|
|
|
150
360
|
output += ';';
|
|
151
361
|
continue;
|
|
152
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* 数学记号归一化(**只在引号外做**)。
|
|
365
|
+
*
|
|
366
|
+
* 实测反馈:理工科用户的直觉写法直接报错,且报错类型还不一致:
|
|
367
|
+
* `2 × 3` → unknown symbol ×
|
|
368
|
+
* `6 ÷ 2` → unknown symbol ÷
|
|
369
|
+
* `π` → unknown symbol π
|
|
370
|
+
* `ln(10)` → **internal**(Undefined function ln)
|
|
371
|
+
*
|
|
372
|
+
* 这些替换没有语义风险(× ÷ 在数学里就是乘除,π 就是圆周率),
|
|
373
|
+
* 但必须跳过引号内 —— `5 cm to "inch"` 的单位名不能被改。
|
|
374
|
+
*/
|
|
375
|
+
if (char === '×' || char === '·') {
|
|
376
|
+
output += '*';
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (char === '÷') {
|
|
380
|
+
output += '/';
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
// U+2212 减号,字形与 ASCII '-' 不同,肉眼难辨
|
|
384
|
+
if (char === '\u2212') {
|
|
385
|
+
output += '-';
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (char === 'π') {
|
|
389
|
+
output += 'pi';
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
153
392
|
output += char;
|
|
154
393
|
}
|
|
155
|
-
return output
|
|
394
|
+
return output
|
|
395
|
+
.replace(/;{2,}/g, ';')
|
|
396
|
+
.replace(/^;+|;+$/g, '')
|
|
397
|
+
.trim()
|
|
398
|
+
// 幂运算:Python / JS 用户习惯写 `**`;mathjs 只认 `^`,而两者都是右结合,无歧义
|
|
399
|
+
.replace(/\*\*/g, '^')
|
|
400
|
+
// 自然对数别名:mathjs 的 `log` 本就是自然对数,但用户直觉会写 `ln`
|
|
401
|
+
.replace(/\bln\s*\(/g, 'log(')
|
|
402
|
+
.replace(/\bLN\s*\(/g, 'log(')
|
|
403
|
+
// 面积别名:`km^2` / `km2` 在表达式里统一成 mathjs 认得的 `km^2`
|
|
404
|
+
// (mathjs 的单位系统能解析 `km^2`,但不能解析裸的 `km2`)
|
|
405
|
+
.replace(/(\d)\s*(m|cm|mm|km|dm|um)2(?![0-9a-zA-Z])/g, '$1 $2^2')
|
|
406
|
+
.replace(/(\d)\s*(m|cm|mm|km|dm|um)\^2(?![0-9a-zA-Z])/g, '$1 $2^2')
|
|
407
|
+
/**
|
|
408
|
+
* 上标字符 → 幂运算。
|
|
409
|
+
*
|
|
410
|
+
* mathjs 的解析器**不认上标**:`5 m²` 报 `Syntax error in part "²"`。
|
|
411
|
+
* 而 `m²` 恰恰是面积单位最自然的写法(`units` 模式里也用它当主名)。
|
|
412
|
+
* 所以表达式里出现 ²/³ 时统一换成 `^2`/`^3`。
|
|
413
|
+
*/
|
|
414
|
+
.replace(/²/g, '^2')
|
|
415
|
+
.replace(/³/g, '^3');
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* 正则元字符转义。
|
|
419
|
+
*
|
|
420
|
+
* @param text - 待转义的字符串
|
|
421
|
+
* @returns 可安全嵌入正则的字符串
|
|
422
|
+
*/
|
|
423
|
+
function escapeRegExp(text) {
|
|
424
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* 中文单位展开表(由 units 数据表派生,避免两处维护)。
|
|
428
|
+
*
|
|
429
|
+
* ## 为什么必须定义在 `normalizeChineseUnits` 之前
|
|
430
|
+
*
|
|
431
|
+
* 踩过:第一版把它写在文件末尾,而函数在文件中间。
|
|
432
|
+
* `const` 有暂时性死区(TDZ)—— 函数**首次被调用时**常量还没初始化,
|
|
433
|
+
* 抛 ReferenceError,被外层的 try/catch 吞掉,表现为「函数静默返回原文」。
|
|
434
|
+
* 这个 bug 的隐蔽处在于:函数看着完全正确,测试却全不生效。
|
|
435
|
+
*/
|
|
436
|
+
const CHINESE_UNIT_EXPANSIONS =
|
|
437
|
+
/**
|
|
438
|
+
* ## 逐个别名判断,不按整个单位过滤
|
|
439
|
+
*
|
|
440
|
+
* 踩过两次,都是同一种错:
|
|
441
|
+
*
|
|
442
|
+
* ① 按 `unit.name` 是否含中文来过滤 → `m` 的单位被整个排除,
|
|
443
|
+
* 于是「米」「克」这些**中文别名**也用不上了(`2 斤 + 300 克` 报错)。
|
|
444
|
+
* ② 反过来收全部单位 → `m²` 被收进来,展开时把表达式里的 `m²`
|
|
445
|
+
* 也替换掉,变成 `... * 1 * 1 m²` 这种嵌套垃圾,把式子弄坏。
|
|
446
|
+
*
|
|
447
|
+
* 正确做法:**只挑出名字本身是纯中文的那些**,逐个判断,与单位主名无关。
|
|
448
|
+
*
|
|
449
|
+
* ```
|
|
450
|
+
* m → aliases 里的「米」 ✓ 收(纯中文)
|
|
451
|
+
* m² → name `m²` 含 m ✗ 不收(英文/符号 mathjs 自己认得)
|
|
452
|
+
* 里 → name「里」 ✓ 收
|
|
453
|
+
* ```
|
|
454
|
+
*/
|
|
455
|
+
ALL_UNITS.flatMap(function (unit) {
|
|
456
|
+
const names = [unit.name, ...(unit.aliases ?? [])];
|
|
457
|
+
return names
|
|
458
|
+
.filter(function (n) {
|
|
459
|
+
/**
|
|
460
|
+
* ⚠️ 方括号不能省:`/\u4e00-\u9fff/`(无方括号)匹配的是
|
|
461
|
+
* **字面字符串「一-鿿」**,不是字符范围 —— 它对任何单个汉字都返回 false。
|
|
462
|
+
*
|
|
463
|
+
* 这个坑很隐蔽:正则语法合法、编译通过、运行不报错,只是永远匹配不到,
|
|
464
|
+
* 表现为「派生表为空、函数静默不生效」。排查时我一度怀疑循环依赖或 TDZ,
|
|
465
|
+
* 实际只是一对缺失的方括号。
|
|
466
|
+
*/
|
|
467
|
+
if (!/[\u4e00-\u9fff]/.test(n))
|
|
468
|
+
return false;
|
|
469
|
+
/** 纯中文:不能含 ASCII 字母数字(否则是 `m²` 这类符号名)。 */
|
|
470
|
+
return !/[A-Za-z0-9]/.test(n);
|
|
471
|
+
})
|
|
472
|
+
.map(function (n) {
|
|
473
|
+
/**
|
|
474
|
+
* 基准单位要转成 mathjs 认得的写法。
|
|
475
|
+
*
|
|
476
|
+
* `BASE_UNIT` 用人类可读的 `m²` / `m³`,但 mathjs 解析器**不认上标字符**
|
|
477
|
+
* (它会报 `Syntax error in part "²"`)。所以改成 `m^2` / `m^3`。
|
|
478
|
+
*/
|
|
479
|
+
const base = BASE_UNIT[unit.dimension].replace(/²/g, '^2').replace(/³/g, '^3');
|
|
480
|
+
return { name: n, dimension: unit.dimension, factor: unit.factor, base };
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
/**
|
|
484
|
+
* 中文单位 → mathjs 单位表达式的归一化。
|
|
485
|
+
*
|
|
486
|
+
* ## 为什么要这一步
|
|
487
|
+
*
|
|
488
|
+
* 实测反馈(2026-11-06):
|
|
489
|
+
*
|
|
490
|
+
* ```
|
|
491
|
+
* 3 km + 500 m → 3.5 km ✓
|
|
492
|
+
* 1 里 + 3 米 → Syntax error ✗
|
|
493
|
+
* 5 亩 + 100 m² → Syntax error ✗
|
|
494
|
+
* ```
|
|
495
|
+
*
|
|
496
|
+
* **英文/符号单位可在表达式内混算,中文单位不行** —— 而 `units` 模式里
|
|
497
|
+
* 「里」「亩」都是支持的。两条路径的单位表没打通。
|
|
498
|
+
*
|
|
499
|
+
* ## 为什么不用 mathjs 的 createUnit 注册中文单位
|
|
500
|
+
*
|
|
501
|
+
* 因为 `buildMath` 里**刻意删掉了 createUnit**:那是安全边界的一部分
|
|
502
|
+
* (表达式求值时不允许往引擎里注入任意定义)。为了中文单位把这个边界放开,
|
|
503
|
+
* 代价远大于收益。
|
|
504
|
+
*
|
|
505
|
+
* ## 所以走文本替换:把「数值 + 中文单位」换成「数值 * 系数 基准单位」
|
|
506
|
+
*
|
|
507
|
+
* 这是纯文本变换,不碰引擎能力边界。规则:
|
|
508
|
+
* · 只替换 `数字 + 可选空格 + 中文单位` 这种**明确带数值**的形态
|
|
509
|
+
* · 替换后乘上该单位的 factor(数值直接内联,避免再算一次)
|
|
510
|
+
* · 单位后必须不是中文/字母(否则是更长词的一部分,如「公里」不是「里」)
|
|
511
|
+
*
|
|
512
|
+
* ```
|
|
513
|
+
* 1 里 + 3 米 → 1 * 500 m + 3 * 1 m → mathjs 得 503 m
|
|
514
|
+
* 5 亩 + 100 m² → 5 * 666.6666666666666 m^2 + 100 m^2 → 3433.33 m²
|
|
515
|
+
* ```
|
|
516
|
+
*
|
|
517
|
+
* 例外:温度单位是**仿射**的(不能乘),所以不在此处替换 ——
|
|
518
|
+
* 带温度的表达式请走 `operation:"units"`。
|
|
519
|
+
*
|
|
520
|
+
* @param source - 已做过基础归一化的表达式
|
|
521
|
+
* @returns 中文单位已展开的表达式
|
|
522
|
+
*/
|
|
523
|
+
/**
|
|
524
|
+
* 中文量级词 → 10 的幂(纯乘数展开)。
|
|
525
|
+
*
|
|
526
|
+
* ## 为什么量级词不能当「单位」注册
|
|
527
|
+
*
|
|
528
|
+
* 单位表的机制是「factor × 基准单位」,要有量纲。而「万」「亿」是**无量纲乘数** ——
|
|
529
|
+
* `2.66亿` 就是 `2.66 × 10⁸`,它本身不是一个可换算的量。
|
|
530
|
+
* 硬塞进单位表会把「亿米」和「亿吨」搞成同一种东西,那比不支持更糟。
|
|
531
|
+
*
|
|
532
|
+
* 所以在这里做**文本乘数展开**:把 `2.66亿` 改写成 `(2.66 * 1e8)`。
|
|
533
|
+
*
|
|
534
|
+
* ## 长词优先 + 边界检查
|
|
535
|
+
*
|
|
536
|
+
* · `万亿`(10¹²)必须排在 `万`(10⁴)与 `亿`(10⁸)之前,否则 `3万亿` 会被拆成 `3万`+`亿`
|
|
537
|
+
* · 量级词后面不能紧跟中文(`亿人` 里「人」是数量名词,可以;但 `亿万` 不行)——
|
|
538
|
+
* 用「后面是中文时继续吃进数量名词」的方式放宽,见实现
|
|
539
|
+
* · 前面必须有数字(`亿` 单独出现不展开,那是语法错,交给引擎报)
|
|
540
|
+
*
|
|
541
|
+
* @param source - 表达式原文
|
|
542
|
+
* @returns 量级词已展开的表达式
|
|
543
|
+
*/
|
|
544
|
+
function normalizeMagnitudeWords(source) {
|
|
545
|
+
if (typeof source !== 'string' || source === '')
|
|
546
|
+
return source;
|
|
547
|
+
let output = source;
|
|
548
|
+
/**
|
|
549
|
+
* 长词在前:`万亿` > `亿` > `万`。
|
|
550
|
+
* 每条是 [词, 10 的幂]。
|
|
551
|
+
*/
|
|
552
|
+
const words = [
|
|
553
|
+
['万亿', 12],
|
|
554
|
+
['亿', 8],
|
|
555
|
+
['万', 4],
|
|
556
|
+
];
|
|
557
|
+
for (const [word, power] of words) {
|
|
558
|
+
/**
|
|
559
|
+
* 匹配「数字 + 空格? + 量级词」。
|
|
560
|
+
*
|
|
561
|
+
* 后面允许:空格、运算符、括号、结尾,或**一个数量名词**(人/吨/米/件…)。
|
|
562
|
+
* 数量名词的处理方式:把它留在原处 —— 展开只针对数字,
|
|
563
|
+
* `2.66亿人` 会变成 `(2.66 * 1e8)人`,再由单位展开或引擎处理「人」。
|
|
564
|
+
*
|
|
565
|
+
* 用负向断言保证「万亿」不会被「万」抢先拆:由于长词先处理,
|
|
566
|
+
* 处理完 `万亿` 之后 `万` 就找不到它了。
|
|
567
|
+
*/
|
|
568
|
+
const pattern = new RegExp('([0-9][0-9,.]*(?:e[+-]?[0-9]+)?)\\s*' + escapeRegExp(word) + '(?![万千百十])', 'g');
|
|
569
|
+
output = output.replace(pattern, function (_match, numeric) {
|
|
570
|
+
return '(' + numeric.replace(/,/g, '') + ' * 1e' + power + ')';
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return output;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* 把中文单位与中文量级词展开成 mathjs 能解析的形态。
|
|
577
|
+
*
|
|
578
|
+
* ## 两次展开,顺序有讲究
|
|
579
|
+
*
|
|
580
|
+
* 1. **量级词**(万/亿/万亿)→ 纯乘数括号,如 `(2.66 * 1e8)`
|
|
581
|
+
* 2. **中文单位**(里/丈/亩/斤/吨)→ `(450 * 1e8) * 1000 kg`
|
|
582
|
+
*
|
|
583
|
+
* 顺序反了会漏:`450 亿吨` 先走单位展开时,「亿吨」不在单位表里,
|
|
584
|
+
* 原样留下;先展开量级词得到 `(450 * 1e8)吨`,再让单位展开处理「吨」才对。
|
|
585
|
+
*
|
|
586
|
+
* ## 为什么走文本变换而不是注册进 mathjs
|
|
587
|
+
*
|
|
588
|
+
* `buildMath` 里刻意删掉了 `createUnit`(表达式求值不允许往引擎注入任意定义)。
|
|
589
|
+
* 放开这个边界来支持中文单位,代价远大于收益。
|
|
590
|
+
*
|
|
591
|
+
* @param source - 已做过基础归一化的表达式
|
|
592
|
+
* @returns 中文单位与量级词已展开的表达式
|
|
593
|
+
*/
|
|
594
|
+
export function normalizeChineseUnits(source) {
|
|
595
|
+
if (typeof source !== 'string' || source === '')
|
|
596
|
+
return source;
|
|
597
|
+
/**
|
|
598
|
+
* ## 先展开中文量级词,再展开中文单位
|
|
599
|
+
*
|
|
600
|
+
* 顺序不能反。`450 亿吨` 若先走单位展开,会因为「亿吨」不在单位表里而原样留下;
|
|
601
|
+
* 而先展开量级词得到 `450 * 1e8 吨`,再由单位展开处理「吨」→ 才对。
|
|
602
|
+
*
|
|
603
|
+
* 这也是实测反馈(2026-11-06 第二份)里 P0 那条:
|
|
604
|
+
* 「`万/亿/万亿` 是这类语料的默认数量级词,几乎每个数都带,
|
|
605
|
+
* 而工具内置 462 条单位里一个中文量级词都没有。」
|
|
606
|
+
*
|
|
607
|
+
* 后果不只是不方便 —— 它让使用者必须手写 `/1e8` 这类除法,
|
|
608
|
+
* 而「两步除法」正是报告里两次把「吨」当成「亿吨」的直接成因。
|
|
609
|
+
*/
|
|
610
|
+
let output = normalizeMagnitudeWords(source);
|
|
611
|
+
/**
|
|
612
|
+
* 长单位名优先:否则「公里」会先匹配成「里」,
|
|
613
|
+
* 把 `3 公里` 换成 `3 * 500 m`(错了 2 倍)。
|
|
614
|
+
*/
|
|
615
|
+
const ordered = CHINESE_UNIT_EXPANSIONS.slice().sort(function (a, b) {
|
|
616
|
+
return b.name.length - a.name.length;
|
|
617
|
+
});
|
|
618
|
+
for (const entry of ordered) {
|
|
619
|
+
if (entry.dimension === 'temperature')
|
|
620
|
+
continue;
|
|
621
|
+
/**
|
|
622
|
+
* ## 「人」「畜」在量级词后面是**计数**,不是功率
|
|
623
|
+
*
|
|
624
|
+
* 单位表里 `人` 是「一个成年人持续体力输出 75 W」(power 量纲),
|
|
625
|
+
* `畜` 是「一头役畜 500 W」。那是为了算「这活需要几个劳力」。
|
|
626
|
+
*
|
|
627
|
+
* 但语料里 `2.66亿人` 指的是**人口数** —— 把它展开成功率会得到 `19.95 GW`,
|
|
628
|
+
* 数字看着完全正常,语义却全错。这类静默错误正是本项目最要防的。
|
|
629
|
+
*
|
|
630
|
+
* 判据:**紧跟量级词**(`亿人`/`万人`/`亿畜`)时按计数处理,不展开。
|
|
631
|
+
* 单独用(`3 人` 表示 3 个劳力)仍按功率单位展开 —— 那是有意义的换算。
|
|
632
|
+
*/
|
|
633
|
+
if (entry.name === '人' || entry.name === '畜') {
|
|
634
|
+
/**
|
|
635
|
+
* 计数用法下**把量级词后面的「人/畜」直接去掉** —— 它是量纲说明词,
|
|
636
|
+
* 不是一个要参与运算的量。`(2.66 * 1e8)人` → `(2.66 * 1e8)`。
|
|
637
|
+
*
|
|
638
|
+
* 为什么不是「保留但改成无量纲」:保留会变成裸标识符(语法错),
|
|
639
|
+
* 而乘 1 又会让输出带上多余的量纲。去掉最干净 ——
|
|
640
|
+
* `2.66亿人` 在算式里本来就只是一个数。
|
|
641
|
+
*/
|
|
642
|
+
const stripPattern = new RegExp('(\\([0-9.\\s*+\\-e]*\\))\\s*' + escapeRegExp(entry.name) + '(?![0-9A-Za-z\\u4e00-\\u9fff])', 'g');
|
|
643
|
+
const stripped = output.replace(stripPattern, '$1');
|
|
644
|
+
if (stripped !== output) {
|
|
645
|
+
output = stripped;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* 匹配「数字 + 空格? + 单位」,且单位后面不能紧跟中文或字母数字,
|
|
651
|
+
* 避免命中更长词的一部分。
|
|
652
|
+
*/
|
|
653
|
+
/**
|
|
654
|
+
* 数值载体有两种:
|
|
655
|
+
* · 直接的数字(`3 米`)
|
|
656
|
+
* · **已展开的量级词括号**(`(450 * 1e8)吨`)
|
|
657
|
+
*
|
|
658
|
+
* 第二种是量级词展开的产物 —— 若只认数字,`亿吨` 展开成 `(450 * 1e8)吨`
|
|
659
|
+
* 之后就没人处理「吨」了,表达式接着报错。这个 bug 很隐蔽:
|
|
660
|
+
* 单看量级词那条是对的,单看单位那条也是对的,两者串起来才漏。
|
|
661
|
+
*/
|
|
662
|
+
const pattern = new RegExp('([0-9][0-9,.]*(?:e[+-]?[0-9]+)?|\\([0-9.\\s*+\\-e]*\\))\\s*' +
|
|
663
|
+
escapeRegExp(entry.name) + '(?![0-9A-Za-z\u4e00-\u9fff])(?![\u4e00-\u9fff])', 'gi');
|
|
664
|
+
output = output.replace(pattern, function (_match, numeric) {
|
|
665
|
+
/**
|
|
666
|
+
* ## 整个替换必须包成括号
|
|
667
|
+
*
|
|
668
|
+
* 踩过(2026-11-06):`450 亿吨 / 31.39 亿吨` 展开成
|
|
669
|
+
*
|
|
670
|
+
* ```
|
|
671
|
+
* (450 * 1e8) * 1000 kg / (31.39 * 1e8) * 1000 kg
|
|
672
|
+
* ```
|
|
673
|
+
*
|
|
674
|
+
* 乘除同级左结合,等于先除再乘 —— 结果带上 `Mg^2` 量纲,
|
|
675
|
+
* **而数值部分恰好是对的(14.33)**。这种「数字对、量纲错」最难发现:
|
|
676
|
+
* 单看数字会以为没问题,而量纲只在结果后缀里露出。
|
|
677
|
+
*
|
|
678
|
+
* 加外层括号后变成 `((450*1e8) * 1000 kg) / ((31.39*1e8) * 1000 kg)`,
|
|
679
|
+
* 比值无量纲、数值 14.34 —— 正确。
|
|
680
|
+
*/
|
|
681
|
+
const value = numeric.startsWith('(') ? numeric + ' * ' + entry.factor : numeric.replace(/,/g, '') + ' * ' + entry.factor;
|
|
682
|
+
return '(' + value + ' ' + entry.base + ')';
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
return output;
|
|
156
686
|
}
|
|
157
687
|
/**
|
|
158
688
|
* 当前显示精度(模块级,每次工具调用开头设置一次)。
|
|
@@ -209,16 +739,27 @@ export function trimNumber(value, precision, displayPrecision) {
|
|
|
209
739
|
const abs = Math.abs(value);
|
|
210
740
|
// 极小值:会被四舍五入成 0 时必须用科学计数法(否则非零值显示成 0,是数据丢失)
|
|
211
741
|
if (abs !== 0 && abs < Math.pow(10, -decimals)) {
|
|
212
|
-
|
|
742
|
+
reportPrecisionLoss(1);
|
|
743
|
+
return value.toExponential(Math.min(Math.max(decimals, 6), 12));
|
|
213
744
|
}
|
|
214
745
|
const fixed = value.toFixed(decimals);
|
|
215
746
|
// 用文本去掉尾随 0(不要 `Number()` 再 `String()`,否则 0.00000081 会变 "8.1e-7")
|
|
216
747
|
const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
|
|
217
748
|
const text = trimmed === '-0' ? '0' : trimmed;
|
|
218
749
|
const shown = Number(text);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
750
|
+
const rel = abs > 0 && Number.isFinite(shown) ? Math.abs(shown - value) / abs : 0;
|
|
751
|
+
/**
|
|
752
|
+
* 与 `formatValue` 同一条判据:定点截断丢精度超过阈值就改用科学计数法。
|
|
753
|
+
*
|
|
754
|
+
* 两处必须一致 —— 否则 `evaluate` 与 `stats`/`solve` 会给同一个小数值
|
|
755
|
+
* 不同的显示形式,那比一个地方错更让人困惑。
|
|
756
|
+
*/
|
|
757
|
+
if (rel > PRECISION_LOSS_THRESHOLD) {
|
|
758
|
+
reportPrecisionLoss(rel);
|
|
759
|
+
// 与 formatValue 一致:显式指定时尊重调用方的位数选择,不擅自改格式
|
|
760
|
+
if (!isDisplayPrecisionExplicit()) {
|
|
761
|
+
return value.toExponential(Math.min(Math.max(decimals, 6), 12));
|
|
762
|
+
}
|
|
222
763
|
}
|
|
223
764
|
return text;
|
|
224
765
|
}
|