@antv/infographic 0.2.14 → 0.2.16

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 (101) hide show
  1. package/README.md +39 -5
  2. package/README.zh-CN.md +39 -5
  3. package/dist/infographic.min.js +168 -166
  4. package/dist/infographic.min.js.map +1 -1
  5. package/esm/designs/structures/index.d.ts +1 -0
  6. package/esm/designs/structures/index.js +1 -0
  7. package/esm/designs/structures/relation-dagre-flow.js +4 -139
  8. package/esm/designs/structures/sequence-interaction.d.ts +54 -0
  9. package/esm/designs/structures/sequence-interaction.js +461 -0
  10. package/esm/designs/structures/sequence-timeline.d.ts +1 -0
  11. package/esm/designs/structures/sequence-timeline.js +4 -2
  12. package/esm/designs/utils/geometry.d.ts +44 -0
  13. package/esm/designs/utils/geometry.js +244 -0
  14. package/esm/designs/utils/index.d.ts +1 -0
  15. package/esm/designs/utils/index.js +1 -0
  16. package/esm/editor/managers/sync-registry.d.ts +2 -1
  17. package/esm/editor/types/editor.d.ts +2 -1
  18. package/esm/editor/types/sync.d.ts +2 -1
  19. package/esm/editor/utils/object.js +46 -39
  20. package/esm/exporter/png.js +2 -2
  21. package/esm/exporter/svg.js +9 -1
  22. package/esm/exporter/types.d.ts +10 -0
  23. package/esm/options/types.d.ts +6 -0
  24. package/esm/runtime/Infographic.js +20 -7
  25. package/esm/syntax/index.js +40 -20
  26. package/esm/syntax/parser.js +80 -3
  27. package/esm/syntax/relations.js +26 -2
  28. package/esm/syntax/schema.js +1 -0
  29. package/esm/templates/built-in.js +5 -4
  30. package/esm/templates/sequence-interaction.d.ts +2 -0
  31. package/esm/templates/sequence-interaction.js +76 -0
  32. package/esm/types/data.d.ts +1 -0
  33. package/esm/utils/index.d.ts +1 -0
  34. package/esm/utils/index.js +1 -0
  35. package/esm/utils/measure-text.js +31 -3
  36. package/esm/utils/types.d.ts +16 -0
  37. package/esm/utils/types.js +12 -0
  38. package/esm/version.d.ts +1 -1
  39. package/esm/version.js +1 -1
  40. package/lib/designs/structures/index.d.ts +1 -0
  41. package/lib/designs/structures/index.js +1 -0
  42. package/lib/designs/structures/relation-dagre-flow.js +5 -140
  43. package/lib/designs/structures/sequence-interaction.d.ts +54 -0
  44. package/lib/designs/structures/sequence-interaction.js +465 -0
  45. package/lib/designs/structures/sequence-timeline.d.ts +1 -0
  46. package/lib/designs/structures/sequence-timeline.js +4 -2
  47. package/lib/designs/utils/geometry.d.ts +44 -0
  48. package/lib/designs/utils/geometry.js +256 -0
  49. package/lib/designs/utils/index.d.ts +1 -0
  50. package/lib/designs/utils/index.js +1 -0
  51. package/lib/editor/managers/sync-registry.d.ts +2 -1
  52. package/lib/editor/types/editor.d.ts +2 -1
  53. package/lib/editor/types/sync.d.ts +2 -1
  54. package/lib/editor/utils/object.js +45 -38
  55. package/lib/exporter/png.js +2 -2
  56. package/lib/exporter/svg.js +9 -1
  57. package/lib/exporter/types.d.ts +10 -0
  58. package/lib/options/types.d.ts +6 -0
  59. package/lib/runtime/Infographic.js +19 -6
  60. package/lib/syntax/index.js +40 -20
  61. package/lib/syntax/parser.js +80 -3
  62. package/lib/syntax/relations.js +26 -2
  63. package/lib/syntax/schema.js +1 -0
  64. package/lib/templates/built-in.js +5 -4
  65. package/lib/templates/sequence-interaction.d.ts +2 -0
  66. package/lib/templates/sequence-interaction.js +79 -0
  67. package/lib/types/data.d.ts +1 -0
  68. package/lib/utils/index.d.ts +1 -0
  69. package/lib/utils/index.js +1 -0
  70. package/lib/utils/measure-text.js +30 -2
  71. package/lib/utils/types.d.ts +16 -0
  72. package/lib/utils/types.js +13 -0
  73. package/lib/version.d.ts +1 -1
  74. package/lib/version.js +1 -1
  75. package/package.json +1 -1
  76. package/src/designs/structures/index.ts +1 -0
  77. package/src/designs/structures/relation-dagre-flow.tsx +14 -178
  78. package/src/designs/structures/sequence-interaction.tsx +931 -0
  79. package/src/designs/structures/sequence-timeline.tsx +18 -15
  80. package/src/designs/utils/geometry.tsx +315 -0
  81. package/src/designs/utils/index.ts +1 -0
  82. package/src/editor/managers/sync-registry.ts +2 -1
  83. package/src/editor/types/editor.ts +2 -1
  84. package/src/editor/types/sync.ts +3 -1
  85. package/src/editor/utils/object.ts +50 -40
  86. package/src/exporter/png.ts +3 -2
  87. package/src/exporter/svg.ts +14 -1
  88. package/src/exporter/types.ts +10 -0
  89. package/src/options/types.ts +7 -0
  90. package/src/runtime/Infographic.tsx +27 -17
  91. package/src/syntax/index.ts +51 -18
  92. package/src/syntax/parser.ts +101 -3
  93. package/src/syntax/relations.ts +29 -2
  94. package/src/syntax/schema.ts +1 -0
  95. package/src/templates/built-in.ts +4 -2
  96. package/src/templates/sequence-interaction.ts +101 -0
  97. package/src/types/data.ts +1 -0
  98. package/src/utils/index.ts +1 -0
  99. package/src/utils/measure-text.ts +35 -3
  100. package/src/utils/types.ts +61 -0
  101. package/src/version.ts +1 -1
