dsh-math-calculator 2.2.0 → 2.3.1

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.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * 公式参数的**量纲校验**:把「位移 m」这类描述里的单位当成真单位来用。
3
+ *
4
+ * ## 为什么这一步值得做
5
+ *
6
+ * 公式库 442 条,每个参数的描述里其实**已经写着单位**:
7
+ *
8
+ * ```
9
+ * velocity_uniform: s → "位移 m" t → "时间 s"
10
+ * kinetic_energy: m → "质量 kg" v → "速度 m/s"
11
+ * ```
12
+ *
13
+ * 这些单位以前只用于**显示**,不参与任何判断。于是:
14
+ *
15
+ * ```
16
+ * {formula:"velocity_uniform", overrides:{s:10, t:1}}
17
+ * → 10 m/s ← 数字对了,但没人能确认 s 给的是米而不是公里
18
+ * ```
19
+ *
20
+ * 现在让调用方可以**带单位给参数**,工具负责校验与换算:
21
+ *
22
+ * ```
23
+ * overrides:{s:"10 m", t:"1 s"} → 10 m/s ✓
24
+ * overrides:{s:"10 km", t:"1 h"} → 2.7778 m/s ✓ 自动换算到 SI
25
+ * overrides:{s:"10 kg", t:"1 s"} → 量纲不符:s 是长度,kg 是质量 ✗
26
+ * ```
27
+ *
28
+ * ## 一个必须守住的边界:**只校验,不猜**
29
+ *
30
+ * 裸数字照旧原样接受 —— 不假装能知道 `10` 是米还是公里。
31
+ * 单位只在调用方**主动写了**的时候才生效。这跟本项目一贯的取向一致:
32
+ * 把判据交给知道答案的一方,工具只负责比对与换算。
33
+ *
34
+ * @module dsh-math-calculator/param-units
35
+ */
36
+
37
+ /**
38
+ * 从参数描述里抠出单位符号:取描述里**最后一个像单位的片段**。
39
+ *
40
+ * 描述写得很自由("位移 m"、"时间 s"、"质量1 kg"、"半径 r / m"),
41
+ * 所以判据定为:末尾那个不含中文、不含空格的短片段,且必须是已知单位。
42
+ *
43
+ * 为什么不用正则硬猜:`"质量1 kg"` 里的 `1` 与 `m3`、`s^-1` 这些形态都会把
44
+ * 朴素正则带偏。先用「已知单位表」兜底,比先写好正则再补漏洞更稳。
45
+ *
46
+ * @param desc - 参数描述
47
+ * @param isKnownUnit - 判断某字符串是否为已知单位(由调用方注入,避免模块耦合)
48
+ * @returns 单位名;抠不出时返回空串
49
+ */
50
+ export function unitFromDescription(desc: string, isKnownUnit: (name: string) => boolean): string {
51
+ if (typeof desc !== 'string' || desc === '') return ''
52
+ /**
53
+ * ## 顺序很重要:先试**整段尾部**,再逐级去掉前导文字
54
+ *
55
+ * 踩过:第一版按空白**与斜杠**切,于是 `"速度 m/s"` 被切成 `["速度", "m", "s"]`,
56
+ * 取末尾得到 `s` —— 把「速度」的声明单位判成了**时间**。
57
+ * 后果是 `kinetic_energy` 的 `v:"速度 m/s"` 变成秒,
58
+ * 于是 `m:{m:'2 t'}, v:{v:'10 m/s'}` 报「m/s 是速度,s 是时间,不能换算」。
59
+ *
60
+ * 所以先按**空白**切(斜杠是复合单位的一部分,不能单独切),
61
+ * 再从最长的尾部片段往短了试 —— `m/s` 会在更短的 `s` 之前被命中。
62
+ */
63
+ const pieces = desc.split(/[\s ]+/).filter((item) => item !== '')
64
+ for (let index = pieces.length - 1; index >= 0; index -= 1) {
65
+ /** 从当前片段起,把后缀逐段拼接试(`m` + `/` + `s` 这类不切分也能整段命中)。 */
66
+ for (let take = pieces.length - index; take >= 1; take -= 1) {
67
+ const candidate = cleanPiece(pieces.slice(index, index + take).join(' ').replace(/\s+/g, ''))
68
+ if (candidate !== '' && isKnownUnit(candidate)) return candidate
69
+ }
70
+ }
71
+ return ''
72
+ }
73
+
74
+ /** 去掉包裹符号与数字尾缀,得到干净的单位候选。 */
75
+ function cleanPiece(piece: string): string {
76
+ return piece
77
+ .replace(/^[((\[【]+|[))\]】。,,;;::]+$/g, '')
78
+ .replace(/[。,,;;::]+$/g, '')
79
+ .trim()
80
+ }
81
+
82
+ /** 一个参数的解析结果。 */
83
+ export interface ParsedParamValue {
84
+ /** 换算到该参数声明单位的数值。 */
85
+ value: number
86
+ /** 调用方写的原始单位(没写时为空)。 */
87
+ givenUnit?: string
88
+ /** 实际做了一次换算(givenUnit 不是声明单位时)。 */
89
+ converted?: boolean
90
+ }
91
+
92
+ /**
93
+ * 解析一个「带单位」参数值。
94
+ *
95
+ * @param raw - 调用方传的值(`"10 m"`、`"10 km"`、或裸数字)
96
+ * @param declaredUnit - 参数描述里声明的单位(如 `m`);为空表示该参数没有单位约定
97
+ * @param convertUnit - 换算函数(由调用方注入,签名对齐 `units.ts` 的 convert)
98
+ * @returns 解析结果;失败时返回 `{ error, hint }`
99
+ */
100
+ export function parseParamValue(
101
+ raw: unknown,
102
+ declaredUnit: string,
103
+ convertUnit: (value: number, from: string, to: string) => { ok: boolean; value?: number; error?: string; hint?: string },
104
+ ): ParsedParamValue | { error: string; hint: string } {
105
+ /**
106
+ * 裸数字(含数字字符串):原样接受,不做任何量纲推断。
107
+ *
108
+ * 这是本模块最重要的一条边界 —— 不知道的事就不猜。
109
+ * `overrides:{s:10}` 里的 10 到底是米还是公里,只有调用方知道。
110
+ */
111
+ const asNumber = typeof raw === 'number' ? raw : Number(String(raw ?? '').trim())
112
+ if (Number.isFinite(asNumber)) {
113
+ return { value: asNumber }
114
+ }
115
+ const text = String(raw ?? '').trim()
116
+ if (text === '') {
117
+ return { error: '空值', hint: '给一个数字,或「数值 + 单位」如 "10 m"。' }
118
+ }
119
+ /** 拆成「数值 余下部分」:数值可带符号、小数点、指数。 */
120
+ const match = text.match(/^([+-]?[0-9][0-9.]*(?:[eE][+-]?[0-9]+)?)\s*(.+)$/)
121
+ if (!match) {
122
+ return {
123
+ error: '不是「数值 + 单位」的形态: ' + text,
124
+ hint: '写法是 "10 m"、"30 里"、"2 kg" —— 数字在前、单位在后。',
125
+ }
126
+ }
127
+ const numeric = Number(match[1])
128
+ const givenUnit = match[2].trim()
129
+ if (!Number.isFinite(numeric)) {
130
+ return { error: '数值部分不是有效数字: ' + match[1], hint: '检查是否多打了小数点。' }
131
+ }
132
+ if (!Number.isFinite(numeric)) return { error: '数值部分无效', hint: '' }
133
+
134
+ /**
135
+ * 参数**没有**声明单位:不擅自判定调用方写的单位对不对。
136
+ *
137
+ * 例如某些公式的参数是纯数(个数、比例、无量纲系数),
138
+ * 那时写了单位多半是误用 —— 但也可能是我们没登记。折中:只剥掉数值,
139
+ * 并在调用方(opFormula)那边决定要不要提示。
140
+ */
141
+ if (declaredUnit === '') {
142
+ return { value: numeric, givenUnit }
143
+ }
144
+
145
+ /** 单位与声明一致:直接用。 */
146
+ if (givenUnit === declaredUnit) {
147
+ return { value: numeric, givenUnit }
148
+ }
149
+ /** 其它情况交给单位表换算 —— 它会同时完成「是否同量纲」的判定。 */
150
+ const converted = convertUnit(numeric, givenUnit, declaredUnit)
151
+ if (!converted.ok) {
152
+ return {
153
+ error: converted.error ?? '无法换算',
154
+ hint: (converted.hint ?? '') +
155
+ ' 这个参数声明的是「' + declaredUnit + '」,请按该量纲给值,或直接给裸数字。',
156
+ }
157
+ }
158
+ return { value: Number(converted.value), givenUnit, converted: true }
159
+ }
160
+
161
+ /**
162
+ * 中文传统单位族:这些单位一旦出现在输入里,输出就该照顾同一体系。
163
+ *
164
+ * ## 为什么需要它(用户 2026-11-07 直接要求)
165
+ *
166
+ * 用户原话:「最好使用同一体系单位,把 60 km 折算成 里。」
167
+ *
168
+ * ```
169
+ * 输入:V='30 里/h', T='2 时辰' → 60 km
170
+ * 用户要的:60 km ≈ 60 里(因为 60 km ÷ 1 m 尺度…)
171
+ * ```
172
+ *
173
+ * 这不是计算问题 —— 数值本来就对。问题是**输出换了体系**:
174
+ * 用里和时辰下的订单,收据开了公里和小时。作者写正文时得自己再换一遍,
175
+ * 而每换一次就多一次出错机会(这正是本项目一直在对付的那类错误)。
176
+ *
177
+ * ## 收哪些族
178
+ *
179
+ * 只收**有稳定换算系数**的传统单位族。不收「一箭之地」「一盏茶工夫」
180
+ * 这类需要上下文的 —— 见 units-data 的收录原则。
181
+ */
182
+ export const TRADITIONAL_FAMILIES: Record<string, string[]> = {
183
+ /** 长度:里/丈/尺/寸/步(1 里 = 500 m,其余见 units-data) */
184
+ length: ['里', '丈', '尺', '寸', '步'],
185
+ /** 质量:斤/两/石/担 */
186
+ mass: ['斤', '两', '石', '担'],
187
+ /** 时间:时辰/刻/更 */
188
+ time: ['时辰', '刻', '更'],
189
+ /** 面积:亩/顷 */
190
+ area: ['亩', '顷'],
191
+ /** 体积:斗/升(升与 SI 同名,需靠语境区分,这里只收斗) */
192
+ volume: ['斗'],
193
+ }
194
+
195
+ /**
196
+ * 判断一批输入的参数用了哪些传统单位族。
197
+ *
198
+ * @param units - 输入里出现过的单位名(如 `['里', '时辰']`)
199
+ * @returns 命中的量纲键(如 `['length', 'time']`)
200
+ */
201
+ export function familiesOfUnits(units: string[]): string[] {
202
+ const hit: string[] = []
203
+ for (const unit of units) {
204
+ for (const [dimension, members] of Object.entries(TRADITIONAL_FAMILIES)) {
205
+ if (members.includes(unit) && !hit.includes(dimension)) hit.push(dimension)
206
+ }
207
+ }
208
+ return hit
209
+ }
210
+
211
+ /**
212
+ * 把 SI 结果**按输入的传统单位族折算一遍**,作为附注。
213
+ *
214
+ * ## 用户原话
215
+ *
216
+ * 「最好使用同一体系单位,把 60 km 折算成 里。」
217
+ *
218
+ * ## 为什么做成附注而不是替换主结果
219
+ *
220
+ * 主结果必须保持 SI —— 那是公式的参数约定,也是与其它计算对接的口径。
221
+ * 而作者写正文时用的是里/丈/时辰,所以**两种都要给**:
222
+ *
223
+ * ```
224
+ * 结果: 2.08333333 m/s
225
+ * 【同体系折算】约 2.25 里/时辰(你的输入用了 里、时辰)
226
+ * ```
227
+ *
228
+ * 替换掉主结果会破坏「公式内部一律 SI」这条约定;只给 SI 又要作者自己换算,
229
+ * 而每换一次就多一次出错机会。附注是唯一不牺牲任何一边的做法。
230
+ *
231
+ * @param siText - 已格式化的 SI 结果文本(如 `2.08333333 m/s`)
232
+ * @param families - 输入里用到的传统单位族(见 `familiesOfUnits`)
233
+ * @param convert - 换算函数(注入以避免模块耦合)
234
+ * @returns 折算附注行;不需要时为 null
235
+ */
236
+ export function traditionalNote(
237
+ siText: string,
238
+ families: string[],
239
+ convert: (value: number, from: string, to: string) => { ok: boolean; value?: number },
240
+ ): string | null {
241
+ if (families.length === 0) return null
242
+ /** 只处理「数值 + 简单单位」—— 复合单位(m/s、kg·m/s)的量纲换算见下。 */
243
+ const match = siText.trim().match(/^(-?[0-9][0-9.]*(?:[eE][+-]?[0-9]+)?|[-+]?[0-9.]+(?:e[+-]?[0-9]+)?)\s+(\S+)$/i)
244
+ if (!match) {
245
+ /**
246
+ * 复合单位:`m/s`、`kg·m/s` 这类没法直接换成单个传统单位。
247
+ * 但作者真正关心的是「速度」这个量在传统体系里怎么表达 ——
248
+ * 而传统时间族里 时辰 = 2 h、长度族里 里 = 500 m。
249
+ * 所以把分子分母分别折算,拼出「里/时辰」这种写法。
250
+ */
251
+ return compoundTraditionalNote(siText, families, convert)
252
+ }
253
+ const numeric = Number(match[1])
254
+ const unit = match[2]
255
+ if (!Number.isFinite(numeric)) return null
256
+ /**
257
+ * ★ 复合单位(`m/s`)会**先被简单式匹配到**(`\S+` 收得住斜杠)——
258
+ * 但单纯的量纲换算换不了它,于是旧版在这里 continue 到头、return null,
259
+ * 压根走不到下面的复合分支。踩过一次,症状是速度永远折不出里/时辰。
260
+ */
261
+ if (/[\/·*^]/.test(unit)) return compoundTraditionalNote(siText, families, convert)
262
+ for (const dimension of families) {
263
+ const target = TRADITIONAL_PREFERRED[dimension]
264
+ if (!target) continue
265
+ const result = convert(numeric, unit, target)
266
+ if (!result.ok || result.value === undefined) continue
267
+ return '【同体系折算】约 ' + fmt(result.value) + ' ' + target + '(你的输入用了传统单位)'
268
+ }
269
+ return null
270
+ }
271
+
272
+ /** 每个量纲在「同体系折算」里用哪个传统单位(挑最常用的那个)。 */
273
+ const TRADITIONAL_PREFERRED: Record<string, string> = {
274
+ length: '里',
275
+ mass: '斤',
276
+ time: '时辰',
277
+ area: '亩',
278
+ volume: '斗',
279
+ }
280
+
281
+ /** 复合单位(速度/加速度等)的传统体系写法。 */
282
+ function compoundTraditionalNote(
283
+ siText: string,
284
+ families: string[],
285
+ convert: (value: number, from: string, to: string) => { ok: boolean; value?: number },
286
+ ): string | null {
287
+ const text = siText.trim()
288
+ /** 形如 `2.08 m/s`、`9.81 m/s^2`、`10 kg / s` 的复合单位。 */
289
+ const compound = text.match(/^([-+]?[0-9.]+(?:e[+-]?[0-9]+)?)\s+([A-Za-z]+)\s*\/\s*([A-Za-z]+)(?:\^([0-9]+))?$/)
290
+ if (!compound) return null
291
+ const numeric = Number(compound[1])
292
+ const numerator = compound[2]
293
+ const denominator = compound[3]
294
+ const power = compound[4] ? Number(compound[4]) : 1
295
+ if (!Number.isFinite(numeric)) return null
296
+ const hasLength = families.includes('length')
297
+ const hasTime = families.includes('time')
298
+ if (!hasLength || !hasTime) return null
299
+ /** 分子折成里、分母折成时辰,得到「里/时辰」量。 */
300
+ const up = convert(numeric, numerator, '里')
301
+ const un = convert(1, denominator, '时辰')
302
+ if (!up.ok || up.value === undefined || !un.ok || un.value === undefined) return null
303
+ /** value = x 里 / (y 时辰) = (x / y) 里每时辰;power>1 时再取幂。 */
304
+ const perHourLike = up.value / Math.pow(un.value, power)
305
+ return '【同体系折算】约 ' + fmt(perHourLike) + ' 里/时辰' +
306
+ (power !== 1 ? '^' + power : '') + '(你的输入用了 里、时辰)'
307
+ }
308
+
309
+ /** 折算结果的位数:保留 4 位有效数字,够看又不啰嗦。 */
310
+ function fmt(value: number): string {
311
+ if (!Number.isFinite(value)) return '—'
312
+ if (value === 0) return '0'
313
+ const abs = Math.abs(value)
314
+ if (abs >= 1e6 || abs < 1e-3) return value.toExponential(3)
315
+ return String(Number(value.toPrecision(4))
316
+ )
317
+ }
318
+
319
+ /**
320
+ * 默认换算适配器:直接接 `units.ts` 的 `convert`。
321
+ *
322
+ * 为什么放在这里而不是让每个调用方自己写一遍:
323
+ * `convert` 返回的三层结构(`{ok, conversion:{result}}`)很容易读错字段 ——
324
+ * 本项目已经踩过一次(读 `conversion.value` 拿到 undefined)。
325
+ * 集中一处,读错了只改一个地方。
326
+ */
327
+ export function makeConverter(
328
+ convert: (value: number, from: string, to: string) =>
329
+ { ok: true; conversion: { result: number } } | { ok: false; error: string; hint: string },
330
+ ): (value: number, from: string, to: string) => { ok: boolean; value?: number; error?: string; hint?: string } {
331
+ return function (value, from, to) {
332
+ const result = convert(value, from, to)
333
+ if (!result.ok) return { ok: false, error: result.error, hint: result.hint }
334
+ return { ok: true, value: result.conversion.result }
335
+ }
336
+ }
package/lib/__wtest.txt DELETED
@@ -1 +0,0 @@
1
- x
@@ -1,353 +0,0 @@
1
- /**
2
- * 客户端入口:`dsh-math-calculator` 的装配壳。
3
- *
4
- * @module client
5
- * @description
6
- * 这个文件只做三件事,UI 代码从 `src/ui/*.ts` 内联在这里(宿主把 client
7
- * 当**脚本**加载,不能有顶层 import/export,所以 UI 无法拆成独立模块):
8
- *
9
- * 1. 用宿主模块加载器注册自己(`__ModuleLoader__.load`,id 必须等于包名);
10
- * 2. 把 UI 组件挂到宿主槽位:左栏主页面 + 侧栏入口 + 右栏页签;
11
- * 3. 上报一次挂载心跳(诊断用)。
12
- *
13
- * ⚠️ 加载壳部分**不要随手改**,它踩过两次真机事故:
14
- * - id 必须是**包名**(曾用 `-client` 后缀 → 宿主按包名查不到 → 桌面端致命退出);
15
- * - 入口必须**幂等**(宿主会重放 bundle,二次注册抛错会升级为致命错误)。
16
- */
17
- /**
18
- * UI 共享契约常量。
19
- *
20
- * @module ui/contract
21
- * @description
22
- * 面板 id 集中在这里,避免 client.ts 与各面板互相 import 形成环。
23
- */
24
- /** 主栏页面与侧栏入口共用的 id。 */
25
- declare var PANEL_ID: string;
26
- /** 右栏页签注册 id。 */
27
- declare var RIGHT_TAB_ID: string;
28
- /**
29
- * UI 主题层:`dsh-math-calculator` 客户端的设计令牌与通用控件。
30
- *
31
- * @module ui/theme
32
- * @description
33
- * 左栏(全局管理)与右栏(本工作区)**共用这一套**,所以两边的字号、描边、
34
- * 圆角、悬停态一定一致 —— 这就是「左右 UI 对应」的落地方式。
35
- *
36
- * 为什么只用 CSS 变量、不写死颜色:
37
- * 社区插件(dsh-context / dsh-better-sidebar / agent-teams / my-guardian)
38
- * 的产物里出现 103 个 `--dsw-*` 令牌、几乎不写死颜色。照做以后,
39
- * 深色主题、用户改字号、圆角风格都会自动跟随原生界面,不会「一眼外挂」。
40
- */
41
- /** 宿主主题令牌。只暴露面板会用到的那些,避免乱用。 */
42
- declare var T: {
43
- readonly text1: 'var(--dsw-alias-label-primary)';
44
- readonly text2: 'var(--dsw-alias-label-secondary)';
45
- readonly text3: 'var(--dsw-alias-label-tertiary)';
46
- readonly border: 'var(--dsw-alias-border-l2)';
47
- readonly hairline: 'var(--dsw-alias-border-l1)';
48
- readonly layer2: 'var(--dsw-alias-bg-layer-2)';
49
- readonly hover: 'var(--dsw-alias-interactive-bg-hover)';
50
- readonly accent: 'var(--dsw-alias-accent)';
51
- readonly accentSoft: 'var(--dsw-alias-interactive-bg-hover-accent)';
52
- readonly danger: 'var(--dsw-alias-danger)';
53
- readonly dangerSoft: 'var(--dsw-alias-interactive-bg-hover-danger)';
54
- readonly warn: 'var(--dsw-alias-state-warn-primary)';
55
- readonly ok: 'var(--dsw-alias-state-success-primary)';
56
- readonly radius: 'var(--dsw-radius-sm)';
57
- readonly radiusLg: 'var(--dsw-radius-md)';
58
- readonly f11: 'var(--dsw-font-xxxs-11)';
59
- readonly f12: 'var(--dsw-font-xxs-12)';
60
- readonly f13: 'var(--dsw-font-xs-13)';
61
- readonly f14s: 'var(--dsw-font-strong-14)';
62
- };
63
- /** 幽灵按钮(描边)的样式。 */
64
- declare function btnStyle(opts?: {
65
- danger?: boolean;
66
- active?: boolean;
67
- tiny?: boolean;
68
- }): any;
69
- /** 输入框/下拉框的样式。 */
70
- declare function inputStyle(width?: number): any;
71
- /** 面板根容器。 */
72
- declare var panel: any;
73
- /** 可滚动的正文区。 */
74
- declare var scroller: any;
75
- /** 一行:左标签、右值/按钮。 */
76
- declare var row: any;
77
- /** 淡分隔线(用在行之间)。 */
78
- declare var hairline: any;
79
- /** 次要文字。 */
80
- declare var dim: any;
81
- /** 空状态文字。 */
82
- declare var empty: any;
83
- /**
84
- * UI 基础控件:`dsh-math-calculator` 客户端的面板骨架。
85
- *
86
- * @module ui/kit
87
- * @description
88
- * 左栏与右栏都用这几个构件拼装,保证两侧**结构一致**:
89
- * `Panel`(外壳:标题栏 + 滚动正文) / `Section`(分组:标题 + 计数 + 内容)
90
- * / `Row`(一行数据) / `Empty`(空状态) / `Field`(输入 + 按钮)。
91
- *
92
- * 控件只做布局,不做业务;业务在 left-panel.ts / right-panel.ts。
93
- */
94
- /** 创建一个面板视图工厂(注入 react 后返回组件集合)。 */
95
- declare function createKit(react: any): {
96
- Panel: (props: any) => any;
97
- Section: (props: any) => any;
98
- Row: (props: any) => any;
99
- Empty: (props: any) => any;
100
- Badge: (props: any) => any;
101
- Field: (props: any) => any;
102
- Loading: (props: any) => any;
103
- Failure: (props: any) => any;
104
- Notice: (props: any) => any;
105
- };
106
- /** 面板视图工厂的类型(供各面板模块引用)。 */
107
- type Kit = ReturnType<typeof createKit>;
108
- /**
109
- * UI 数据层:`dsh-math-calculator` 客户端与后端的所有交互。
110
- *
111
- * @module ui/data
112
- * @description
113
- * 面板**不自己推导数据**,一切以后端为准(避免两份真相)。
114
- * 这里集中:HTTP 调用、工作区变更的跨面板通知、客户端心跳上报。
115
- */
116
- /** 后端 API 前缀(挂载时由宿主注入的同源路由)。 */
117
- declare var API_BASE: string;
118
- /**
119
- * 调用后端接口。
120
- *
121
- * @param path - 相对路径,如 `/workspaces/current`
122
- * @param body - 有 body 即 POST,无 body 即 GET
123
- * @returns 解析后的 JSON(后端统一包在 `data` 里时自动取出)
124
- */
125
- declare function callApi(path: string, body?: any): Promise<any>;
126
- /** 工作区变更的监听者集合(同一个 client bundle 内共享)。 */
127
- declare var listeners: Set<() => void>;
128
- /**
129
- * 广播「工作区变了」。
130
- *
131
- * 左栏切换/新建/删除工作区后调用,右栏据此立刻换成对应工作区的数据表。
132
- * 这是「换工作区自动切换」的实现方式:一个 bundle 内的轻量订阅,不轮询。
133
- */
134
- declare function notifyWorkspaceChanged(): void;
135
- /**
136
- * 订阅工作区变更。
137
- *
138
- * @param listener - 变更回调
139
- * @returns 取消订阅
140
- */
141
- declare function subscribeWorkspace(listener: () => void): () => void;
142
- /**
143
- * 上报客户端挂载情况(诊断用,失败静默)。
144
- *
145
- * 面板活在浏览器里、宿主看不见它的死活。回报一次后,宿主侧可以用
146
- * `GET /client-status` 直接读答案,排查时不必再猜。
147
- *
148
- * @param payload - 任意可 JSON 化的状态
149
- */
150
- declare function reportClientStatus(payload: Record<string, unknown>): void;
151
- /** 字节数格式化。 */
152
- declare function formatBytes(bytes: number): string;
153
- /** 时间格式化(只到分钟)。 */
154
- declare function formatTime(iso: string): string;
155
- /**
156
- * 左栏:**全局管理**(`dsh-math-calculator` 主页面)。
157
- *
158
- * @module ui/left-panel
159
- * @description
160
- * 这里管的是「所有工作区」与「跨工作区的配置」,不负责单个工作区的数据表
161
- * (那是右栏的职责)。四个页签:
162
- *
163
- * 1. 工作区 —— 列表(切换/删除/重置)、新建、清理
164
- * 2. 常数表 —— 本区常数旋钮,可改可恢复
165
- * 3. 公式表 —— 本区公式覆盖与停用
166
- * 4. 设置 —— 提示注入档位(关/简/全)
167
- *
168
- * 设计原则:面板只是**后端的视图**。所有写入都发给后端,页面重新拉取,
169
- * 不做本地乐观拼接 —— 数据结构只有后端一份。
170
- */
171
- /**
172
- * 创建左栏页面组件。
173
- *
174
- * @param react - 宿主提供的 react
175
- * @returns 主栏页面组件
176
- */
177
- declare function createLeftPanel(react: any): () => any;
178
- /**
179
- * 右栏:**本工作区工作台**(`dsh-math-calculator` 侧边栏页签)。
180
- *
181
- * @module ui/right-panel
182
- * @description
183
- * 打开就看到**当前工作区**的数据表,并允许就地管理它:
184
- *
185
- * - 顶部:工作区切换器(换了立刻拉新区的表)+ 刷新
186
- * - 开关:提示档位、公式启停
187
- * - 数据表:改动的常数 / 公式改动 / 地点 / 变量
188
- * - 管理:变量与地点的新增、删除
189
- *
190
- * 与左栏的分工:左栏管「所有工作区」,这里管「这一个工作区」。
191
- * 两侧共用 theme / kit,所以视觉与结构一致。
192
- */
193
- /** 地点类型候选(与后端 terrain.kind 对齐)。 */
194
- declare var TERRAIN_KINDS: string[];
195
- /**
196
- * 创建右栏「本工作区」组件。
197
- *
198
- * @param react - 宿主提供的 react
199
- * @returns 侧边栏页签内容组件
200
- */
201
- /** 六大目录的静态结构(顺序即推演顺序)。 */
202
- /**
203
- * 面向作者的面板(不是推演栈的镜像)。
204
- *
205
- * ## 为什么砍到 5 个
206
- *
207
- * 上一版照搬方案里的六大目录,做了 18 个节点。但那 18 个是**推演栈的结构**,
208
- * 不是「作者要做什么」。真正改设定的作者只需要五件事:
209
- *
210
- * 1. 调这个世界的样子(重力、日长、轨道…)
211
- * 2. 看这样调会怎样(体感 / 文明极限)
212
- * 3. 要个我能拿走的成品(白皮书 / 提示词)
213
- * 4. 翻我的改动记录
214
- * 5. 用魔法 / 灵能这类超出物理的设定
215
- *
216
- * 其余的(依赖图、接口卡、相图、敏感性、变更清单…)是**给 AI 的数据**,
217
- * 不该占用户界面——AI 需要时自己调工具,不必让作者看懂它们。
218
- */
219
- /**
220
- * 推演结果面板:请求 /derive 并渲染成可读文本。
221
- *
222
- * 为什么直接显示文本而不是自己解析成图表:
223
- * 后端的 opDerivation 已经按「给人读」排好版,界面再解析一遍只会**二次失真**。
224
- * 图表留给以后真有需要再做(DAG 画布是独立的大工程)。
225
- *
226
- * @param react - 宿主 react
227
- */
228
- /**
229
- * DAG 画布:用原生 SVG 画依赖图。
230
- *
231
- * 为什么不用 React Flow:本项目 client.js 是**单文件、无 import、无打包**,
232
- * 引不进第三方库。而 SVG 元素在 react 里就是普通标签,
233
- * 所以手写布局 + SVG 就够用了。
234
- *
235
- * @param react - 宿主 react
236
- */
237
- /**
238
- * 依赖图(可折叠版)。
239
- *
240
- * 用户反馈的三点都在这:
241
- * · 可折叠——按层收起,默认只展开最上一层;
242
- * · 有标注——节点显示中文名(下面小字给出符号原名);
243
- * · 连线明显——加粗、加深,带箭头。
244
- *
245
- * @param react - 宿主 react
246
- */
247
- /**
248
- * 依赖图:SVG 画布,可按层折叠。
249
- *
250
- * 用户的三点要求都在这:
251
- * · 「像图片一样点开伸缩」→ 按层折叠,默认只展开相邻两层;
252
- * · 「加上标注」→ 节点显示中文名,下面小字给符号原名;
253
- * · 「连线希望更明显」→ 描边加粗到 1.6px、用强调色、末端加箭头。
254
- *
255
- * 为什么按层折叠而不是把所有节点塞进一张图:
256
- * 40 个节点铺开要 1970px 宽,侧栏里只能横向滚动,很难用。
257
- *
258
- * @param react - 宿主 react
259
- */
260
- /**
261
- * 基本设定表单:作者最常调的四个参数。
262
- *
263
- * 为什么只有这几个:world 支持十几个参数,但作者 90% 的时间只动
264
- * 重力(行星质量/半径)、日长、轨道、大气。其余留默认,
265
- * 想精调的人可以用 AI 对话改,或者去「常数改动」。
266
- *
267
- * @param react - 宿主 react
268
- */
269
- /**
270
- * 工作台(v3):左调右看,一屏完成。
271
- *
272
- * @module ui/workbench
273
- * @description
274
- * ## 为什么重写
275
- *
276
- * v2 是「推演栈镜像」的目录树,改一个参数看结果要走 6 步(4 步导航)。
277
- * 实测后端一次综合推演只要 2.1ms,完全可以实时——导航和保存按钮都是多余的。
278
- *
279
- * ## 结构
280
- *
281
- * ┌──────────────┬──────────────────────┐
282
- * │ ① 设定 │ ② 这个世界长什么样(只读)│
283
- * │ (基础量) │ ③ 这意味着什么 │
284
- * │ ▸ 进阶参数 │ │
285
- * │ ④ 我想要(反解)│ │
286
- * └──────────────┴──────────────────────┘
287
- *
288
- * ## 三条铁律
289
- *
290
- * 1. **① 区只放基础量**——派生量(重力/均温)绝不出现输入框;
291
- * 2. **② 区只读**——它由 ① 现算,用户不能改;
292
- * 3. **改完自动存**——没有保存按钮,300ms 防抖后自动写入并重算。
293
- */
294
- /** 工作台需要的样式(复用主文件的 T 与 btnStyle/inputStyle)。 */
295
- declare function makeWorkbench(react: any): (props: any) => any;
296
- declare function makeWorldForm(react: any): (props: any) => any;
297
- declare function makeGraphCanvas(react: any): (props: any) => any;
298
- declare function makeHeatmapView(react: any): (props: any) => any;
299
- declare function makeDerivationPane(react: any): (props: any) => any;
300
- declare function createRightPanel(react: any): (props: any) => any;
301
- /** 面板出错时的兜底文案样式。 */
302
- declare var boundaryStyle: {
303
- padding: number;
304
- font: "var(--dsw-font-xxs-12)";
305
- color: "var(--dsw-alias-danger)";
306
- };
307
- /**
308
- * 错误边界:面板内部异常只影响本面板,不传染给原生界面。
309
- *
310
- * @param react - 宿主 react
311
- */
312
- declare function makeBoundary(react: any): (() => any) | {
313
- new (props: any): {
314
- [x: string]: any;
315
- componentDidCatch(): void;
316
- render(): any;
317
- };
318
- [x: string]: any;
319
- getDerivedStateFromError(error: any): {
320
- error: any;
321
- };
322
- };
323
- /**
324
- * 侧栏那一行的图标。
325
- *
326
- * ⚠️ 这里**只能返回图标,不能再写字**。
327
- * 宿主会把 `label` 回调的返回值当标题文字渲染,如果组件也写字,
328
- * 就会出现「数学工作区 数学工作区」这样重复两遍(曾经就是这样)。
329
- *
330
- * @param react - 宿主 react
331
- */
332
- declare function makeSidebarTitle(react: any): () => any;
333
- /** 右栏页签标题。 */
334
- /**
335
- * AI 工具卡片(方案第四部):把推演结果显示成对话流里的一张卡。
336
- *
337
- * 宿主为工具结果提供了 `tool.view.cordis` 这个 slot,
338
- * 注册进去就能在对话流里渲染自己的结果,而不是一坨纯文本。
339
- *
340
- * 卡片只显示**结论与状态**,不堆细节——细节在右侧控制台里看。
341
- *
342
- * @param react - 宿主 react
343
- */
344
- declare function makeToolCard(react: any): (props: any) => any;
345
- /**
346
- * 右栏页签标题:只返回图标,文字交给注册时的 title。
347
- *
348
- * 同 SidebarTitle:组件再写一遍文字会渲染两遍。
349
- * 图标与左侧入口保持一致(同一个 ∑),这样一眼能看出左右是一套。
350
- *
351
- * @param react - 宿主 react
352
- */
353
- declare function makeRightTabTitle(react: any): () => any;