dsh-math-calculator 2.1.0 → 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 +951 -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 +1367 -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 +1502 -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/src/format.ts
CHANGED
|
@@ -8,7 +8,36 @@
|
|
|
8
8
|
* @module dsh-math-calculator/format
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
PRECISION_LOSS_THRESHOLD,
|
|
13
|
+
isDisplayPrecisionExplicit,
|
|
14
|
+
reportPrecisionLoss,
|
|
15
|
+
} from './precision-report.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 科学计数法该保留几位有效数字,并裁掉尾随零。
|
|
19
|
+
*
|
|
20
|
+
* 位数取 `displayPrecision`(用户设定的位数)与 6 的下限、12 的上限之间:
|
|
21
|
+
* · 下限 6:太少会显得不精确(`5.6704e-8` 只给 5 位)
|
|
22
|
+
* · 上限 12:太多又回到「一行几十位」的老问题
|
|
23
|
+
*
|
|
24
|
+
* 裁尾随零是因为 `toExponential` 会补齐位数:
|
|
25
|
+
* `5.67e-8` 在 8 位下得 `"5.67000000e-8"` —— 那八个 0 不携带信息,
|
|
26
|
+
* 而且会让读者以为「精度很低,只有两位有效数字」。
|
|
27
|
+
*
|
|
28
|
+
* @param value - 数值
|
|
29
|
+
* @param decimals - 用户设定的小数位
|
|
30
|
+
* @returns 科学计数法字符串
|
|
31
|
+
*/
|
|
32
|
+
function toScientific(value: number, decimals: number): string {
|
|
33
|
+
const digits = Math.min(Math.max(decimals, 6), 12)
|
|
34
|
+
const raw = value.toExponential(digits)
|
|
35
|
+
const [mantissa, exponent] = raw.split('e')
|
|
36
|
+
const trimmed = mantissa.includes('.')
|
|
37
|
+
? mantissa.replace(/0+$/, '').replace(/\.$/, '')
|
|
38
|
+
: mantissa
|
|
39
|
+
return trimmed + 'e' + exponent
|
|
40
|
+
}
|
|
12
41
|
|
|
13
42
|
/** 可无损 JSON 化的值。 */
|
|
14
43
|
export type JsonValue =
|
|
@@ -77,6 +106,7 @@ export function isIntegerLike(value: any): boolean {
|
|
|
77
106
|
* @param value - 待格式化的值
|
|
78
107
|
* @param precision - 计算精度(BigNumber 有效位数)
|
|
79
108
|
* @param displayPrecision - **小数点后保留几位**;省略则输出完整精度
|
|
109
|
+
* @param respectExplicitUnit - 调用方是否在表达式里**显式写了目标单位**(`.to('cm')`)
|
|
80
110
|
* @returns 显示串
|
|
81
111
|
*/
|
|
82
112
|
export function formatValue(
|
|
@@ -84,6 +114,7 @@ export function formatValue(
|
|
|
84
114
|
value: any,
|
|
85
115
|
precision: number,
|
|
86
116
|
displayPrecision?: number,
|
|
117
|
+
respectExplicitUnit = false,
|
|
87
118
|
): string {
|
|
88
119
|
if (value === undefined) return 'undefined'
|
|
89
120
|
if (value === null) return 'null'
|
|
@@ -95,8 +126,85 @@ export function formatValue(
|
|
|
95
126
|
const isBig = Boolean(value && value.isBigNumber)
|
|
96
127
|
const integer = isIntegerLike(value)
|
|
97
128
|
|
|
129
|
+
/**
|
|
130
|
+
* ## 带单位的值:先让 mathjs 格式化,再挑一个「人会说」的单位
|
|
131
|
+
*
|
|
132
|
+
* 实测反馈(2026-11-06):
|
|
133
|
+
*
|
|
134
|
+
* ```
|
|
135
|
+
* 一个很大的重量 → 1e12 g 被输出成 `1 Tg`
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* 「太克」这种 SI 前缀现实中没人说;而中文语料里这个量级叫「100 万吨」。
|
|
139
|
+
* mathjs 的 `Unit` 在**构造时**就选定了前缀,`format` 选项改不了它 ——
|
|
140
|
+
* 所以在文本层做一次归位。
|
|
141
|
+
*
|
|
142
|
+
* 只调**质量**这一个量纲:它是唯一实测出过问题的场景,
|
|
143
|
+
* 而长度/时间/功率的 SI 前缀(km / s / kW)本来就是常用写法,动它们反而添乱。
|
|
144
|
+
*/
|
|
145
|
+
/**
|
|
146
|
+
* ## 调用方写了目标单位时,**不许归位**
|
|
147
|
+
*
|
|
148
|
+
* 实测(本轮 1107 报告核查时发现,是本项目最难发现的一类回归):
|
|
149
|
+
*
|
|
150
|
+
* ```
|
|
151
|
+
* 2 m to cm → mathjs 算出 200 cm → 工具输出 "2 m" ← 换算被撤回了
|
|
152
|
+
* 1 t to kg → mathjs 算出 1000 kg → 工具输出 "1 t"
|
|
153
|
+
* 2.5 m to mm → mathjs 算出 2500 mm → 工具输出 "2.5 m"
|
|
154
|
+
* ```
|
|
155
|
+
*
|
|
156
|
+
* 根因:归位逻辑不看用户的意图,只看 `unit.value`(基准单位数值),
|
|
157
|
+
* 于是把**显式换算的目标**按阶梯重新挑了一遍 —— 恰好挑回用户出发的那个单位。
|
|
158
|
+
* 数字看着合理(`2 m` 就是个合法的长度),所以不会有人怀疑。
|
|
159
|
+
*
|
|
160
|
+
* 判据很简单:归位是为了「没人指定时挑一个人会说的档」,
|
|
161
|
+
* **一旦指定了,就轮不到工具替他挑**。
|
|
162
|
+
*/
|
|
163
|
+
if (value && value.isUnit) {
|
|
164
|
+
if (!respectExplicitUnit) {
|
|
165
|
+
const normalized = normalizeUnitPrefix(value)
|
|
166
|
+
if (normalized !== '') return normalized
|
|
167
|
+
} else {
|
|
168
|
+
/**
|
|
169
|
+
* ## 显式换算时,小数位也必须按 displayPrecision 收
|
|
170
|
+
*
|
|
171
|
+
* 实测(本轮):`5 cm to inch` 在修好归位之后吐出了整整 21 位小数 ——
|
|
172
|
+
*
|
|
173
|
+
* ```
|
|
174
|
+
* 1.96850393700787401575 inch
|
|
175
|
+
* ```
|
|
176
|
+
*
|
|
177
|
+
* 根因在**归位路径之外**:归位走的是 `formatScalar`(只给 6 位),
|
|
178
|
+
* 掩盖了「Unit 值根本不经过 displayPrecision」这件事。
|
|
179
|
+
* 一旦跳开归位,这个既有缺陷就露出来了。
|
|
180
|
+
*
|
|
181
|
+
* 处理方式:拆成「数字 + 单位」两段,数字那段递归回本函数
|
|
182
|
+
* 的纯数字路径(那里有完整的小数位与丢精度判据),单位原样拼回。
|
|
183
|
+
* 这样既不重复实现截断逻辑,也不会改变整数完整这条不变量。
|
|
184
|
+
*/
|
|
185
|
+
try {
|
|
186
|
+
const rendered = trimUnitNumber(
|
|
187
|
+
math, math.format(value, { notation: 'auto', precision }), precision, displayPrecision,
|
|
188
|
+
)
|
|
189
|
+
if (rendered !== '') return rendered
|
|
190
|
+
} catch {
|
|
191
|
+
/* 拆不开就退回原格式 */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
98
196
|
try {
|
|
99
|
-
|
|
197
|
+
/**
|
|
198
|
+
* ## 整数:**一律完整输出**,任何阈值都不截断
|
|
199
|
+
*
|
|
200
|
+
* 试过一版「≥1e15 改科学计数法」,被 12 条断言当场打回 ——
|
|
201
|
+
* 那违反本项目最硬的一条不变量:**整数永不丢位**。
|
|
202
|
+
* 写成 `1e20` 是**丢信息**,不是简洁(`10^20` 与 `10^20+5` 会长得一样)。
|
|
203
|
+
*
|
|
204
|
+
* 大数占行宽的问题改由**别处解决**:批量显示时补千分位分组
|
|
205
|
+
* (纯视觉分组、不改值),`result` 数据通道保持完整整数。
|
|
206
|
+
* 这才是「既让人看得清、又不丢一位」的解法。
|
|
207
|
+
*/
|
|
100
208
|
if (isBig && integer) {
|
|
101
209
|
return math.format(value, { notation: 'fixed' })
|
|
102
210
|
}
|
|
@@ -146,16 +254,17 @@ export function formatValue(
|
|
|
146
254
|
|
|
147
255
|
const abs = Math.abs(numeric)
|
|
148
256
|
// 量级过大时小数位无意义,改用科学计数法
|
|
149
|
-
if (abs >= 1e15) return numeric
|
|
257
|
+
if (abs >= 1e15) return toScientific(numeric, decimals)
|
|
150
258
|
/**
|
|
151
259
|
* 极小值:**只要会被四舍五入成 0,就必须改用科学计数法**。
|
|
152
260
|
*
|
|
153
261
|
* 踩过:`3.3e-9` 在 8 位小数下 toFixed 得 "0.00000000",转回数字变成 `0`
|
|
154
262
|
* —— 那不是「不精确」,是**把非零值显示成零**,属于数据丢失。
|
|
155
|
-
* 判据用 `10^-decimals` 而不是更保守的阈值,正好卡在「会不会归零」上。
|
|
156
263
|
*/
|
|
157
264
|
if (abs !== 0 && abs < Math.pow(10, -decimals)) {
|
|
158
|
-
|
|
265
|
+
const rel = 1
|
|
266
|
+
reportPrecisionLoss(rel)
|
|
267
|
+
return toScientific(numeric, decimals)
|
|
159
268
|
}
|
|
160
269
|
// toFixed 会补尾零(1.5 → "1.50000000"),用**文本**去掉,不要转 Number 再转回
|
|
161
270
|
const fixed = numeric.toFixed(decimals)
|
|
@@ -170,16 +279,43 @@ export function formatValue(
|
|
|
170
279
|
* 保留定点文本既忠实于位数设定,也不会让人误判精度。
|
|
171
280
|
*/
|
|
172
281
|
const normalizedText = trimmed === '-0' ? '0' : trimmed
|
|
282
|
+
const shown = Number(normalizedText)
|
|
283
|
+
const rel = abs > 0 && Number.isFinite(shown) ? Math.abs(shown - numeric) / abs : 0
|
|
284
|
+
|
|
173
285
|
/**
|
|
174
|
-
*
|
|
286
|
+
* ── 定点截断丢精度时,自动改用科学计数法 ──
|
|
287
|
+
*
|
|
288
|
+
* 这是本文件最重要的一条判据,实测代价很具体:
|
|
289
|
+
*
|
|
290
|
+
* 斯特藩-玻尔兹曼常数 σ = 5.670374419e-8
|
|
291
|
+
* 8 位小数定点 → 0.00000006 相对误差 5.8%
|
|
292
|
+
* 科学计数法 → 5.6703744e-8 相对误差 1e-9
|
|
293
|
+
*
|
|
294
|
+
* 前者让 σ 变成 6.0e-8,散热链整条偏移 6%,而且**数字看起来很正常**——
|
|
295
|
+
* 一份报告里 `0.00000006` 不会引起任何人怀疑。
|
|
175
296
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
297
|
+
* 判据用**相对误差**而不是「会不会归零」:`3.3e-9` 归零是明显的事,
|
|
298
|
+
* 而 `5.67e-8` 不归零却已经错了 5.8%,后者更隐蔽。
|
|
299
|
+
*
|
|
300
|
+
* 注意这不违背「displayPrecision 表示小数点后几位」的设定 ——
|
|
301
|
+
* 绝大多数数仍按小数位显示(`1/7` → 0.14285714 误差 1e-9,走定点),
|
|
302
|
+
* 只有定点表达会**丢数据**的极小值才换格式。用户要的是「8 位小数」,
|
|
303
|
+
* 不是「一个读错 5.8% 的数」。
|
|
179
304
|
*/
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
305
|
+
if (rel > PRECISION_LOSS_THRESHOLD) {
|
|
306
|
+
reportPrecisionLoss(rel)
|
|
307
|
+
/**
|
|
308
|
+
* 只在**未显式指定**精度时才擅自改格式。
|
|
309
|
+
*
|
|
310
|
+
* 为什么区分:调用方写 `displayPrecision:4` 是明确要求 4 位小数,
|
|
311
|
+
* `1/7` → `0.1429` 正是他要的(相对误差 0.03%,可接受)。
|
|
312
|
+
* 若替他改成 `1.4286e-1`,那是**违背明确指令**。
|
|
313
|
+
*
|
|
314
|
+
* 代价改由工具层提示告知(「你选的位数丢了 X% 精度」),而不是悄悄改掉。
|
|
315
|
+
*/
|
|
316
|
+
if (!isDisplayPrecisionExplicit()) {
|
|
317
|
+
return toScientific(numeric, decimals)
|
|
318
|
+
}
|
|
183
319
|
}
|
|
184
320
|
return normalizedText
|
|
185
321
|
} catch {
|
|
@@ -270,6 +406,74 @@ export function unwrapResult(value: any): any {
|
|
|
270
406
|
return value
|
|
271
407
|
}
|
|
272
408
|
|
|
409
|
+
/**
|
|
410
|
+
* 判断一个 mathjs 原始返回值里是否含非有限数(Infinity / -Infinity / NaN)。
|
|
411
|
+
*
|
|
412
|
+
* ## 为什么不能用序列化后的文本判断
|
|
413
|
+
*
|
|
414
|
+
* 既有实现 `containsNonFinite` 只认**恰好等于** `"Infinity"` 的字符串。
|
|
415
|
+
* 而带单位的结果序列化成 `"Infinity g"` —— 于是:
|
|
416
|
+
*
|
|
417
|
+
* ```
|
|
418
|
+
* (1/0)*2 kg → ok=true,result "Infinity g" ← 漏检
|
|
419
|
+
* 2 kg → ok=true,result "2 kg" ← 正常
|
|
420
|
+
* ```
|
|
421
|
+
*
|
|
422
|
+
* 同一件事在裸数字上拦得住、在带单位值上拦不住,是**判据挑错了层**:
|
|
423
|
+
* 文本层只能看见「长得像」,值层才知道「是不是」。
|
|
424
|
+
*
|
|
425
|
+
* ## Unit 的非有限数藏在 `value` 里
|
|
426
|
+
*
|
|
427
|
+
* ```
|
|
428
|
+
* (1/0)*2 kg → Unit { value: BigNumber(Infinity), units: [g] }
|
|
429
|
+
* (2/0) kg to g → Unit { value: BigNumber(Infinity) }
|
|
430
|
+
* ```
|
|
431
|
+
*
|
|
432
|
+
* 而正则读文本时那个 `g` 会把判定带偏。所以逐层下钻到真正的数字。
|
|
433
|
+
*
|
|
434
|
+
* @param value - mathjs 的原始返回值(不是 toJsonValue 之后的)
|
|
435
|
+
* @returns 含非有限数时为 true
|
|
436
|
+
*/
|
|
437
|
+
export function isNonFiniteValue(value: any, depth = 0): boolean {
|
|
438
|
+
if (value === null || value === undefined || depth > 8) return false
|
|
439
|
+
if (typeof value === 'number') return !Number.isFinite(value)
|
|
440
|
+
if (typeof value === 'bigint') return false
|
|
441
|
+
if (typeof value !== 'object') return false
|
|
442
|
+
if (value.isBigNumber) {
|
|
443
|
+
/**
|
|
444
|
+
* BigNumber 自己带 `isFinite()`,用它最准。
|
|
445
|
+
*
|
|
446
|
+
* 退回 `Number(value)` 会踩坑:`1e400` 是**合法的有限大数**,
|
|
447
|
+
* 但转成双精度就成了 Infinity —— 那会把「大」误判成「错」。
|
|
448
|
+
*/
|
|
449
|
+
try {
|
|
450
|
+
if (typeof value.isFinite === 'function') return !value.isFinite()
|
|
451
|
+
const text = String(value)
|
|
452
|
+
return text === 'Infinity' || text === '-Infinity' || text === 'NaN'
|
|
453
|
+
} catch {
|
|
454
|
+
return false
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (value.isUnit) return isNonFiniteValue(value.value, depth + 1)
|
|
458
|
+
/** 复数:实部虚部都要有限。`sqrt(-1)` 是 `0 + 1i`,两者都有限,放行。 */
|
|
459
|
+
if (value.isComplex) {
|
|
460
|
+
return isNonFiniteValue(value.re, depth + 1) || isNonFiniteValue(value.im, depth + 1)
|
|
461
|
+
}
|
|
462
|
+
if (Array.isArray(value)) return value.some((item) => isNonFiniteValue(item, depth + 1))
|
|
463
|
+
/** Matrix 走 toArray,别递归到它的内部字段。 */
|
|
464
|
+
if (value.isMatrix && typeof value.toArray === 'function') {
|
|
465
|
+
try {
|
|
466
|
+
return isNonFiniteValue(value.toArray(), depth + 1)
|
|
467
|
+
} catch {
|
|
468
|
+
return false
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (value.isResultSet && Array.isArray(value.entries)) {
|
|
472
|
+
return value.entries.some((item: any) => isNonFiniteValue(item, depth + 1))
|
|
473
|
+
}
|
|
474
|
+
if (value.isFraction) return false
|
|
475
|
+
return false
|
|
476
|
+
}
|
|
273
477
|
/** 递归判断一个 JSON 值里是否含非有限数字。 */
|
|
274
478
|
export function containsNonFinite(value: JsonValue | undefined): boolean {
|
|
275
479
|
if (value === undefined) return false
|
|
@@ -282,6 +486,310 @@ export function containsNonFinite(value: JsonValue | undefined): boolean {
|
|
|
282
486
|
return false
|
|
283
487
|
}
|
|
284
488
|
|
|
489
|
+
/**
|
|
490
|
+
* 每个量纲的「人会说」单位阶梯。
|
|
491
|
+
*
|
|
492
|
+
* ## 为什么需要这张表
|
|
493
|
+
*
|
|
494
|
+
* mathjs 会按数量级自动选 SI 前缀,但它**只按数量级走**,
|
|
495
|
+
* 不管现实中人说哪个词。于是:
|
|
496
|
+
*
|
|
497
|
+
* ```
|
|
498
|
+
* 一个很大的重量 → 1 Tg(太克) ← 没人这么说
|
|
499
|
+
* 1e6 m → 1 Mm(兆米) ← 没人这么说
|
|
500
|
+
* 1e6 L → 1 ML ← 没人这么说
|
|
501
|
+
* ```
|
|
502
|
+
*
|
|
503
|
+
* 实测反馈原话:「之前出现过一个很大的重量,居然最后用的单位是 g,
|
|
504
|
+
* 而不是用公斤或吨这种更加常用的单位。」
|
|
505
|
+
*
|
|
506
|
+
* ## 阶梯怎么定
|
|
507
|
+
*
|
|
508
|
+
* 每一档是 `[阈值, 换算系数, 单位名]`,从大到小排;
|
|
509
|
+
* 取第一个「真实值 ≥ 阈值」的档。阈值与系数都以**基准单位**为准。
|
|
510
|
+
*
|
|
511
|
+
* 原则:**只收现实中会出现在正文里的单位**。
|
|
512
|
+
* 兆米、太克、兆升这些「数学上合法、语用上没人用」的档位一律不进表 ——
|
|
513
|
+
* 超出最大常用档就用科学计数法配常用单位(如 `1e6 t`)。
|
|
514
|
+
*/
|
|
515
|
+
const PREFERRED_UNIT_LADDER: Record<string, Array<[number, number, string]>> = {
|
|
516
|
+
/**
|
|
517
|
+
* 质量以千克为基准(mathjs 的 Unit.value 就是千克数)。
|
|
518
|
+
*
|
|
519
|
+
* 只列到 `t` 为止:再往上走 `kt / Mt / Gt / Tt…`,
|
|
520
|
+
* 由下面的「前缀叠加」在 `t` 上加一层(`t` 不带前缀,不会拼出无效组合)。
|
|
521
|
+
*/
|
|
522
|
+
mass: [
|
|
523
|
+
[1e3, 1e-3, 't'],
|
|
524
|
+
[1, 1, 'kg'],
|
|
525
|
+
[1e-3, 1e3, 'g'],
|
|
526
|
+
[0, 1e6, 'mg'],
|
|
527
|
+
],
|
|
528
|
+
/**
|
|
529
|
+
* 长度以米为基准。km 档从 1e3 起(不是 1e6),否则 1e6 m 会落到 m 档。
|
|
530
|
+
*
|
|
531
|
+
* 高量级直接在这里列全(Gm / Tm / Pm…),而不是靠「前缀叠加」——
|
|
532
|
+
* 因为长度阶梯里已经有 `km`(自带前缀),再叠一层会拼出 `kkm` 这种无效组合。
|
|
533
|
+
* 与其在叠加逻辑里特判「当前符号带不带前缀」,不如把档位摊平在数据里。
|
|
534
|
+
*/
|
|
535
|
+
length: [
|
|
536
|
+
[1e18, 1e-18, 'Em'],
|
|
537
|
+
[1e15, 1e-15, 'Pm'],
|
|
538
|
+
[1e12, 1e-12, 'Tm'],
|
|
539
|
+
[1e9, 1e-9, 'Gm'],
|
|
540
|
+
[1e6, 1e-6, 'Mm'],
|
|
541
|
+
[1e3, 1e-3, 'km'],
|
|
542
|
+
[1, 1, 'm'],
|
|
543
|
+
[1e-2, 1e2, 'cm'],
|
|
544
|
+
[1e-3, 1e3, 'mm'],
|
|
545
|
+
[0, 1e6, 'µm'],
|
|
546
|
+
],
|
|
547
|
+
/** 时间以秒为基准。 */
|
|
548
|
+
time: [
|
|
549
|
+
[86400 * 365.25, 1 / (86400 * 365.25), 'year'],
|
|
550
|
+
[86400, 1 / 86400, 'day'],
|
|
551
|
+
[3600, 1 / 3600, 'h'],
|
|
552
|
+
[60, 1 / 60, 'min'],
|
|
553
|
+
[0, 1, 's'],
|
|
554
|
+
],
|
|
555
|
+
/**
|
|
556
|
+
* ## 体积刻意不做归位
|
|
557
|
+
*
|
|
558
|
+
* 试过一版(L↔m³ 互换),结果是 `1000 L` 变 `1 m^3` 而 `1 m^3` 变 `1000 L` ——
|
|
559
|
+
* **两者在互相吞吐**,因为 `m^3` 那一档把 `L` 家族整个吃掉了。
|
|
560
|
+
*
|
|
561
|
+
* 体积的「常用写法」本身就不统一:水用 L、土方用 m³、气体用 mL 到都合理。
|
|
562
|
+
* 强行归位只会改变调用方给的语义,收益为零。所以**从阶梯里去掉**,
|
|
563
|
+
* 保持 mathjs 原有选择(它唯一的问题只是 `ML` 这种冷门前缀,而那很少见)。
|
|
564
|
+
*/
|
|
565
|
+
/** 功率以瓦为基准。 */
|
|
566
|
+
power: [
|
|
567
|
+
[1e18, 1e-18, 'EW'],
|
|
568
|
+
[1e15, 1e-15, 'PW'],
|
|
569
|
+
[1e12, 1e-12, 'TW'],
|
|
570
|
+
[1e9, 1e-9, 'GW'],
|
|
571
|
+
[1e6, 1e-6, 'MW'],
|
|
572
|
+
[1e3, 1e-3, 'kW'],
|
|
573
|
+
[0, 1, 'W'],
|
|
574
|
+
],
|
|
575
|
+
/** 能量以焦耳为基准。 */
|
|
576
|
+
energy: [
|
|
577
|
+
[1e18, 1e-18, 'EJ'],
|
|
578
|
+
[1e15, 1e-15, 'PJ'],
|
|
579
|
+
[1e12, 1e-12, 'TJ'],
|
|
580
|
+
[1e9, 1e-9, 'GJ'],
|
|
581
|
+
[1e6, 1e-6, 'MJ'],
|
|
582
|
+
[1e3, 1e-3, 'kJ'],
|
|
583
|
+
[0, 1, 'J'],
|
|
584
|
+
],
|
|
585
|
+
/** 压强以帕为基准。 */
|
|
586
|
+
pressure: [
|
|
587
|
+
[1e6, 1e-6, 'MPa'],
|
|
588
|
+
[1e3, 1e-3, 'kPa'],
|
|
589
|
+
[0, 1, 'Pa'],
|
|
590
|
+
],
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** mathjs 的基准单位名 → 量纲键。未列出的量纲不归位(保持 mathjs 的选择)。 */
|
|
594
|
+
const BASE_TO_DIMENSION: Record<string, string> = {
|
|
595
|
+
g: 'mass',
|
|
596
|
+
t: 'mass',
|
|
597
|
+
kg: 'mass',
|
|
598
|
+
m: 'length',
|
|
599
|
+
s: 'time',
|
|
600
|
+
W: 'power',
|
|
601
|
+
J: 'energy',
|
|
602
|
+
Pa: 'pressure',
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* 把「不常用前缀的单位」归到人会说的大小。
|
|
607
|
+
*
|
|
608
|
+
* ## 判据
|
|
609
|
+
*
|
|
610
|
+
* 三件事同时满足才动手:
|
|
611
|
+
* 1. 单个单位的 1 次幂(复合单位如 kg/m³、m/s 不动)
|
|
612
|
+
* 2. 基准单位在 `BASE_TO_DIMENSION` 里(未登记的量纲保持原样)
|
|
613
|
+
* 3. 该量纲有阶梯表
|
|
614
|
+
*
|
|
615
|
+
* ## 关键陷阱:`unit.value` 已经是基准单位的数值
|
|
616
|
+
*
|
|
617
|
+
* ```
|
|
618
|
+
* 1 t → value = 1000 (基准是 kg,所以这是 1000 kg)
|
|
619
|
+
* 2 kg → value = 2
|
|
620
|
+
* 1e12 g → value = 1e9
|
|
621
|
+
* 1e6 m → value = 1e6
|
|
622
|
+
* ```
|
|
623
|
+
*
|
|
624
|
+
* 踩过两次:第一次乘了 `prefix.value`、第二次乘了 `baseFactor` ——
|
|
625
|
+
* 两次都是「凭直觉推断内部表示」而不实测反推。教训:第三方库的内部语义
|
|
626
|
+
* 必须用数据验证。
|
|
627
|
+
*
|
|
628
|
+
* @param unit - mathjs 的 Unit 对象
|
|
629
|
+
* @returns 归一后的文本;不需要处理时返回空串(由调用方走原路径)
|
|
630
|
+
*/
|
|
631
|
+
function normalizeUnitPrefix(unit: any): string {
|
|
632
|
+
try {
|
|
633
|
+
const parts = Array.isArray(unit.units) ? unit.units : []
|
|
634
|
+
if (parts.length !== 1 || (parts[0].power ?? 1) !== 1) return ''
|
|
635
|
+
const baseName: string = parts[0].unit?.name ?? ''
|
|
636
|
+
const dimension = BASE_TO_DIMENSION[baseName]
|
|
637
|
+
if (!dimension) return ''
|
|
638
|
+
const ladder = PREFERRED_UNIT_LADDER[dimension]
|
|
639
|
+
if (!ladder) return ''
|
|
640
|
+
|
|
641
|
+
const baseValue = Number(unit.value)
|
|
642
|
+
if (!Number.isFinite(baseValue) || baseValue === 0) return ''
|
|
643
|
+
|
|
644
|
+
const abs = Math.abs(baseValue)
|
|
645
|
+
const tier = ladder.find(function (row) { return abs >= row[0] })
|
|
646
|
+
if (!tier) return ''
|
|
647
|
+
const [threshold, factor, symbol] = tier
|
|
648
|
+
let value = baseValue * factor
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* ## 阶梯之上再叠 SI 前缀:k / M / G / T / P / E / Z / Y / R / Q
|
|
652
|
+
*
|
|
653
|
+
* 用户明确要求:「K,M,G,T,P,E,Z,Y,R,Q,不是禁止,可以斟酌使用。」
|
|
654
|
+
*
|
|
655
|
+
* 上一版把前缀**全部禁用**、超档一律丢给科学计数法(`1e6 t`),那是矫枉过正:
|
|
656
|
+
* `Mt`(百万吨)、`Gt`(十亿吨)、`kt`(千吨)这些是**正经写法**,
|
|
657
|
+
* 工程与气候文献里常见;`1e12 t` 反而不如 `1 Tt` 好读。
|
|
658
|
+
*
|
|
659
|
+
* 所以规则改成两层:
|
|
660
|
+
* · **先按阶梯选常用单位**(保证不会出现 `Tg`、`Mm` 这种冷门组合)
|
|
661
|
+
* · **数值仍超出该档范围时,在常用单位上叠前缀**(`t` → `kt` / `Mt` / `Gt`)
|
|
662
|
+
*
|
|
663
|
+
* 阈值:|值| ≥ 1000 才升档(`2000 t` → `2 kt`),
|
|
664
|
+
* 这样 `1000 t` 仍是 `1000 t`(不升级)而 `10000 t` 变 `10 kt`。
|
|
665
|
+
* 再往上每 1000 倍升一级,到 Q(10³⁰)为止。
|
|
666
|
+
*/
|
|
667
|
+
if (Math.abs(value) >= 1e4 && STEP_PREFIXABLE.has(symbol)) {
|
|
668
|
+
/**
|
|
669
|
+
* `steps` = 该值要**除以 10³ 几次**才能落到 1—999 的区间。
|
|
670
|
+
*
|
|
671
|
+
* ```
|
|
672
|
+
* value = 2e3 → log10 = 3.3 → floor(3.3/3) = 1 → 疑似 k
|
|
673
|
+
* value = 1e4 → log10 = 4 → floor(4/3) = 1 → k
|
|
674
|
+
* value = 1e6 → log10 = 6 → floor(6/3) = 2 → M
|
|
675
|
+
* ```
|
|
676
|
+
*
|
|
677
|
+
* 但要注意:**前缀数组的 0 号是 'k'**(即除以 10³ 一次),
|
|
678
|
+
* 所以下标 = steps - 1,不是 steps。
|
|
679
|
+
*
|
|
680
|
+
* 踩过:第一版直接用 `SI_STEP_PREFIXES[steps]`,于是 `1e4 t` 输出成 `10 Mt`
|
|
681
|
+
* —— 倍率差了一千倍(10 Mégatonne ≈ 1e10 t,而实际只要 1e4 t)。
|
|
682
|
+
*/
|
|
683
|
+
const exponent = Math.floor(Math.log10(Math.abs(value)))
|
|
684
|
+
const steps = Math.min(Math.floor(exponent / 3), SI_STEP_PREFIXES.length)
|
|
685
|
+
if (steps >= 1) {
|
|
686
|
+
/** 数组 0 号 = 'k'(10³),所以下标是 steps - 1。 */
|
|
687
|
+
const prefix = SI_STEP_PREFIXES[steps - 1]
|
|
688
|
+
const divisor = Math.pow(10, steps * 3)
|
|
689
|
+
return formatScalar(value / divisor) + ' ' + prefix + symbol
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return formatScalar(value) + ' ' + symbol
|
|
693
|
+
} catch {
|
|
694
|
+
return ''
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* 「千进制一位」的 SI 前缀序列(不含 k,因为阶梯里的 km/kW/kJ/kPa 已经自带)。
|
|
700
|
+
*
|
|
701
|
+
* 下标即「往上叠了几个 10³」:
|
|
702
|
+
* [0]='k' → 10³(`2 kt`)
|
|
703
|
+
* [1]='M' → 10⁶(`2 Mt`)
|
|
704
|
+
* [2]='G' → 10⁹(`2 Gt`)
|
|
705
|
+
* …… 直到 [9]='Q' → 10³⁰
|
|
706
|
+
*/
|
|
707
|
+
const SI_STEP_PREFIXES = ['k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y', 'R', 'Q']
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* 允许叠加前缀的单位符号。
|
|
711
|
+
*
|
|
712
|
+
* ## 为什么 `km` / `GW` / `TJ` 也算「可叠加」
|
|
713
|
+
*
|
|
714
|
+
* 它们本身已是阶梯选出来的常用档(`1e6 m` → `1000 km`)。
|
|
715
|
+
* 再往上(`1e9 m`)如果不能再叠,就只能给 `1e6 km` —— 那正是用户不想要的。
|
|
716
|
+
*
|
|
717
|
+
* 所以「可叠加」跟「带不带前缀」无关,而跟**叠上去之后说不说人话**有关:
|
|
718
|
+
*
|
|
719
|
+
* ```
|
|
720
|
+
* 1e9 m → 1 Gm ✓(吉米,物理/天文会说)
|
|
721
|
+
* 1e6 km → 1 Mm ✗ 兆米(很少说),但 1 Gm 是对同量级的更好写法
|
|
722
|
+
* 1e9 W → 1 GW ✓(已是 GW,再升就是 TW)
|
|
723
|
+
* 1e12 W → 1 TW ✓
|
|
724
|
+
* ```
|
|
725
|
+
*
|
|
726
|
+
* 因此集合里放的正是**阶梯可能产出的那些符号**,
|
|
727
|
+
* 这样升档逻辑统一在「在当前符号前面加一层前缀」这一个动作上。
|
|
728
|
+
*/
|
|
729
|
+
const STEP_PREFIXABLE = new Set(['t', 'm', 's', 'W', 'J', 'Pa', 'g', 'kg'])
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 把「数值 + 单位」的文本拆开,数字那段按 displayPrecision 重新渲染。
|
|
733
|
+
*
|
|
734
|
+
* ## 为什么需要它
|
|
735
|
+
*
|
|
736
|
+
* formatValue 对 isUnit 有两个分支:
|
|
737
|
+
* · 归位(normalizeUnitPrefix)→ 走 formatScalar,**只给 6 位**
|
|
738
|
+
* · 不归位(显式换算)→ 原来直接 math.format,**吐 64 位**
|
|
739
|
+
*
|
|
740
|
+
* 后者是长久存在的缺陷,只是被前者掩盖了:2 m to cm 曾经被改写成 2 m
|
|
741
|
+
* 走归位路径,于是没人发现 5 cm to inch 会给出 21 位小数。
|
|
742
|
+
*
|
|
743
|
+
* 修好归位之后它立刻暴露,所以在这里补上:数字递归回本函数
|
|
744
|
+
* 的纯数字分支(小数位、科学计数法、丢精度判据全都在那儿),单位原样拼回。
|
|
745
|
+
*
|
|
746
|
+
* ## 拆不开就返回空串
|
|
747
|
+
*
|
|
748
|
+
* 复合单位(kg / s^2)、带括号的、含 Infinity 的,一律交给调用方回退到
|
|
749
|
+
* 原格式 —— 那些位置重排数字有风险,而收益为负(本来就不长)。
|
|
750
|
+
*
|
|
751
|
+
* @param math - mathjs 实例
|
|
752
|
+
* @param text - mathjs 已经格式化好的「数字 单位」
|
|
753
|
+
* @param precision - 计算精度
|
|
754
|
+
* @param displayPrecision - 小数位;undefined 表示按完整精度
|
|
755
|
+
* @returns 重新渲染的文本;拆不开时返回空串
|
|
756
|
+
*/
|
|
757
|
+
function trimUnitNumber(
|
|
758
|
+
math: any,
|
|
759
|
+
text: string,
|
|
760
|
+
precision: number,
|
|
761
|
+
displayPrecision: number | undefined,
|
|
762
|
+
): string {
|
|
763
|
+
if (displayPrecision === undefined || !Number.isFinite(displayPrecision)) return ''
|
|
764
|
+
/** 只认「数字 + 一个单位符号」:数字可带符号、小数点、指数。 */
|
|
765
|
+
const match = text.match(/^(-?[0-9][0-9.]*(?:[eE][+-]?[0-9]+)?)\s+([A-Za-z\u00b5]+)$/)
|
|
766
|
+
if (!match) return ''
|
|
767
|
+
const digits = match[1]
|
|
768
|
+
const unitPart = match[2]
|
|
769
|
+
/** 太短的数没必要重排(2 m 就两个字符)。 */
|
|
770
|
+
if (digits.length < 6) return ''
|
|
771
|
+
const numericText = formatValue(math, math.bignumber(digits), precision, displayPrecision)
|
|
772
|
+
if (numericText === '' || numericText === 'undefined') return ''
|
|
773
|
+
return numericText + ' ' + unitPart
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* 数值 → 可读文本:大数用科学计数法,其余保留有限位。
|
|
777
|
+
*
|
|
778
|
+
* @param value - 数值
|
|
779
|
+
* @returns 文本
|
|
780
|
+
*/
|
|
781
|
+
function formatScalar(value: number): string {
|
|
782
|
+
if (!Number.isFinite(value) || value === 0) return '0'
|
|
783
|
+
const abs = Math.abs(value)
|
|
784
|
+
if (abs >= 1e6 || abs < 1e-4) {
|
|
785
|
+
/** 科学计数法:尾数最多 6 位有效数字,够读且不啰嗦。 */
|
|
786
|
+
const [mantissa, exponent] = value.toExponential(6).split('e')
|
|
787
|
+
const trimmed = mantissa.includes('.') ? mantissa.replace(/0+$/, '').replace(/\.$/, '') : mantissa
|
|
788
|
+
return trimmed + 'e' + exponent.replace('+', '')
|
|
789
|
+
}
|
|
790
|
+
return String(Number(value.toPrecision(10)))
|
|
791
|
+
}
|
|
792
|
+
|
|
285
793
|
/**
|
|
286
794
|
* 尽力把任意值转成有限数字。失败返回 undefined。
|
|
287
795
|
* 复数只接受虚部≈0 的情况。
|