dsh-thesis 2.0.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +5 -4
  2. package/docs/config.md +12 -0
  3. package/docs/design.md +95 -2
  4. package/docs/release.md +94 -85
  5. package/docs/tools.md +8 -5
  6. package/docs/troubleshooting.md +18 -0
  7. package/docs/usage.md +4 -1
  8. package/lib/config.d.ts +13 -0
  9. package/lib/config.js +8 -1
  10. package/lib/figure/actions.d.ts +83 -0
  11. package/lib/figure/actions.js +439 -0
  12. package/lib/figure/index.d.ts +24 -0
  13. package/lib/figure/index.js +40 -0
  14. package/lib/figure/lib/check.d.ts +83 -0
  15. package/lib/figure/lib/check.js +214 -0
  16. package/lib/figure/lib/drawio.d.ts +123 -0
  17. package/lib/figure/lib/drawio.js +156 -0
  18. package/lib/figure/lib/export.d.ts +120 -0
  19. package/lib/figure/lib/export.js +308 -0
  20. package/lib/figure/lib/layout.d.ts +78 -0
  21. package/lib/figure/lib/layout.js +496 -0
  22. package/lib/figure/lib/spec.d.ts +114 -0
  23. package/lib/figure/lib/spec.js +290 -0
  24. package/lib/index.d.ts +1 -0
  25. package/lib/index.js +5 -1
  26. package/lib/paper/lib/check.d.ts +5 -0
  27. package/lib/paper/lib/check.js +21 -0
  28. package/lib/paper/lib/docx.d.ts +26 -0
  29. package/lib/paper/lib/docx.js +190 -27
  30. package/lib/paper/lib/image.d.ts +19 -0
  31. package/lib/paper/lib/image.js +82 -0
  32. package/lib/paper/lib/layout.js +26 -1
  33. package/lib/paper/lib/lit-refs.d.ts +30 -0
  34. package/lib/paper/lib/lit-refs.js +394 -0
  35. package/lib/paper/lib/lit-verify.d.ts +79 -0
  36. package/lib/paper/lib/lit-verify.js +556 -0
  37. package/lib/paper/register.d.ts +1 -1
  38. package/lib/paper/register.js +37 -1
  39. package/lib/paper/tools/build.d.ts +4 -0
  40. package/lib/paper/tools/build.js +79 -4
  41. package/lib/paper/tools/lit-verify.d.ts +35 -0
  42. package/lib/paper/tools/lit-verify.js +193 -0
  43. package/lib/ppt/check.js +5 -0
  44. package/lib/ppt/design.d.ts +86 -0
  45. package/lib/ppt/design.js +218 -0
  46. package/package.json +4 -1
  47. package/skills/thesis-defense/SKILL.md +25 -1
  48. package/skills/thesis-eng-design/SKILL.md +40 -20
  49. package/skills/thesis-literature/SKILL.md +28 -5
  50. package/skills/thesis-opener/SKILL.md +1 -1
  51. package/skills/thesis-pipeline/SKILL.md +4 -3
  52. package/skills/thesis-writing/SKILL.md +4 -1