@@ -0,0 +1,931 @@
1
+ import type { ComponentType, JSXElement } from '../../jsx';
2
+ import { Defs, getElementBounds, Group, Path, Rect, Text } from '../../jsx';
3
+ import type { RelationEdgeDatum } from '../../types';
4
+ import {
5
+ BtnAdd,
6
+ BtnRemove,
7
+ BtnsGroup,
8
+ ItemLabel,
9
+ ItemsGroup,
10
+ } from '../components';
11
+ import { FlexLayout } from '../layouts';
12
+ import {
13
+ createArrowElements,
14
+ getColorPrimary,
15
+ getEdgePathD,
16
+ getLabelPosition,
17
+ getNodesAnchors,
18
+ getPaletteColor,
19
+ getTangentAngle,
20
+ getThemeColors,
21
+ } from '../utils';
22
+ import { registerStructure } from './registry';
23
+ import type { BaseStructureProps } from './types';
24
+
25
+ /**
26
+ * 泳道内的节点数据
27
+ */
28
+ export interface InteractionChildDatum {
29
+ id: string;
30
+ label?: string;
31
+ desc?: string;
32
+ icon?: string;
33
+ /**
34
+ * 手动指定节点的垂直顺序(层级),默认为数组索引
35
+ * 相同 step 的节点会处于同一高度
36
+ */
37
+ step?: number;
38
+ }
39
+
40
+ /**
41
+ * 泳道数据(顶层item)
42
+ * label 作为泳道标题
43
+ * children 作为泳道内的节点列表
44
+ */
45
+ export interface InteractionLaneDatum {
46
+ label: string;
47
+ desc?: string;
48
+ icon?: string;
49
+ children?: InteractionChildDatum[];
50
+ }
51
+
52
+ /**
53
+ * 交互流程的数据结构
54
+ * items: 泳道列表,每个泳道有 label 和 children
55
+ * relations: 节点间的关系
56
+ */
57
+ export interface InteractionFlowData {
58
+ title?: string;
59
+ desc?: string;
60
+ items?: InteractionLaneDatum[];
61
+ relations?: RelationEdgeDatum[];
62
+ }
63
+
64
+ // 创建节点ID到位置的映射
65
+ interface NodeLayout {
66
+ x: number;
67
+ y: number;
68
+ width: number;
69
+ height: number;
70
+ centerX: number;
71
+ centerY: number;
72
+ laneIndex: number;
73
+ rowIndex: number;
74
+ }
75
+
76
+ export interface SequenceInteractionProps extends BaseStructureProps {
77
+ laneGap?: number;
78
+ nodeGap?: number;
79
+ lifelineWidth?: number;
80
+ arrowWidth?: number;
81
+ showLifeline?: boolean;
82
+ padding?: number;
83
+ arrowType?: 'arrow' | 'triangle';
84
+ showLaneHeader?: boolean;
85
+ laneHeaderHeight?: number;
86
+ edgeStyle?: 'solid' | 'dashed';
87
+ animated?: boolean;
88
+ edgeColorMode?: 'solid' | 'gradient';
89
+ }
90
+
91
+ const DEFAULT_LANE_GAP = 350;
92
+ const DEFAULT_NODE_GAP = 80;
93
+ const DEFAULT_LIFELINE_WIDTH = 2;
94
+ const DEFAULT_ARROW_WIDTH = 2;
95
+ const DEFAULT_PADDING = 40;
96
+ const DEFAULT_LANE_HEADER_HEIGHT = 60;
97
+ const DEFAULT_ITEM_WIDTH = 120;
98
+ const DEFAULT_ITEM_HEIGHT = 50;
99
+
100
+ const FONT_SIZE = 14;
101
+ const ARROW_SIZE = 14;
102
+ const CORNER_RADIUS_NODE = 6;
103
+ const LIFELINE_MASK_GAP = 2;
104
+ const LANE_PADDING = 60;
105
+
106
+ const BTN_HALF_SIZE = 12;
107
+ const BTN_MARGIN = 10;
108
+ const BTN_LANE_ADD_Gap = 20;
109
+ const BOTTOM_AREA_HEIGHT = 60;
110
+
111
+ const LANE_HEADER_MARGIN = 10;
112
+ const LABEL_OFFSET_Y = 10;
113
+ const FIRST_GAP = 20;
114
+
115
+ const PATH_OFFSET = 40;
116
+
117
+ const calculateEdgePath = (
118
+ fromId: string,
119
+ toId: string,
120
+ fromLayout: NodeLayout,
121
+ toLayout: NodeLayout,
122
+ edgeMap: Map<string, RelationEdgeDatum[]>,
123
+ fromOutDegree: number,
124
+ toInDegree: number,
125
+ fromInDegree: number,
126
+ toOutDegree: number,
127
+ ) => {
128
+ const fromAnchors = getNodesAnchors(fromLayout);
129
+ const toAnchors = getNodesAnchors(toLayout);
130
+
131
+ const reverseKey = `${toId}-${fromId}`;
132
+ const hasReverse = edgeMap.has(reverseKey);
133
+
134
+ const isStartLane = fromLayout.laneIndex === 0;
135
+
136
+ let points: [number, number][] = [];
137
+
138
+ if (fromId === toId) {
139
+ // 1. 自连接 (A->A)
140
+ // RT -> Right Arc -> RB
141
+ const start = isStartLane ? fromAnchors.LT : fromAnchors.RT;
142
+ const end = isStartLane ? fromAnchors.LB : fromAnchors.RB;
143
+ const offset = isStartLane ? -PATH_OFFSET : PATH_OFFSET;
144
+
145
+ points = [
146
+ [start.x, start.y],
147
+ [start.x + offset, start.y],
148
+ [end.x + offset, end.y],
149
+ [end.x, end.y],
150
+ ];
151
+ } else if (fromLayout.laneIndex === toLayout.laneIndex) {
152
+ // 2. 同泳道回环 (Bottom -> Top)
153
+ const start = isStartLane ? fromAnchors.LB : fromAnchors.RB;
154
+ const end = isStartLane ? toAnchors.LT : toAnchors.RT;
155
+ const offset = isStartLane ? -PATH_OFFSET : PATH_OFFSET;
156
+
157
+ points = [
158
+ [start.x, start.y],
159
+ [start.x + offset, start.y],
160
+ [end.x + offset, end.y],
161
+ [end.x, end.y],
162
+ ];
163
+ } else {
164
+ // 3. 互连 & 单向连接
165
+ const isToRight = toLayout.centerX > fromLayout.centerX;
166
+ const isToLeft = toLayout.centerX < fromLayout.centerX;
167
+
168
+ const isSameY = Math.abs(fromLayout.centerY - toLayout.centerY) < 1;
169
+ const isTargetBelow = toLayout.centerY > fromLayout.centerY;
170
+ const isTargetStrictRight = toLayout.x >= fromLayout.x + fromLayout.width;
171
+
172
+ let startPoint: { x: number; y: number };
173
+ let endPoint: { x: number; y: number };
174
+
175
+ // 优先处理同行情况
176
+ if (isSameY) {
177
+ startPoint = isToRight ? fromAnchors.RC : fromAnchors.LC;
178
+ endPoint = isToRight ? toAnchors.LC : toAnchors.RC;
179
+ }
180
+ // 处理互连情况 (避免重叠,使用对角锚点)
181
+ else if (hasReverse) {
182
+ if (isTargetBelow) {
183
+ startPoint = isToRight ? fromAnchors.RB : fromAnchors.LT;
184
+ endPoint = isToRight ? toAnchors.LT : toAnchors.RB;
185
+ } else {
186
+ startPoint = isToRight ? fromAnchors.RT : fromAnchors.LT;
187
+ endPoint = isToRight ? toAnchors.LB : toAnchors.RB;
188
+ }
189
+ }
190
+ // 处理普通单向连接
191
+ else {
192
+ // 1. 确定终点 (End Point)
193
+ if (toInDegree === 1 && toOutDegree === 0) {
194
+ endPoint = isToRight ? toAnchors.LC : toAnchors.RC;
195
+ } else if (isTargetBelow) {
196
+ endPoint = isTargetStrictRight ? toAnchors.LT : toAnchors.RT;
197
+ } else {
198
+ endPoint = isTargetStrictRight ? toAnchors.LB : toAnchors.RB;
199
+ }
200
+
201
+ // 2. 确定起点 (Start Point)
202
+ if (fromOutDegree === 1 && fromInDegree === 0) {
203
+ startPoint = isToRight ? fromAnchors.RC : fromAnchors.LC;
204
+ } else if (isToRight) {
205
+ startPoint = fromAnchors.RB;
206
+ } else if (isToLeft) {
207
+ startPoint = fromAnchors.LB;
208
+ } else {
209
+ startPoint = fromAnchors.RB;
210
+ }
211
+ }
212
+
213
+ if (hasReverse && !isSameY) {
214
+ // 1. 跨行(不同 Y 轴)的双向连接
215
+ const startArr: [number, number] = [startPoint.x, startPoint.y];
216
+ const endArr: [number, number] = [endPoint.x, endPoint.y];
217
+
218
+ const cx = (startArr[0] + endArr[0]) / 2;
219
+ const cy = startArr[1];
220
+
221
+ points = [startArr, [cx, cy], endArr];
222
+ } else if (hasReverse && isSameY) {
223
+ // 2. 同行(相同 Y 轴)的双向连接
224
+ const startArr: [number, number] = [startPoint.x, startPoint.y];
225
+ const endArr: [number, number] = [endPoint.x, endPoint.y];
226
+
227
+ const midX = (startArr[0] + endArr[0]) / 2;
228
+ const midY = (startArr[1] + endArr[1]) / 2;
229
+
230
+ const offsetY = 30;
231
+ const isL2R = startArr[0] < endArr[0];
232
+ const cpY = isL2R ? midY - offsetY : midY + offsetY;
233
+
234
+ points = [startArr, [midX, cpY], endArr];
235
+ } else {
236
+ // 3. 普通单向连接
237
+ points = [
238
+ [startPoint.x, startPoint.y],
239
+ [endPoint.x, endPoint.y],
240
+ ];
241
+ }
242
+ }
243
+
244
+ return { points };
245
+ };
246
+
247
+ export const SequenceInteractionFlow: ComponentType<
248
+ SequenceInteractionProps
249
+ > = (props) => {
250
+ // 生成实例级唯一ID以避免多图表冲突
251
+ const instanceId = Math.random().toString(36).slice(2, 9);
252
+
253
+ const {
254
+ Title,
255
+ Item,
256
+ data,
257
+ laneGap = DEFAULT_LANE_GAP,
258
+ nodeGap = DEFAULT_NODE_GAP,
259
+ lifelineWidth = DEFAULT_LIFELINE_WIDTH,
260
+ arrowWidth = DEFAULT_ARROW_WIDTH,
261
+ showLifeline = true,
262
+ padding = DEFAULT_PADDING,
263
+ arrowType = 'triangle',
264
+ showLaneHeader = true,
265
+ laneHeaderHeight = DEFAULT_LANE_HEADER_HEIGHT,
266
+ edgeStyle = 'solid',
267
+ animated = false,
268
+ edgeColorMode = 'gradient',
269
+ options,
270
+ } = props;
271
+
272
+ // 获取主题颜色
273
+ const themeColors = getThemeColors(options.themeConfig, options);
274
+ const colorText = themeColors?.colorText ?? '#333333';
275
+ const colorBg = themeColors?.colorBg ?? '#ffffff';
276
+ const colorBorder = themeColors?.colorTextSecondary ?? '#e0e0e0';
277
+
278
+ const flowData = data as InteractionFlowData;
279
+ const { title, desc, items = [], relations = [] } = flowData;
280
+
281
+ const titleContent = Title ? <Title title={title} desc={desc} /> : null;
282
+
283
+ // 空状态处理
284
+ if (!items || items.length === 0) {
285
+ const btnBounds = getElementBounds(<BtnAdd indexes={[0]} />);
286
+ return (
287
+ <FlexLayout
288
+ id="infographic-container"
289
+ flexDirection="column"
290
+ justifyContent="center"
291
+ alignItems="center"
292
+ >
293
+ {titleContent}
294
+ <Group>
295
+ <BtnsGroup>
296
+ <BtnAdd
297
+ indexes={[0]}
298
+ x={-btnBounds.width / 2}
299
+ y={-btnBounds.height / 2}
300
+ />
301
+ </BtnsGroup>
302
+ <Text
303
+ x={0}
304
+ y={btnBounds.height / 2 + BTN_MARGIN}
305
+ width={200}
306
+ height={40}
307
+ fontSize={14}
308
+ alignHorizontal="center"
309
+ alignVertical="middle"
310
+ fill={themeColors?.colorTextSecondary ?? '#999'}
311
+ >
312
+ 暂无数据
313
+ </Text>
314
+ </Group>
315
+ </FlexLayout>
316
+ );
317
+ }
318
+
319
+ // 泳道列表(每个顶层item是一个泳道)
320
+ const lanes = items as InteractionLaneDatum[];
321
+
322
+ // 计算最大行数(所有泳道中 children 的最大 step 或 索引),至少为1
323
+ let maxStep = 0;
324
+ lanes.forEach((lane) => {
325
+ lane.children?.forEach((child, index) => {
326
+ const currentStep = child.step ?? index;
327
+ if (currentStep > maxStep) {
328
+ maxStep = currentStep;
329
+ }
330
+ });
331
+ });
332
+ const maxRows = Math.max(1, maxStep + 1);
333
+
334
+ const nodeLayoutById = new Map<string, NodeLayout>();
335
+
336
+ // 测量Item尺寸
337
+ const designItem = options.design?.item;
338
+ const itemConfig = Array.isArray(designItem) ? designItem[0] : designItem;
339
+
340
+ // 使用类型安全的访问或默认值
341
+ let itemWidth = itemConfig?.width ?? DEFAULT_ITEM_WIDTH;
342
+ let itemHeight = itemConfig?.height ?? DEFAULT_ITEM_HEIGHT;
343
+
344
+ // 构建一个扁平化的节点列表用于Item渲染
345
+ const flatNodes: {
346
+ datum: InteractionChildDatum;
347
+ laneIndex: number;
348
+ rowIndex: number;
349
+ }[] = [];
350
+ lanes.forEach((lane, laneIndex) => {
351
+ lane.children?.forEach((child, rowIndex) => {
352
+ flatNodes.push({ datum: child, laneIndex, rowIndex });
353
+ });
354
+ });
355
+
356
+ // 尝试通过采样修正尺寸 (仅当配置未指定时)
357
+ if (
358
+ (!itemConfig?.width || !itemConfig?.height) &&
359
+ Item &&
360
+ flatNodes.length > 0
361
+ ) {
362
+ const sampleNode = flatNodes[0];
363
+ const sampleBounds = getElementBounds(
364
+ <Item
365
+ indexes={[0]}
366
+ datum={sampleNode.datum}
367
+ positionH="center"
368
+ positionV="middle"
369
+ />,
370
+ );
371
+ // 确保尺寸有效
372
+ if (sampleBounds.width > 0) itemWidth = sampleBounds.width;
373
+ if (sampleBounds.height > 0) itemHeight = sampleBounds.height;
374
+ }
375
+
376
+ // 测量relations标签的最大宽度,自动调整泳道间距
377
+ let maxLabelWidth = 0;
378
+ relations.forEach((relation) => {
379
+ if (relation.label) {
380
+ const labelBounds = getElementBounds(
381
+ <Text fontSize={FONT_SIZE} fontWeight="normal">
382
+ {relation.label}
383
+ </Text>,
384
+ );
385
+ maxLabelWidth = Math.max(maxLabelWidth, labelBounds.width);
386
+ }
387
+ });
388
+
389
+ // 动态计算泳道宽度:需要兼顾节点宽度、标签宽度需求以及用户设置的间距
390
+ const baseWidth = itemWidth + LANE_PADDING;
391
+ const labelWidthRequirement = itemWidth + maxLabelWidth + LANE_PADDING * 2;
392
+ const laneWidth = Math.max(laneGap, baseWidth, labelWidthRequirement);
393
+
394
+ // 计算行高度和总高度
395
+ const headerOffset = showLaneHeader ? laneHeaderHeight : 0;
396
+ const contentHeight =
397
+ FIRST_GAP + maxRows * itemHeight + Math.max(0, maxRows - 1) * nodeGap;
398
+ const totalHeight =
399
+ headerOffset + contentHeight + padding * 2 + BOTTOM_AREA_HEIGHT;
400
+ const totalWidth = laneWidth * lanes.length + padding * 2;
401
+
402
+ // 计算每个泳道的中心X坐标
403
+ const getLaneCenterX = (laneIndex: number) => {
404
+ return padding + laneWidth / 2 + laneIndex * laneWidth;
405
+ };
406
+
407
+ // 计算每行的Y坐标
408
+ const getRowY = (rowIndex: number) => {
409
+ return (
410
+ padding +
411
+ headerOffset +
412
+ FIRST_GAP +
413
+ rowIndex * (itemHeight + nodeGap) +
414
+ itemHeight / 2
415
+ );
416
+ };
417
+
418
+ const itemElements: JSXElement[] = [];
419
+ const decorElements: JSXElement[] = [];
420
+ const defsElements: JSXElement[] = [];
421
+ const btnElements: JSXElement[] = [];
422
+
423
+ // 绘制泳道标题
424
+ if (showLaneHeader) {
425
+ lanes.forEach((lane, laneIndex) => {
426
+ const centerX = getLaneCenterX(laneIndex);
427
+ const laneColor = getPaletteColor(options, [laneIndex]);
428
+ const laneThemeColors = getThemeColors(
429
+ { colorPrimary: laneColor },
430
+ options,
431
+ );
432
+
433
+ // 泳道标题背景
434
+ if (Item) {
435
+ decorElements.push(
436
+ <Item
437
+ indexes={[laneIndex]}
438
+ datum={{
439
+ label: lane.label,
440
+ icon: lane.icon,
441
+ desc: lane.desc,
442
+ }}
443
+ x={centerX - itemWidth / 2}
444
+ y={padding}
445
+ width={itemWidth}
446
+ height={laneHeaderHeight - LANE_HEADER_MARGIN}
447
+ themeColors={laneThemeColors}
448
+ positionH="center"
449
+ />,
450
+ );
451
+
452
+ // 泳道标题删除按钮 (右上角)
453
+ btnElements.push(
454
+ <BtnRemove
455
+ indexes={[laneIndex]}
456
+ x={centerX + itemWidth / 2 - BTN_MARGIN}
457
+ y={padding - BTN_MARGIN}
458
+ />,
459
+ );
460
+ }
461
+ });
462
+ }
463
+
464
+ // 绘制节点(按行对齐)
465
+ lanes.forEach((lane, laneIndex) => {
466
+ lane.children?.forEach((child, rowIndex) => {
467
+ // 使用 step 属性作为行索引,如果未定义则回退到数组索引
468
+ const effectiveRowIndex = child.step ?? rowIndex;
469
+
470
+ const centerX = getLaneCenterX(laneIndex);
471
+ const centerY = getRowY(effectiveRowIndex);
472
+
473
+ const x = centerX - itemWidth / 2;
474
+ const y = centerY - itemHeight / 2;
475
+
476
+ // 保存节点布局信息
477
+ nodeLayoutById.set(child.id, {
478
+ x,
479
+ y,
480
+ width: itemWidth,
481
+ height: itemHeight,
482
+ centerX,
483
+ centerY,
484
+ laneIndex,
485
+ rowIndex: effectiveRowIndex,
486
+ });
487
+
488
+ const nodeColor = getPaletteColor(options, [laneIndex]);
489
+ const nodeThemeColors = getThemeColors(
490
+ { colorPrimary: nodeColor },
491
+ options,
492
+ );
493
+
494
+ // 构造类似 hierarchy-tree 的 _originalIndex
495
+ const originalIndex = [laneIndex, rowIndex];
496
+ // 附加到数据上,确保 Item 组件能正确识别
497
+ const childWithIndex = {
498
+ ...child,
499
+ _originalIndex: originalIndex,
500
+ };
501
+
502
+ if (Item) {
503
+ itemElements.push(
504
+ <Item
505
+ indexes={originalIndex}
506
+ datum={childWithIndex}
507
+ data={data}
508
+ x={x}
509
+ y={y}
510
+ positionH="center"
511
+ positionV="middle"
512
+ themeColors={nodeThemeColors}
513
+ />,
514
+ );
515
+
516
+ // 节点删除按钮 (底部剧中)
517
+ btnElements.push(
518
+ <BtnRemove
519
+ indexes={originalIndex}
520
+ x={x + itemWidth / 2 - BTN_MARGIN}
521
+ y={y + itemHeight + BTN_MARGIN / 2}
522
+ />,
523
+ );
524
+ } else {
525
+ // 默认节点渲染
526
+ decorElements.push(
527
+ <Rect
528
+ x={x}
529
+ y={y}
530
+ width={itemWidth}
531
+ height={itemHeight}
532
+ fill={nodeThemeColors?.colorPrimaryBg ?? colorBg}
533
+ stroke={nodeColor}
534
+ strokeWidth={2}
535
+ rx={CORNER_RADIUS_NODE}
536
+ data-element-type="shape"
537
+ />,
538
+ );
539
+
540
+ if (child.label) {
541
+ decorElements.push(
542
+ <Text
543
+ x={x}
544
+ y={y}
545
+ width={itemWidth}
546
+ height={itemHeight}
547
+ fontSize={14}
548
+ fontWeight="bold"
549
+ alignHorizontal="center"
550
+ alignVertical="middle"
551
+ fill={colorText}
552
+ >
553
+ {child.label}
554
+ </Text>,
555
+ );
556
+ }
557
+ }
558
+ });
559
+
560
+ // 每个泳道底部的添加节点按钮
561
+ const childCount = lane.children?.length ?? 0;
562
+
563
+ // 找出当前泳道最大的 step
564
+ let lastEffectRowIndex = -1;
565
+ lane.children?.forEach((child, index) => {
566
+ const s = child.step ?? index;
567
+ if (s > lastEffectRowIndex) lastEffectRowIndex = s;
568
+ });
569
+
570
+ const lastRowY =
571
+ lastEffectRowIndex >= 0
572
+ ? getRowY(lastEffectRowIndex)
573
+ : padding + headerOffset;
574
+ const addNodeY =
575
+ childCount > 0
576
+ ? lastRowY + itemHeight / 2 + BTN_LANE_ADD_Gap
577
+ : lastRowY + FIRST_GAP + BTN_MARGIN;
578
+ const centerX = getLaneCenterX(laneIndex);
579
+
580
+ btnElements.push(
581
+ <BtnAdd
582
+ indexes={[laneIndex, childCount]}
583
+ x={centerX - BTN_HALF_SIZE}
584
+ y={addNodeY}
585
+ />,
586
+ );
587
+ });
588
+
589
+ // 绘制生命线(使用 mask 挖空节点区域,避免虚线穿透半透明节点)
590
+ if (showLifeline) {
591
+ // 预先按泳道分组节点,避免每条泳道都遍历全部节点
592
+ const nodeRectsByLane = new Map<
593
+ number,
594
+ { x: number; y: number; width: number; height: number }[]
595
+ >();
596
+ nodeLayoutById.forEach((layout) => {
597
+ let list = nodeRectsByLane.get(layout.laneIndex);
598
+ if (!list) {
599
+ list = [];
600
+ nodeRectsByLane.set(layout.laneIndex, list);
601
+ }
602
+ list.push({
603
+ x: layout.x,
604
+ y: layout.y,
605
+ width: layout.width,
606
+ height: layout.height,
607
+ });
608
+ });
609
+
610
+ lanes.forEach((_lane, laneIndex) => {
611
+ const centerX = getLaneCenterX(laneIndex);
612
+ const startY = padding + headerOffset;
613
+ const endY = totalHeight - padding;
614
+
615
+ const laneNodeRects = nodeRectsByLane.get(laneIndex) ?? [];
616
+
617
+ // 如果该泳道有节点,创建 mask 来挖空节点区域
618
+ let lifelineMaskAttr: string | undefined;
619
+ if (laneNodeRects.length > 0) {
620
+ const maskId = `lifeline-mask-${instanceId}-${laneIndex}`;
621
+ defsElements.push(
622
+ <mask
623
+ id={maskId}
624
+ maskUnits="userSpaceOnUse"
625
+ x={0}
626
+ y={0}
627
+ width={totalWidth}
628
+ height={totalHeight}
629
+ >
630
+ {/* 白色底:显示所有线条 */}
631
+ <Rect
632
+ x={0}
633
+ y={0}
634
+ width={totalWidth}
635
+ height={totalHeight}
636
+ fill="white"
637
+ />
638
+ {/* 黑色块:在节点位置挖空生命线,上下各留 LIFELINE_MASK_GAP 间距 */}
639
+ {laneNodeRects.map((rect) => (
640
+ <Rect
641
+ x={rect.x}
642
+ y={rect.y - LIFELINE_MASK_GAP}
643
+ width={rect.width}
644
+ height={rect.height + LIFELINE_MASK_GAP * 2}
645
+ fill="black"
646
+ />
647
+ ))}
648
+ </mask>,
649
+ );
650
+ lifelineMaskAttr = `url(#${maskId})`;
651
+ }
652
+
653
+ decorElements.push(
654
+ <Path
655
+ d={`M ${centerX} ${startY} L ${centerX} ${endY}`}
656
+ stroke={colorBorder}
657
+ strokeWidth={lifelineWidth}
658
+ strokeDasharray="5,5"
659
+ fill="none"
660
+ data-element-type="shape"
661
+ mask={lifelineMaskAttr}
662
+ />,
663
+ );
664
+
665
+ // 绘制生命线末端箭头(实心)
666
+ decorElements.push(
667
+ ...createArrowElements(
668
+ centerX,
669
+ endY,
670
+ Math.PI / 2,
671
+ 'triangle',
672
+ colorBorder,
673
+ 1,
674
+ 10,
675
+ ),
676
+ );
677
+ });
678
+ }
679
+
680
+ // 添加新泳道按钮 (最右侧)
681
+ const lastLaneRightX = getLaneCenterX(lanes.length - 1) + laneWidth / 2;
682
+ const newLaneX =
683
+ lanes.length > 0 ? lastLaneRightX + BTN_LANE_ADD_Gap : padding;
684
+ const newLaneY = padding + headerOffset / 2 - BTN_HALF_SIZE; // 垂直居中于标题栏
685
+ btnElements.push(
686
+ <BtnAdd indexes={[lanes.length]} x={newLaneX} y={newLaneY} />,
687
+ );
688
+
689
+ // 预处理边,方便快速查找反向边
690
+ const edgeMap = new Map<string, RelationEdgeDatum[]>();
691
+ // 统计入度和出度
692
+ const inDegreeMap = new Map<string, number>();
693
+ const outDegreeMap = new Map<string, number>();
694
+
695
+ relations.forEach((r) => {
696
+ const key = `${r.from}-${r.to}`;
697
+ if (!edgeMap.has(key)) edgeMap.set(key, []);
698
+ edgeMap.get(key)?.push(r);
699
+
700
+ const fromId = String(r.from);
701
+ const toId = String(r.to);
702
+ if (fromId === toId) return;
703
+ outDegreeMap.set(fromId, (outDegreeMap.get(fromId) || 0) + 1);
704
+ inDegreeMap.set(toId, (inDegreeMap.get(toId) || 0) + 1);
705
+ });
706
+
707
+ // 绘制消息箭头
708
+ relations.forEach((relation, relIndex) => {
709
+ const fromId = String(relation.from);
710
+ const toId = String(relation.to);
711
+
712
+ // 使用精确的节点布局信息
713
+ const fromLayout = nodeLayoutById.get(fromId);
714
+ const toLayout = nodeLayoutById.get(toId);
715
+
716
+ if (!fromLayout || !toLayout) return;
717
+
718
+ // 颜色处理
719
+ const fromColor =
720
+ getPaletteColor(options, [fromLayout.laneIndex]) || '#000000';
721
+ const toColor = getPaletteColor(options, [toLayout.laneIndex]) || '#000000';
722
+ const themePrimary = getColorPrimary(options);
723
+
724
+ // 确定线条和箭头颜色
725
+ let edgeStroke = themePrimary || '#999999';
726
+ let targetArrowColor = themePrimary || '#999999';
727
+ let sourceArrowColor = themePrimary || '#999999';
728
+
729
+ // 如果是渐变模式,使用渐变色
730
+ const gradientId = `arrow-gradient-${instanceId}-${relIndex}`;
731
+ if (edgeColorMode === 'gradient') {
732
+ edgeStroke = `url(#${gradientId})`;
733
+ targetArrowColor = toColor;
734
+ sourceArrowColor = fromColor;
735
+ }
736
+
737
+ const { points } = calculateEdgePath(
738
+ fromId,
739
+ toId,
740
+ fromLayout,
741
+ toLayout,
742
+ edgeMap,
743
+ outDegreeMap.get(fromId) || 0,
744
+ inDegreeMap.get(toId) || 0,
745
+ inDegreeMap.get(fromId) || 0,
746
+ outDegreeMap.get(toId) || 0,
747
+ );
748
+
749
+ let maskId: string | undefined;
750
+ let labelRenderNode: JSXElement | null = null;
751
+
752
+ if (relation.label) {
753
+ const labelPoint = getLabelPosition(points);
754
+
755
+ if (labelPoint) {
756
+ const labelX = labelPoint[0];
757
+ const labelY = labelPoint[1] - LABEL_OFFSET_Y;
758
+
759
+ // 预先计算 Label 的尺寸
760
+ const labelBounds = getElementBounds(
761
+ <ItemLabel
762
+ indexes={[relIndex]}
763
+ fontSize={FONT_SIZE}
764
+ fontWeight="normal"
765
+ >
766
+ {relation.label}
767
+ </ItemLabel>,
768
+ );
769
+
770
+ const bgX = labelX - labelBounds.width / 2;
771
+ const bgY = labelY - labelBounds.height / 2;
772
+ const bgW = labelBounds.width;
773
+ const bgH = labelBounds.height;
774
+
775
+ maskId = `edge-mask-${instanceId}-${relIndex}`;
776
+
777
+ // 将 Mask 推入 defsElements
778
+ // 逻辑:白色区域显示(全图),黑色区域隐藏(标签位置)
779
+ defsElements.push(
780
+ <mask
781
+ id={maskId}
782
+ maskUnits="userSpaceOnUse"
783
+ x={0}
784
+ y={0}
785
+ width={totalWidth}
786
+ height={totalHeight}
787
+ >
788
+ {/* 1. 全屏白色底,保证线条其他部分显示 */}
789
+ <Rect
790
+ x={0}
791
+ y={0}
792
+ width={totalWidth}
793
+ height={totalHeight}
794
+ fill="white"
795
+ />
796
+ {/* 2. 标签位置黑色块,将线条“挖空” */}
797
+ <Rect x={bgX} y={bgY} width={bgW} height={bgH} fill="black" />
798
+ </mask>,
799
+ );
800
+
801
+ labelRenderNode = (
802
+ <ItemLabel
803
+ indexes={[relIndex]}
804
+ x={labelX - labelBounds.width / 2}
805
+ y={labelY - labelBounds.height / 2}
806
+ width={labelBounds.width}
807
+ height={labelBounds.height}
808
+ fontSize={FONT_SIZE}
809
+ fontWeight="normal"
810
+ alignHorizontal="center"
811
+ alignVertical="middle"
812
+ fill={colorText}
813
+ >
814
+ {relation.label}
815
+ </ItemLabel>
816
+ );
817
+ }
818
+ }
819
+
820
+ // 生成路径字符串
821
+ const pathD = getEdgePathD(points);
822
+ if (edgeColorMode === 'gradient') {
823
+ const startPoint = points[0];
824
+ const endPoint = points[points.length - 1];
825
+ defsElements.push(
826
+ <linearGradient
827
+ id={gradientId}
828
+ gradientUnits="userSpaceOnUse"
829
+ x1={startPoint[0]}
830
+ y1={startPoint[1]}
831
+ x2={endPoint[0]}
832
+ y2={endPoint[1]}
833
+ >
834
+ <stop offset="0%" stopColor={fromColor} />
835
+ <stop offset="100%" stopColor={toColor} />
836
+ </linearGradient>,
837
+ );
838
+ }
839
+
840
+ decorElements.push(
841
+ <Path
842
+ d={pathD}
843
+ stroke={edgeStroke}
844
+ strokeWidth={arrowWidth}
845
+ fill="none"
846
+ data-element-type="shape"
847
+ // 如果存在 maskId,则应用遮罩
848
+ mask={maskId ? `url(#${maskId})` : undefined}
849
+ strokeDasharray={
850
+ relation.lineStyle === 'solid'
851
+ ? undefined
852
+ : (relation.lineStyle ?? edgeStyle) === 'dashed' || animated
853
+ ? '5,5'
854
+ : undefined
855
+ }
856
+ >
857
+ {animated && (
858
+ <animate
859
+ attributeName="stroke-dashoffset"
860
+ from="10"
861
+ to="0"
862
+ dur="1s"
863
+ repeatCount="indefinite"
864
+ />
865
+ )}
866
+ </Path>,
867
+ );
868
+
869
+ // 绘制箭头头部 (箭头不需要遮罩,保持原样)
870
+ const effectiveArrowSize = ARROW_SIZE;
871
+ const direction = relation.direction ?? 'forward';
872
+
873
+ const arrowConfigs = [
874
+ {
875
+ show: direction === 'forward' || direction === 'both',
876
+ angle: getTangentAngle(points, 1),
877
+ point: points[points.length - 1],
878
+ color: targetArrowColor,
879
+ },
880
+ {
881
+ show: direction === 'both',
882
+ angle: getTangentAngle(points, 0) + Math.PI,
883
+ point: points[0],
884
+ color: sourceArrowColor,
885
+ },
886
+ ];
887
+
888
+ arrowConfigs.forEach((cfg) => {
889
+ if (cfg.show) {
890
+ decorElements.push(
891
+ ...createArrowElements(
892
+ cfg.point[0],
893
+ cfg.point[1],
894
+ cfg.angle,
895
+ relation.arrowType ?? arrowType,
896
+ cfg.color,
897
+ arrowWidth,
898
+ effectiveArrowSize,
899
+ ),
900
+ );
901
+ }
902
+ });
903
+
904
+ if (labelRenderNode) {
905
+ decorElements.push(labelRenderNode);
906
+ }
907
+ });
908
+
909
+ return (
910
+ <FlexLayout
911
+ id="infographic-container"
912
+ flexDirection="column"
913
+ justifyContent="center"
914
+ alignItems="center"
915
+ >
916
+ {Title ? <Title title={title} desc={desc} /> : null}
917
+ <Group>
918
+ <Rect x={0} y={0} width={totalWidth} height={totalHeight} fill="none" />
919
+ <Defs>{defsElements}</Defs>
920
+ <Group>{decorElements}</Group>
921
+ <ItemsGroup>{itemElements}</ItemsGroup>
922
+ <BtnsGroup>{btnElements}</BtnsGroup>
923
+ </Group>
924
+ </FlexLayout>
925
+ );
926
+ };
927
+
928
+ registerStructure('sequence-interaction', {
929
+ component: SequenceInteractionFlow,
930
+ composites: ['title', 'item'],
931
+ });