@dongfangw/study-kit 0.1.3 → 0.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/README.md +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.map +1 -1
- package/lib/slidev.d.ts.map +1 -1
- package/lib/slidev.js +50 -13
- package/lib/slidev.js.map +1 -1
- package/lib/slidevEnv.d.ts.map +1 -1
- package/lib/slidevEnv.js +30 -1
- package/lib/slidevEnv.js.map +1 -1
- package/package.json +138 -138
- package/skills/make-mindmap/SKILL.md +237 -0
- package/skills/make-mindmap/example//347/244/272/344/276/213.mmd +45 -0
- package/skills/make-mindmap/render.mjs +855 -0
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 零依赖思维导图渲染器:缩进大纲 → SVG。
|
|
4
|
+
*
|
|
5
|
+
* 为什么自带渲染器(而不是挂 PlantUML / mermaid-cli / Kroki):
|
|
6
|
+
* AI 学伴的阅读器用**裸 @asciidoctor/core** 逐页渲染(study-kit 的 ext/adoc-convert),
|
|
7
|
+
* 没有装 asciidoctor-diagram / kroki 扩展。所以 `[plantuml]`、`[mermaid]` 这些图块
|
|
8
|
+
* 在阅读器里会退化成一段等宽纯文本——正是要消灭的那种"文本路线图"。
|
|
9
|
+
* 而阅读器会把 `image::` 解析成 <img src="/aero-kb/asset?...">,asset 路由又把 .svg
|
|
10
|
+
* 当 image/svg+xml 伺服。于是路线是:**先离线渲染成 SVG 落盘,再用 image:: 引用**。
|
|
11
|
+
* 这条路不需要 Java、不需要 Chrome、不需要联网,打印时还是矢量。
|
|
12
|
+
*
|
|
13
|
+
* 输入语法 = Mermaid mindmap 的缩进子集(可以直接把 mermaid 的 mindmap 粘过来):
|
|
14
|
+
*
|
|
15
|
+
* --- ← 可选 YAML 前置配置(见 SKILL.md 的配置项总表)
|
|
16
|
+
* title: 思维导图插图
|
|
17
|
+
* layout: right
|
|
18
|
+
* theme: paper
|
|
19
|
+
* ---
|
|
20
|
+
* mindmap ← 可选关键字
|
|
21
|
+
* root((思维导图插图))
|
|
22
|
+
* 写大纲
|
|
23
|
+
* 缩进即层级
|
|
24
|
+
* 渲染成 SVG
|
|
25
|
+
* 零依赖,纯 Node
|
|
26
|
+
*
|
|
27
|
+
* 节点形状:id((圆)) / id(圆角) / id[方框] / id{{六边形}} / 裸文本(用默认形状)
|
|
28
|
+
* 其中 id 可省略;`(( ))` 之类也可以只写形状不带 id。
|
|
29
|
+
* 样式类: 行尾或下一行写 `:::强调` 引用配置里的 classes;`- ` / `* ` 项目符号会被忽略。
|
|
30
|
+
*
|
|
31
|
+
* 用法:
|
|
32
|
+
* node render.mjs <源文件> [选项]
|
|
33
|
+
* --out <路径> 输出 SVG(缺省与源文件同名 .svg)
|
|
34
|
+
* --theme <名> paper|aero|ink|warm|forest|slate|contrast
|
|
35
|
+
* --layout <名> right|left|both|tree
|
|
36
|
+
* --connector <名> curve|elbow|straight|arc
|
|
37
|
+
* --density <名> compact|normal|roomy
|
|
38
|
+
* --title <文本> 覆盖标题
|
|
39
|
+
* --transparent 无底色(深色阅读主题下更协调)
|
|
40
|
+
* --scale <系数> 整体字号系数
|
|
41
|
+
* --json 只打印统计(节点数 / 画布尺寸),不写文件
|
|
42
|
+
* -h, --help
|
|
43
|
+
*/
|
|
44
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
45
|
+
import path from 'node:path'
|
|
46
|
+
|
|
47
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
48
|
+
// 0. 极简 YAML(只够读前置配置:标量 / 行内 [a,b] {a: 1} / 块级列表与映射 / 注释)
|
|
49
|
+
// 不引第三方依赖:技能要能在任何机器上直接跑;解析不了就报清楚的行号。
|
|
50
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
51
|
+
function stripComment(line) {
|
|
52
|
+
let q = null
|
|
53
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
54
|
+
const ch = line[i]
|
|
55
|
+
if (q !== null) { if (ch === q) q = null; continue }
|
|
56
|
+
if (ch === '"' || ch === "'") { q = ch; continue }
|
|
57
|
+
if (ch === '#' && (i === 0 || /\s/u.test(line[i - 1]))) return line.slice(0, i)
|
|
58
|
+
}
|
|
59
|
+
return line
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function splitTop(text, sep) {
|
|
63
|
+
const out = []
|
|
64
|
+
let depth = 0
|
|
65
|
+
let q = null
|
|
66
|
+
let cur = ''
|
|
67
|
+
for (const ch of text) {
|
|
68
|
+
if (q !== null) { cur += ch; if (ch === q) q = null; continue }
|
|
69
|
+
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue }
|
|
70
|
+
if (ch === '[' || ch === '{' || ch === '(') depth += 1
|
|
71
|
+
if (ch === ']' || ch === '}' || ch === ')') depth -= 1
|
|
72
|
+
if (ch === sep && depth === 0) { out.push(cur); cur = ''; continue }
|
|
73
|
+
cur += ch
|
|
74
|
+
}
|
|
75
|
+
out.push(cur)
|
|
76
|
+
return out.map((s) => s.trim()).filter((s) => s !== '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const unquote = (s) => {
|
|
80
|
+
const t = String(s).trim()
|
|
81
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) return t.slice(1, -1)
|
|
82
|
+
return t
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseScalar(raw) {
|
|
86
|
+
const s = String(raw).trim()
|
|
87
|
+
if (s === '' || s === '~' || s === 'null') return null
|
|
88
|
+
if (s === 'true') return true
|
|
89
|
+
if (s === 'false') return false
|
|
90
|
+
if (/^-?\d+(\.\d+)?$/u.test(s)) return Number(s)
|
|
91
|
+
if (s.startsWith('[') && s.endsWith(']')) return splitTop(s.slice(1, -1), ',').map(parseScalar)
|
|
92
|
+
if (s.startsWith('{') && s.endsWith('}')) {
|
|
93
|
+
const obj = {}
|
|
94
|
+
for (const part of splitTop(s.slice(1, -1), ',')) {
|
|
95
|
+
const idx = part.indexOf(':')
|
|
96
|
+
if (idx < 0) continue
|
|
97
|
+
obj[unquote(part.slice(0, idx))] = parseScalar(part.slice(idx + 1))
|
|
98
|
+
}
|
|
99
|
+
return obj
|
|
100
|
+
}
|
|
101
|
+
if (s.startsWith('"') && s.endsWith('"') && s.length > 1) return s.slice(1, -1).replace(/\\"/gu, '"').replace(/\\n/gu, '\n')
|
|
102
|
+
if (s.startsWith("'") && s.endsWith("'") && s.length > 1) return s.slice(1, -1)
|
|
103
|
+
return s
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function keyColon(body) {
|
|
107
|
+
let q = null
|
|
108
|
+
let depth = 0
|
|
109
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
110
|
+
const ch = body[i]
|
|
111
|
+
if (q !== null) { if (ch === q) q = null; continue }
|
|
112
|
+
if (ch === '"' || ch === "'") { q = ch; continue }
|
|
113
|
+
if (ch === '[' || ch === '{') depth += 1
|
|
114
|
+
if (ch === ']' || ch === '}') depth -= 1
|
|
115
|
+
if (ch === ':' && depth === 0 && (i === body.length - 1 || /\s/u.test(body[i + 1]))) return i
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseMiniYaml(text) {
|
|
121
|
+
const rows = text.split(/\r?\n/u)
|
|
122
|
+
.map((raw) => stripComment(raw).replace(/\s+$/u, ''))
|
|
123
|
+
.filter((raw) => raw.trim() !== '')
|
|
124
|
+
.map((raw) => ({ indent: raw.length - raw.trimStart().length, body: raw.trim() }))
|
|
125
|
+
let at = 0
|
|
126
|
+
|
|
127
|
+
function parseBlock(indent) {
|
|
128
|
+
if (at >= rows.length) return null
|
|
129
|
+
const isList = rows[at].body === '-' || rows[at].body.startsWith('- ')
|
|
130
|
+
if (isList) {
|
|
131
|
+
const arr = []
|
|
132
|
+
while (at < rows.length && rows[at].indent === indent && (rows[at].body === '-' || rows[at].body.startsWith('- '))) {
|
|
133
|
+
const body = rows[at].body === '-' ? '' : rows[at].body.slice(2).trim()
|
|
134
|
+
at += 1
|
|
135
|
+
if (body === '') {
|
|
136
|
+
arr.push(at < rows.length && rows[at].indent > indent ? parseBlock(rows[at].indent) : null)
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (body.startsWith('{')) { arr.push(parseScalar(body)); continue }
|
|
140
|
+
const colon = keyColon(body)
|
|
141
|
+
if (colon === null) { arr.push(parseScalar(body)); continue }
|
|
142
|
+
const obj = {}
|
|
143
|
+
obj[unquote(body.slice(0, colon))] = parseScalar(body.slice(colon + 1))
|
|
144
|
+
while (at < rows.length && rows[at].indent > indent && !rows[at].body.startsWith('- ')) {
|
|
145
|
+
const b = rows[at].body
|
|
146
|
+
const c = keyColon(b)
|
|
147
|
+
at += 1
|
|
148
|
+
if (c === null) continue
|
|
149
|
+
const k = unquote(b.slice(0, c))
|
|
150
|
+
const rest = b.slice(c + 1).trim()
|
|
151
|
+
obj[k] = rest === '' && at < rows.length && rows[at].indent > indent ? parseBlock(rows[at].indent) : parseScalar(rest)
|
|
152
|
+
}
|
|
153
|
+
arr.push(obj)
|
|
154
|
+
}
|
|
155
|
+
return arr
|
|
156
|
+
}
|
|
157
|
+
const obj = {}
|
|
158
|
+
while (at < rows.length && rows[at].indent === indent) {
|
|
159
|
+
const body = rows[at].body
|
|
160
|
+
const colon = keyColon(body)
|
|
161
|
+
if (colon === null) { at += 1; continue }
|
|
162
|
+
const key = unquote(body.slice(0, colon))
|
|
163
|
+
const rest = body.slice(colon + 1).trim()
|
|
164
|
+
at += 1
|
|
165
|
+
obj[key] = rest === '' ? (at < rows.length && rows[at].indent > indent ? parseBlock(rows[at].indent) : null) : parseScalar(rest)
|
|
166
|
+
}
|
|
167
|
+
return obj
|
|
168
|
+
}
|
|
169
|
+
if (rows.length === 0) return {}
|
|
170
|
+
return parseBlock(rows[0].indent) ?? {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
174
|
+
// 1. 主题 / 密度 / 枚举
|
|
175
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
176
|
+
const THEMES = {
|
|
177
|
+
paper: {
|
|
178
|
+
bg: '#ffffff', ink: '#1f2328', muted: '#6b7280', rule: '#d8dde5',
|
|
179
|
+
palette: ['#3b6ea5', '#b2623c', '#3f7f5f', '#7f5aa8', '#9a7b2f', '#3d7b8a'],
|
|
180
|
+
root: '#25405f',
|
|
181
|
+
},
|
|
182
|
+
aero: {
|
|
183
|
+
bg: '#fbfdff', ink: '#16233a', muted: '#5f7086', rule: '#d3dfec',
|
|
184
|
+
palette: ['#1f5fa8', '#2b8a8a', '#c2703a', '#6a4fa3', '#2f7d4f', '#a8434f'],
|
|
185
|
+
root: '#1b3a63',
|
|
186
|
+
},
|
|
187
|
+
ink: {
|
|
188
|
+
bg: '#ffffff', ink: '#111111', muted: '#5a5a5a', rule: '#cfcfcf',
|
|
189
|
+
palette: ['#3a3a3a', '#6a6a6a', '#262626', '#4f4f4f', '#7a7a7a', '#1a1a1a'],
|
|
190
|
+
root: '#111111',
|
|
191
|
+
},
|
|
192
|
+
warm: {
|
|
193
|
+
bg: '#fdf8f0', ink: '#3a3126', muted: '#8d7f68', rule: '#e4d8c2',
|
|
194
|
+
palette: ['#a8672f', '#8a6a2b', '#5f7d42', '#a84f52', '#3f6f89', '#7a5a9a'],
|
|
195
|
+
root: '#7a4a1e',
|
|
196
|
+
},
|
|
197
|
+
forest: {
|
|
198
|
+
bg: '#f5f9f3', ink: '#22301d', muted: '#77866e', rule: '#d5e2cc',
|
|
199
|
+
palette: ['#2f7d4f', '#4f8a3a', '#8a7d2f', '#2f7d7d', '#6a4fa3', '#a85a3a'],
|
|
200
|
+
root: '#1f4f33',
|
|
201
|
+
},
|
|
202
|
+
slate: {
|
|
203
|
+
bg: '#22262e', ink: '#e6ebf2', muted: '#93a1b3', rule: '#39424f',
|
|
204
|
+
palette: ['#7aa2f7', '#7dcfff', '#e0af68', '#bb9af7', '#9ece6a', '#f7768e'],
|
|
205
|
+
root: '#3d59a1',
|
|
206
|
+
},
|
|
207
|
+
contrast: {
|
|
208
|
+
bg: '#ffffff', ink: '#000000', muted: '#333333', rule: '#000000',
|
|
209
|
+
palette: ['#000000', '#8b0000', '#003f8b', '#00502a', '#5a2d82', '#8a5a00'],
|
|
210
|
+
root: '#000000',
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const DENSITY = {
|
|
215
|
+
compact: { level: 40, sibling: 4, branch: 13, padX: 9, padY: 4 },
|
|
216
|
+
normal: { level: 66, sibling: 7, branch: 26, padX: 13, padY: 6.5 },
|
|
217
|
+
roomy: { level: 88, sibling: 13, branch: 40, padX: 17, padY: 9.5 },
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const CONNECTORS = new Set(['curve', 'elbow', 'straight', 'arc'])
|
|
221
|
+
const LAYOUTS = new Set(['right', 'left', 'both', 'tree'])
|
|
222
|
+
const SHAPES = new Set(['rect', 'rounded', 'pill', 'hex', 'circle', 'underline', 'none'])
|
|
223
|
+
|
|
224
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
225
|
+
// 2. 颜色工具
|
|
226
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
227
|
+
function toRgb(color) {
|
|
228
|
+
if (typeof color !== 'string') return null
|
|
229
|
+
const s = color.trim()
|
|
230
|
+
let m = /^#([0-9a-f]{3})$/iu.exec(s)
|
|
231
|
+
if (m) return [0, 1, 2].map((i) => parseInt(m[1][i] + m[1][i], 16))
|
|
232
|
+
m = /^#([0-9a-f]{6})$/iu.exec(s)
|
|
233
|
+
if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16))
|
|
234
|
+
m = /^rgba?\(([^)]+)\)$/iu.exec(s)
|
|
235
|
+
if (m) {
|
|
236
|
+
const p = m[1].split(',').map((v) => parseFloat(v.trim()))
|
|
237
|
+
if (p.length >= 3) return p.slice(0, 3)
|
|
238
|
+
}
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
const toHex = (rgb) => `#${rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`
|
|
242
|
+
function mix(a, b, t, fallback = '#888888') {
|
|
243
|
+
const x = toRgb(a) ?? toRgb(fallback)
|
|
244
|
+
const y = toRgb(b) ?? toRgb(fallback)
|
|
245
|
+
if (x === null || y === null) return typeof a === 'string' && a !== 'none' ? a : fallback
|
|
246
|
+
return toHex(x.map((v, i) => v + (y[i] - v) * t))
|
|
247
|
+
}
|
|
248
|
+
const isTransparent = (bg) => bg === 'transparent' || bg === 'none'
|
|
249
|
+
function readableOn(color) {
|
|
250
|
+
const rgb = toRgb(color)
|
|
251
|
+
if (rgb === null) return '#ffffff'
|
|
252
|
+
const [r, g, b] = rgb.map((v) => {
|
|
253
|
+
const s = v / 255
|
|
254
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
255
|
+
})
|
|
256
|
+
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
257
|
+
return lum > 0.45 ? '#111318' : '#ffffff'
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
261
|
+
// 3. 源文件解析
|
|
262
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
263
|
+
const CIRCLED = [...'①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳']
|
|
264
|
+
|
|
265
|
+
function splitFrontMatter(text) {
|
|
266
|
+
const t = text.replace(/^\uFEFF/u, '')
|
|
267
|
+
const m = /^\s*---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/u.exec(t)
|
|
268
|
+
if (m === null) return { config: {}, body: t }
|
|
269
|
+
return { config: parseMiniYaml(m[1]), body: t.slice(m[0].length) }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseSource(body) {
|
|
273
|
+
const lines = body.split(/\r?\n/u)
|
|
274
|
+
const flat = []
|
|
275
|
+
let order = 0
|
|
276
|
+
for (const raw of lines) {
|
|
277
|
+
const line = raw.replace(/\t/gu, ' ').replace(/\s+$/u, '')
|
|
278
|
+
if (line.trim() === '') continue
|
|
279
|
+
if (/^\s*%%/u.test(line)) continue
|
|
280
|
+
if (/^\s*(mindmap|outline)\s*$/iu.test(line)) continue
|
|
281
|
+
let text = line.trim()
|
|
282
|
+
text = text.replace(/^[-*+]\s+/u, '')
|
|
283
|
+
if (text === '') continue
|
|
284
|
+
flat.push({ raw: text, indent: line.length - line.trimStart().length, order: order++ })
|
|
285
|
+
}
|
|
286
|
+
if (flat.length === 0) throw new Error('源文件里没有任何节点行')
|
|
287
|
+
|
|
288
|
+
const stack = []
|
|
289
|
+
const roots = []
|
|
290
|
+
for (const n of flat) {
|
|
291
|
+
n.children = []
|
|
292
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= n.indent) stack.pop()
|
|
293
|
+
if (stack.length === 0) roots.push(n)
|
|
294
|
+
else stack[stack.length - 1].children.push(n)
|
|
295
|
+
stack.push(n)
|
|
296
|
+
}
|
|
297
|
+
if (roots.length === 1) return { root: roots[0], multi: false }
|
|
298
|
+
const synthetic = { raw: '', indent: -1, order: -1, children: roots, synthetic: true }
|
|
299
|
+
return { root: synthetic, multi: true }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// `id((文本))` / `((文本))` / `id[文本]` / 裸文本
|
|
303
|
+
const SHAPE_RULES = [
|
|
304
|
+
{ re: /^(?:[\w-]*)(?=\()\s*\(\(([\s\S]*)\)\)$/u, shape: 'circle' },
|
|
305
|
+
{ re: /^(?:[\w-]*)\{\{([\s\S]*)\}\}$/u, shape: 'hex' },
|
|
306
|
+
{ re: /^(?:[\w-]*)\[([\s\S]*)\]$/u, shape: 'rect' },
|
|
307
|
+
{ re: /^(?:[\w-]*)(?=\()\s*\(([\s\S]*)\)$/u, shape: 'rounded' },
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
function decorate(node, depth, top) {
|
|
311
|
+
let text = node.raw
|
|
312
|
+
let classes = []
|
|
313
|
+
const cls = /\s*:::([\w\-\s]+)$/u.exec(text)
|
|
314
|
+
if (cls !== null) {
|
|
315
|
+
classes = cls[1].trim().split(/\s+/u)
|
|
316
|
+
text = text.slice(0, cls.index).trim()
|
|
317
|
+
}
|
|
318
|
+
let shape = null
|
|
319
|
+
for (const r of SHAPE_RULES) {
|
|
320
|
+
const m = r.re.exec(text)
|
|
321
|
+
if (m !== null) {
|
|
322
|
+
// 只在括号内确有内容时才认(避免把 "详见 (a)" 这类当形状)
|
|
323
|
+
text = m[1].trim()
|
|
324
|
+
shape = r.shape
|
|
325
|
+
break
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
node.text = text.replace(/<br\s*\/?>/giu, '\n')
|
|
329
|
+
node.shape = shape
|
|
330
|
+
node.classes = classes
|
|
331
|
+
node.depth = depth
|
|
332
|
+
node.top = depth <= 1 ? node : top
|
|
333
|
+
node.index = node.index ?? 0
|
|
334
|
+
node.children.forEach((child, i) => { child.index = i; decorate(child, depth + 1, node.top) })
|
|
335
|
+
return node
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
339
|
+
// 4. 文字测量(无 canvas:CJK / 拉丁 / 标点分别估算,确定性优先)
|
|
340
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
341
|
+
const CJK = /[\u2E80-\u9FFF\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/u
|
|
342
|
+
function charWidth(ch) {
|
|
343
|
+
if (CJK.test(ch)) return 1.0
|
|
344
|
+
if (ch === ' ') return 0.32
|
|
345
|
+
// ①–⑳ 这类圈号在全角 em 上排,按 1.05em 算,否则右边缘会被字形顶出框
|
|
346
|
+
if (/[\u2460-\u24FF\u2776-\u2793]/u.test(ch)) return 1.05
|
|
347
|
+
// 希腊字母(α ε Λ β)在中文正文里按 0.66em 估,太窄会让公式行溢出
|
|
348
|
+
if (/[\u0370-\u03FF]/u.test(ch)) return 0.66
|
|
349
|
+
if (/[A-Z0-9]/u.test(ch)) return 0.62
|
|
350
|
+
if (/[a-z]/u.test(ch)) return 0.55
|
|
351
|
+
if (/[.,:;'"`|!()[\]{}]/u.test(ch)) return 0.34
|
|
352
|
+
if (/[-–—_/\\+*=<>~^%$#@&·×∈≤≥≈→←↓↑]/u.test(ch)) return 0.56
|
|
353
|
+
if ((ch.codePointAt(0) ?? 0) > 0xffff) return 1.0
|
|
354
|
+
return 0.58
|
|
355
|
+
}
|
|
356
|
+
const textWidth = (s, size) => {
|
|
357
|
+
let w = 0
|
|
358
|
+
for (const ch of s) w += charWidth(ch)
|
|
359
|
+
return w * size
|
|
360
|
+
}
|
|
361
|
+
function wrapText(text, maxWidth, size) {
|
|
362
|
+
const out = []
|
|
363
|
+
for (const para of String(text).split('\n')) {
|
|
364
|
+
if (maxWidth <= 0 || textWidth(para, size) <= maxWidth) { out.push(para); continue }
|
|
365
|
+
const tokens = []
|
|
366
|
+
let buf = ''
|
|
367
|
+
for (const ch of para) {
|
|
368
|
+
if (CJK.test(ch) || ch === ' ') {
|
|
369
|
+
if (buf !== '') { tokens.push(buf); buf = '' }
|
|
370
|
+
tokens.push(ch)
|
|
371
|
+
} else buf += ch
|
|
372
|
+
}
|
|
373
|
+
if (buf !== '') tokens.push(buf)
|
|
374
|
+
let line = ''
|
|
375
|
+
for (const tk of tokens) {
|
|
376
|
+
if (line !== '' && textWidth(line + tk, size) > maxWidth) {
|
|
377
|
+
out.push(line.trimEnd())
|
|
378
|
+
line = tk === ' ' ? '' : tk
|
|
379
|
+
} else line += tk
|
|
380
|
+
}
|
|
381
|
+
out.push(line.trimEnd())
|
|
382
|
+
}
|
|
383
|
+
return out.length === 0 ? [''] : out
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
387
|
+
// 5. 样式
|
|
388
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
389
|
+
function buildStyles(cfg) {
|
|
390
|
+
const theme = THEMES[cfg.theme] ?? THEMES.paper
|
|
391
|
+
const density = DENSITY[cfg.density] ?? DENSITY.normal
|
|
392
|
+
const fs = Number(cfg.font?.size ?? 15) * Number(cfg.font?.scale ?? 1) * Number(cfg.scale ?? 1)
|
|
393
|
+
const gap = {
|
|
394
|
+
level: Number(cfg.gap?.level ?? density.level),
|
|
395
|
+
sibling: Number(cfg.gap?.sibling ?? density.sibling),
|
|
396
|
+
branch: Number(cfg.gap?.branch ?? density.branch),
|
|
397
|
+
}
|
|
398
|
+
const pad = {
|
|
399
|
+
x: Number(cfg.padding?.x ?? density.padX),
|
|
400
|
+
y: Number(cfg.padding?.y ?? density.padY),
|
|
401
|
+
}
|
|
402
|
+
const bg = isTransparent(cfg.background) ? 'transparent' : (cfg.background ?? theme.bg)
|
|
403
|
+
const paper = isTransparent(bg) ? '#ffffff' : bg
|
|
404
|
+
const palette = Array.isArray(cfg.palette) && cfg.palette.length > 0 ? cfg.palette : theme.palette
|
|
405
|
+
const defaultShape = SHAPES.has(cfg.shape) ? cfg.shape : 'rounded'
|
|
406
|
+
const base = [
|
|
407
|
+
{ size: fs * 1.42, weight: 700, shape: 'pill', fill: theme.root, color: readableOn(theme.root), stroke: 'none', sw: 0, radius: 999 },
|
|
408
|
+
{ size: fs * 1.1, weight: 650, shape: 'pill', fill: 'auto', color: 'auto', stroke: 'auto', sw: 1.4, radius: 999 },
|
|
409
|
+
{ size: fs * 1.0, weight: 500, shape: defaultShape, fill: 'none', color: 'auto', stroke: 'none', sw: 0, radius: Number(cfg.radius ?? 9) },
|
|
410
|
+
{ size: fs * 0.93, weight: 400, shape: 'none', fill: 'none', color: theme.muted, stroke: 'none', sw: 0, radius: Number(cfg.radius ?? 9) },
|
|
411
|
+
]
|
|
412
|
+
const declared = Array.isArray(cfg.levels) ? cfg.levels : []
|
|
413
|
+
const levels = base.map((b, i) => ({ ...b, ...(declared[i] ?? {}) }))
|
|
414
|
+
const classes = cfg.classes && typeof cfg.classes === 'object' ? cfg.classes : {}
|
|
415
|
+
return { theme, paper, fs, gap, pad, bg, palette, levels, classes, defaultShape }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function branchColorOf(node, st) {
|
|
419
|
+
const owner = node.top === node ? node : node.top
|
|
420
|
+
return st.palette[(owner?.index ?? 0) % st.palette.length]
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function resolveStyle(node, st) {
|
|
424
|
+
const depth = node.depth
|
|
425
|
+
const branch = branchColorOf(node, st)
|
|
426
|
+
const lt = st.levels[Math.min(depth, st.levels.length - 1)]
|
|
427
|
+
const style = {
|
|
428
|
+
size: Number(lt.size),
|
|
429
|
+
weight: Number(lt.weight),
|
|
430
|
+
shape: lt.shape === 'auto' || lt.shape === undefined ? st.defaultShape : lt.shape,
|
|
431
|
+
fill: lt.fill,
|
|
432
|
+
stroke: lt.stroke,
|
|
433
|
+
sw: Number(lt.sw ?? 0),
|
|
434
|
+
color: lt.color,
|
|
435
|
+
radius: Number(lt.radius ?? 9),
|
|
436
|
+
italic: lt.italic === true,
|
|
437
|
+
padX: lt.padX === undefined ? undefined : Number(lt.padX),
|
|
438
|
+
padY: lt.padY === undefined ? undefined : Number(lt.padY),
|
|
439
|
+
maxWidth: lt.maxWidth === undefined ? undefined : Number(lt.maxWidth),
|
|
440
|
+
}
|
|
441
|
+
if (style.fill === 'auto' || style.fill == null || style.fill === '') {
|
|
442
|
+
style.fill = depth === 0 ? st.theme.root : mix(branch, st.paper, 0.87)
|
|
443
|
+
}
|
|
444
|
+
if (style.stroke === 'auto') style.stroke = branch
|
|
445
|
+
if (style.color === 'auto' || style.color == null) {
|
|
446
|
+
style.color = depth === 0 ? readableOn(style.fill) : mix(branch, st.theme.ink, 0.42)
|
|
447
|
+
}
|
|
448
|
+
if (typeof style.fill !== 'string') style.fill = 'none'
|
|
449
|
+
if (typeof style.stroke !== 'string') style.stroke = 'none'
|
|
450
|
+
for (const name of node.classes ?? []) {
|
|
451
|
+
const c = st.classes[name]
|
|
452
|
+
if (!c) continue
|
|
453
|
+
for (const [k, v] of Object.entries(c)) {
|
|
454
|
+
if (k === 'size' || k === 'sw' || k === 'radius') style[k] = Number(v)
|
|
455
|
+
else if (k === 'italic') style[k] = v === true || v === 'true'
|
|
456
|
+
else style[k] = v
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (node.shape !== null) style.shape = node.shape
|
|
460
|
+
if (style.shape === 'circle') style.radius = 999
|
|
461
|
+
style.branch = branch
|
|
462
|
+
return style
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
466
|
+
// 6. 度量 + 布局
|
|
467
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
468
|
+
function measure(root, cfg, st) {
|
|
469
|
+
const maxW = Number(cfg.maxNodeWidth ?? 240)
|
|
470
|
+
const rootMaxW = Number(cfg.rootMaxWidth ?? maxW * 2)
|
|
471
|
+
const lh = Number(cfg.font?.lineHeight ?? 1.32)
|
|
472
|
+
const walk = (node) => {
|
|
473
|
+
node.style = resolveStyle(node, st)
|
|
474
|
+
const padX = node.style.padX ?? st.pad.x
|
|
475
|
+
const padY = node.style.padY ?? st.pad.y
|
|
476
|
+
const budget = node.depth === 0 ? Math.max(rootMaxW, maxW) : (node.style.maxWidth ?? maxW)
|
|
477
|
+
const prefixText = node.numberPrefix ? `${node.numberPrefix} ` : ''
|
|
478
|
+
node.lines = wrapText(prefixText + node.text, Math.max(40, budget - padX * 2), node.style.size)
|
|
479
|
+
const wMax = Math.max(...node.lines.map((l) => textWidth(l, node.style.size)), 8)
|
|
480
|
+
node.tw = wMax
|
|
481
|
+
node.th = node.lines.length * node.style.size * lh
|
|
482
|
+
node.w = wMax + padX * 2
|
|
483
|
+
node.h = node.th + padY * 2
|
|
484
|
+
if (node.style.shape === 'none' || node.style.shape === 'underline') {
|
|
485
|
+
node.w = wMax + padX * 0.5
|
|
486
|
+
node.h = node.th + 2
|
|
487
|
+
} else if (node.style.shape === 'pill' || node.style.shape === 'circle') {
|
|
488
|
+
// 胶囊/圆的端头是半圆,水平内边距必须至少 h/2 才不会让字顶出弧线
|
|
489
|
+
node.w = Math.max(node.w, wMax + node.h * 1.02)
|
|
490
|
+
}
|
|
491
|
+
node.children.forEach(walk)
|
|
492
|
+
}
|
|
493
|
+
const number = (node, prefix) => {
|
|
494
|
+
node.numberPrefix = ''
|
|
495
|
+
if (cfg.numbering === 'circled' && node.depth === 1) node.numberPrefix = CIRCLED[node.index] ?? `${node.index + 1}`
|
|
496
|
+
if (cfg.numbering === 'arabic') node.numberPrefix = prefix === '' ? `${node.index + 1}` : `${prefix}.${node.index + 1}`
|
|
497
|
+
if (cfg.numbering === 'path' && node.depth >= 1) node.numberPrefix = `${prefix}${node.index + 1}`
|
|
498
|
+
node.children.forEach((c) => number(c, node.depth === 0 ? '' : (node.numberPrefix || `${node.index + 1}`)))
|
|
499
|
+
}
|
|
500
|
+
number(root, '')
|
|
501
|
+
walk(root)
|
|
502
|
+
return { lh }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const collect = (node, acc = []) => {
|
|
506
|
+
acc.push(node)
|
|
507
|
+
for (const c of node.children) collect(c, acc)
|
|
508
|
+
return acc
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 横向布局(right / left / both)。
|
|
513
|
+
* 主轴:depth 方向(x);副轴:兄弟方向(y)。
|
|
514
|
+
* 主轴用「父节点外侧边 + 层间距 + 自身半宽」累加,这样父宽度不影响子层对齐,
|
|
515
|
+
* 但父→子的连线不会穿进自己的框。
|
|
516
|
+
*/
|
|
517
|
+
function layoutHorizontal(root, st, mode) {
|
|
518
|
+
const kids = root.children
|
|
519
|
+
let groups
|
|
520
|
+
if (mode === 'both') {
|
|
521
|
+
const half = Math.ceil(kids.length / 2)
|
|
522
|
+
groups = [
|
|
523
|
+
{ side: 'right', nodes: kids.slice(0, half) },
|
|
524
|
+
{ side: 'left', nodes: kids.slice(half) },
|
|
525
|
+
]
|
|
526
|
+
for (const g of groups) for (const n of g.nodes) markSide(n, g.side)
|
|
527
|
+
} else {
|
|
528
|
+
const side = mode === 'left' ? 'left' : 'right'
|
|
529
|
+
groups = [{ side, nodes: kids }]
|
|
530
|
+
for (const n of kids) markSide(n, side)
|
|
531
|
+
}
|
|
532
|
+
root.side = mode === 'left' ? 'left' : 'right'
|
|
533
|
+
|
|
534
|
+
const maxAtDepth = {}
|
|
535
|
+
for (const n of collect(root)) maxAtDepth[n.depth] = Math.max(maxAtDepth[n.depth] ?? 0, n.w)
|
|
536
|
+
|
|
537
|
+
for (const g of groups) {
|
|
538
|
+
if (g.nodes.length === 0) continue
|
|
539
|
+
const ext = (n) => {
|
|
540
|
+
const kids2 = n.children
|
|
541
|
+
const total = kids2.reduce((s, k) => s + ext(k), 0) + Math.max(0, kids2.length - 1) * st.gap.sibling
|
|
542
|
+
n.kidsTotal = total
|
|
543
|
+
n.ext = Math.max(n.h, total)
|
|
544
|
+
return n.ext
|
|
545
|
+
}
|
|
546
|
+
const groupTotal = g.nodes.reduce((s, k) => s + ext(k), 0) + Math.max(0, g.nodes.length - 1) * st.gap.branch
|
|
547
|
+
let cursor = -groupTotal / 2
|
|
548
|
+
const placeCross = (n, start) => {
|
|
549
|
+
n.cy = start + n.ext / 2
|
|
550
|
+
let c = start + (n.ext - n.kidsTotal) / 2
|
|
551
|
+
for (const k of n.children) { placeCross(k, c); c += k.ext + st.gap.sibling }
|
|
552
|
+
}
|
|
553
|
+
for (const k of g.nodes) { placeCross(k, cursor); cursor += k.ext + st.gap.branch }
|
|
554
|
+
|
|
555
|
+
const walkMain = (node, parent) => {
|
|
556
|
+
const dir = g.side === 'left' ? -1 : 1
|
|
557
|
+
if (parent === root) {
|
|
558
|
+
node.cx = dir * (root.w / 2 + st.gap.level + node.w / 2)
|
|
559
|
+
} else {
|
|
560
|
+
node.cx = parent.cx + dir * (parent.w / 2 + st.gap.level + node.w / 2)
|
|
561
|
+
}
|
|
562
|
+
node.children.forEach((c) => walkMain(c, node))
|
|
563
|
+
}
|
|
564
|
+
for (const k of g.nodes) walkMain(k, root)
|
|
565
|
+
}
|
|
566
|
+
root.cx = 0
|
|
567
|
+
root.cy = 0
|
|
568
|
+
// 上下居中:把整幅按 y 归零
|
|
569
|
+
const ys = collect(root).map((n) => n.cy)
|
|
570
|
+
const mid = (Math.min(...ys) + Math.max(...ys)) / 2
|
|
571
|
+
for (const n of collect(root)) n.cy -= mid
|
|
572
|
+
return { vertical: false }
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function markSide(node, side) {
|
|
576
|
+
node.side = side
|
|
577
|
+
node.children.forEach((c) => markSide(c, side))
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** 纵向布局(tree / org):主轴是 y,副轴是 x。文字仍然横排。 */
|
|
581
|
+
function layoutVertical(root, st) {
|
|
582
|
+
for (const n of collect(root)) n.side = 'right'
|
|
583
|
+
const ext = (n) => {
|
|
584
|
+
const total = n.children.reduce((s, k) => s + ext(k), 0) + Math.max(0, n.children.length - 1) * st.gap.sibling
|
|
585
|
+
n.kidsTotal = total
|
|
586
|
+
n.ext = Math.max(n.w, total)
|
|
587
|
+
return n.ext
|
|
588
|
+
}
|
|
589
|
+
const total = ext(root)
|
|
590
|
+
const placeCross = (n, start) => {
|
|
591
|
+
n.cx = start + n.ext / 2
|
|
592
|
+
let c = start + (n.ext - n.kidsTotal) / 2
|
|
593
|
+
for (const k of n.children) { placeCross(k, c); c += k.ext + st.gap.sibling }
|
|
594
|
+
}
|
|
595
|
+
placeCross(root, -total / 2)
|
|
596
|
+
const walkMain = (node, parent) => {
|
|
597
|
+
node.cy = parent === null ? 0 : parent.cy + parent.h / 2 + st.gap.level + node.h / 2
|
|
598
|
+
node.children.forEach((c) => walkMain(c, node))
|
|
599
|
+
}
|
|
600
|
+
walkMain(root, null)
|
|
601
|
+
const xs = collect(root).map((n) => n.cx)
|
|
602
|
+
const mid = (Math.min(...xs) + Math.max(...xs)) / 2
|
|
603
|
+
for (const n of collect(root)) n.cx -= mid
|
|
604
|
+
return { vertical: true }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
608
|
+
// 7. SVG
|
|
609
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
610
|
+
const esc = (s) => String(s).replace(/&/gu, '&').replace(/</gu, '<').replace(/>/gu, '>').replace(/"/gu, '"')
|
|
611
|
+
const n2 = (v) => Math.round(v * 100) / 100
|
|
612
|
+
|
|
613
|
+
function nodeAttrs(n) {
|
|
614
|
+
const x = n2(n.cx - n.w / 2)
|
|
615
|
+
const y = n2(n.cy - n.h / 2)
|
|
616
|
+
return { x, y, w: n2(n.w), h: n2(n.h) }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function shapeMarkup(n) {
|
|
620
|
+
const s = n.style
|
|
621
|
+
const { x, y, w, h } = nodeAttrs(n)
|
|
622
|
+
if (s.shape === 'none' || s.shape === 'underline') {
|
|
623
|
+
if (s.shape !== 'underline') return ''
|
|
624
|
+
const yy = n2(n.cy + n.h / 2)
|
|
625
|
+
return `<line x1="${n2(n.cx - n.w / 2)}" y1="${yy}" x2="${n2(n.cx + n.w / 2)}" y2="${yy}" stroke="${s.branch}" stroke-width="1.1" opacity="0.5" stroke-linecap="round"/>`
|
|
626
|
+
}
|
|
627
|
+
const strokeAttr = s.sw > 0 && s.stroke !== 'none' ? ` stroke="${s.stroke}" stroke-width="${n2(s.sw)}"` : ' stroke="none"'
|
|
628
|
+
const fillAttr = ` fill="${s.fill}"`
|
|
629
|
+
let d
|
|
630
|
+
if (s.shape === 'circle' || s.shape === 'pill') {
|
|
631
|
+
const rx = w / 2
|
|
632
|
+
const ry = h / 2
|
|
633
|
+
d = `M${n2(x)},${n2(y + ry)} a${n2(rx)},${n2(ry)} 0 1 0 ${n2(w)},0 a${n2(rx)},${n2(ry)} 0 1 0 ${n2(-w)},0 Z`
|
|
634
|
+
} else if (s.shape === 'hex') {
|
|
635
|
+
const inset = Math.min(h * 0.45, w * 0.16)
|
|
636
|
+
d = `M${n2(x + inset)},${n2(y)} H${n2(x + w - inset)} L${n2(x + w)},${n2(y + h / 2)} L${n2(x + w - inset)},${n2(y + h)} H${n2(x + inset)} L${n2(x)},${n2(y + h / 2)} Z`
|
|
637
|
+
} else {
|
|
638
|
+
const r = Math.max(0, Math.min(s.shape === 'rect' ? 0 : s.radius, w / 2, h / 2))
|
|
639
|
+
if (r < 0.6) d = `M${n2(x)},${n2(y)} h${n2(w)} v${n2(h)} h${n2(-w)} Z`
|
|
640
|
+
else d = `M${n2(x + r)},${n2(y)} h${n2(w - 2 * r)} a${n2(r)},${n2(r)} 0 0 1 ${n2(r)},${n2(r)} v${n2(h - 2 * r)} a${n2(r)},${n2(r)} 0 0 1 ${n2(-r)},${n2(r)} h${n2(-(w - 2 * r))} a${n2(r)},${n2(r)} 0 0 1 ${n2(-r)},${n2(-r)} v${n2(-(h - 2 * r))} a${n2(r)},${n2(r)} 0 0 1 ${n2(r)},${n2(-r)} Z`
|
|
641
|
+
}
|
|
642
|
+
return `<path d="${d}"${fillAttr}${strokeAttr}/>`
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function edgePath(pa, ch, connector, vertical) {
|
|
646
|
+
if (vertical) {
|
|
647
|
+
const sy = pa.cy + pa.h / 2
|
|
648
|
+
const ey = ch.cy - ch.h / 2
|
|
649
|
+
const sx = pa.cx
|
|
650
|
+
const ex = ch.cx
|
|
651
|
+
const dy = ey - sy
|
|
652
|
+
if (connector === 'straight') return `M${n2(sx)},${n2(sy)} L${n2(ex)},${n2(ey)}`
|
|
653
|
+
if (connector === 'elbow') return `M${n2(sx)},${n2(sy)} V${n2(sy + dy / 2)} H${n2(ex)} V${n2(ey)}`
|
|
654
|
+
const c = Math.max(8, Math.abs(dy) * 0.45)
|
|
655
|
+
return `M${n2(sx)},${n2(sy)} C${n2(sx)},${n2(sy + c)} ${n2(ex)},${n2(ey - c)} ${n2(ex)},${n2(ey)}`
|
|
656
|
+
}
|
|
657
|
+
const dir = ch.side === 'left' ? -1 : 1
|
|
658
|
+
const sx = pa.cx + (dir > 0 ? pa.w / 2 : -pa.w / 2)
|
|
659
|
+
const ex = ch.cx - (dir > 0 ? ch.w / 2 : -ch.w / 2)
|
|
660
|
+
const sy = pa.cy
|
|
661
|
+
const ey = ch.cy
|
|
662
|
+
const dx = ex - sx
|
|
663
|
+
if (connector === 'straight') return `M${n2(sx)},${n2(sy)} L${n2(ex)},${n2(ey)}`
|
|
664
|
+
if (connector === 'elbow') return `M${n2(sx)},${n2(sy)} H${n2(sx + dx / 2)} V${n2(ey)} H${n2(ex)}`
|
|
665
|
+
if (connector === 'arc') {
|
|
666
|
+
const r = Math.max(Math.abs(dx) * 0.9, Math.abs(ey - sy) * 1.1, 10)
|
|
667
|
+
return `M${n2(sx)},${n2(sy)} A${n2(r)},${n2(r)} 0 0 ${dir > 0 ? 1 : 0} ${n2(ex)},${n2(ey)}`
|
|
668
|
+
}
|
|
669
|
+
const c = Math.max(10, Math.abs(dx) * 0.42)
|
|
670
|
+
return `M${n2(sx)},${n2(sy)} C${n2(sx + dir * c)},${n2(sy)} ${n2(ex - dir * c)},${n2(ey)} ${n2(ex)},${n2(ey)}`
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function render(root, cfg, st, geo) {
|
|
674
|
+
const all = collect(root)
|
|
675
|
+
const connector = CONNECTORS.has(cfg.connector) ? cfg.connector : 'curve'
|
|
676
|
+
const family = String(cfg.font?.family ?? "'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', -apple-system, 'Segoe UI', sans-serif")
|
|
677
|
+
const lh = Number(cfg.font?.lineHeight ?? 1.32)
|
|
678
|
+
const pad = Number(cfg.canvasPad ?? 26)
|
|
679
|
+
|
|
680
|
+
let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity
|
|
681
|
+
for (const n of all) {
|
|
682
|
+
minX = Math.min(minX, n.cx - n.w / 2)
|
|
683
|
+
maxX = Math.max(maxX, n.cx + n.w / 2)
|
|
684
|
+
minY = Math.min(minY, n.cy - n.h / 2)
|
|
685
|
+
maxY = Math.max(maxY, n.cy + n.h / 2)
|
|
686
|
+
}
|
|
687
|
+
const contentW = maxX - minX
|
|
688
|
+
const contentH = maxY - minY
|
|
689
|
+
|
|
690
|
+
// 页眉页脚在**绝对坐标**里画,避免与 group 平移叠算
|
|
691
|
+
const titleSize = st.fs * 1.5
|
|
692
|
+
const subSize = st.fs * 0.95
|
|
693
|
+
const hasTitle = typeof cfg.title === 'string' && cfg.title !== ''
|
|
694
|
+
const hasSub = typeof cfg.subtitle === 'string' && cfg.subtitle !== ''
|
|
695
|
+
const hasFoot = typeof cfg.footnote === 'string' && cfg.footnote !== ''
|
|
696
|
+
let header = 0
|
|
697
|
+
if (hasTitle) header += titleSize * 1.05
|
|
698
|
+
if (hasSub) header += subSize * 1.5
|
|
699
|
+
if (hasTitle || hasSub) header += pad * 0.5
|
|
700
|
+
const footH = hasFoot ? st.fs * 2.1 + pad * 0.4 : 0
|
|
701
|
+
|
|
702
|
+
const W = Math.max(contentW + pad * 2, hasTitle ? textWidth(cfg.title, titleSize) * 0.55 + pad * 2 : 0)
|
|
703
|
+
const H = contentH + pad * 2 + header + footH
|
|
704
|
+
const offsetX = (W - contentW) / 2 - minX
|
|
705
|
+
const offsetY = pad + header - minY
|
|
706
|
+
|
|
707
|
+
const out = []
|
|
708
|
+
out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${n2(W)}" height="${n2(H)}" viewBox="0 0 ${n2(W)} ${n2(H)}" role="img" aria-label="${esc(cfg.title ?? (root.text || 'mindmap'))}">`)
|
|
709
|
+
out.push(`<title>${esc(cfg.title ?? (root.text || 'mindmap'))}</title>`)
|
|
710
|
+
if (cfg.shadow === true) {
|
|
711
|
+
out.push('<defs><filter id="mm-shadow" x="-25%" y="-25%" width="150%" height="150%"><feDropShadow dx="0" dy="1.4" stdDeviation="1.8" flood-color="#0f1926" flood-opacity="0.15"/></filter></defs>')
|
|
712
|
+
}
|
|
713
|
+
if (st.bg !== 'transparent') out.push(`<rect width="${n2(W)}" height="${n2(H)}" fill="${st.bg}"/>`)
|
|
714
|
+
if (cfg.border === true) out.push(`<rect x="0.75" y="0.75" width="${n2(W - 1.5)}" height="${n2(H - 1.5)}" rx="10" fill="none" stroke="${st.theme.rule}" stroke-width="1.5"/>`)
|
|
715
|
+
|
|
716
|
+
if (hasTitle) {
|
|
717
|
+
const ty = pad * 0.9 + titleSize * 0.82
|
|
718
|
+
out.push(`<text x="${n2(W / 2)}" y="${n2(ty)}" text-anchor="middle" font-family="${esc(family)}" font-size="${n2(titleSize)}" font-weight="700" fill="${st.theme.ink}">${esc(cfg.title)}</text>`)
|
|
719
|
+
if (hasSub) {
|
|
720
|
+
out.push(`<text x="${n2(W / 2)}" y="${n2(ty + subSize * 1.55)}" text-anchor="middle" font-family="${esc(family)}" font-size="${n2(subSize)}" fill="${st.theme.muted}">${esc(cfg.subtitle)}</text>`)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
out.push(`<g transform="translate(${n2(offsetX)},${n2(offsetY)})" font-family="${esc(family)}">`)
|
|
725
|
+
|
|
726
|
+
const drawEdge = (n) => {
|
|
727
|
+
for (const c of n.children) {
|
|
728
|
+
const ew = Array.isArray(cfg.edgeWidth)
|
|
729
|
+
? Number(cfg.edgeWidth[Math.min(c.depth - 1, cfg.edgeWidth.length - 1)])
|
|
730
|
+
: (c.depth === 1 ? 3 : c.depth === 2 ? 2 : 1.35)
|
|
731
|
+
const color = c.depth === 1 ? c.style.branch : mix(c.style.branch, st.paper, c.depth === 2 ? 0.16 : 0.46)
|
|
732
|
+
out.push(`<path d="${edgePath(n, c, connector, geo.vertical)}" fill="none" stroke="${color}" stroke-width="${n2(ew)}" stroke-linecap="round" opacity="${c.depth >= 3 ? 0.85 : 1}"/>`)
|
|
733
|
+
drawEdge(c)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
drawEdge(root)
|
|
737
|
+
|
|
738
|
+
for (const n of all) {
|
|
739
|
+
const s = n.style
|
|
740
|
+
if (s.fill !== 'none' || s.sw > 0 || s.shape === 'underline') {
|
|
741
|
+
const mark = shapeMarkup(n)
|
|
742
|
+
if (mark !== '') out.push(mark)
|
|
743
|
+
}
|
|
744
|
+
const blockH = n.lines.length * s.size * lh
|
|
745
|
+
let baseline = n.cy - blockH / 2 + s.size * (0.5 * lh + 0.34)
|
|
746
|
+
const weight = Number.isFinite(s.weight) ? s.weight : 400
|
|
747
|
+
const italic = s.italic ? ' font-style="italic"' : ''
|
|
748
|
+
const letter = s.letterSpacing !== undefined ? ` letter-spacing="${s.letterSpacing}"` : ''
|
|
749
|
+
for (const line of n.lines) {
|
|
750
|
+
out.push(`<text x="${n2(n.cx)}" y="${n2(baseline)}" text-anchor="middle" font-size="${n2(s.size)}" font-weight="${weight}"${italic}${letter} fill="${s.color}">${esc(line)}</text>`)
|
|
751
|
+
baseline += s.size * lh
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
out.push('</g>')
|
|
755
|
+
|
|
756
|
+
if (hasFoot) {
|
|
757
|
+
out.push(`<text x="${n2(W / 2)}" y="${n2(H - pad * 0.6)}" text-anchor="middle" font-family="${esc(family)}" font-size="${n2(st.fs * 0.82)}" fill="${st.theme.muted}">${esc(cfg.footnote)}</text>`)
|
|
758
|
+
}
|
|
759
|
+
out.push('</svg>')
|
|
760
|
+
return { svg: out.join('\n'), width: W, height: H }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
764
|
+
// 8. CLI
|
|
765
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
766
|
+
const HELP = `思维导图渲染器(零依赖,缩进大纲 → SVG)
|
|
767
|
+
|
|
768
|
+
node render.mjs <源文件.mmd|.txt> [选项]
|
|
769
|
+
|
|
770
|
+
选项
|
|
771
|
+
--out <路径> 输出 SVG(缺省与源文件同名 .svg)
|
|
772
|
+
--theme <名> paper | aero | ink | warm | forest | slate | contrast
|
|
773
|
+
--layout <名> right | left | both | tree
|
|
774
|
+
--connector <名> curve | elbow | straight | arc
|
|
775
|
+
--density <名> compact | normal | roomy
|
|
776
|
+
--title <文本> 画布标题(覆盖前置配置)
|
|
777
|
+
--transparent 无底色(跟随阅读器主题)
|
|
778
|
+
--scale <系数> 整体字号系数(1 = 原样)
|
|
779
|
+
--json 只打印统计,不写文件
|
|
780
|
+
-h, --help 本帮助
|
|
781
|
+
|
|
782
|
+
前置配置(YAML,夹在 --- 之间)见同级 SKILL.md 的「配置项总表」。`
|
|
783
|
+
|
|
784
|
+
function parseArgs(argv) {
|
|
785
|
+
const opts = { file: null }
|
|
786
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
787
|
+
const a = argv[i]
|
|
788
|
+
const next = () => { i += 1; return argv[i] }
|
|
789
|
+
switch (a) {
|
|
790
|
+
case '-h': case '--help': opts.help = true; break
|
|
791
|
+
case '--out': opts.out = next(); break
|
|
792
|
+
case '--theme': opts.theme = next(); break
|
|
793
|
+
case '--layout': opts.layout = next(); break
|
|
794
|
+
case '--connector': opts.connector = next(); break
|
|
795
|
+
case '--density': opts.density = next(); break
|
|
796
|
+
case '--title': opts.title = next(); break
|
|
797
|
+
case '--scale': opts.scale = Number(next()); break
|
|
798
|
+
case '--transparent': opts.transparent = true; break
|
|
799
|
+
case '--json': opts.json = true; break
|
|
800
|
+
default:
|
|
801
|
+
if (a.startsWith('--')) throw new Error(`未知选项:${a}`)
|
|
802
|
+
if (opts.file === null) opts.file = a
|
|
803
|
+
else throw new Error(`多余参数:${a}`)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return opts
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function main() {
|
|
810
|
+
let opts
|
|
811
|
+
try { opts = parseArgs(process.argv.slice(2)) } catch (e) { console.error(String(e.message ?? e)); process.exitCode = 2; return }
|
|
812
|
+
if (opts.help === true) { console.log(HELP); return }
|
|
813
|
+
if (opts.file === null) { console.error(HELP); process.exitCode = 2; return }
|
|
814
|
+
|
|
815
|
+
const abs = path.resolve(process.cwd(), opts.file)
|
|
816
|
+
const { config, body } = splitFrontMatter(readFileSync(abs, 'utf8'))
|
|
817
|
+
const cfg = { ...config }
|
|
818
|
+
for (const k of ['theme', 'layout', 'connector', 'density', 'title', 'scale']) {
|
|
819
|
+
if (opts[k] !== undefined) cfg[k] = opts[k]
|
|
820
|
+
}
|
|
821
|
+
if (opts.transparent === true) cfg.background = 'transparent'
|
|
822
|
+
if (cfg.theme !== undefined && THEMES[cfg.theme] === undefined) throw new Error(`未知 theme:${cfg.theme}(可选 ${Object.keys(THEMES).join(' / ')})`)
|
|
823
|
+
if (cfg.layout !== undefined && !LAYOUTS.has(cfg.layout)) throw new Error(`未知 layout:${cfg.layout}(可选 ${[...LAYOUTS].join(' / ')})`)
|
|
824
|
+
if (cfg.connector !== undefined && !CONNECTORS.has(cfg.connector)) throw new Error(`未知 connector:${cfg.connector}(可选 ${[...CONNECTORS].join(' / ')})`)
|
|
825
|
+
|
|
826
|
+
const st = buildStyles(cfg)
|
|
827
|
+
const parsed = parseSource(body)
|
|
828
|
+
const tree = decorate(parsed.root, 0, null)
|
|
829
|
+
measure(tree, cfg, st)
|
|
830
|
+
const mode = cfg.layout ?? 'right'
|
|
831
|
+
const geo = mode === 'tree' ? layoutVertical(tree, st) : layoutHorizontal(tree, st, mode)
|
|
832
|
+
const { svg, width, height } = render(tree, cfg, st, geo)
|
|
833
|
+
const all = collect(tree)
|
|
834
|
+
const stats = {
|
|
835
|
+
nodes: all.length,
|
|
836
|
+
depth: Math.max(...all.map((n) => n.depth)) + 1,
|
|
837
|
+
width: Math.round(width),
|
|
838
|
+
height: Math.round(height),
|
|
839
|
+
theme: st.theme === THEMES[cfg.theme] ? (cfg.theme ?? 'paper') : (cfg.theme ?? 'paper'),
|
|
840
|
+
layout: mode,
|
|
841
|
+
connector: cfg.connector ?? 'curve',
|
|
842
|
+
}
|
|
843
|
+
if (opts.json === true) { console.log(JSON.stringify(stats, null, 2)); return }
|
|
844
|
+
const out = path.resolve(process.cwd(), opts.out ?? `${abs.replace(/\.[^./\\]+$/u, '')}.svg`)
|
|
845
|
+
mkdirSync(path.dirname(out), { recursive: true })
|
|
846
|
+
writeFileSync(out, svg, 'utf8')
|
|
847
|
+
console.log(`${path.basename(out)} ${stats.width}×${stats.height} ${stats.nodes} 节点 / ${stats.depth} 层 theme=${stats.theme} layout=${mode} connector=${stats.connector}`)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
try {
|
|
851
|
+
main()
|
|
852
|
+
} catch (error) {
|
|
853
|
+
console.error(`渲染失败:${error instanceof Error ? error.message : String(error)}`)
|
|
854
|
+
process.exitCode = 1
|
|
855
|
+
}
|