@@ -0,0 +1,496 @@
1
+ /**
2
+ * 版面算法:把规范化模型排成**确定的坐标**。
3
+ *
4
+ * 这里是「图不糙」的关键。四种图的版面规则都很朴素,但足以覆盖本科毕设的绝大多数插图,
5
+ * 而且每条规则都可解释、可测试:
6
+ *
7
+ * - 架构图:层是横向色带,模块在层内按行排布(每行最多 4 个),层与层之间留出箭头的通道;
8
+ * - 流程图:按「最长路径」分层(Kahn 拓扑序),起点在上、终点在下,同级节点横向均分;
9
+ * - E-R 图:实体是表格式图元(标题栏 + 字段行),两列摆放,关系线带基数标注;
10
+ * - 时序图:参与者等距排开,生命线用 draw.io 自带形状,消息按时间从上往下逐行。
11
+ *
12
+ * 所有尺寸都是常量(见 {@link LAYOUT}),没有随机、没有外部依赖,同一份规格永远得到
13
+ * 同一张图——这样它才能被测试,也才能在 diff 里被审阅。
14
+ *
15
+ * @module dsh-thesis/figure/layout
16
+ */
17
+ import { STYLE } from "./drawio.js";
18
+ /** 版面常量(单位与 draw.io 一致;A4 纵向页宽 827)。 */
19
+ export const LAYOUT = {
20
+ margin: 26,
21
+ /** 内容区宽度上限:A4 纵向、2.54cm 页边距下正文宽约 790,留出导出边距。 */
22
+ contentWidth: 740,
23
+ arch: {
24
+ labelWidth: 96,
25
+ moduleWidth: 140,
26
+ moduleHeight: 50,
27
+ gapX: 16,
28
+ gapY: 16,
29
+ bandPadding: 12,
30
+ bandGap: 42,
31
+ },
32
+ flow: {
33
+ nodeWidth: 176,
34
+ nodeHeight: 54,
35
+ decisionWidth: 150,
36
+ decisionHeight: 86,
37
+ gapX: 36,
38
+ gapY: 48,
39
+ },
40
+ er: {
41
+ columnGap: 96,
42
+ rowGap: 34,
43
+ titleHeight: 30,
44
+ rowHeight: 22,
45
+ bottomPadding: 6,
46
+ minWidth: 156,
47
+ maxWidth: 300,
48
+ },
49
+ sequence: {
50
+ spacing: 170,
51
+ minSpacing: 118,
52
+ boxWidth: 132,
53
+ header: 68,
54
+ messageGap: 46,
55
+ tail: 24,
56
+ },
57
+ };
58
+ /** 版面错误(如流程有环):与规格错误区分开,调用方都按「生成失败」处理。 */
59
+ export class FigureLayoutError extends Error {
60
+ code = 'INVALID_FIGURE_LAYOUT';
61
+ constructor(message) {
62
+ super(message);
63
+ this.name = 'FigureLayoutError';
64
+ }
65
+ }
66
+ /** 中文按整字宽、西文按半字宽估算文本像素宽(用于 ER 实体自适应宽度)。 */
67
+ export function displayWidth(text, fontSize = 12) {
68
+ let width = 0;
69
+ for (const char of text) {
70
+ width += /[\u2e80-\u9fff\uff00-\uffef]/.test(char) ? fontSize : fontSize * 0.56;
71
+ }
72
+ return Math.round(width);
73
+ }
74
+ /** 逐层累进 id,保证同一张图里的 id 唯一且稳定。 */
75
+ function idCounter(prefix) {
76
+ let next = 0;
77
+ return (hint) => {
78
+ next += 1;
79
+ return hint === undefined ? `${prefix}${next}` : `${prefix}${next}-${hint}`;
80
+ };
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // 架构图
84
+ // ---------------------------------------------------------------------------
85
+ function buildArchitecture(model) {
86
+ const cfg = LAYOUT.arch;
87
+ const notes = [];
88
+ const cells = [];
89
+ const nextVertex = idCounter('v');
90
+ const nextEdge = idCounter('e');
91
+ const moduleAreaWidth = LAYOUT.contentWidth - cfg.labelWidth - cfg.bandPadding * 2;
92
+ const perRow = Math.max(1, Math.floor((moduleAreaWidth + cfg.gapX) / (cfg.moduleWidth + cfg.gapX)));
93
+ const bandWidth = LAYOUT.contentWidth;
94
+ const bandIds = new Map();
95
+ const moduleIds = new Map();
96
+ let y = LAYOUT.margin;
97
+ for (const layer of model.layers) {
98
+ const rows = Math.ceil(layer.modules.length / perRow);
99
+ if (rows > 1)
100
+ notes.push(`「${layer.name}」有 ${layer.modules.length} 个模块,超过每行 ${perRow} 个,自动折成 ${rows} 行`);
101
+ const bandHeight = rows * cfg.moduleHeight + (rows - 1) * cfg.gapY + cfg.bandPadding * 2;
102
+ const bandId = nextVertex('band');
103
+ bandIds.set(layer.name, bandId);
104
+ cells.push({
105
+ kind: 'vertex',
106
+ id: bandId,
107
+ value: '',
108
+ style: STYLE.layerBand,
109
+ rect: { x: LAYOUT.margin, y, width: bandWidth, height: bandHeight },
110
+ });
111
+ const labelId = nextVertex('label');
112
+ cells.push({
113
+ kind: 'vertex',
114
+ id: labelId,
115
+ value: layer.note === undefined ? layer.name : `${layer.name}\n${layer.note}`,
116
+ style: STYLE.layerLabel,
117
+ rect: { x: LAYOUT.margin, y, width: cfg.labelWidth, height: bandHeight },
118
+ });
119
+ layer.modules.forEach((module, index) => {
120
+ const row = Math.floor(index / perRow);
121
+ const col = index % perRow;
122
+ const moduleId = nextVertex('box');
123
+ moduleIds.set(module.text, moduleId);
124
+ const rect = {
125
+ x: LAYOUT.margin + cfg.labelWidth + cfg.bandPadding + col * (cfg.moduleWidth + cfg.gapX),
126
+ y: y + cfg.bandPadding + row * (cfg.moduleHeight + cfg.gapY),
127
+ width: cfg.moduleWidth,
128
+ height: cfg.moduleHeight,
129
+ };
130
+ cells.push({
131
+ kind: 'vertex',
132
+ id: moduleId,
133
+ value: module.note === undefined ? module.text : `${module.text}\n${module.note}`,
134
+ style: module.accent === true ? STYLE.moduleAccent : STYLE.module,
135
+ rect,
136
+ });
137
+ });
138
+ y += bandHeight + cfg.bandGap;
139
+ }
140
+ const height = y - cfg.bandGap + LAYOUT.margin;
141
+ const centerX = LAYOUT.margin + cfg.labelWidth + (bandWidth - cfg.labelWidth) / 2;
142
+ if (model.edges.length === 0) {
143
+ // 没有指名连线时,画「层 → 层」的默认调用箭头:分层架构的读法就是从上层到下层。
144
+ for (let index = 0; index < model.layers.length - 1; index += 1) {
145
+ const from = bandIds.get(model.layers[index].name);
146
+ const to = bandIds.get(model.layers[index + 1].name);
147
+ if (from === undefined || to === undefined)
148
+ continue;
149
+ cells.push({
150
+ kind: 'edge',
151
+ id: nextEdge(),
152
+ value: '',
153
+ style: `${STYLE.edge}exitX=${(centerX - LAYOUT.margin) / bandWidth};exitY=1;entryX=${(centerX - LAYOUT.margin) / bandWidth};entryY=0;`,
154
+ source: from,
155
+ target: to,
156
+ });
157
+ }
158
+ }
159
+ else {
160
+ for (const edge of model.edges) {
161
+ const from = moduleIds.get(edge.from) ?? bandIds.get(edge.from);
162
+ const to = moduleIds.get(edge.to) ?? bandIds.get(edge.to);
163
+ if (from === undefined || to === undefined)
164
+ throw new FigureLayoutError(`连线端点「${edge.from} → ${edge.to}」在版面里找不到`);
165
+ cells.push({
166
+ kind: 'edge',
167
+ id: nextEdge(),
168
+ value: edge.label ?? '',
169
+ style: STYLE.edge,
170
+ source: from,
171
+ target: to,
172
+ });
173
+ }
174
+ }
175
+ return { cells, width: LAYOUT.contentWidth + LAYOUT.margin * 2, height, notes };
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // 流程图
179
+ // ---------------------------------------------------------------------------
180
+ /** Kahn 拓扑序 + 最长路径分层;有环时抛错(流程图不允许环)。 */
181
+ function rankFlow(model) {
182
+ const incoming = new Map(model.nodes.map(node => [node.id, []]));
183
+ const outgoing = new Map(model.nodes.map(node => [node.id, []]));
184
+ for (const edge of model.edges) {
185
+ incoming.get(edge.to)?.push(edge.from);
186
+ outgoing.get(edge.from)?.push(edge.to);
187
+ }
188
+ const inDegree = new Map(model.nodes.map(node => [node.id, (incoming.get(node.id) ?? []).length]));
189
+ const queue = model.nodes.filter(node => inDegree.get(node.id) === 0).map(node => node.id);
190
+ const order = [];
191
+ while (queue.length > 0) {
192
+ const id = queue.shift();
193
+ order.push(id);
194
+ for (const next of outgoing.get(id) ?? []) {
195
+ const remaining = (inDegree.get(next) ?? 0) - 1;
196
+ inDegree.set(next, remaining);
197
+ if (remaining === 0)
198
+ queue.push(next);
199
+ }
200
+ }
201
+ if (order.length !== model.nodes.length) {
202
+ const stuck = model.nodes.filter(node => (inDegree.get(node.id) ?? 0) > 0).map(node => node.text);
203
+ throw new FigureLayoutError(`流程图存在环(${stuck.join(' → ')} 之间互相指回)。请拆掉回边,或用「返回上一步」之类的节点替代。`);
204
+ }
205
+ const rank = new Map(model.nodes.map(node => [node.id, 0]));
206
+ for (const id of order) {
207
+ for (const next of outgoing.get(id) ?? []) {
208
+ rank.set(next, Math.max(rank.get(next) ?? 0, (rank.get(id) ?? 0) + 1));
209
+ }
210
+ }
211
+ return rank;
212
+ }
213
+ /**
214
+ * 主干路径:从 start 出发,每一步优先走「非否」的分支。
215
+ *
216
+ * 为什么需要它:如果每一层都把节点均匀铺开,判断节点的「是」分支会被挤到一边,
217
+ * 整张流程图的阅读主线变成一条斜线。把主干固定在竖直中线上、把「否/失败」分支摆到
218
+ * 右侧,才是教材里常见、也最好讲的那种流程图。
219
+ */
220
+ function flowMainPath(model) {
221
+ const byId = new Map(model.nodes.map(node => [node.id, node]));
222
+ const outgoing = new Map();
223
+ for (const edge of model.edges) {
224
+ const bucket = outgoing.get(edge.from) ?? [];
225
+ bucket.push(edge);
226
+ outgoing.set(edge.from, bucket);
227
+ }
228
+ const incomingCount = new Map(model.nodes.map(node => [node.id, 0]));
229
+ for (const edge of model.edges)
230
+ incomingCount.set(edge.to, (incomingCount.get(edge.to) ?? 0) + 1);
231
+ const start = model.nodes.find(node => node.kind === 'start')
232
+ ?? model.nodes.find(node => (incomingCount.get(node.id) ?? 0) === 0)
233
+ ?? model.nodes[0];
234
+ const path = new Set();
235
+ let current = start;
236
+ while (current !== undefined && !path.has(current.id)) {
237
+ path.add(current.id);
238
+ const outs = outgoing.get(current.id) ?? [];
239
+ if (outs.length === 0)
240
+ break;
241
+ const preferred = outs.find(edge => !/^(否|不通过|失败|错误|no|n|false)$/i.test((edge.label ?? '').trim())) ?? outs[0];
242
+ current = preferred === undefined ? undefined : byId.get(preferred.to);
243
+ }
244
+ return path;
245
+ }
246
+ function buildFlow(model) {
247
+ const cfg = LAYOUT.flow;
248
+ const notes = [];
249
+ const cells = [];
250
+ const nextVertex = idCounter('v');
251
+ const nextEdge = idCounter('e');
252
+ const rank = rankFlow(model);
253
+ const mainPath = flowMainPath(model);
254
+ const rows = new Map();
255
+ for (const node of model.nodes) {
256
+ const row = rank.get(node.id) ?? 0;
257
+ const bucket = rows.get(row) ?? [];
258
+ bucket.push(node);
259
+ rows.set(row, bucket);
260
+ }
261
+ const rowRanks = [...rows.keys()].sort((a, b) => a - b);
262
+ const nodeWidthOf = (node) => (node.kind === 'decision' ? cfg.decisionWidth : cfg.nodeWidth);
263
+ const nodeHeightOf = (node) => (node.kind === 'decision' ? cfg.decisionHeight : cfg.nodeHeight);
264
+ // 先以「中轴 x=0」排布,最后整体平移:主干节点落在中轴上,其它节点摆在它右侧。
265
+ const nodeIds = new Map();
266
+ const positions = new Map();
267
+ let minX = 0;
268
+ let maxX = 0;
269
+ rowRanks.forEach((rankNo, rowIndex) => {
270
+ const nodes = rows.get(rankNo) ?? [];
271
+ const rowY = LAYOUT.margin + rowIndex * (cfg.nodeHeight + cfg.gapY);
272
+ if (nodes.length > 1)
273
+ notes.push(`第 ${rankNo + 1} 层有 ${nodes.length} 个并行节点(${nodes.map(n => n.text).join('、')})`);
274
+ const main = nodes.find(node => mainPath.has(node.id));
275
+ const mainWidth = main === undefined ? 0 : nodeWidthOf(main);
276
+ const others = nodes.filter(node => node !== main);
277
+ const othersWidth = others.reduce((sum, node) => sum + nodeWidthOf(node), 0) + Math.max(0, others.length - 1) * cfg.gapX;
278
+ const rowWidth = mainWidth + (main !== undefined && others.length > 0 ? cfg.gapX : 0) + othersWidth;
279
+ let cursor = main === undefined ? -rowWidth / 2 : -mainWidth / 2;
280
+ if (main !== undefined) {
281
+ const nodeWidth = nodeWidthOf(main);
282
+ const nodeHeight = nodeHeightOf(main);
283
+ const rect = { x: cursor, y: rowY + (cfg.nodeHeight - nodeHeight) / 2, width: nodeWidth, height: nodeHeight };
284
+ positions.set(main.id, rect);
285
+ cursor += nodeWidth + cfg.gapX;
286
+ }
287
+ else {
288
+ cursor = -rowWidth / 2;
289
+ }
290
+ for (const node of others) {
291
+ const nodeWidth = nodeWidthOf(node);
292
+ const nodeHeight = nodeHeightOf(node);
293
+ positions.set(node.id, { x: cursor, y: rowY + (cfg.nodeHeight - nodeHeight) / 2, width: nodeWidth, height: nodeHeight });
294
+ cursor += nodeWidth + cfg.gapX;
295
+ }
296
+ });
297
+ for (const rect of positions.values()) {
298
+ minX = Math.min(minX, rect.x);
299
+ maxX = Math.max(maxX, rect.x + rect.width);
300
+ }
301
+ const width = Math.max(460, maxX - minX + LAYOUT.margin * 2);
302
+ const shift = LAYOUT.margin - minX;
303
+ for (const [id, rect] of positions) {
304
+ positions.set(id, { ...rect, x: rect.x + shift });
305
+ }
306
+ for (const node of model.nodes) {
307
+ const rect = positions.get(node.id);
308
+ if (rect === undefined)
309
+ continue;
310
+ const id = nextVertex('node');
311
+ nodeIds.set(node.id, id);
312
+ const style = node.kind === 'start' ? STYLE.start : node.kind === 'end' ? STYLE.end : node.kind === 'decision' ? STYLE.decision : STYLE.module;
313
+ cells.push({
314
+ kind: 'vertex',
315
+ id,
316
+ value: node.note === undefined ? node.text : `${node.text}\n${node.note}`,
317
+ style,
318
+ rect,
319
+ });
320
+ }
321
+ for (const edge of model.edges) {
322
+ const source = nodeIds.get(edge.from);
323
+ const target = nodeIds.get(edge.to);
324
+ if (source === undefined || target === undefined)
325
+ throw new FigureLayoutError(`连线端点「${edge.from} → ${edge.to}」在版面里找不到`);
326
+ const fromRect = positions.get(edge.from);
327
+ const toRect = positions.get(edge.to);
328
+ const horizontal = fromRect !== undefined && toRect !== undefined && Math.abs(fromRect.y - toRect.y) < 1;
329
+ cells.push({
330
+ kind: 'edge',
331
+ id: nextEdge(),
332
+ value: edge.label ?? '',
333
+ style: horizontal
334
+ ? `${STYLE.edge}exitX=1;exitY=0.5;entryX=0;entryY=0.5;`
335
+ : `${STYLE.edge}exitX=0.5;exitY=1;entryX=0.5;entryY=0;`,
336
+ source,
337
+ target,
338
+ });
339
+ }
340
+ const height = LAYOUT.margin + rowRanks.length * (cfg.nodeHeight + cfg.gapY) - cfg.gapY + LAYOUT.margin;
341
+ return { cells, width, height, notes };
342
+ }
343
+ // ---------------------------------------------------------------------------
344
+ // E-R 图
345
+ // ---------------------------------------------------------------------------
346
+ function buildEr(model) {
347
+ const cfg = LAYOUT.er;
348
+ const cells = [];
349
+ const notes = [];
350
+ const nextVertex = idCounter('v');
351
+ const nextEdge = idCounter('e');
352
+ const entityWidth = new Map();
353
+ for (const entity of model.entities) {
354
+ const titleWidth = displayWidth(entity.name, 12) + 28;
355
+ const fieldWidth = Math.max(...entity.fields.map(field => displayWidth(`${field.key === true ? 'PK ' : ''}${field.name}${field.type === undefined ? '' : ` : ${field.type}`}`, 11) + 24));
356
+ const width = Math.min(cfg.maxWidth, Math.max(cfg.minWidth, titleWidth, fieldWidth));
357
+ entityWidth.set(entity.name, width);
358
+ }
359
+ const maxWidth = Math.max(...entityWidth.values());
360
+ const columns = model.entities.length > 1 ? 2 : 1;
361
+ const width = LAYOUT.margin * 2 + maxWidth * columns + (columns - 1) * cfg.columnGap;
362
+ const entityIds = new Map();
363
+ const columnY = new Array(columns).fill(LAYOUT.margin);
364
+ model.entities.forEach((entity, index) => {
365
+ const column = index % columns;
366
+ const widthForEntity = entityWidth.get(entity.name) ?? cfg.minWidth;
367
+ const height = cfg.titleHeight + entity.fields.length * cfg.rowHeight + cfg.bottomPadding;
368
+ const x = LAYOUT.margin + column * (maxWidth + cfg.columnGap);
369
+ const y = columnY[column] ?? LAYOUT.margin;
370
+ const id = nextVertex('entity');
371
+ entityIds.set(entity.name, id);
372
+ // 不可见的容器先画(在下层):关系线连到整张表,而不是某一行;
373
+ // 先入队保证它垫在标题与字段下面,不遮挡文字与点击。
374
+ cells.push({
375
+ kind: 'vertex',
376
+ id,
377
+ value: '',
378
+ style: 'rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;',
379
+ rect: { x, y, width: widthForEntity, height },
380
+ });
381
+ cells.push({
382
+ kind: 'vertex',
383
+ id: `${id}-title`,
384
+ value: entity.name,
385
+ style: STYLE.entityTitle,
386
+ rect: { x, y, width: widthForEntity, height: cfg.titleHeight },
387
+ });
388
+ entity.fields.forEach((field, fieldIndex) => {
389
+ const parts = [`${field.key === true ? 'PK ' : ''}${field.name}`];
390
+ if (field.type !== undefined)
391
+ parts.push(field.type);
392
+ cells.push({
393
+ kind: 'vertex',
394
+ id: `${id}-f${fieldIndex + 1}`,
395
+ value: parts.join(' '),
396
+ style: STYLE.entityRow,
397
+ rect: { x, y: y + cfg.titleHeight + fieldIndex * cfg.rowHeight, width: widthForEntity, height: cfg.rowHeight },
398
+ });
399
+ });
400
+ columnY[column] = y + height + cfg.rowGap;
401
+ });
402
+ for (const relation of model.relations) {
403
+ const source = entityIds.get(relation.from);
404
+ const target = entityIds.get(relation.to);
405
+ if (source === undefined || target === undefined)
406
+ throw new FigureLayoutError(`关系端点「${relation.from} → ${relation.to}」在版面里找不到`);
407
+ const label = [relation.cardinality, relation.label].filter(part => part !== undefined && part !== '').join(' ');
408
+ cells.push({
409
+ kind: 'edge',
410
+ id: nextEdge(),
411
+ value: label,
412
+ style: `${STYLE.edge}endArrow=none;startArrow=none;`,
413
+ source,
414
+ target,
415
+ });
416
+ }
417
+ const height = Math.max(...columnY) - cfg.rowGap + LAYOUT.margin;
418
+ return { cells, width, height, notes };
419
+ }
420
+ // ---------------------------------------------------------------------------
421
+ // 时序图
422
+ // ---------------------------------------------------------------------------
423
+ function buildSequence(model) {
424
+ const cfg = LAYOUT.sequence;
425
+ const cells = [];
426
+ const notes = [];
427
+ const nextVertex = idCounter('v');
428
+ const nextEdge = idCounter('e');
429
+ const count = model.participants.length;
430
+ const spacing = count > 1
431
+ ? Math.min(cfg.spacing, Math.max(cfg.minSpacing, Math.floor((LAYOUT.contentWidth - cfg.boxWidth) / (count - 1))))
432
+ : cfg.spacing;
433
+ const width = LAYOUT.margin * 2 + cfg.boxWidth + (count - 1) * spacing;
434
+ const lifelineHeight = cfg.header + Math.max(1, model.messages.length) * cfg.messageGap + cfg.tail;
435
+ const lifelineIds = new Map();
436
+ const centers = new Map();
437
+ model.participants.forEach((participant, index) => {
438
+ const centerX = LAYOUT.margin + cfg.boxWidth / 2 + index * spacing;
439
+ const id = nextVertex('life');
440
+ lifelineIds.set(participant.id, id);
441
+ centers.set(participant.id, centerX);
442
+ cells.push({
443
+ kind: 'vertex',
444
+ id,
445
+ value: participant.text,
446
+ style: STYLE.lifeline,
447
+ rect: { x: centerX - cfg.boxWidth / 2, y: LAYOUT.margin, width: cfg.boxWidth, height: lifelineHeight },
448
+ });
449
+ });
450
+ model.messages.forEach((message, index) => {
451
+ const y = LAYOUT.margin + cfg.header + index * cfg.messageGap;
452
+ const source = lifelineIds.get(message.from);
453
+ const target = lifelineIds.get(message.to);
454
+ const fromCenter = centers.get(message.from);
455
+ if (source === undefined || target === undefined || fromCenter === undefined) {
456
+ throw new FigureLayoutError(`消息「${message.text}」的参与者在版面里找不到`);
457
+ }
458
+ const fraction = (y - LAYOUT.margin) / lifelineHeight;
459
+ const base = message.kind === 'return' ? STYLE.messageReturn : STYLE.message;
460
+ if (message.from === message.to) {
461
+ // 自调用:从生命线右侧绕一个小回环再回来(draw.io 的折点由 Array 提供)。
462
+ const loopX = fromCenter + cfg.boxWidth / 2 + 36;
463
+ const returnFraction = (y + 28 - LAYOUT.margin) / lifelineHeight;
464
+ cells.push({
465
+ kind: 'edge',
466
+ id: nextEdge(),
467
+ value: message.text,
468
+ style: `${base}exitX=1;exitY=${fraction.toFixed(3)};entryX=1;entryY=${returnFraction.toFixed(3)};`,
469
+ source,
470
+ target,
471
+ points: [{ x: loopX, y }, { x: loopX, y: y + 28 }],
472
+ });
473
+ return;
474
+ }
475
+ cells.push({
476
+ kind: 'edge',
477
+ id: nextEdge(),
478
+ value: message.text,
479
+ style: `${base}exitX=0.5;exitY=${fraction.toFixed(3)};entryX=0.5;entryY=${fraction.toFixed(3)};`,
480
+ source,
481
+ target,
482
+ });
483
+ });
484
+ return { cells, width, height: LAYOUT.margin * 2 + lifelineHeight, notes };
485
+ }
486
+ // ---------------------------------------------------------------------------
487
+ /** 按图种类构建版面。 */
488
+ export function buildDrawing(model) {
489
+ switch (model.kind) {
490
+ case 'architecture': return buildArchitecture(model.model);
491
+ case 'flow': return buildFlow(model.model);
492
+ case 'er': return buildEr(model.model);
493
+ case 'sequence': return buildSequence(model.model);
494
+ case 'custom': return { cells: [], width: 0, height: 0, notes: ['custom 图直接使用你提供的 XML,不做版面计算'] };
495
+ }
496
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 图的「结构化规格」:模型只需描述**有什么**,不描述**放在哪**。
3
+ *
4
+ * 教学场景里的图之所以糙,多半不是因为画得少,而是因为让模型直接写坐标:
5
+ * 节点一多就重叠、箭头乱穿、字号忽大忽小。于是这里把输入收窄成业务语义
6
+ * (层与模块、步骤与分支、实体与字段、参与者与消息),坐标交给
7
+ * `layout.ts` 的确定性版面算法——同样的输入永远得到同一张图。
8
+ *
9
+ * 解析是**严格**的:字段缺失、引用不存在的节点、重名,全部当场报错并给出示例,
10
+ * 绝不让一张「少了两个模块」的图静默落盘。
11
+ *
12
+ * @module dsh-thesis/figure/spec
13
+ */
14
+ /** 支持的图种类。 */
15
+ export type FigureKind = 'architecture' | 'flow' | 'er' | 'sequence' | 'custom';
16
+ /** 全部种类的取值(用于工具参数的枚举与报错提示)。 */
17
+ export declare const FIGURE_KINDS: readonly FigureKind[];
18
+ /** 规格非法:带上**怎么改**的提示,模型据此重试。 */
19
+ export declare class FigureSpecError extends Error {
20
+ readonly code = "INVALID_FIGURE_SPEC";
21
+ constructor(message: string);
22
+ }
23
+ export interface ArchModule {
24
+ readonly text: string;
25
+ readonly note?: string;
26
+ readonly accent?: boolean;
27
+ }
28
+ export interface ArchLayer {
29
+ readonly name: string;
30
+ readonly note?: string;
31
+ readonly modules: readonly ArchModule[];
32
+ }
33
+ export interface ArchEdge {
34
+ readonly from: string;
35
+ readonly to: string;
36
+ readonly label?: string;
37
+ }
38
+ export interface ArchitectureModel {
39
+ readonly layers: readonly ArchLayer[];
40
+ readonly edges: readonly ArchEdge[];
41
+ }
42
+ export type FlowNodeKind = 'start' | 'process' | 'decision' | 'end';
43
+ export interface FlowNode {
44
+ readonly id: string;
45
+ readonly text: string;
46
+ readonly note?: string;
47
+ readonly kind: FlowNodeKind;
48
+ }
49
+ export interface FlowEdge {
50
+ readonly from: string;
51
+ readonly to: string;
52
+ readonly label?: string;
53
+ }
54
+ export interface FlowModel {
55
+ readonly nodes: readonly FlowNode[];
56
+ readonly edges: readonly FlowEdge[];
57
+ }
58
+ export interface ErField {
59
+ readonly name: string;
60
+ readonly type?: string;
61
+ readonly key?: boolean;
62
+ }
63
+ export interface ErEntity {
64
+ readonly name: string;
65
+ readonly fields: readonly ErField[];
66
+ }
67
+ export interface ErRelation {
68
+ readonly from: string;
69
+ readonly to: string;
70
+ readonly label?: string;
71
+ readonly cardinality?: string;
72
+ }
73
+ export interface ErModel {
74
+ readonly entities: readonly ErEntity[];
75
+ readonly relations: readonly ErRelation[];
76
+ }
77
+ export interface SeqParticipant {
78
+ readonly id: string;
79
+ readonly text: string;
80
+ }
81
+ export type SeqMessageKind = 'call' | 'return';
82
+ export interface SeqMessage {
83
+ readonly from: string;
84
+ readonly to: string;
85
+ readonly text: string;
86
+ readonly kind: SeqMessageKind;
87
+ }
88
+ export interface SequenceModel {
89
+ readonly participants: readonly SeqParticipant[];
90
+ readonly messages: readonly SeqMessage[];
91
+ }
92
+ export interface CustomModel {
93
+ readonly xml: string;
94
+ }
95
+ export type FigureModel = {
96
+ readonly kind: 'architecture';
97
+ readonly model: ArchitectureModel;
98
+ } | {
99
+ readonly kind: 'flow';
100
+ readonly model: FlowModel;
101
+ } | {
102
+ readonly kind: 'er';
103
+ readonly model: ErModel;
104
+ } | {
105
+ readonly kind: 'sequence';
106
+ readonly model: SequenceModel;
107
+ } | {
108
+ readonly kind: 'custom';
109
+ readonly model: CustomModel;
110
+ };
111
+ /** 解析并校验规格;成功时返回规范化模型,失败时抛 {@link FigureSpecError}。 */
112
+ export declare function parseFigureModel(kind: string, raw: unknown): FigureModel;
113
+ /** 一句话概括模型规模(工具返回值里给模型看,确认自己传对了)。 */
114
+ export declare function describeModel(model: FigureModel): string;