@mx-sose-front/mx-sose-graph 1.2.9 → 1.3.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.
@@ -0,0 +1,388 @@
1
+ import ELK, { type ElkNode, type ElkExtendedEdge } from 'elkjs/lib/elk.bundled.js'
2
+ import type { Shape, Bounds } from '../types'
3
+ import { getShapeInitialDimensions } from './shapeInitialBounds'
4
+ import { attachPinToBlock, isPortShape } from './pinUtils'
5
+
6
+ export type LayoutDirection = 'horizontal' | 'vertical'
7
+
8
+ /** ELK 布局算法:layered 分层布局;mrtree 多根树布局 */
9
+ export type LayoutAlgorithm = 'layered' | 'mrtree'
10
+
11
+ export interface AutoLayoutOptions {
12
+ /** ELK 布局算法,默认 layered */
13
+ algorithm?: LayoutAlgorithm
14
+ direction?: LayoutDirection
15
+ /** 同层节点间距(像素) */
16
+ nodeSpacing?: number
17
+ /** 层间距(像素) */
18
+ layerSpacing?: number
19
+ /** 布局结果左上角偏移 */
20
+ padding?: number
21
+ /** 子图元在父容器内的内边距 */
22
+ childPadding?: number
23
+ /** 子图元之间的间距 */
24
+ childSpacing?: number
25
+ /** 每行最多子图元数量 */
26
+ maxChildrenPerRow?: number
27
+ }
28
+
29
+ export interface LayoutResult {
30
+ /** shapeId → 新的 bounds(x/y 为布局结果;width/height 重置为各图元类型的默认尺寸) */
31
+ nodeUpdates: Map<string, Bounds>
32
+ /** 所有参与布局的节点 ID(用于批量刷新连线) */
33
+ affectedNodeIds: string[]
34
+ }
35
+
36
+ const DEFAULT_NODE_WIDTH = 100
37
+ const DEFAULT_NODE_HEIGHT = 50
38
+
39
+ /** 布局用端点:端口映射到父图元,普通图元保持自身 id */
40
+ function resolveLayoutEndpoint(
41
+ shapeId: string,
42
+ shapeById: Map<string, Shape>
43
+ ): { layoutId: string; portId?: string } {
44
+ const shape = shapeById.get(shapeId)
45
+ if (!shape) return { layoutId: shapeId }
46
+ if (shape.shapeType === 'pin' && isPortShape(shape) && shape.parenShapeId) {
47
+ return { layoutId: shape.parenShapeId, portId: shape.id }
48
+ }
49
+ return { layoutId: shapeId }
50
+ }
51
+
52
+ function getLayoutBounds(
53
+ shapeId: string,
54
+ nodeUpdates: Map<string, Bounds>,
55
+ shapeById: Map<string, Shape>
56
+ ): Bounds {
57
+ const updated = nodeUpdates.get(shapeId)
58
+ if (updated) return updated
59
+ const shape = shapeById.get(shapeId)
60
+ const b = shape?.bounds
61
+ return {
62
+ x: b?.x ?? 0,
63
+ y: b?.y ?? 0,
64
+ width: b?.width ?? DEFAULT_NODE_WIDTH,
65
+ height: b?.height ?? DEFAULT_NODE_HEIGHT,
66
+ }
67
+ }
68
+
69
+ function blockCenter(bounds: Bounds): { x: number; y: number } {
70
+ const x = bounds.x ?? 0
71
+ const y = bounds.y ?? 0
72
+ const w = bounds.width ?? DEFAULT_NODE_WIDTH
73
+ const h = bounds.height ?? DEFAULT_NODE_HEIGHT
74
+ return { x: x + w / 2, y: y + h / 2 }
75
+ }
76
+
77
+ interface EdgePortLayoutInfo {
78
+ sourcePortId?: string
79
+ targetPortId?: string
80
+ layoutSourceId: string
81
+ layoutTargetId: string
82
+ }
83
+
84
+ /** 布局完成后,将端口吸附到父图元朝向对端的边上 */
85
+ function positionPortsOnEdges(
86
+ edgePortInfos: EdgePortLayoutInfo[],
87
+ shapes: Shape[],
88
+ nodeUpdates: Map<string, Bounds>,
89
+ affectedNodeIds: string[]
90
+ ): void {
91
+ const shapeById = new Map(shapes.map(s => [s.id, s]))
92
+ const affectedSet = new Set(affectedNodeIds)
93
+
94
+ for (const info of edgePortInfos) {
95
+ const otherSourceBounds = getLayoutBounds(info.layoutSourceId, nodeUpdates, shapeById)
96
+ const otherTargetBounds = getLayoutBounds(info.layoutTargetId, nodeUpdates, shapeById)
97
+
98
+ if (info.sourcePortId) {
99
+ const port = shapeById.get(info.sourcePortId)
100
+ const parentId = port?.parenShapeId
101
+ const parent = parentId ? shapeById.get(parentId) : undefined
102
+ if (port && parent) {
103
+ const parentWithBounds: Shape = {
104
+ ...parent,
105
+ bounds: getLayoutBounds(parent.id, nodeUpdates, shapeById),
106
+ }
107
+ const portBounds = attachPinToBlock(port, parentWithBounds, blockCenter(otherTargetBounds))
108
+ nodeUpdates.set(port.id, portBounds)
109
+ if (!affectedSet.has(port.id)) {
110
+ affectedNodeIds.push(port.id)
111
+ affectedSet.add(port.id)
112
+ }
113
+ }
114
+ }
115
+
116
+ if (info.targetPortId) {
117
+ const port = shapeById.get(info.targetPortId)
118
+ const parentId = port?.parenShapeId
119
+ const parent = parentId ? shapeById.get(parentId) : undefined
120
+ if (port && parent) {
121
+ const parentWithBounds: Shape = {
122
+ ...parent,
123
+ bounds: getLayoutBounds(parent.id, nodeUpdates, shapeById),
124
+ }
125
+ const portBounds = attachPinToBlock(port, parentWithBounds, blockCenter(otherSourceBounds))
126
+ nodeUpdates.set(port.id, portBounds)
127
+ if (!affectedSet.has(port.id)) {
128
+ affectedNodeIds.push(port.id)
129
+ affectedSet.add(port.id)
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ /** 根据子图元排列计算父容器所需尺寸 */
137
+ function computeParentSize(
138
+ children: Shape[],
139
+ maxPerRow: number,
140
+ childSpacing: number,
141
+ childPadding: number,
142
+ headerHeight: number,
143
+ ): { width: number; height: number } {
144
+ if (!children.length) return { width: DEFAULT_NODE_WIDTH, height: DEFAULT_NODE_HEIGHT }
145
+
146
+ const childDims = children.map(c => getShapeInitialDimensions(c))
147
+ const rows: { width: number; height: number }[][] = []
148
+ for (let i = 0; i < childDims.length; i += maxPerRow) {
149
+ rows.push(childDims.slice(i, i + maxPerRow))
150
+ }
151
+
152
+ let totalWidth = 0
153
+ let totalHeight = 0
154
+ for (const row of rows) {
155
+ const rowW = row.reduce((sum, d) => sum + d.width, 0) + (row.length - 1) * childSpacing
156
+ const rowH = Math.max(...row.map(d => d.height))
157
+ totalWidth = Math.max(totalWidth, rowW)
158
+ totalHeight += rowH
159
+ }
160
+ totalHeight += (rows.length - 1) * childSpacing
161
+
162
+ return {
163
+ width: totalWidth + childPadding * 2,
164
+ height: totalHeight + childPadding * 2 + headerHeight,
165
+ }
166
+ }
167
+
168
+ /** 在父容器内将子图元横向排列(每行最多 maxPerRow 个) */
169
+ function arrangeChildrenInParent(
170
+ parentBounds: Bounds,
171
+ children: Shape[],
172
+ maxPerRow: number,
173
+ childSpacing: number,
174
+ childPadding: number,
175
+ headerHeight: number,
176
+ ): Map<string, Bounds> {
177
+ const result = new Map<string, Bounds>()
178
+ if (!children.length) return result
179
+
180
+ const childDims = children.map(c => getShapeInitialDimensions(c))
181
+ const startX = (parentBounds.x ?? 0) + childPadding
182
+ const startY = (parentBounds.y ?? 0) + childPadding + headerHeight
183
+
184
+ let curY = startY
185
+ for (let i = 0; i < children.length; i += maxPerRow) {
186
+ const rowChildren = children.slice(i, i + maxPerRow)
187
+ const rowDims = childDims.slice(i, i + maxPerRow)
188
+ const rowH = Math.max(...rowDims.map(d => d.height))
189
+
190
+ let curX = startX
191
+ for (let j = 0; j < rowChildren.length; j++) {
192
+ result.set(rowChildren[j].id, {
193
+ x: curX,
194
+ y: curY,
195
+ width: rowDims[j].width,
196
+ height: rowDims[j].height,
197
+ })
198
+ curX += rowDims[j].width + childSpacing
199
+ }
200
+ curY += rowH + childSpacing
201
+ }
202
+
203
+ return result
204
+ }
205
+
206
+ /**
207
+ * 基于 ELK 的自动布局
208
+ *
209
+ * 从 shapes 中分离出节点与边(排除 edge、diagram、pin/端口),交由 ELK 计算布局(layered / mrtree),
210
+ * 返回每个节点的新坐标。
211
+ *
212
+ * 端口不参与节点布局:构建 ELK 边时将连到端口的 source/target 换为其父图元 id;
213
+ * 布局完成后边的 sourceId/targetId 仍指向端口,再按对端父图元中心用 attachPinToBlock 吸附端口。
214
+ *
215
+ * 对于有嵌套子图元的父图元:
216
+ * 1. 先根据子图元数量计算父图元所需宽高
217
+ * 2. 以计算后的尺寸参与 ELK 布局
218
+ * 3. 布局完成后将子图元在父内横向排列(每行最多 maxChildrenPerRow 个)
219
+ */
220
+ export async function autoLayout(
221
+ shapes: Shape[],
222
+ options: AutoLayoutOptions = {}
223
+ ): Promise<LayoutResult> {
224
+ const {
225
+ algorithm = 'layered',
226
+ direction = 'vertical',
227
+ nodeSpacing = 50,
228
+ layerSpacing = 80,
229
+ padding = 40,
230
+ childPadding = 16,
231
+ childSpacing = 12,
232
+ maxChildrenPerRow = 3,
233
+ } = options
234
+
235
+ const HEADER_HEIGHT = 30
236
+
237
+ const shapeById = new Map(shapes.map(s => [s.id, s]))
238
+
239
+ // 排除边、画布、端口(pin)——端口不参与 ELK 节点布局
240
+ const nodeShapes = shapes.filter(
241
+ s => s.shapeType !== 'edge' && s.shapeType !== 'diagram' && s.shapeType !== 'pin'
242
+ )
243
+ const edgeShapes = shapes.filter(
244
+ s => s.shapeType === 'edge' && s.sourceId && s.targetId
245
+ )
246
+
247
+ // 构建父子关系
248
+ const parentChildMap = new Map<string, Shape[]>()
249
+ const childIdSet = new Set<string>()
250
+ for (const s of nodeShapes) {
251
+ if (s.parenShapeId && s.shapeType === 'shape') {
252
+ const parent = nodeShapes.find(p => p.id === s.parenShapeId)
253
+ if (parent) {
254
+ if (!parentChildMap.has(s.parenShapeId)) {
255
+ parentChildMap.set(s.parenShapeId, [])
256
+ }
257
+ parentChildMap.get(s.parenShapeId)!.push(s)
258
+ childIdSet.add(s.id)
259
+ }
260
+ }
261
+ }
262
+
263
+ // 顶层图元:不是别人的子图元
264
+ const topLevelShapes = nodeShapes.filter(s => !childIdSet.has(s.id))
265
+ const topLevelIdSet = new Set(topLevelShapes.map(s => s.id))
266
+
267
+ // 为父图元计算基于子图元排列的尺寸
268
+ const parentSizeMap = new Map<string, { width: number; height: number }>()
269
+ for (const [parentId, children] of parentChildMap) {
270
+ parentSizeMap.set(
271
+ parentId,
272
+ computeParentSize(children, maxChildrenPerRow, childSpacing, childPadding, HEADER_HEIGHT)
273
+ )
274
+ }
275
+
276
+ const elkNodes: ElkNode[] = topLevelShapes.map(s => {
277
+ const overrideSize = parentSizeMap.get(s.id)
278
+ if (overrideSize) {
279
+ return {
280
+ id: s.id,
281
+ width: overrideSize.width,
282
+ height: overrideSize.height,
283
+ }
284
+ }
285
+ const { width, height } = getShapeInitialDimensions(s)
286
+ return {
287
+ id: s.id,
288
+ width: width || DEFAULT_NODE_WIDTH,
289
+ height: height || DEFAULT_NODE_HEIGHT,
290
+ }
291
+ })
292
+
293
+ // 边的端点:端口换为其父图元 id,供 ELK 排序;并记录需事后吸附的端口
294
+ const edgePortInfos: EdgePortLayoutInfo[] = []
295
+ const elkEdges: ElkExtendedEdge[] = edgeShapes
296
+ .map(e => {
297
+ const src = resolveLayoutEndpoint(e.sourceId!, shapeById)
298
+ const tgt = resolveLayoutEndpoint(e.targetId!, shapeById)
299
+ return { edge: e, src, tgt }
300
+ })
301
+ .filter(({ src, tgt }) => topLevelIdSet.has(src.layoutId) && topLevelIdSet.has(tgt.layoutId))
302
+ .map(({ edge, src, tgt }) => {
303
+ if (src.portId || tgt.portId) {
304
+ edgePortInfos.push({
305
+ sourcePortId: src.portId,
306
+ targetPortId: tgt.portId,
307
+ layoutSourceId: src.layoutId,
308
+ layoutTargetId: tgt.layoutId,
309
+ })
310
+ }
311
+ return {
312
+ id: edge.id,
313
+ sources: [src.layoutId],
314
+ targets: [tgt.layoutId],
315
+ }
316
+ })
317
+
318
+ const elkDirection = direction === 'horizontal' ? 'RIGHT' : 'DOWN'
319
+
320
+ const elk = new ELK()
321
+
322
+ const baseLayoutOptions: Record<string, string> = {
323
+ 'elk.algorithm': algorithm,
324
+ 'elk.direction': elkDirection,
325
+ 'elk.spacing.nodeNode': String(nodeSpacing),
326
+ 'elk.padding': `[top=${padding},left=${padding},bottom=${padding},right=${padding}]`,
327
+ }
328
+
329
+ const layoutOptions: Record<string, string> =
330
+ algorithm === 'layered'
331
+ ? {
332
+ ...baseLayoutOptions,
333
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing),
334
+ 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
335
+ 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
336
+ 'elk.edgeRouting': 'ORTHOGONAL',
337
+ }
338
+ : baseLayoutOptions
339
+
340
+ const graph: ElkNode = {
341
+ id: 'root',
342
+ layoutOptions,
343
+ children: elkNodes,
344
+ edges: elkEdges,
345
+ }
346
+
347
+ const layoutResult = await elk.layout(graph)
348
+
349
+ const nodeUpdates = new Map<string, Bounds>()
350
+ const affectedNodeIds: string[] = []
351
+
352
+ if (layoutResult.children) {
353
+ for (const child of layoutResult.children) {
354
+ const original = topLevelShapes.find(s => s.id === child.id)
355
+ if (!original) continue
356
+
357
+ const overrideSize = parentSizeMap.get(child.id)
358
+ const width = overrideSize?.width ?? (getShapeInitialDimensions(original).width || DEFAULT_NODE_WIDTH)
359
+ const height = overrideSize?.height ?? (getShapeInitialDimensions(original).height || DEFAULT_NODE_HEIGHT)
360
+
361
+ const parentBounds: Bounds = {
362
+ x: child.x ?? 0,
363
+ y: child.y ?? 0,
364
+ width,
365
+ height,
366
+ }
367
+ nodeUpdates.set(child.id, parentBounds)
368
+ affectedNodeIds.push(child.id)
369
+
370
+ // 布局子图元:横向排列
371
+ const children = parentChildMap.get(child.id)
372
+ if (children?.length) {
373
+ const childBoundsMap = arrangeChildrenInParent(
374
+ parentBounds, children, maxChildrenPerRow, childSpacing, childPadding, HEADER_HEIGHT
375
+ )
376
+ for (const [childId, bounds] of childBoundsMap) {
377
+ nodeUpdates.set(childId, bounds)
378
+ affectedNodeIds.push(childId)
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ // 将端口放到连线两端:朝向对端父图元中心吸附到父图元边缘
385
+ positionPortsOnEdges(edgePortInfos, shapes, nodeUpdates, affectedNodeIds)
386
+
387
+ return { nodeUpdates, affectedNodeIds }
388
+ }
@@ -2,6 +2,11 @@ import type { Shape, Bounds } from '../types'
2
2
  import { getBounds } from './geom'
3
3
 
4
4
  const PortKeys = ['OperationalPort', 'ServicePort', 'ResourcePort']
5
+
6
+ /** 是否为业务/服务/资源端口图元 */
7
+ export function isPortShape(shape: Shape): boolean {
8
+ return PortKeys.includes(shape.shapeKey)
9
+ }
5
10
  /**
6
11
  * 计算点到矩形边的距离
7
12
  */
@@ -0,0 +1,42 @@
1
+ import type { Shape } from '../types'
2
+ import { PinKeyMap, ShapeKeyMap } from '../constants'
3
+
4
+ /**
5
+ * 各渲染组件中与「原始 / 兜底」bounds 宽高一致的默认值
6
+ * (对应 Block.vue、Package.vue、DividingLine.vue、ActivityAction.vue、ConceptualRole.vue、Diagram.vue 等)
7
+ */
8
+ const RENDERER_DEFAULTS: Record<string, { width: number; height: number }> = {
9
+ Block: { width: 150, height: 60 },
10
+ Package: { width: 150, height: 85 },
11
+ DividingLine: { width: 150, height: 135 },
12
+ ActivityAction: { width: 100, height: 40 },
13
+ ConceptualRole: { width: 30, height: 30 },
14
+ Diagram: { width: 100, height: 120 },
15
+ }
16
+
17
+ /** Pin.vue 中 vbW/vbH 的兜底 */
18
+ const PIN_DEFAULTS = { width: 20, height: 20 }
19
+ /** Port.vue 中 vbW/vbH 的兜底 */
20
+ const PORT_DEFAULTS = { width: 20, height: 20 }
21
+
22
+ const FALLBACK = { width: 100, height: 50 }
23
+
24
+ /**
25
+ * 返回图元用于布局/重置时的「初始」宽高(与对应 Vue 组件默认一致),不读取当前可能被拉伸的 bounds。
26
+ */
27
+ export function getShapeInitialDimensions(shape: Shape): { width: number; height: number } {
28
+ if (shape.shapeType === 'pin') {
29
+ const kind = (PinKeyMap as Record<string, string>)[shape.shapeKey]
30
+ if (kind === 'Port') return { ...PORT_DEFAULTS }
31
+ if (kind === 'Pin') return { ...PIN_DEFAULTS }
32
+ return { ...PIN_DEFAULTS }
33
+ }
34
+
35
+ if (shape.shapeType === 'shape') {
36
+ const renderer = (ShapeKeyMap as Record<string, string>)[shape.shapeKey]
37
+ const d = renderer ? RENDERER_DEFAULTS[renderer] : undefined
38
+ if (d) return { ...d }
39
+ }
40
+
41
+ return { ...FALLBACK }
42
+ }
@@ -588,6 +588,8 @@ defineExpose({
588
588
  position: absolute;
589
589
  top: 0;
590
590
  left: 0;
591
+ transform-origin: 0 0;
592
+ transform: scale(v-bind('currentScale'));
591
593
  pointer-events: none;
592
594
  z-index: 998;
593
595
  }