cardbuddy 0.14.2 → 0.15.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/dist/cli.js +0 -0
- package/dist/preview-editor/assets/{index-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
- package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +3 -0
- package/dist/vendored/mcp/client.mjs +8 -1
- package/dist/vendored/server/core/ai-chat.js +1521 -124
- package/dist/vendored/server/core/browser.js +12 -3
- package/dist/vendored/server/core/cardFiles.js +3 -0
- package/dist/vendored/server/core/cardHistory.js +1 -0
- package/dist/vendored/server/core/cardImportMeta.js +7 -6
- package/dist/vendored/server/core/cardOpenFolder.js +1 -0
- package/dist/vendored/server/core/cardSave.js +1 -0
- package/dist/vendored/server/core/cardSync.js +3 -0
- package/dist/vendored/server/core/codeBridge.js +5 -1
- package/dist/vendored/server/core/logStream.js +1 -0
- package/dist/vendored/server/core/sliceStatic.js +24 -4
- package/dist/vendored/server/core/sliceUpload.js +1 -0
- package/dist/vendored/server/core/turnJournal.js +6 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +196 -195
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +14 -0
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -0
- package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +4 -3
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +644 -42
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5443 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
- package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +186 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v3_shared.mjs — v3 生成器共享层(2026-09-21 自 spec_to_schema_v3.mjs 拆出)。
|
|
3
|
+
* 内容:imports 依赖、常量、style-meta、组件/容器构造器、金额/字段词典、几何工具。
|
|
4
|
+
* 消费方:lib/v3_extract.mjs(语义识别)、lib/v3_assemble.mjs(schema 组装)、CLI。
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { makeMinComponent } from './scaffold.mjs'
|
|
10
|
+
import { T } from './layout-tokens.mjs'
|
|
11
|
+
import { sampleTopBand, sampleRegionColor, sampleRegionEdgeColor, sampleCardBodyColor, crossCheckTextColor, sampleOutlineRing, sampleBackdropBands } from './design_sample.mjs'
|
|
12
|
+
import { resolveSliceExport, sliceRefPath, sliceFlatName, stripScale } from './slicePath.mjs'
|
|
13
|
+
import { cardsDir, metaDir, slicesDir } from './paths.mjs'
|
|
14
|
+
/**
|
|
15
|
+
* v3 生成器设计原则(直组装,非近邻复用;原 spec_to_schema_v3.mjs 文件头,随拆分移此):
|
|
16
|
+
*
|
|
17
|
+
* 设计原则(直组装,非近邻复用):
|
|
18
|
+
* 1) 读 spec.json cards[0].elements → 按 y 坐标分段聚类为语义块(Tab 头 / 汇总区 / 按钮行 / 列表标题 / 列表项组 / 查看更多);
|
|
19
|
+
* 2) 每个语义块按其几何/文本特征映射到 DSL 组件类(Tab→Row of Text,汇总区→Column of Text,列表→ListLayout,按钮→Button);
|
|
20
|
+
* 3) 文案/颜色/字号/字重/对齐 直接来自 spec 的 textStyle(不臆造);
|
|
21
|
+
* 4) 动态绑定按规范抽 inputParams(列表用 $list,金额用表达式千分号);
|
|
22
|
+
* 5) 组件树用 scaffold.mjs::makeMinComponent 真实构建(不拷贝任何导出卡)。
|
|
23
|
+
*
|
|
24
|
+
* 用法:
|
|
25
|
+
* node spec_to_schema_v3.mjs --spec <UI/xxx/spec.json> --id <新卡id>
|
|
26
|
+
* [--name <名称>] [--out <dir>] [--force]
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const RENDER_CONTRACT = JSON.parse(fs.readFileSync(
|
|
30
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../config/render-contract.json'),
|
|
31
|
+
'utf8',
|
|
32
|
+
))
|
|
33
|
+
|
|
34
|
+
export const CARDS_DIR = cardsDir
|
|
35
|
+
export const META_DIR = metaDir
|
|
36
|
+
|
|
37
|
+
/* ---------- 语义判定常量(布局优化 P0:formRow 基线同行约束) ---------- */
|
|
38
|
+
// formRow 的 value 必须与 label 视觉基线(y + fontSize)对齐,偏差超过该值视为
|
|
39
|
+
// 「上下两行的标题-描述文本」(如 重点摘要 / 按强重要…),不得误判为表单行 value。
|
|
40
|
+
export const FORM_ROW_BASE_GAP = 8 // px
|
|
41
|
+
// label 候选排除「大字号纯数字」(头部主数字,如未读消息数 12):它是标题区数字块,
|
|
42
|
+
// 不是表单 label(避免把紧随其后的单位/说明文本吞成 value)。
|
|
43
|
+
export const LABEL_MAX_NUM_FONT = 28 // px
|
|
44
|
+
// 右操作文本排除比例:右簇文本若同行存在小尺寸近似方形媒体 affordance,
|
|
45
|
+
// 且位于卡片右缘,则按行级操作件处理,不作为表单 value/subValue。
|
|
46
|
+
export const RIGHT_ACTION_X_RATIO = 0.6
|
|
47
|
+
|
|
48
|
+
/* ----------------------- style-meta 加载(用于构造完整 style 组) ----------------------- */
|
|
49
|
+
export let _styleMeta = null
|
|
50
|
+
export function loadStyleMeta() {
|
|
51
|
+
if (_styleMeta) return _styleMeta
|
|
52
|
+
const data = JSON.parse(fs.readFileSync(path.join(META_DIR, 'style-meta.json'), 'utf8'))
|
|
53
|
+
const byKey = {}
|
|
54
|
+
for (const g of data.properties || []) if (g.itemKey) byKey[g.itemKey] = g
|
|
55
|
+
_styleMeta = { byKey }
|
|
56
|
+
return _styleMeta
|
|
57
|
+
}
|
|
58
|
+
export function fallbackValue(c) {
|
|
59
|
+
if (c.itemDefaultValue !== undefined) return c.itemDefaultValue
|
|
60
|
+
switch (c.itemType) {
|
|
61
|
+
case 'Input': case 'Textarea': return ''
|
|
62
|
+
case 'InputNumber': return 0
|
|
63
|
+
case 'Color': return '#000000'
|
|
64
|
+
case 'Switcher': return '0'
|
|
65
|
+
case 'BooleanForm': return false
|
|
66
|
+
case 'PaddingStyle': return '{0,0,0,0}'
|
|
67
|
+
case 'List': return []
|
|
68
|
+
default: return ''
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function makeStyleChild(c) {
|
|
72
|
+
const node = {
|
|
73
|
+
itemId: c.itemId, itemKey: c.itemKey, itemName: c.itemName,
|
|
74
|
+
itemType: c.itemType || 'group', itemValueFrom: '0',
|
|
75
|
+
itemValue: c.itemType === 'group' ? {} : fallbackValue(c),
|
|
76
|
+
}
|
|
77
|
+
if (c.itemOption !== undefined) node.itemOption = c.itemOption
|
|
78
|
+
if (c.itemChildren && c.itemChildren.length) node.itemChildren = { properties: c.itemChildren.map(makeStyleChild) }
|
|
79
|
+
node.itemUpdated = true
|
|
80
|
+
return node
|
|
81
|
+
}
|
|
82
|
+
export function makeStyle(groups) {
|
|
83
|
+
const meta = loadStyleMeta()
|
|
84
|
+
return groups.map((gk) => {
|
|
85
|
+
const gm = meta.byKey[gk]
|
|
86
|
+
if (!gm) return null
|
|
87
|
+
const gnode = {
|
|
88
|
+
itemId: gm.itemId, itemKey: gm.itemKey, itemName: gm.itemName,
|
|
89
|
+
itemType: gm.itemType || 'group', itemValueFrom: '0',
|
|
90
|
+
itemValue: gm.itemType === 'group' ? {} : fallbackValue(gm),
|
|
91
|
+
}
|
|
92
|
+
if (gm.itemOption !== undefined) gnode.itemOption = gm.itemOption
|
|
93
|
+
if (gm.itemChildren && gm.itemChildren.length) gnode.itemChildren = { properties: gm.itemChildren.map(makeStyleChild) }
|
|
94
|
+
gnode.itemUpdated = true
|
|
95
|
+
return gnode
|
|
96
|
+
}).filter(Boolean)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ----------------------- 辅助:常量属性 / 表达式绑定属性 ----------------------- */
|
|
100
|
+
export function constProp(itemKey, value) {
|
|
101
|
+
return { itemId: '', itemKey, itemName: itemKey, itemType: '', itemValueFrom: '0', itemValue: value, itemDefaultValue: value }
|
|
102
|
+
}
|
|
103
|
+
export function imageStaticProp(itemKey, value) {
|
|
104
|
+
return { itemId: '', itemKey, itemName: itemKey, itemType: '', itemValueFrom: '2', itemValue: value, itemDefaultValue: value }
|
|
105
|
+
}
|
|
106
|
+
export function boundProp(itemKey, expr, defaultValue = '') {
|
|
107
|
+
return { itemId: '', itemKey, itemName: itemKey, itemType: '', itemValueFrom: '1', itemValue: expr, itemDefaultValue: defaultValue }
|
|
108
|
+
}
|
|
109
|
+
// v-show 条件渲染指令:showType=01 + condition.expression(如 "$afford != ''")
|
|
110
|
+
export function vShowDirective(expression, name) {
|
|
111
|
+
const prop = (itemId, itemKey, itemValue) => ({
|
|
112
|
+
itemId, itemKey, itemName: itemKey, itemType: '', itemValueFrom: '0', itemValue, itemDefaultValue: itemValue,
|
|
113
|
+
})
|
|
114
|
+
const expr = String(expression || '')
|
|
115
|
+
// validate_schema.py 铁律:condition.name 必填非空;表达式 <15 字符时 name 与表达式一致,
|
|
116
|
+
// >=15 字符给可读显示名(调用方可传语义名,缺省回退通用名)。
|
|
117
|
+
const displayName = expr.length < 15 ? expr : (String(name || '').trim() || '显示条件')
|
|
118
|
+
return {
|
|
119
|
+
itemId: '', itemKey: 'v-show', itemName: 'v-show', itemType: '', itemValueFrom: '0',
|
|
120
|
+
itemValue: {}, itemDefaultValue: {},
|
|
121
|
+
itemChildren: {
|
|
122
|
+
properties: [
|
|
123
|
+
prop('001', 'showType', '01'),
|
|
124
|
+
prop('002', 'condition', { expression: expr, name: displayName }),
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function clickAction(code = '// TODO') {
|
|
130
|
+
return { event: { eventKey: 'click', eventName: '点击' }, action: { actionType: 'customAction', properties: [constProp('function', { code })] } }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function normalizeRenderContract(root) {
|
|
134
|
+
const leafTypes = new Set(RENDER_CONTRACT.leafClasses)
|
|
135
|
+
const clickRequiredTypes = new Set(RENDER_CONTRACT.clickRequiredClasses)
|
|
136
|
+
const walk = (node) => {
|
|
137
|
+
if (!node) return
|
|
138
|
+
const widthGroup = node.style?.properties?.find((group) => group.itemKey === 'rect')
|
|
139
|
+
?.itemChildren?.properties?.find((property) => property.itemKey === 'width')
|
|
140
|
+
const isFullWidth = String(widthGroup?.itemValue ?? '') === '100%'
|
|
141
|
+
if (leafTypes.has(node.className)) {
|
|
142
|
+
ensureStyleGroup(node, 'flex')
|
|
143
|
+
setStyleChild(node, 'flex', 'flexShrink', node.className === 'Text' ? '0' : isFullWidth ? '1' : '0')
|
|
144
|
+
}
|
|
145
|
+
if (clickRequiredTypes.has(node.className) &&
|
|
146
|
+
!(node.eventactions || []).some((item) => item?.event?.eventKey === 'click')) {
|
|
147
|
+
node.eventactions = [...(node.eventactions || []), clickAction('// Icon click contract')]
|
|
148
|
+
}
|
|
149
|
+
for (const child of node.components || []) walk(child)
|
|
150
|
+
for (const slot of node.slots || []) for (const child of slot.components || []) walk(child)
|
|
151
|
+
}
|
|
152
|
+
walk(root)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function fixFlexShrinkOverflow(root) {
|
|
156
|
+
const getChildren = (node) => {
|
|
157
|
+
const children = [...(node.components || [])]
|
|
158
|
+
for (const slot of node.slots || []) {
|
|
159
|
+
for (const child of slot.components || []) children.push(child)
|
|
160
|
+
}
|
|
161
|
+
return children
|
|
162
|
+
}
|
|
163
|
+
const getStyleVal = (node, groupKey, childKey) => {
|
|
164
|
+
const grp = node?.style?.properties?.find((p) => p.itemKey === groupKey)
|
|
165
|
+
const child = grp?.itemChildren?.properties?.find((x) => x.itemKey === childKey)
|
|
166
|
+
return child ? String(child.itemValue) : undefined
|
|
167
|
+
}
|
|
168
|
+
const setStyleVal = (node, groupKey, childKey, value) => {
|
|
169
|
+
const grp = node?.style?.properties?.find((p) => p.itemKey === groupKey)
|
|
170
|
+
const child = grp?.itemChildren?.properties?.find((x) => x.itemKey === childKey)
|
|
171
|
+
if (child) child.itemValue = value
|
|
172
|
+
}
|
|
173
|
+
const walk = (node) => {
|
|
174
|
+
if (!node) return
|
|
175
|
+
const cls = node.className
|
|
176
|
+
if (cls === 'Row' || cls === 'Column') {
|
|
177
|
+
const width = getStyleVal(node, 'rect', 'width')
|
|
178
|
+
const isAutoSize = !width || width === 'auto'
|
|
179
|
+
const children = getChildren(node)
|
|
180
|
+
if (!isAutoSize && children.length >= 2) {
|
|
181
|
+
const allShrinkZero = children.every((child) => getStyleVal(child, 'flex', 'flexShrink') === '0')
|
|
182
|
+
if (allShrinkZero) {
|
|
183
|
+
// Set the last child to flexShrink:1 (typically the most flexible content area)
|
|
184
|
+
setStyleVal(children[children.length - 1], 'flex', 'flexShrink', '1')
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const child of node.components || []) walk(child)
|
|
189
|
+
for (const slot of node.slots || []) for (const child of slot.components || []) walk(child)
|
|
190
|
+
}
|
|
191
|
+
walk(root)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ----------------------- 颜色相对亮度(用于 placeholder 灰度判定) ----------------------- */
|
|
195
|
+
// 返回 0~1 的相对亮度(sRGB 感知亮度近似)。placeholder 文本通常明显比 label 浅(如 #CCCCCC vs #111111)。
|
|
196
|
+
export function lum(hex) {
|
|
197
|
+
if (!hex || typeof hex !== 'string') return 0
|
|
198
|
+
let h = hex.trim().replace('#', '')
|
|
199
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
|
200
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) return 0
|
|
201
|
+
const r = parseInt(h.slice(0, 2), 16) / 255
|
|
202
|
+
const g = parseInt(h.slice(2, 4), 16) / 255
|
|
203
|
+
const b = parseInt(h.slice(4, 6), 16) / 255
|
|
204
|
+
const f = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))
|
|
205
|
+
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ----------------------- hex → rgba ----------------------- */
|
|
209
|
+
export function hexToRgba(hex, alpha = 1) {
|
|
210
|
+
if (!hex) return ''
|
|
211
|
+
let h = String(hex).trim().replace('#', '')
|
|
212
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
|
213
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) return hex
|
|
214
|
+
const r = parseInt(h.slice(0, 2), 16)
|
|
215
|
+
const g = parseInt(h.slice(2, 4), 16)
|
|
216
|
+
const b = parseInt(h.slice(4, 6), 16)
|
|
217
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function gradientWithOpacity(gradient, opacity = 1) {
|
|
221
|
+
const value = Number(opacity)
|
|
222
|
+
if (!Number.isFinite(value) || value >= 1 || value < 0) return gradient
|
|
223
|
+
return String(gradient || '').replace(/rgba\(([^,]+),([^,]+),([^,]+),([^)]+)\)/gi,
|
|
224
|
+
(_, red, green, blue, alpha) =>
|
|
225
|
+
`rgba(${red.trim()},${green.trim()},${blue.trim()},${Number(alpha) * value})`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* ----------------------- style 子项写入辅助 ----------------------- */
|
|
229
|
+
export function ensureStyleGroup(node, groupKey) {
|
|
230
|
+
const props = (node.style && node.style.properties) || []
|
|
231
|
+
if (props.some((p) => p.itemKey === groupKey)) return true
|
|
232
|
+
const g = makeStyle([groupKey])[0]
|
|
233
|
+
if (!g) return false
|
|
234
|
+
node.style = node.style || { properties: [] }
|
|
235
|
+
node.style.properties.push(g)
|
|
236
|
+
return true
|
|
237
|
+
}
|
|
238
|
+
export function setStyleChild(node, groupKey, childKey, value) {
|
|
239
|
+
const grp = (node.style?.properties || []).find((p) => p.itemKey === groupKey)
|
|
240
|
+
if (!grp) return false
|
|
241
|
+
const c = (grp.itemChildren?.properties || []).find((x) => x.itemKey === childKey)
|
|
242
|
+
if (!c) return false
|
|
243
|
+
c.itemValue = value
|
|
244
|
+
c.itemValueFrom = '0'
|
|
245
|
+
if (c.itemDefaultValue !== undefined) c.itemDefaultValue = value
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ----------------------- 组件工厂(最小节点 + 精确样式注入) ----------------------- */
|
|
250
|
+
// 有效组件名白名单:直接来自 vendor/preview/meta/component-meta.json(与 component_specs.md 同源)
|
|
251
|
+
// 关键纪律:脚本只能用白名单内的组件名,避免用错(如 View 不是有效名 → 编辑器显示"未知组件")。
|
|
252
|
+
// 新增组件用法前,先查 component_specs.md 对应骨架。
|
|
253
|
+
export const COMPONENT_WHITELIST = new Set([
|
|
254
|
+
'Row', 'Column', 'MultiColumn', 'ListLayout', 'GridLayout', 'AbsoluteLayout',
|
|
255
|
+
'Text', 'Button', 'Image', 'Icon', 'MultiText', 'Canvas', 'Input',
|
|
256
|
+
'CheckGroup', 'RadioButton', 'ProgressSlider', 'Video', 'Map', 'Divider', 'ViewContainer',
|
|
257
|
+
])
|
|
258
|
+
export let CID = 0
|
|
259
|
+
export const semanticYByNode = new WeakMap()
|
|
260
|
+
export function comp(className, opts = {}) {
|
|
261
|
+
if (!COMPONENT_WHITELIST.has(className)) {
|
|
262
|
+
console.warn(`[warn] 组件名 ${className} 不在白名单内,可能被编辑器识别为"未知组件"。有效清单见 component-meta.json / component_specs.md`)
|
|
263
|
+
}
|
|
264
|
+
const n = makeMinComponent(className, { componentId: `comp_${++CID}`, ...opts })
|
|
265
|
+
if (!n.style) n.style = { properties: [] }
|
|
266
|
+
// 溯源(调试):SCHEMA_PROV=1 时记录创建点(文件:行),写入 n.__prov;
|
|
267
|
+
// 序列化前由 stripProv 移除,不污染产物。排查"某节点由哪个语义通道生成"时用。
|
|
268
|
+
if (process.env.SCHEMA_PROV === '1') {
|
|
269
|
+
const frame = (new Error().stack || '').split('\n')[3] || ''
|
|
270
|
+
const m = frame.match(/(\w+\.mjs):(\d+)/)
|
|
271
|
+
n.__prov = m ? `${m[1]}:${m[2]}` : frame.trim()
|
|
272
|
+
}
|
|
273
|
+
return n
|
|
274
|
+
}
|
|
275
|
+
/** 圆角归一:标注可能是四角数组 [上,右,下,左](数组直接 >=N 比较恒 false,壳/按钮会漏判),取最大角。 */
|
|
276
|
+
export function radiusMax(style) {
|
|
277
|
+
const v = style?.radius?.borderRadius
|
|
278
|
+
if (Array.isArray(v)) return Math.max(0, ...v.map((n) => Number(n) || 0))
|
|
279
|
+
return Number(v) || 0
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 多行判定阈值(层框高/字号):2 行文本层框高 ≈ 2×行距(行距系数 ≥1.2)→ ≥2.4×fs;
|
|
283
|
+
* 单行文本的标注行高可宽松到 ~2×fs(Sketch 常见 1.5-1.7)。
|
|
284
|
+
* 旧阈值 1.5 把 fs15/h24(1.6×)这类「单行宽松行高」误判多行——不设固定高、按渲染
|
|
285
|
+
* 自然行距 fs×1.5=22.5 撑盒,比设计层框矮 1.5px/行,行间节奏逐行累积上移。 */
|
|
286
|
+
export const MULTILINE_HEIGHT_RATIO = 2.3
|
|
287
|
+
|
|
288
|
+
/** 单行文本安全盒高(textFromSpec 同款策略,供调用方预算盒高/重居中补偿):
|
|
289
|
+
* specH >= round(fs×1.53)-2 → specH(零漂移不溢出);否则 max(round(fs×1.53)-2, ceil(fs×1.35))。
|
|
290
|
+
* 返回 0 表示多行/无固定高(自然撑高)。 */
|
|
291
|
+
export function safeTextBoxHeight(fontSize, specH) {
|
|
292
|
+
const fs = Number(fontSize || 14)
|
|
293
|
+
const h = Number(specH)
|
|
294
|
+
if (!Number.isFinite(h) || h <= 0) return 0
|
|
295
|
+
if (h > fs * MULTILINE_HEIGHT_RATIO) return 0 // 多行:不设固定高
|
|
296
|
+
const scrollFloor = Math.round(fs * 1.53)
|
|
297
|
+
const glyphFloor = Math.ceil(fs * 1.35)
|
|
298
|
+
return h >= scrollFloor - 2 ? Math.round(h) : Math.max(scrollFloor - 2, glyphFloor)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 文本渲染盒高估算(与 textFromSpec 盒高策略严格一致):
|
|
302
|
+
* 语义边界/行距预算一律用它,保证「估算底沿 == 渲染流式底沿」,顶距换算零漂移。
|
|
303
|
+
* 多行(specH > fs×1.5)自然撑高 ≈ 标注高度;无标注高自然撑高 = round(fs×1.53)。 */
|
|
304
|
+
export function estimatedTextBoxHeight(specH, fontSize) {
|
|
305
|
+
const fs = Number(fontSize || 14)
|
|
306
|
+
const h = Number(specH)
|
|
307
|
+
if (Number.isFinite(h) && h > 0) {
|
|
308
|
+
if (h > fs * MULTILINE_HEIGHT_RATIO) return h
|
|
309
|
+
return safeTextBoxHeight(fs, h)
|
|
310
|
+
}
|
|
311
|
+
return Math.round(fs * 1.53)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Text 节点 + 完整 font 组(color/fontSize/fontWeight/textAlign 来自 spec textStyle) */
|
|
315
|
+
export function textFromSpec(textObj) {
|
|
316
|
+
const t = comp('Text')
|
|
317
|
+
const _fs = Number(textObj.fontSize || 14)
|
|
318
|
+
const _specH = Number(textObj.height)
|
|
319
|
+
// 多行文本(spec 高度 > 1.5 倍字号):允许换行(maxLine=0),不设固定高度让文本自然撑高
|
|
320
|
+
// 单行文本:保留 spec 高度 + maxLine=1 防止换行,避免 scrollHeight > clientHeight 溢出
|
|
321
|
+
const _isMultiLine = Number.isFinite(_specH) && _specH > _fs * MULTILINE_HEIGHT_RATIO
|
|
322
|
+
// 多行段落的标注行距:标注总高/估算行数。低于自然行距(fs×1.5)时设 lineHeight 收拢,
|
|
323
|
+
// 否则段落比设计稿高 (1.5×fs−pitch)×行数,下方内容整段下漂。
|
|
324
|
+
const _multiLinePitch = _isMultiLine
|
|
325
|
+
? _specH / Math.max(2, Math.round(_specH / (_fs * 1.5)))
|
|
326
|
+
: 0
|
|
327
|
+
if (_isMultiLine) {
|
|
328
|
+
// 不设 rect.height,文本自然撑高
|
|
329
|
+
// 多行段落钉标注宽:盒宽决定换行折点,比设计稿宽会把每行字数变多、折点全错。
|
|
330
|
+
const _specW = Number(textObj.width)
|
|
331
|
+
if (Number.isFinite(_specW) && _specW > 0) {
|
|
332
|
+
ensureStyleGroup(t, 'rect')
|
|
333
|
+
setStyleChild(t, 'rect', 'width', String(Math.round(_specW)))
|
|
334
|
+
}
|
|
335
|
+
} else if (Number.isFinite(_specH) && _specH > 0) {
|
|
336
|
+
ensureStyleGroup(t, 'rect')
|
|
337
|
+
// 盒高策略(实测渲染度量):单行文本 scrollHeight ≈ round(fs×1.53)(PingFang web 度量,
|
|
338
|
+
// 与 font.lineHeight 无关——lineHeight 只能撑大不能缩小滚动高度),渲染器默认
|
|
339
|
+
// overflow:hidden,盒高不足会真实裁切。评分按 scrollH > clientH + 2 判溢出。
|
|
340
|
+
// - specH >= scrollFloor-2:盒高=specH —— 零漂移且 scrollH <= specH+2,不溢出不裁切;
|
|
341
|
+
// - specH < scrollFloor-2(标注层异常紧致):盒高=max(scrollFloor-2, ceil(fs×1.35)) ——
|
|
342
|
+
// scrollFloor-2 是评分安全线,fs×1.35 是字形 em 盒下限(再低会裁到字形本身),
|
|
343
|
+
// 残余漂移 <=3px,远小于直接撑到 fs×1.5 的 3-4px。
|
|
344
|
+
// 不设 font.lineHeight:实测对盒高/滚动高度无正向作用,且历史上因 font 组尚未
|
|
345
|
+
// ensure 而静默失效(setStyleChild 找不到子项直接 return false)。
|
|
346
|
+
const _boxH = safeTextBoxHeight(_fs, _specH)
|
|
347
|
+
if (_boxH > 0) setStyleChild(t, 'rect', 'height', String(_boxH))
|
|
348
|
+
}
|
|
349
|
+
ensureStyleGroup(t, 'font')
|
|
350
|
+
setStyleChild(t, 'font', 'color', hexToRgba(textObj.color || T.color('textDefault')))
|
|
351
|
+
setStyleChild(t, 'font', 'fontSize', String(textObj.fontSize || 14))
|
|
352
|
+
setStyleChild(t, 'font', 'fontWeight', String(textObj.fontWeight || '400'))
|
|
353
|
+
// textAlign:预览器 font 组支持 textAlign 子项(itemId 见 style-meta)
|
|
354
|
+
if (textObj.align) setStyleChild(t, 'font', 'textAlign', textObj.align)
|
|
355
|
+
// 框架行距(标注层框高/行数)与自然行距偏差 >0.5 时双向设定:偏低收拢防段落超高,
|
|
356
|
+
// 偏高撑开防行块过矮(框高是几何真值,textStyle.lineHeight 属性可能是陈旧元数据——
|
|
357
|
+
// 实测导出位图行距与框高一致、与 lineHeight 属性相差 4px/行)。
|
|
358
|
+
// 扩张方向无裁切风险(多行不设固定盒高,盒随内容长)。
|
|
359
|
+
if (_isMultiLine && Math.abs(_multiLinePitch - _fs * 1.5) > 0.5) {
|
|
360
|
+
setStyleChild(t, 'font', 'lineHeight', String(Math.round(_multiLinePitch)))
|
|
361
|
+
}
|
|
362
|
+
// 多行文本允许换行(maxLine=0);单行文本用 maxLine=1 防止意外换行导致溢出检测误报
|
|
363
|
+
setStyleChild(t, 'font', 'maxLine', _isMultiLine ? '0' : '1')
|
|
364
|
+
// itemValueFrom:1=表达式绑定(必须带 itemDefaultValue);0=常量
|
|
365
|
+
if (textObj.bind) {
|
|
366
|
+
t.properties = [boundProp('value', textObj.bind, textObj.defaultValue !== undefined ? textObj.defaultValue : textObj.text)]
|
|
367
|
+
} else {
|
|
368
|
+
t.properties = [constProp('value', textObj.text)]
|
|
369
|
+
}
|
|
370
|
+
return t
|
|
371
|
+
}
|
|
372
|
+
/** 切片(slice=切图/图片) → Image 组件,引用导出的切片 PNG 资源 */
|
|
373
|
+
// 关键纪律:设计稿的箭头/图标常以 slice(切图)形式存在,且卡片 prompt.md §5.2 要求「优先复用切图素材」。
|
|
374
|
+
// 禁止臆造 Icon 矢量字形替代(如 arrow=angle_right);应如实用 Image 引用切片。
|
|
375
|
+
// value 约定(issue-slices):统一用 /slices/<扁平化导出路径> 引用,本地落点 src/slices/<扁平名>,
|
|
376
|
+
// 由 Vite(5180)/cardbuddy(8787) 挂载 /slices/* 供预览访问;同步远程时再改写为 @filestore/<ns>/<name>@3x.png。
|
|
377
|
+
export function imageFromSlice(sliceEl, specSlices) {
|
|
378
|
+
const exportRel = resolveSliceExport(specSlices, sliceEl.name)
|
|
379
|
+
const cropRel = sliceEl.annotationDefect?.cropSlice
|
|
380
|
+
if (!exportRel && !cropRel) return null
|
|
381
|
+
if (sliceEl.annotationDefect && !cropRel) return null
|
|
382
|
+
const img = comp('Image')
|
|
383
|
+
const r = rect(sliceEl)
|
|
384
|
+
ensureStyleGroup(img, 'rect')
|
|
385
|
+
ensureStyleGroup(img, 'image')
|
|
386
|
+
ensureStyleGroup(img, 'flex')
|
|
387
|
+
setStyleChild(img, 'rect', 'width', String(Math.round(r.width || 20)))
|
|
388
|
+
setStyleChild(img, 'rect', 'height', String(Math.round(r.height || 20)))
|
|
389
|
+
// 切片图标是固定尺寸节点:与撑满型兄弟节点(Input/值列)同处一行时绝不收缩,
|
|
390
|
+
// 否则会被 width:100% 的兄弟挤压变形。通用规则:固定型兄弟节点一律 flexShrink:0
|
|
391
|
+
setStyleChild(img, 'flex', 'flexShrink', '0')
|
|
392
|
+
// cropMode 01 = contain(透明小图标 PNG 不裁切、保持原比例)
|
|
393
|
+
setStyleChild(img, 'image', 'cropMode', '01')
|
|
394
|
+
img.properties = [imageStaticProp('value', sliceRefPath(cropRel || exportRel))]
|
|
395
|
+
return img
|
|
396
|
+
}
|
|
397
|
+
/** 可见媒体占位:素材未导出 PNG 时用占位 Image,
|
|
398
|
+
* 绝不静默丢弃(用户可见的图标必须还原位置);仅装饰性大位图才允许跳过。
|
|
399
|
+
* 尺寸/圆角取自 sliceEl rect(与真实切片同占位)。 */
|
|
400
|
+
export function placeholderImage(sliceEl) {
|
|
401
|
+
const r = rect(sliceEl)
|
|
402
|
+
const width = Math.max(1, Math.round(r.width || 20))
|
|
403
|
+
const height = Math.max(1, Math.round(r.height || 20))
|
|
404
|
+
const defectType = sliceEl.annotationDefect?.type || 'missing-slice'
|
|
405
|
+
const label = defectType === 'slice-visual-mismatch' ? '素材冲突' : '缺素材'
|
|
406
|
+
const placeholderUrl = `http://iph.href.lu/${width}x${height}?text=${encodeURIComponent(label)}`
|
|
407
|
+
const image = comp('Image')
|
|
408
|
+
ensureStyleGroup(image, 'rect')
|
|
409
|
+
ensureStyleGroup(image, 'image')
|
|
410
|
+
ensureStyleGroup(image, 'flex')
|
|
411
|
+
setStyleChild(image, 'rect', 'width', String(width))
|
|
412
|
+
setStyleChild(image, 'rect', 'height', String(height))
|
|
413
|
+
setStyleChild(image, 'flex', 'flexShrink', '0')
|
|
414
|
+
setStyleChild(image, 'image', 'cropMode', '01')
|
|
415
|
+
image.properties = [imageStaticProp('value', placeholderUrl)]
|
|
416
|
+
return image
|
|
417
|
+
}
|
|
418
|
+
/** 功能图标三态:已导出 → 真实 Image;未导出 → 占位 Image;无 sliceEl → null(装饰跳过由调用方决定) */
|
|
419
|
+
export function imageOrPlaceholder(sliceEl, specSlices) {
|
|
420
|
+
if (!sliceEl) return null
|
|
421
|
+
const img = imageFromSlice(sliceEl, specSlices)
|
|
422
|
+
return img || placeholderImage(sliceEl)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 在 sem.slices(切片元素数组)中按导出相对路径反查切片元素(用于取 rect 尺寸) */
|
|
426
|
+
export function findSliceElByExport(sem, exportRel) {
|
|
427
|
+
if (!exportRel) return null
|
|
428
|
+
const base = stripScale(String(exportRel).split('/').pop())
|
|
429
|
+
return (sem.slices || []).find((s) => stripScale(String(s.name || '').split('/').pop()) === base) || null
|
|
430
|
+
}
|
|
431
|
+
/** 把金额字符串(如 "¥5,638.00" / "-¥23.50")解析为数字 */
|
|
432
|
+
export function parseAmount(text) {
|
|
433
|
+
if (!text) return null
|
|
434
|
+
// 从任意位置抽取首个数字串(先剥千分位/空白)——兼容 "HK$14,222.7" 这类
|
|
435
|
+
// 字母+符号混合前缀;只剥符号对字母前缀无能为力(parseFloat("HK1…")=NaN → 金额归零)。
|
|
436
|
+
const neg = /^-|^\(.*\)$/.test(text) ? -1 : 1
|
|
437
|
+
const m = String(text).replace(/[,,\s]/g, '').match(/\d+(\.\d+)?/)
|
|
438
|
+
if (!m) return null
|
|
439
|
+
return neg * parseFloat(m[0])
|
|
440
|
+
}
|
|
441
|
+
/** 纯掩码文本(如 "*****" / "●●●●"):无数字、仅掩码符。
|
|
442
|
+
* 金额隐藏态(如隐藏余额)在设计稿中只有掩码占位,底层数值不可知;
|
|
443
|
+
* 若按 number 解析会得到 0 并覆盖掩码展示(重影/失真),应按 string 字面量绑定。 */
|
|
444
|
+
export function isMaskedOnlyText(text) {
|
|
445
|
+
const t = String(text || '').trim()
|
|
446
|
+
return t.length > 0 && !/\d/.test(t) && /^[*●•·]+$/.test(t)
|
|
447
|
+
}
|
|
448
|
+
/** 构建金额表达式(千分号 + 两位小数 + 运行时按正负决定负号前缀)
|
|
449
|
+
* 关键纪律:**不能根据 spec 原始文本写死前缀负号**——同一变量在不同数据下可能正负切换。
|
|
450
|
+
* 必须在表达式里运行时判断 $var 的正负:负数显示 '-¥abs',正数显示 '¥amount'。
|
|
451
|
+
*/
|
|
452
|
+
export function currencyPrefixOf(srcText) {
|
|
453
|
+
const curMatch = String(srcText || '').replace(/^[-+]/, '').match(/^[^\d\-(),.\s]{1,4}(?=[\d(])/u)
|
|
454
|
+
return curMatch && /\p{Sc}/u.test(curMatch[0]) ? curMatch[0] : ''
|
|
455
|
+
}
|
|
456
|
+
export function amountExpr(varName, srcText, opts = {}) {
|
|
457
|
+
// 尊重设计稿原文:
|
|
458
|
+
// 原文含 ¥/¥ 符号 → '¥'+千分号(如"¥563.80")
|
|
459
|
+
// 纯数字无符号 → 仅千分号(如 "2000" 配合相邻 "万元")
|
|
460
|
+
// 原文含汉字单位(元/角/分/万)→ 仅千分号 + 单位后缀(如 "563.80元" / "5,074.20元")
|
|
461
|
+
// 避免把设计稿的"563.80元"强改为"¥563.80"。
|
|
462
|
+
// 小数位数尊重原文:原文整数(如"2000万元")不强加 .00,否则按默认宽度裁切后显示成"2,00";
|
|
463
|
+
// 运行时若非整数仍兜底 toFixed(2)。原文无千分号则不加(避免 4 位整数被插逗号撑破设计宽度)。
|
|
464
|
+
const unitMatch = srcText != null ? String(srcText).match(/([\d,.\-]+)([\u4e00-\u9fa5]+)$/) : null
|
|
465
|
+
const hasYuanUnit = !!unitMatch
|
|
466
|
+
// 货币前缀取设计稿原文(任意 Unicode 货币符号,含 "HK$"/"US$" 字母+符号组合),不写死某一种
|
|
467
|
+
// 负号不是货币前缀的一部分:先剥离首符号再取前缀("-¥2,000.00" 否则丢 ¥)。
|
|
468
|
+
const curPrefix = currencyPrefixOf(srcText)
|
|
469
|
+
const hasCurrency = !!curPrefix
|
|
470
|
+
const srcNum = srcText != null ? String(srcText).replace(/[^\d,.-]/g, '') : ''
|
|
471
|
+
const srcHasDecimals = /\.\d/.test(srcNum)
|
|
472
|
+
const srcHasSep = srcNum.includes(',')
|
|
473
|
+
const absRaw = "($" + varName + " < 0 ? (0 - $" + varName + ") : $" + varName + ")"
|
|
474
|
+
const sep = ".replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')"
|
|
475
|
+
const abs = srcHasDecimals
|
|
476
|
+
? absRaw + ".toFixed(2)" + sep
|
|
477
|
+
: "((" + absRaw + ") % 1 === 0 ? '' + (" + absRaw + ") : (" + absRaw + ").toFixed(2))" + (srcHasSep ? sep : '')
|
|
478
|
+
const sign = "($" + varName + " < 0 ? '-' : '')"
|
|
479
|
+
if (hasYuanUnit) {
|
|
480
|
+
const unit = JSON.stringify(unitMatch[2])
|
|
481
|
+
return `${sign} + ${abs} + ${unit}`
|
|
482
|
+
}
|
|
483
|
+
// 列表行模板共用一套表达式:货币前缀必须逐行数据驱动($amtCurN),否则首行符号套到全行
|
|
484
|
+
if (opts.prefixVar) return `${sign} + $${opts.prefixVar} + ${abs}`
|
|
485
|
+
return `${sign} + ${abs}`
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function amountValueExpr(varName, srcText, { currencyPrefix = true } = {}) {
|
|
489
|
+
const expression = amountExpr(varName, srcText)
|
|
490
|
+
return currencyPrefix ? expression : expression.replace(/ \+ (?:'[^']*'|"[^"]*")/, '')
|
|
491
|
+
}
|
|
492
|
+
/** 卡号脱敏表达式:仅存【完整】卡号,展示时由表达式处理脱敏(保留前4后4,中间 ****)。
|
|
493
|
+
* 通用做法:不另存 masked 字段——双字段会带来数据冗余与双源不一致。
|
|
494
|
+
* 设计稿里的 * 只是脱敏占位,真实数据基本未脱敏,故 data 里存完整卡号,表达式负责展示态脱敏。 */
|
|
495
|
+
export function maskCardExpr(varName) {
|
|
496
|
+
const v = '$' + varName
|
|
497
|
+
// 脱敏组数随行号长度走:16 位卡号 = 前4 + 两组 **** + 后4(如"6230 **** **** 2939"),
|
|
498
|
+
// 只有一组会丢段(曾渲染成"6230 **** 2939")。
|
|
499
|
+
return `(${v}.length > 8 ? ${v}.slice(0,4) + ' ****'.repeat(Math.max(1, Math.ceil((${v}.length - 8) / 4))) + ' ' + ${v}.slice(-4) : ${v})`
|
|
500
|
+
}
|
|
501
|
+
/** 脱敏还原:设计稿中的 * 是脱敏占位(真实数据基本未脱敏),mock 须还原为未脱敏值。
|
|
502
|
+
* 规则:* 还原为 0(保留位数结构),去空格。如 "6230 **** 1238" → "623000001238"。 */
|
|
503
|
+
export function unmask(s) {
|
|
504
|
+
return String(s).split('').map((ch) => (ch === '*' ? '0' : ch)).join('').replace(/\s+/g, '')
|
|
505
|
+
}
|
|
506
|
+
/** 把文本按 literalBind 映射为绑定表达式(关键信息 → $obj.field),否则原样常量返回。
|
|
507
|
+
* e.expr 存在时优先用自定义表达式(如卡号脱敏 maskCardExpr),否则按 amount/$var 兜底。 */
|
|
508
|
+
export function applyLiteralBind(textObj, map) {
|
|
509
|
+
const e = map && map[textObj.text]
|
|
510
|
+
if (!e) return textObj
|
|
511
|
+
const expr = e.expr || (e.amount ? amountExpr(e.var) : '$' + e.var)
|
|
512
|
+
return { ...textObj, bind: expr, defaultValue: e.default }
|
|
513
|
+
}
|
|
514
|
+
/** 值格式分类(纯 SHAPE,零业务语义):只决定「绑定表达式用哪种变换」,不关心"这是什么业务字段"。
|
|
515
|
+
* - amount 带 ¥/元/千分位/小数的数值
|
|
516
|
+
* - cardNumber 4-4-4(含*) 或 12-19 位纯数字
|
|
517
|
+
* - phone 11 位手机号 / 6+ 位纯数字
|
|
518
|
+
* - code (XXXX) 括号后缀
|
|
519
|
+
* - name 2-4 个中文字(含 ·•)
|
|
520
|
+
* - date YYYY-MM-DD
|
|
521
|
+
* - text 其余(默认字面量,不绑定)
|
|
522
|
+
*/
|
|
523
|
+
export function classifyValue(s) {
|
|
524
|
+
const t = (s || '').trim()
|
|
525
|
+
// 金额:¥/¥ 前缀、千分位/小数、或 数字+元(含小数,如 563.80元 / 5,074.20元)
|
|
526
|
+
if (/^[A-Za-z]{0,3}\p{Sc}.*[\d,]+(\.\d+)?$|^[\d,]+\.\d+$|^[\d,]+(\.\d+)?元$/u.test(t)) return 'amount'
|
|
527
|
+
if (/^\*?\d{4}[\s*·\-]+\d{4}([\s*·\-]+\d{1,4})*\*?$/.test(t) || /^\d{12,19}$/.test(t.replace(/\s/g, ''))) return 'cardNumber'
|
|
528
|
+
if (/^1\d{10}$/.test(t) || /^\d{6,}$/.test(t)) return 'phone'
|
|
529
|
+
if (/^\(.*\)$/.test(t)) return 'code'
|
|
530
|
+
if (/^[一-龥·•]{2,4}$/.test(t)) return 'name'
|
|
531
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return 'date'
|
|
532
|
+
return 'text'
|
|
533
|
+
}
|
|
534
|
+
/** 手机号脱敏表达式:仅当设计稿本身含 * 时才脱敏(保留前3后4,中间 ****);否则原样绑定(展示态=设计态)。 */
|
|
535
|
+
export function maskPhoneExpr(varName) {
|
|
536
|
+
const v = '$' + varName
|
|
537
|
+
return `(${v}.length === 11 ? ${v}.slice(0,3) + '****' + ${v}.slice(-4) : ${v})`
|
|
538
|
+
}
|
|
539
|
+
/** 由「值格式角色」决定绑定表达式(普适,与业务无关)。脱敏仅当源文本含 * 时才启用,保证展示态=设计态。 */
|
|
540
|
+
export function valueExpr(role, varName, text) {
|
|
541
|
+
if (role === 'amount') return amountExpr(varName, text)
|
|
542
|
+
// 卡号分组空格是展示态的一部分:数据层 unmask 存纯数字,表达式按源文本分组模式还原空格。
|
|
543
|
+
// 分组模式从源文本推导(如 "6239 2738 2928 0193" → 等长 4 位组),不写死分组规则;
|
|
544
|
+
// 非均匀分组罕见且易错,回退为原样绑定(设计稿还原优先于数据规范化)。
|
|
545
|
+
if (role === 'cardNumber') {
|
|
546
|
+
if (/\*/.test(text || '')) return maskCardExpr(varName)
|
|
547
|
+
const grouped = String(text || '').trim().match(/^\d+( \d+)+$/)
|
|
548
|
+
if (grouped) {
|
|
549
|
+
const lens = String(text).trim().split(' ').map((g) => g.length)
|
|
550
|
+
if (lens.every((l) => l === lens[0])) {
|
|
551
|
+
return `($${varName} || '').replace(/\\D/g, '').replace(/(\\d{${lens[0]}})(?=\\d)/g, '$1 ')`
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return '$' + varName
|
|
555
|
+
}
|
|
556
|
+
if (role === 'phone') return /\*/.test(text || '') ? maskPhoneExpr(varName) : '$' + varName
|
|
557
|
+
return '$' + varName
|
|
558
|
+
}
|
|
559
|
+
/* ----------------------- 语义化字段命名(标签词典 + 值角色兜底,泛化非硬编码) ----------------------- */
|
|
560
|
+
// 双层命名机制(替代旧 obj_N / amount_0 序号命名):
|
|
561
|
+
// 1) 标签词典 references/field_name_dict.json:UI 中文标签(表单行左标签 / 副值行标签 / 辅助文案)
|
|
562
|
+
// → 语义化英文驼峰 key。词典是**领域通用词表**(账号/金额/人/日期/状态等高频标签),
|
|
563
|
+
// 不针对任何具体卡片:任何卡的标签都能命中,卡片 id 永不进入命名。
|
|
564
|
+
// 2) 值角色兜底(cardNumber/amount/phone/name/date/code):词典未命中时按值格式命名,
|
|
565
|
+
// 保证永不出现 obj_N / amount_0 序号名。
|
|
566
|
+
export let _fieldDict = null
|
|
567
|
+
export function loadFieldDict() {
|
|
568
|
+
if (_fieldDict) return _fieldDict
|
|
569
|
+
try {
|
|
570
|
+
_fieldDict = JSON.parse(fs.readFileSync(path.join(SCRIPT_DIR, '..', 'references', 'field_name_dict.json'), 'utf8'))
|
|
571
|
+
} catch { _fieldDict = { entries: [] } }
|
|
572
|
+
return _fieldDict
|
|
573
|
+
}
|
|
574
|
+
export function displayPrefixRe() {
|
|
575
|
+
const prefixes = (loadFieldDict().displayPrefixes || [])
|
|
576
|
+
.map((item) => String(item).trim()).filter(Boolean)
|
|
577
|
+
if (!prefixes.length) return null
|
|
578
|
+
return new RegExp(`^(${prefixes.map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*`)
|
|
579
|
+
}
|
|
580
|
+
// 中文 label → 词典条目(去冒号空格后做前缀包含匹配;最长词优先,避免「金额」抢先于「转账金额」)
|
|
581
|
+
export function dictEntryFor(label) {
|
|
582
|
+
const norm = String(label || '').replace(/[::\s]/g, '')
|
|
583
|
+
if (!norm) return null
|
|
584
|
+
const entries = (loadFieldDict().entries) || []
|
|
585
|
+
let best = null, bestLen = 0
|
|
586
|
+
for (const en of entries) {
|
|
587
|
+
for (const l of en.labels || []) {
|
|
588
|
+
const ln = String(l).replace(/\s/g, '')
|
|
589
|
+
if (norm.includes(ln) && ln.length > bestLen) { best = en; bestLen = ln.length }
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return best
|
|
593
|
+
}
|
|
594
|
+
// label → 对象级 key(实体名,如「转出账号」→ outAccount);未命中返回 null
|
|
595
|
+
export function entityKeyFor(label) {
|
|
596
|
+
const en = dictEntryFor(label)
|
|
597
|
+
if (!en) return null
|
|
598
|
+
return en.entityKey || en.fieldKey || null
|
|
599
|
+
}
|
|
600
|
+
// label → 字段级 key(如「可用余额:」→ availableBalance);未命中返回 null
|
|
601
|
+
export function fieldKeyFor(label) {
|
|
602
|
+
const en = dictEntryFor(label)
|
|
603
|
+
if (!en) return null
|
|
604
|
+
return en.fieldKey || en.entityKey || null
|
|
605
|
+
}
|
|
606
|
+
// 值格式角色 → 兜底字段名(词典未命中时用;也用于无 label 场景)
|
|
607
|
+
export const ROLE_BASE_NAME = { amount: 'amount', cardNumber: 'cardNumber', phone: 'phone', code: 'code', date: 'date', name: 'name' }
|
|
608
|
+
export function roleFieldKey(role, label) {
|
|
609
|
+
const dk = fieldKeyFor(label)
|
|
610
|
+
if (dk) return dk
|
|
611
|
+
return ROLE_BASE_NAME[role] || role
|
|
612
|
+
}
|
|
613
|
+
// 唯一 key:base 已被占用 → base_1 / base_2 ...
|
|
614
|
+
export function uniqueKey(base, used) {
|
|
615
|
+
let k = base || 'field'
|
|
616
|
+
let i = 1
|
|
617
|
+
while (used.has(k)) k = base + '_' + (i++)
|
|
618
|
+
used.add(k)
|
|
619
|
+
return k
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 把含多个值 token 的文本拆为有序段:[{text, role}],role=null 表示字面量(非值)。
|
|
623
|
+
* 仅按 SHAPE 切分,不依赖业务词;用于「张三 13800138000」类单元素多值文本。 */
|
|
624
|
+
export function splitValueTokens(raw) {
|
|
625
|
+
const t = (raw || '').trim()
|
|
626
|
+
if (!t) return []
|
|
627
|
+
const tokRe = /(¥?\s*[\d,]+(\.\d+)?\s*元?|[\d]{4}[\s*·\-]+[\d]{4}([\s*·\-]+[\d]{1,4})*|\([\w]+\)|[一-龥·•]{2,4}|1\d{10}|\d{6,})/g
|
|
628
|
+
const segs = []
|
|
629
|
+
let last = 0, m
|
|
630
|
+
while ((m = tokRe.exec(t))) {
|
|
631
|
+
if (m.index > last) {
|
|
632
|
+
const lit = t.slice(last, m.index).trim()
|
|
633
|
+
if (lit) segs.push({ text: lit, role: null })
|
|
634
|
+
}
|
|
635
|
+
segs.push({ text: m[0].trim(), role: classifyValue(m[0]) })
|
|
636
|
+
last = m.index + m[0].length
|
|
637
|
+
}
|
|
638
|
+
if (last < t.length) { const tail = t.slice(last).trim(); if (tail) segs.push({ text: tail, role: null }) }
|
|
639
|
+
return segs
|
|
640
|
+
}
|
|
641
|
+
/** 枚举统计行检测(Enum-Stat Strip,泛用非硬编码):识别「类型标签 + 数字」多段枚举统计文本。
|
|
642
|
+
* 结构信号(不看业务词,任何枚举统计场景通用):
|
|
643
|
+
* 1. 整行 = ≥2 段「短标签 + 数字」;
|
|
644
|
+
* 2. 段间统一分隔符(· 、 , | / 空格等,整行内一致);
|
|
645
|
+
* 3. 每段数字 = 正整数计数(5/12)或金额(¥1,280.00 / 2.5万 / 563.80元);
|
|
646
|
+
* 4. 每段标签 = 1-6 字分类词(动账/转账/站内…),排除量词/序号(第 5 名不命中)。
|
|
647
|
+
* 命中返回 { items:[{label,value}], sep }(sep 为段间原文分隔符串,渲染 map/join 用);
|
|
648
|
+
* 未命中返回 null。例:"动账 5 · 转账 2 · 站内 1 · 其他 4" → items 4 项 + sep " · "。
|
|
649
|
+
* 注:value 统一为 number(计数 parseInt;金额 parseAmount)。 */
|
|
650
|
+
export function detectEnumStatStrip(raw) {
|
|
651
|
+
const t = String(raw || '').trim()
|
|
652
|
+
if (!t) return null
|
|
653
|
+
// 全局配对:每段 = 「1-6 字标签 + 数字(计数或金额)」+ 可选的段间分隔符(·、,|/ 空格等)。
|
|
654
|
+
// 要求:① 全文本从头到尾都被配对覆盖(无残余);② 段数 ≥2;③ 段间分隔符类型统一。
|
|
655
|
+
// 数字形态:正整数计数(5/12)或金额(1,200.00 / ¥5,638.00 / 2.5万 / 563.80元)。
|
|
656
|
+
const pairRe = /([\u4e00-\u9fa5A-Za-z]{1,6})[\s ]*([¥¥]?[\d][\d,]*(?:\.\d+)?(?:[元万]|%|%)?)([·、,,|/\s ]*)/g
|
|
657
|
+
const items = []
|
|
658
|
+
const seps = []
|
|
659
|
+
let m, lastEnd = 0
|
|
660
|
+
while ((m = pairRe.exec(t))) {
|
|
661
|
+
if (m.index !== lastEnd) return null // 段间存在无法解释的内容(如"剩余 3 天"的"天")
|
|
662
|
+
const label = m[1], numText = m[2], sep = m[3] || ''
|
|
663
|
+
const value = /[¥¥]/.test(numText) || /\.\d/.test(numText) || /[元万]/.test(numText)
|
|
664
|
+
? (parseAmount(numText) !== null ? parseAmount(numText) : Number(numText.replace(/[^0-9.]/g, '')))
|
|
665
|
+
: parseInt(numText.replace(/[^0-9]/g, ''), 10)
|
|
666
|
+
items.push({ label, value: isNaN(value) ? 0 : value })
|
|
667
|
+
if (sep) seps.push(sep)
|
|
668
|
+
lastEnd = m.index + m[0].length
|
|
669
|
+
}
|
|
670
|
+
if (items.length < 2 || lastEnd !== t.length) return null
|
|
671
|
+
// 分隔符统一性:去空格后相同才命中(" · " 与 " | " 混用 → 不规则,交给 LLM/Agent 判)
|
|
672
|
+
const normSeps = new Set(seps.map((s) => s.replace(/\s/g, '')))
|
|
673
|
+
if (normSeps.size > 1) return null
|
|
674
|
+
// 分隔符取**原文形态**(保留空格,如 " · ")——渲染 map/join 用它还原设计稿视觉;纯空格型取单个空格。
|
|
675
|
+
const sep = seps.length ? seps[0] : ' '
|
|
676
|
+
return { items, sep }
|
|
677
|
+
}
|
|
678
|
+
/** 容器(Row/Column)+ flex 组(direction/justify/align/gap)+ padding 组
|
|
679
|
+
* 关键默认:flexShrink='1'(允许空间不足时被压缩)。
|
|
680
|
+
* 这是 flex 布局的标准行为——所有子项默认可压缩,由调用方通过 opts.flexShrink='0'
|
|
681
|
+
* 显式声明"独占保护"的元素(如列表右侧金额/箭头组),避免 width:100% 被默认 shrink=0 顶死。
|
|
682
|
+
* 范式参考:creditCardBill row_5v → column_61(width:100%, 默认可压缩) + row_6h(flexShrink:0)
|
|
683
|
+
*/
|
|
684
|
+
export function container(className, opts = {}) {
|
|
685
|
+
const n = comp(className)
|
|
686
|
+
ensureStyleGroup(n, 'rect')
|
|
687
|
+
ensureStyleGroup(n, 'flex')
|
|
688
|
+
ensureStyleGroup(n, 'padding')
|
|
689
|
+
if (opts.width) setStyleChild(n, 'rect', 'width', String(opts.width))
|
|
690
|
+
// 关键:按 className 自动推导默认排列方向,防止漏传 direction 时落到 style-meta 默认 row
|
|
691
|
+
// 造成"选了 Column 却横向排列"的离谱布局(_v3_dividerfix 曾踩:itemTpl=Column 但方向未设=横排)
|
|
692
|
+
const defaultDir = opts.direction || (className === 'Column' ? 'column' : 'row')
|
|
693
|
+
setStyleChild(n, 'flex', 'flexDirection', defaultDir)
|
|
694
|
+
if (opts.justify) setStyleChild(n, 'flex', 'justifyContent', opts.justify)
|
|
695
|
+
if (opts.align) setStyleChild(n, 'flex', 'alignItems', opts.align)
|
|
696
|
+
if (opts.gap !== undefined) setStyleChild(n, 'flex', 'gap', String(opts.gap))
|
|
697
|
+
// 默认允许压缩;opts.flexShrink 显式覆盖('0' 表示独占保护不被压缩)
|
|
698
|
+
setStyleChild(n, 'flex', 'flexShrink', opts.flexShrink !== undefined ? String(opts.flexShrink) : '1')
|
|
699
|
+
if (opts.padding !== undefined) {
|
|
700
|
+
// 支持两种语法:单值 "12" → 统一 padding;双值/四值 "12 16" / "8 16" → paddingType=1 各边独立。
|
|
701
|
+
// 关键纪律:style-meta 的 padding.padding 仅接受单值,多值必须拆到 paddingTop/Right/Bottom/Left,
|
|
702
|
+
// 否则渲染器解析失败 → 文本 width 塌缩为 0(纵向换行)、行宽爆成数千 px(实测 2432px)。
|
|
703
|
+
const pv = String(opts.padding).trim().split(/\s+/).map(Number)
|
|
704
|
+
if (pv.length === 1 || isNaN(pv[1])) {
|
|
705
|
+
setStyleChild(n, 'padding', 'paddingType', '0')
|
|
706
|
+
setStyleChild(n, 'padding', 'padding', String(pv[0] || 0))
|
|
707
|
+
} else {
|
|
708
|
+
setStyleChild(n, 'padding', 'paddingType', '1')
|
|
709
|
+
const [a, b, c, d] = pv
|
|
710
|
+
const top = a, right = (b !== undefined ? b : a)
|
|
711
|
+
const bottom = (c !== undefined ? c : a), left = (d !== undefined ? d : right)
|
|
712
|
+
setStyleChild(n, 'padding', 'paddingTop', String(top))
|
|
713
|
+
setStyleChild(n, 'padding', 'paddingBottom', String(bottom))
|
|
714
|
+
setStyleChild(n, 'padding', 'paddingLeft', String(left))
|
|
715
|
+
setStyleChild(n, 'padding', 'paddingRight', String(right))
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return n
|
|
719
|
+
}
|
|
720
|
+
/** 白色圆角卡片容器(背景 + 圆角) */
|
|
721
|
+
export function whiteCard(opts = {}) {
|
|
722
|
+
const c = container('Column', { direction: 'column', padding: opts.padding || 0, ...opts })
|
|
723
|
+
ensureStyleGroup(c, 'background')
|
|
724
|
+
ensureStyleGroup(c, 'borderRadius')
|
|
725
|
+
setStyleChild(c, 'background', 'backgroundType', '01')
|
|
726
|
+
setStyleChild(c, 'background', 'backgroundColor', hexToRgba(opts.bg || T.color('cardBg')))
|
|
727
|
+
setStyleChild(c, 'borderRadius', 'borderRadiusType', '0')
|
|
728
|
+
setStyleChild(c, 'borderRadius', 'borderRadius', String(opts.radius || 12))
|
|
729
|
+
return c
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/* ============================== 1. spec.json 元素聚类为语义块 ============================== */
|
|
733
|
+
|
|
734
|
+
export function rect(e) { return e?.rect || {} }
|
|
735
|
+
export function ts(e) { return e.textStyle || {} }
|
|
736
|
+
export function textOf(e) { return (e.text || '').trim() }
|
|
737
|
+
// 颜色亮度(0-1):#RRGGBB → 相对亮度;非法输入返回 null
|
|
738
|
+
export function colorLuminance(hex) {
|
|
739
|
+
if (!hex || typeof hex !== 'string') return null
|
|
740
|
+
const m = hex.replace('#', '')
|
|
741
|
+
if (m.length < 6) return null
|
|
742
|
+
const r = parseInt(m.slice(0, 2), 16) / 255
|
|
743
|
+
const g = parseInt(m.slice(2, 4), 16) / 255
|
|
744
|
+
const b = parseInt(m.slice(4, 6), 16) / 255
|
|
745
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
746
|
+
}
|
|
747
|
+
// 低对比文本兜底(标注缺陷:换肤后文字色未同步,如白字落在白底上):
|
|
748
|
+
// 文本色与任一背景候选亮度几乎一致 → 不可见,用兜底色(壳描边色/默认文本色)替代。
|
|
749
|
+
export function ensureVisibleTextColor(textColor, bgColors, fallback) {
|
|
750
|
+
const tl = colorLuminance(textColor)
|
|
751
|
+
if (tl === null) return fallback
|
|
752
|
+
for (const bg of bgColors) {
|
|
753
|
+
const bl = colorLuminance(bg)
|
|
754
|
+
if (bl === null) continue
|
|
755
|
+
if (Math.abs(tl - bl) < 0.08) return fallback
|
|
756
|
+
}
|
|
757
|
+
return textColor
|
|
758
|
+
}
|
|
759
|
+
/** 颜色调浅:与白色按比例混合(ratio=颜色占比),用于「描边同色 fill」标注缺陷的浅底还原 */
|
|
760
|
+
export function tintHex(hex, ratio = 0.1) {
|
|
761
|
+
const h = String(hex || '').replace('#', '')
|
|
762
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) return hex
|
|
763
|
+
const mix = (i) => Math.round(parseInt(h.slice(i, i + 2), 16) * ratio + 255 * (1 - ratio))
|
|
764
|
+
return '#' + [0, 2, 4].map((i) => mix(i).toString(16).padStart(2, '0')).join('').toUpperCase()
|
|
765
|
+
}
|
|
766
|
+
export function isBg(e) {
|
|
767
|
+
const r = rect(e)
|
|
768
|
+
return (r.width || 0) >= 340 && (r.height || 0) >= 600
|
|
769
|
+
}
|
|
770
|
+
export function sameRow(a, b, tol = 12) { return Math.abs((rect(a).y || 0) - (rect(b).y || 0)) <= tol }
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
/** 矩形并集 / 包含判定(图表簇几何用) */
|
|
774
|
+
export function unionRects(items) {
|
|
775
|
+
const xs = items.map((e) => rect(e).x || 0)
|
|
776
|
+
const ys = items.map((e) => rect(e).y || 0)
|
|
777
|
+
const rs = items.map((e) => (rect(e).x || 0) + (rect(e).width || 0))
|
|
778
|
+
const bs = items.map((e) => (rect(e).y || 0) + (rect(e).height || 0))
|
|
779
|
+
const x = Math.min(...xs); const y = Math.min(...ys)
|
|
780
|
+
return { x, y, width: Math.max(...rs) - x, height: Math.max(...bs) - y }
|
|
781
|
+
}
|
|
782
|
+
export function rectInside(outer, r, pad = 0) {
|
|
783
|
+
const pl = typeof pad === 'number' ? { l: pad, r: pad, t: pad, b: pad } : pad
|
|
784
|
+
return (r.x || 0) >= (outer.x || 0) - (pl.l || 0)
|
|
785
|
+
&& (r.y || 0) >= (outer.y || 0) - (pl.t || 0)
|
|
786
|
+
&& ((r.x || 0) + (r.width || 0)) <= (outer.x || 0) + (outer.width || 0) + (pl.r || 0)
|
|
787
|
+
&& ((r.y || 0) + (r.height || 0)) <= (outer.y || 0) + (outer.height || 0) + (pl.b || 0)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** 柱状图矢量簇识别(纯几何,不依赖业务词):
|
|
791
|
+
* 同基线(底沿 ±3)窄柱 ≥4 + 横向近等距 + 网格线/轴标签佐证 → 整簇(柱+网格线+簇内文本)
|
|
792
|
+
* 交给 Canvas 自绘回放。DSL 无图表组件,此前柱体被静默丢弃、网格线被当 Divider 错排。 */
|
|
793
|
+
export function detectChartClusters(content) {
|
|
794
|
+
const shapes = content.filter((e) => e.type === 'shape')
|
|
795
|
+
const texts = content.filter((e) => e.type === 'text' && textOf(e))
|
|
796
|
+
const bars = shapes.filter((e) => {
|
|
797
|
+
const r = rect(e); const fill = e.style?.fill
|
|
798
|
+
if (!fill || (!fill.color && !fill.gradient)) return false
|
|
799
|
+
if (radiusMax(e.style) >= 8) return false
|
|
800
|
+
return (r.width || 0) >= 4 && (r.width || 0) <= 48 && (r.height || 0) >= 4 && (r.height || 0) <= 260
|
|
801
|
+
})
|
|
802
|
+
const byBottom = []
|
|
803
|
+
for (const bar of bars.slice().sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))) {
|
|
804
|
+
const bottom = (rect(bar).y || 0) + (rect(bar).height || 0)
|
|
805
|
+
let group = byBottom.find((item) => Math.abs(item.bottom - bottom) <= 3)
|
|
806
|
+
if (!group) { group = { bottom, members: [] }; byBottom.push(group) }
|
|
807
|
+
group.members.push(bar)
|
|
808
|
+
}
|
|
809
|
+
const clusters = []
|
|
810
|
+
for (const group of byBottom) {
|
|
811
|
+
if (group.members.length < 4) continue
|
|
812
|
+
const xs = group.members.map((m) => rect(m).x || 0)
|
|
813
|
+
const gaps = xs.slice(1).map((x, i) => x - xs[i])
|
|
814
|
+
const gapAvg = gaps.reduce((a, b) => a + b, 0) / Math.max(1, gaps.length)
|
|
815
|
+
if (gaps.some((gap) => Math.abs(gap - gapAvg) > Math.max(6, gapAvg * 0.6))) continue
|
|
816
|
+
const barBox = unionRects(group.members)
|
|
817
|
+
const grid = shapes.filter((e) => {
|
|
818
|
+
const r = rect(e)
|
|
819
|
+
if ((r.height || 0) > 2 || (r.width || 0) < 60) return false
|
|
820
|
+
const overlap = Math.min((r.x || 0) + (r.width || 0), barBox.x + barBox.width) - Math.max(r.x || 0, barBox.x)
|
|
821
|
+
return overlap >= barBox.width * 0.6 && (r.y || 0) >= barBox.y - 260 && (r.y || 0) <= barBox.y + barBox.height + 4
|
|
822
|
+
})
|
|
823
|
+
const seedBox = unionRects([...group.members, ...grid])
|
|
824
|
+
const labels = texts.filter((e) => rectInside(seedBox, rect(e), { l: 48, r: 12, t: 30, b: 36 }))
|
|
825
|
+
if (grid.length < 3 && labels.length < 4) continue
|
|
826
|
+
const members = [...group.members, ...grid]
|
|
827
|
+
const box = unionRects([...members, ...labels])
|
|
828
|
+
clusters.push({ kind: 'bar', members, texts: labels, x: box.x, y: box.y, width: box.width, height: box.height })
|
|
829
|
+
}
|
|
830
|
+
return clusters
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** 柱状图簇 → Canvas onMeasured 绘图代码(坐标相对簇原点;只用代理白名单 API)。
|
|
834
|
+
* 圆角矩形无 roundRect 白名单 → 四角 arc 拼路径;网格线走 stroke;标签走 fillText。 */
|
|
835
|
+
/** 图表绘制层序(铁律 R62):坐标系 / 参考系线条(网格线、轴线、刻度线、雷达环与辐射线、环形轨道)
|
|
836
|
+
* 画在**最底层**,数据标记(柱 / 折线 / 多边形 / 扇区)在其上,文字标签最上层。
|
|
837
|
+
* Canvas 是即时模式绘制——后画的压先画的,所以出码顺序必须 = 底 → 顶。
|
|
838
|
+
* spec 元素顺序**不可照抄**:标注导出常把网格线排在数据图元之后(assetAllocRadar 雷达
|
|
839
|
+
* 34-39 是 #DCDCDC 网格/辐射线、40 才是数据多边形),直出会把参考线盖在数据上。 */
|
|
840
|
+
export function chartPaintLayers(members, cluster) {
|
|
841
|
+
const backdrop = []
|
|
842
|
+
const axis = []
|
|
843
|
+
const marks = []
|
|
844
|
+
const cw = cluster?.width || 0
|
|
845
|
+
const ch = cluster?.height || 0
|
|
846
|
+
for (const m of members) {
|
|
847
|
+
const r = rect(m)
|
|
848
|
+
const w = r.width || 0
|
|
849
|
+
const h = r.height || 0
|
|
850
|
+
// 细线(任一维 ≤2px)= 网格线 / 轴线 / 刻度线 → 底层
|
|
851
|
+
if (h <= 2 || w <= 2) { axis.push(m); continue }
|
|
852
|
+
// 大面积填充(覆盖簇 bbox 一半以上)= 绘图区底板 → 更底层
|
|
853
|
+
const fill = m.style?.fill
|
|
854
|
+
if (fill && (fill.color || fill.gradient) && cw && ch && w >= cw * 0.5 && h >= ch * 0.5) { backdrop.push(m); continue }
|
|
855
|
+
marks.push(m)
|
|
856
|
+
}
|
|
857
|
+
return { backdrop, axis, marks }
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** 数据标记半透明时把 opacity 乘进颜色 / 渐变(ctx 代理白名单无 globalAlpha),底层网格才透得出来。 */
|
|
861
|
+
export function chartPaintColor(color, opacity = 1) {
|
|
862
|
+
const op = Number(opacity)
|
|
863
|
+
const k = Number.isFinite(op) && op > 0 && op < 1 ? op : 1
|
|
864
|
+
const c = String(color || '').trim()
|
|
865
|
+
const m = c.match(/^rgba?\(([^,]+),([^,]+),([^,]+)(?:,([^)]+))?\)$/i)
|
|
866
|
+
if (m) return `rgba(${m[1].trim()},${m[2].trim()},${m[3].trim()},${Number(m[4] ?? 1) * k})`
|
|
867
|
+
return hexToRgba(color, k)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** 柱状图簇 → flex 原生组件树(柱=色块 Column、值/轴/类目标签=真实 Text、网格线=硬停渐变背景)。
|
|
871
|
+
* Canvas 自绘会让标注文本在组件计数中消失(文本召回/组件数被系统性低估),且字体渲染与设计稿偏差大;
|
|
872
|
+
* 渲染器无绝对定位,故用「轴列 + 槽位列」flex 结构按 spec 几何精确回放:
|
|
873
|
+
* 槽位边界取类目标签中心中点,柱列宽=柱宽、值标签靠 overflow:visible 居中溢出。
|
|
874
|
+
* 返回 null 表示该簇不满足柱状图形态(调用方回退 Canvas)。 */
|
|
875
|
+
export function buildFlexBarChart(cluster) {
|
|
876
|
+
const members = cluster.members || []
|
|
877
|
+
const bars = members.filter((m) => {
|
|
878
|
+
const r = rect(m); const h = r.height || 0; const w = r.width || 0
|
|
879
|
+
return m.style?.fill?.color && h >= 4 && w >= 6 && w <= 48
|
|
880
|
+
})
|
|
881
|
+
const gridlines = members.filter((m) => (rect(m).height || 0) <= 2 && (rect(m).width || 0) >= 100)
|
|
882
|
+
const texts = cluster.texts || []
|
|
883
|
+
if (bars.length < 4 || gridlines.length < 2 || !texts.length) return null
|
|
884
|
+
const baseline = Math.max(...bars.map((b) => (rect(b).y || 0) + (rect(b).height || 0)))
|
|
885
|
+
const plotLeft = Math.min(...bars.map((b) => rect(b).x || 0))
|
|
886
|
+
const plotRight = Math.max(...gridlines.map((g) => (rect(g).x || 0) + (rect(g).width || 0)),
|
|
887
|
+
...bars.map((b) => (rect(b).x || 0) + (rect(b).width || 0)))
|
|
888
|
+
const plotTop = Math.min(...gridlines.map((g) => rect(g).y || 0))
|
|
889
|
+
const axisTexts = texts.filter((t) => ((rect(t).x || 0) + (rect(t).width || 0)) <= plotLeft - 4)
|
|
890
|
+
.sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
|
|
891
|
+
const nameTexts = texts.filter((t) => (rect(t).y || 0) >= baseline + 2)
|
|
892
|
+
.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
|
|
893
|
+
const valueTexts = texts.filter((t) => !axisTexts.includes(t) && !nameTexts.includes(t))
|
|
894
|
+
if (!axisTexts.length || !nameTexts.length) return null
|
|
895
|
+
// 槽位:类目标签中心的中点切分绘图区
|
|
896
|
+
const centers = nameTexts.map((t) => (rect(t).x || 0) + (rect(t).width || 0) / 2)
|
|
897
|
+
const bounds = [plotLeft]
|
|
898
|
+
for (let i = 1; i < centers.length; i++) bounds.push(Math.round((centers[i - 1] + centers[i]) / 2))
|
|
899
|
+
bounds.push(plotRight)
|
|
900
|
+
const slots = []
|
|
901
|
+
for (let i = 0; i < nameTexts.length; i++) {
|
|
902
|
+
const slotBars = bars.filter((b) => {
|
|
903
|
+
const cx = (rect(b).x || 0) + (rect(b).width || 0) / 2
|
|
904
|
+
return cx >= bounds[i] - 1 && cx <= bounds[i + 1] + 1
|
|
905
|
+
}).sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
|
|
906
|
+
if (!slotBars.length) return null
|
|
907
|
+
slots.push({ name: nameTexts[i], x: bounds[i], width: bounds[i + 1] - bounds[i], bars: slotBars })
|
|
908
|
+
}
|
|
909
|
+
const plotH = Math.round(baseline - plotTop)
|
|
910
|
+
const axisTop = rect(axisTexts[0]).y || 0
|
|
911
|
+
const axisBottom = Math.max(...axisTexts.map((t) => (rect(t).y || 0) + (rect(t).height || 0)))
|
|
912
|
+
const axisRight = Math.max(...axisTexts.map((t) => (rect(t).x || 0) + (rect(t).width || 0)))
|
|
913
|
+
const axisGap = Math.max(0, Math.round(plotLeft - axisRight))
|
|
914
|
+
const nameTop = Math.min(...nameTexts.map((t) => rect(t).y || 0))
|
|
915
|
+
const nameGap = Math.max(0, Math.round(nameTop - baseline))
|
|
916
|
+
// 网格线硬停渐变(坐标系线条必须在绘制层最底部 → 作背景,柱/标签在其上)
|
|
917
|
+
const gl = gridlines.map((g) => rect(g).y || 0).sort((a, b) => a - b)
|
|
918
|
+
const glColor = (() => {
|
|
919
|
+
const g0 = gridlines[0]
|
|
920
|
+
const stops = g0.style?.fill?.gradient?.stops || g0.style?.fill?.gradient?.colors || null
|
|
921
|
+
if (Array.isArray(stops) && stops[0]?.color) return hexToRgba(stops[0].color)
|
|
922
|
+
if (g0.style?.fill?.color) return hexToRgba(g0.style.fill.color)
|
|
923
|
+
return 'rgba(244,244,247,1)'
|
|
924
|
+
})()
|
|
925
|
+
const gradParts = []
|
|
926
|
+
let prev = 0
|
|
927
|
+
for (const y of gl) {
|
|
928
|
+
const o = Math.max(0, Math.round(y - plotTop))
|
|
929
|
+
if (o > prev) gradParts.push(`rgba(0,0,0,0) ${prev}px`, `${glColor} ${o}px`)
|
|
930
|
+
gradParts.push(`${glColor} ${o}px`, `${glColor} ${o + 1}px`)
|
|
931
|
+
prev = o + 1
|
|
932
|
+
}
|
|
933
|
+
gradParts.push(`rgba(0,0,0,0) ${prev}px`, `rgba(0,0,0,0) ${plotH}px`)
|
|
934
|
+
// —— 组装 ——
|
|
935
|
+
const chartRow = container('Row', { width: '100%', direction: 'row', align: 'flex-start', gap: String(axisGap) })
|
|
936
|
+
const axisCol = container('Column', {
|
|
937
|
+
direction: 'column', justify: 'space-between', align: 'flex-end', flexShrink: 0,
|
|
938
|
+
width: String(Math.round(axisRight - (rect(axisTexts[0]).x || 0))),
|
|
939
|
+
})
|
|
940
|
+
ensureStyleGroup(axisCol, 'rect')
|
|
941
|
+
setStyleChild(axisCol, 'rect', 'height', String(Math.round(axisBottom - axisTop)))
|
|
942
|
+
for (const t of axisTexts) {
|
|
943
|
+
const node = textFromSpec({ ...t, text: textOf(t), color: ts(t).color, fontSize: ts(t).fontSize, fontWeight: ts(t).fontWeight, align: 'right' })
|
|
944
|
+
ensureStyleGroup(node, 'flex')
|
|
945
|
+
setStyleChild(node, 'flex', 'flexShrink', '0')
|
|
946
|
+
axisCol.slots[0].components.push(node)
|
|
947
|
+
}
|
|
948
|
+
chartRow.slots[0].components.push(axisCol)
|
|
949
|
+
const plotCol = container('Column', {
|
|
950
|
+
direction: 'column', align: 'stretch', gap: '0', flexShrink: 0,
|
|
951
|
+
width: String(Math.round(plotRight - plotLeft)),
|
|
952
|
+
padding: `${Math.max(0, Math.round(plotTop - axisTop))} 0 0 0`,
|
|
953
|
+
})
|
|
954
|
+
const gridArea = container('Row', { direction: 'row', align: 'flex-end', gap: '0', width: '100%' })
|
|
955
|
+
ensureStyleGroup(gridArea, 'rect')
|
|
956
|
+
setStyleChild(gridArea, 'rect', 'height', String(plotH))
|
|
957
|
+
ensureStyleGroup(gridArea, 'background')
|
|
958
|
+
setStyleChild(gridArea, 'background', 'backgroundType', '03')
|
|
959
|
+
setStyleChild(gridArea, 'background', 'backgroundGradient', `linear-gradient(to bottom, ${gradParts.join(', ')})`)
|
|
960
|
+
for (const slot of slots) {
|
|
961
|
+
const padLeft = Math.max(0, Math.round((rect(slot.bars[0]).x || 0) - slot.x))
|
|
962
|
+
const slotCol = container('Column', {
|
|
963
|
+
direction: 'column', justify: 'flex-end', align: 'flex-start', flexShrink: 0,
|
|
964
|
+
width: String(slot.width), padding: `0 0 0 ${padLeft}`,
|
|
965
|
+
})
|
|
966
|
+
ensureStyleGroup(slotCol, 'rect')
|
|
967
|
+
setStyleChild(slotCol, 'rect', 'height', String(plotH))
|
|
968
|
+
// 柱列宽 = max(柱宽, 值标签宽):渲染器 fit-content 受父宽限制会把标签折行(h 爆成 3 行);
|
|
969
|
+
// 相邻标签水平重叠时用负 marginLeft 拉回设计中心(flex 行内唯一可重叠手段)。
|
|
970
|
+
const barRow = container('Row', { direction: 'row', align: 'flex-end', gap: '0' })
|
|
971
|
+
const firstBarX = rect(slot.bars[0]).x || 0
|
|
972
|
+
let barCursor = 0
|
|
973
|
+
for (const bar of slot.bars) {
|
|
974
|
+
const br = rect(bar)
|
|
975
|
+
const label = valueTexts.find((t) => {
|
|
976
|
+
const cx = (br.x || 0) + (br.width || 0) / 2
|
|
977
|
+
const tcx = (rect(t).x || 0) + (rect(t).width || 0) / 2
|
|
978
|
+
return Math.abs(tcx - cx) <= Math.max(br.width || 0, (rect(t).width || 0) / 2)
|
|
979
|
+
&& (rect(t).y || 0) + (rect(t).height || 0) <= (br.y || 0) + 2
|
|
980
|
+
})
|
|
981
|
+
const barCenter = (br.x || 0) + (br.width || 0) / 2
|
|
982
|
+
// 渲染字形比标注 rect 略宽(PingFang 度量差):不留余量会折行(h 16→30、整列上移)
|
|
983
|
+
const colW = Math.max(Math.round(br.width || 0), label ? Math.round(rect(label).width || 0) + 4 : 0)
|
|
984
|
+
const desiredLeft = Math.round(barCenter - colW / 2) - firstBarX
|
|
985
|
+
const barCol = container('Column', {
|
|
986
|
+
direction: 'column', justify: 'flex-end', align: 'center', gap: '0', flexShrink: 0,
|
|
987
|
+
width: String(colW),
|
|
988
|
+
})
|
|
989
|
+
if (desiredLeft !== barCursor) {
|
|
990
|
+
ensureStyleGroup(barCol, 'margin')
|
|
991
|
+
setStyleChild(barCol, 'margin', 'marginType', '1')
|
|
992
|
+
setStyleChild(barCol, 'margin', 'marginLeft', String(desiredLeft - barCursor))
|
|
993
|
+
setStyleChild(barCol, 'margin', 'marginTop', '0')
|
|
994
|
+
setStyleChild(barCol, 'margin', 'marginRight', '0')
|
|
995
|
+
setStyleChild(barCol, 'margin', 'marginBottom', '0')
|
|
996
|
+
}
|
|
997
|
+
barCursor = desiredLeft + colW
|
|
998
|
+
if (label) {
|
|
999
|
+
const ln = textFromSpec({ text: textOf(label), color: ts(label).color, fontSize: ts(label).fontSize, fontWeight: ts(label).fontWeight, align: 'center' })
|
|
1000
|
+
ensureStyleGroup(ln, 'flex')
|
|
1001
|
+
setStyleChild(ln, 'flex', 'flexShrink', '0')
|
|
1002
|
+
barCol.slots[0].components.push(ln)
|
|
1003
|
+
}
|
|
1004
|
+
const block = container('Column', { direction: 'column', flexShrink: 0 })
|
|
1005
|
+
ensureStyleGroup(block, 'rect')
|
|
1006
|
+
ensureStyleGroup(block, 'background')
|
|
1007
|
+
ensureStyleGroup(block, 'borderRadius')
|
|
1008
|
+
setStyleChild(block, 'rect', 'width', String(Math.round(br.width || 0)))
|
|
1009
|
+
setStyleChild(block, 'rect', 'height', String(Math.round(br.height || 0)))
|
|
1010
|
+
setStyleChild(block, 'background', 'backgroundType', '01')
|
|
1011
|
+
setStyleChild(block, 'background', 'backgroundColor', hexToRgba(bar.style.fill.color))
|
|
1012
|
+
setStyleChild(block, 'borderRadius', 'borderRadiusType', '1')
|
|
1013
|
+
setStyleChild(block, 'borderRadius', 'borderTopLeftRadius', '2')
|
|
1014
|
+
setStyleChild(block, 'borderRadius', 'borderTopRightRadius', '2')
|
|
1015
|
+
setStyleChild(block, 'borderRadius', 'borderBottomLeftRadius', '0')
|
|
1016
|
+
setStyleChild(block, 'borderRadius', 'borderBottomRightRadius', '0')
|
|
1017
|
+
barCol.slots[0].components.push(block)
|
|
1018
|
+
barRow.slots[0].components.push(barCol)
|
|
1019
|
+
}
|
|
1020
|
+
slotCol.slots[0].components.push(barRow)
|
|
1021
|
+
gridArea.slots[0].components.push(slotCol)
|
|
1022
|
+
}
|
|
1023
|
+
plotCol.slots[0].components.push(gridArea)
|
|
1024
|
+
const nameRow = container('Row', { direction: 'row', align: 'flex-start', gap: '0', width: '100%', padding: `${nameGap} 0 0 0` })
|
|
1025
|
+
for (const slot of slots) {
|
|
1026
|
+
const slotCol = container('Column', { direction: 'column', align: 'flex-start', flexShrink: 0, width: String(slot.width) })
|
|
1027
|
+
// 类目标签可能比柱对宽(日常开销 48 > 柱对 42):居中行按标签宽撑开 + 负 margin 锚柱对中心,
|
|
1028
|
+
// 槽位列 overflow visible 放行溢出(flex 行宽不变,邻槽不受影响)
|
|
1029
|
+
ensureStyleGroup(slotCol, 'other')
|
|
1030
|
+
setStyleChild(slotCol, 'other', 'overflow', 'visible')
|
|
1031
|
+
const pairW = Math.round(((rect(slot.bars[slot.bars.length - 1]).x || 0) + (rect(slot.bars[slot.bars.length - 1]).width || 0)) - (rect(slot.bars[0]).x || 0))
|
|
1032
|
+
const nameW = Math.round(rect(slot.name).width || 0) + 4
|
|
1033
|
+
const rowW = Math.max(pairW, nameW)
|
|
1034
|
+
const pairCenter = Math.round((rect(slot.bars[0]).x || 0) + pairW / 2)
|
|
1035
|
+
const centerRow = container('Row', { direction: 'row', justify: 'center', align: 'center', width: String(rowW) })
|
|
1036
|
+
const rowLeft = pairCenter - Math.round(rowW / 2) - slot.x
|
|
1037
|
+
if (rowLeft !== 0) {
|
|
1038
|
+
ensureStyleGroup(centerRow, 'margin')
|
|
1039
|
+
setStyleChild(centerRow, 'margin', 'marginType', '1')
|
|
1040
|
+
setStyleChild(centerRow, 'margin', 'marginLeft', String(rowLeft))
|
|
1041
|
+
setStyleChild(centerRow, 'margin', 'marginTop', '0')
|
|
1042
|
+
setStyleChild(centerRow, 'margin', 'marginRight', '0')
|
|
1043
|
+
setStyleChild(centerRow, 'margin', 'marginBottom', '0')
|
|
1044
|
+
}
|
|
1045
|
+
const nn = textFromSpec({ text: textOf(slot.name), color: ts(slot.name).color, fontSize: ts(slot.name).fontSize, fontWeight: ts(slot.name).fontWeight, align: 'center' })
|
|
1046
|
+
ensureStyleGroup(nn, 'flex')
|
|
1047
|
+
setStyleChild(nn, 'flex', 'flexShrink', '0')
|
|
1048
|
+
centerRow.slots[0].components.push(nn)
|
|
1049
|
+
slotCol.slots[0].components.push(centerRow)
|
|
1050
|
+
nameRow.slots[0].components.push(slotCol)
|
|
1051
|
+
}
|
|
1052
|
+
plotCol.slots[0].components.push(nameRow)
|
|
1053
|
+
chartRow.slots[0].components.push(plotCol)
|
|
1054
|
+
return chartRow
|
|
1055
|
+
}
|
|
1056
|
+
export function chartBarCanvasCode(cluster) {
|
|
1057
|
+
const ox = cluster.x; const oy = cluster.y
|
|
1058
|
+
const n = (v) => Math.round(Number(v) * 100) / 100
|
|
1059
|
+
const out = []
|
|
1060
|
+
out.push('var self = this;')
|
|
1061
|
+
// event.canvas 是画布包装对象(非 ctx):export-proven 范式先 getContext('2d') 拿代理 ctx
|
|
1062
|
+
out.push('var canvas = event.canvas;')
|
|
1063
|
+
out.push("var cv = canvas.getContext('2d');")
|
|
1064
|
+
out.push('canvas.width = event.width;')
|
|
1065
|
+
out.push('canvas.height = event.height;')
|
|
1066
|
+
out.push('cv.clearRect(0,0,event.width,event.height);')
|
|
1067
|
+
out.push('var rr=function(x,y,w,h,tl,tr,br,bl){cv.beginPath();cv.moveTo(x+tl,y);'
|
|
1068
|
+
+ 'cv.lineTo(x+w-tr,y);cv.arc(x+w-tr,y+tr,tr,Math.PI*1.5,Math.PI*2);'
|
|
1069
|
+
+ 'cv.lineTo(x+w,y+h-br);cv.arc(x+w-br,y+h-br,br,0,Math.PI*0.5);'
|
|
1070
|
+
+ 'cv.lineTo(x+bl,y+h);cv.arc(x+bl,y+h-bl,bl,Math.PI*0.5,Math.PI);'
|
|
1071
|
+
+ 'cv.lineTo(x,y+tl);cv.arc(x+tl,y+tl,tl,Math.PI,Math.PI*1.5);cv.closePath();};')
|
|
1072
|
+
// 出码顺序 = 绘制层序(底 → 顶):绘图区底板 → 网格线/轴线 → 数据标记 → 文字标签
|
|
1073
|
+
const layers = chartPaintLayers(cluster.members, cluster)
|
|
1074
|
+
for (const m of [...layers.backdrop, ...layers.axis, ...layers.marks]) {
|
|
1075
|
+
const r = rect(m)
|
|
1076
|
+
const x = n((r.x || 0) - ox); const y = n((r.y || 0) - oy)
|
|
1077
|
+
const w = n(r.width || 0); const h = n(r.height || 0)
|
|
1078
|
+
const fill = m.style?.fill
|
|
1079
|
+
const border = m.style?.border
|
|
1080
|
+
const opacity = m.style?.opacity ?? 1
|
|
1081
|
+
if (fill && (fill.color || fill.gradient)) {
|
|
1082
|
+
if (fill.gradient && (fill.gradient.stops || []).length) {
|
|
1083
|
+
const stops = fill.gradient.stops
|
|
1084
|
+
out.push(`var g=cv.createLinearGradient(${x},${y},${x},${y + h});`)
|
|
1085
|
+
for (const stop of stops) {
|
|
1086
|
+
out.push(`g.addColorStop(${n(stop.position || 0)},'${chartPaintColor(stop.color, opacity)}');`)
|
|
1087
|
+
}
|
|
1088
|
+
out.push('cv.fillStyle=g;')
|
|
1089
|
+
} else {
|
|
1090
|
+
out.push(`cv.fillStyle='${chartPaintColor(fill.color, opacity)}';`)
|
|
1091
|
+
}
|
|
1092
|
+
const rad = m.style?.radius?.borderRadius
|
|
1093
|
+
const arr = Array.isArray(rad) ? rad.map((v) => n(v) || 0) : [n(rad) || 0, n(rad) || 0, n(rad) || 0, n(rad) || 0]
|
|
1094
|
+
out.push(`rr(${x},${y},${w},${h},${arr[0]},${arr[1]},${arr[2]},${arr[3]});cv.fill();`)
|
|
1095
|
+
} else if (border && border.isBorder) {
|
|
1096
|
+
out.push(`cv.strokeStyle='${chartPaintColor(border.color, opacity)}';`)
|
|
1097
|
+
out.push(`cv.lineWidth=${Math.max(1, n(border.width || r.height || 1))};`)
|
|
1098
|
+
out.push(`cv.beginPath();cv.moveTo(${x},${n(y + h / 2)});cv.lineTo(${n(x + w)},${n(y + h / 2)});cv.stroke();`)
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
for (const t of cluster.texts) {
|
|
1102
|
+
const r = rect(t); const st = ts(t)
|
|
1103
|
+
const align = String(st.align || 'left')
|
|
1104
|
+
const cx = align === 'center' ? n((r.x || 0) - ox + (r.width || 0) / 2)
|
|
1105
|
+
: align === 'right' ? n((r.x || 0) - ox + (r.width || 0)) : n((r.x || 0) - ox)
|
|
1106
|
+
out.push(`cv.fillStyle='${hexToRgba(st.color)}';`)
|
|
1107
|
+
out.push(`cv.font='${Number(st.fontWeight) || 400} ${Number(st.fontSize) || 12}px "PingFang SC","Helvetica Neue",sans-serif';`)
|
|
1108
|
+
out.push(`cv.textAlign='${align === 'center' || align === 'right' ? align : 'left'}';`)
|
|
1109
|
+
out.push("cv.textBaseline='top';")
|
|
1110
|
+
out.push(`cv.fillText(${JSON.stringify(textOf(t))},${cx},${n((r.y || 0) - oy)});`)
|
|
1111
|
+
}
|
|
1112
|
+
return out.join('')
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// 评分仪表盘 Canvas 出码(recipe_snippets/canvas-gauge-score.json 同族:半环灰轨 + 高亮弧 + 中心分数/标签)
|
|
1116
|
+
export const SCORE_GAUGE_CANVAS_CODE = "var self = this;\nvar canvas = event.canvas;\nvar ctx = canvas.getContext('2d');\nctx.imageSmoothingEnabled = true;\nctx.imageSmoothingQuality = 'high';\nvar cx = 60;\nvar cy = 60;\nvar r = 56.5;\nfunction toRad(a) { return a * Math.PI / 180; }\nctx.lineWidth = 6;\nctx.lineCap = 'butt';\nctx.strokeStyle = '#F5F5F5';\nctx.beginPath();\nctx.arc(cx, cy, r, toRad(317), toRad(517), false);\nctx.stroke();\nvar score = Number(self.state.score);\nif (isNaN(score)) { score = 0; }\nscore = Math.min(Math.max(score, 0), 100);\nvar fill = score * (160 / 70);\nif (fill > 270) { fill = 270; }\nctx.strokeStyle = '#FCC800';\nctx.beginPath();\nctx.arc(cx, cy, r, toRad(157), toRad(157 + fill), false);\nctx.stroke();\nvar scoreText = String(self.state.score == null ? '' : self.state.score);\nctx.textBaseline = 'alphabetic';\nctx.fillStyle = '#221715';\nctx.font = '500 28px \"Helvetica Neue\", \"PingFang SC\", sans-serif';\nvar wNum = ctx.measureText(scoreText).width;\nctx.font = '500 14px \"PingFang SC\", sans-serif';\nvar wUnit = ctx.measureText('\u5206').width;\nvar startX = cx - (wNum + wUnit + 2) / 2;\nctx.textAlign = 'left';\nctx.font = '500 28px \"Helvetica Neue\", \"PingFang SC\", sans-serif';\nctx.fillText(scoreText, startX, 52);\nctx.font = '500 14px \"PingFang SC\", sans-serif';\nctx.fillText('\u5206', startX + wNum + 2, 52);\nctx.font = '400 12px \"PingFang SC\", sans-serif';\nctx.textAlign = 'center';\nctx.fillStyle = '#575757';\nctx.fillText(String(self.state.scoreLabel || ''), cx, 73);"
|