@super-linear/supertopo 0.0.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.
- package/dist/cjs/index.js +10 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/types.d.ts +60 -0
- package/package.json +37 -0
- package/rollup.config.js +39 -0
- package/src/core/config.ts +44 -0
- package/src/core/graph.ts +298 -0
- package/src/index.ts +2 -0
- package/src/layouts/clos.ts +590 -0
- package/src/layouts/index.ts +9 -0
- package/src/react/supertopo.tsx +139 -0
- package/src/react/topology.tsx +148 -0
- package/src/styles/graph-style.ts +30 -0
- package/src/types/graph.ts +76 -0
- package/src/types/index.ts +1 -0
- package/tsconfig.json +48 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clos Topology Layout - Cytoscape Extension
|
|
3
|
+
*
|
|
4
|
+
* 直接使用 Cytoscape API 获取节点连接关系
|
|
5
|
+
* 动态分类:Spine(脊节点)和 Leaf(叶节点)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CollectionArgument } from "cytoscape";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 自然排序:正确处理数字排序(gpu9 < gpu10,part2 < part10)
|
|
12
|
+
*/
|
|
13
|
+
function naturalSortCompare(a: string, b: string): number {
|
|
14
|
+
const numA = parseInt(a.match(/\d+/g)?.join("") || "0");
|
|
15
|
+
const numB = parseInt(b.match(/\d+/g)?.join("") || "0");
|
|
16
|
+
|
|
17
|
+
if (numA !== numB) return numA - numB;
|
|
18
|
+
return a.localeCompare(b);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class UnionFind {
|
|
22
|
+
parent = new Map<string, string>();
|
|
23
|
+
rank = new Map<string, number>();
|
|
24
|
+
|
|
25
|
+
makeSet(x: string) {
|
|
26
|
+
this.parent.set(x, x);
|
|
27
|
+
this.rank.set(x, 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
find(x: string): string {
|
|
31
|
+
if (!this.parent.has(x)) this.makeSet(x);
|
|
32
|
+
const px = this.parent.get(x)!;
|
|
33
|
+
if (px === x) return x;
|
|
34
|
+
const root = this.find(px);
|
|
35
|
+
this.parent.set(x, root);
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
union(x: string, y: string) {
|
|
40
|
+
const px = this.find(x),
|
|
41
|
+
py = this.find(y);
|
|
42
|
+
if (px === py) return;
|
|
43
|
+
const rx = this.rank.get(px) || 0,
|
|
44
|
+
ry = this.rank.get(py) || 0;
|
|
45
|
+
if (rx < ry) this.parent.set(px, py);
|
|
46
|
+
else if (rx > ry) this.parent.set(py, px);
|
|
47
|
+
else {
|
|
48
|
+
this.parent.set(py, px);
|
|
49
|
+
this.rank.set(px, rx + 1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function classifyNodes(nodes: any[], switchElements: any, serverElements: any) {
|
|
55
|
+
const servers: any[] = [];
|
|
56
|
+
const switches: any[] = [];
|
|
57
|
+
|
|
58
|
+
nodes.forEach((n) => {
|
|
59
|
+
const type = String(n.data("type") || "").toLowerCase();
|
|
60
|
+
if (type === "switch") {
|
|
61
|
+
switches.push(n);
|
|
62
|
+
} else {
|
|
63
|
+
servers.push(n);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (switches.length === 0) {
|
|
68
|
+
return { servers, leaf: [], spine: [], maxSpineSize: 0 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const switchIdSet = new Set(switches.map((s) => String(s.id())));
|
|
72
|
+
const serverIdSet = new Set(servers.map((s) => String(s.id())));
|
|
73
|
+
|
|
74
|
+
const switchInfo = new Map<
|
|
75
|
+
string,
|
|
76
|
+
{
|
|
77
|
+
id: string;
|
|
78
|
+
connectedToServers: Set<string>;
|
|
79
|
+
connectedToSwitches: Set<string>;
|
|
80
|
+
}
|
|
81
|
+
>();
|
|
82
|
+
|
|
83
|
+
switches.forEach((sw: any) => {
|
|
84
|
+
const swId = String(sw.id());
|
|
85
|
+
const swNode = switchElements.getElementById(swId);
|
|
86
|
+
|
|
87
|
+
if (swNode && swNode.length > 0) {
|
|
88
|
+
const neighbors = swNode.neighborhood().nodes();
|
|
89
|
+
const connectedToServers = new Set<string>();
|
|
90
|
+
const connectedToSwitches = new Set<string>();
|
|
91
|
+
|
|
92
|
+
neighbors.forEach((neighborNode: any) => {
|
|
93
|
+
const neighborId = String(neighborNode.id());
|
|
94
|
+
if (serverIdSet.has(neighborId)) {
|
|
95
|
+
connectedToServers.add(neighborId);
|
|
96
|
+
} else if (switchIdSet.has(neighborId)) {
|
|
97
|
+
connectedToSwitches.add(neighborId);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
switchInfo.set(swId, {
|
|
102
|
+
id: swId,
|
|
103
|
+
connectedToServers,
|
|
104
|
+
connectedToSwitches,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 算法:识别 Spine 和 Leaf
|
|
110
|
+
// 第一步:找出所有不和任何 server 连接的 switch,这些一定是 spine
|
|
111
|
+
const spine: any[] = [];
|
|
112
|
+
switches.forEach((sw: any) => {
|
|
113
|
+
const info = switchInfo.get(String(sw.id()));
|
|
114
|
+
if (!info) return;
|
|
115
|
+
|
|
116
|
+
if (info.connectedToServers.size === 0) {
|
|
117
|
+
spine.push(sw);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const spineIdSet = new Set(spine.map((s) => String(s.id())));
|
|
122
|
+
|
|
123
|
+
// 第二步:遍历剩下的 switches
|
|
124
|
+
// 如果某个 switch 不连接任何已确定的 spine,则判定为 spine
|
|
125
|
+
const remainingSwitches = switches.filter(
|
|
126
|
+
(sw: any) => !spineIdSet.has(String(sw.id())),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
remainingSwitches.forEach((sw: any) => {
|
|
130
|
+
const swId = String(sw.id());
|
|
131
|
+
const info = switchInfo.get(swId);
|
|
132
|
+
if (!info) return;
|
|
133
|
+
|
|
134
|
+
// 检查是否连接到任何一个已确定的 spine
|
|
135
|
+
const connectsToSpine = Array.from(info.connectedToSwitches).some((swId) =>
|
|
136
|
+
spineIdSet.has(swId),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// 如果不连接任何 spine,则加入 spine 组
|
|
140
|
+
if (!connectsToSpine) {
|
|
141
|
+
spine.push(sw);
|
|
142
|
+
spineIdSet.add(swId);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 第三步:剩下的所有 switch 都是 leaf
|
|
147
|
+
const leaf: any[] = switches.filter(
|
|
148
|
+
(sw: any) => !spineIdSet.has(String(sw.id())),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const maxSpineSize =
|
|
152
|
+
spine.length > 0 ? Math.max(...spine.map((s) => s.data("size") || 40)) : 0;
|
|
153
|
+
|
|
154
|
+
console.log("[CLOS] 节点分类结果:");
|
|
155
|
+
console.log(" Server:", servers.length);
|
|
156
|
+
console.log(" Leaf (连接到 spine):", leaf.length);
|
|
157
|
+
console.log(" Spine (不连接 server 或 spine):", spine.length);
|
|
158
|
+
|
|
159
|
+
return { servers, leaf, spine, maxSpineSize };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function groupBySU(
|
|
163
|
+
servers: any[],
|
|
164
|
+
leaf: any[],
|
|
165
|
+
allElements: any,
|
|
166
|
+
suScope?: any,
|
|
167
|
+
): Map<string, { leaves: any[]; servers: any[]; maxNodeSize: number }> {
|
|
168
|
+
const validServerIds = new Set<string>();
|
|
169
|
+
const serverMap = new Map<string, any>();
|
|
170
|
+
|
|
171
|
+
servers.forEach((s) => {
|
|
172
|
+
const id = String(s.id());
|
|
173
|
+
// 创建混合对象,既有扁平属性又有 Cytoscape API
|
|
174
|
+
// 用户可以通过 node.id 或 node.id() 访问 ID
|
|
175
|
+
const serverNode = s as any;
|
|
176
|
+
if (suScope?.exclude?.(serverNode)) return;
|
|
177
|
+
if (suScope?.include && !suScope.include(serverNode)) return;
|
|
178
|
+
validServerIds.add(id);
|
|
179
|
+
serverMap.set(id, s);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const leafToValidServers = new Map<string, Set<string>>();
|
|
183
|
+
|
|
184
|
+
leaf.forEach((l) => {
|
|
185
|
+
const lNode = allElements.getElementById(String(l.id()));
|
|
186
|
+
if (lNode && lNode.length > 0) {
|
|
187
|
+
const neighbors = lNode.neighborhood().nodes();
|
|
188
|
+
const connected = new Set<string>();
|
|
189
|
+
|
|
190
|
+
neighbors.forEach((neighbor: any) => {
|
|
191
|
+
const neighborId = String(neighbor.id());
|
|
192
|
+
if (validServerIds.has(neighborId)) {
|
|
193
|
+
connected.add(neighborId);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
leafToValidServers.set(String(l.id()), connected);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const uf = new UnionFind();
|
|
202
|
+
leaf.forEach((l) => uf.makeSet(String(l.id())));
|
|
203
|
+
|
|
204
|
+
const leafIds = Array.from(leafToValidServers.keys());
|
|
205
|
+
for (let i = 0; i < leafIds.length; i++) {
|
|
206
|
+
for (let j = i + 1; j < leafIds.length; j++) {
|
|
207
|
+
const a = leafIds[i]!,
|
|
208
|
+
b = leafIds[j]!;
|
|
209
|
+
const serversA = leafToValidServers.get(a) || new Set();
|
|
210
|
+
const serversB = leafToValidServers.get(b) || new Set();
|
|
211
|
+
for (const sid of serversA) {
|
|
212
|
+
if (serversB.has(sid)) {
|
|
213
|
+
uf.union(a, b);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const leafGroupsByRoot = new Map<string, string[]>();
|
|
221
|
+
leafIds.forEach((leafId) => {
|
|
222
|
+
const root = uf.find(leafId);
|
|
223
|
+
const group = leafGroupsByRoot.get(root) || [];
|
|
224
|
+
group.push(leafId);
|
|
225
|
+
leafGroupsByRoot.set(root, group);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const sortedLeafGroups: { root: string; leafGroup: string[] }[] = [];
|
|
229
|
+
leafGroupsByRoot.forEach((leafGroup, root) => {
|
|
230
|
+
if (!root) return;
|
|
231
|
+
leafGroup.sort((a, b) => {
|
|
232
|
+
const nodeA = leaf.find((l) => String(l.id()) === a);
|
|
233
|
+
const nodeB = leaf.find((l) => String(l.id()) === b);
|
|
234
|
+
const labelA = String(nodeA?.data("label") || nodeA?.id() || a).toLowerCase();
|
|
235
|
+
const labelB = String(nodeB?.data("label") || nodeB?.id() || b).toLowerCase();
|
|
236
|
+
return labelA.localeCompare(labelB);
|
|
237
|
+
});
|
|
238
|
+
sortedLeafGroups.push({ root, leafGroup });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const leafToAllServers = new Map<string, Set<string>>();
|
|
242
|
+
leaf.forEach((l) => {
|
|
243
|
+
const lNode = allElements.getElementById(String(l.id()));
|
|
244
|
+
if (lNode && lNode.length > 0) {
|
|
245
|
+
const neighbors = lNode.neighborhood().nodes();
|
|
246
|
+
const connected = new Set<string>();
|
|
247
|
+
|
|
248
|
+
neighbors.forEach((neighbor: any) => {
|
|
249
|
+
const neighborId = String(neighbor.id());
|
|
250
|
+
if (servers.some((s) => String(s.id()) === neighborId)) {
|
|
251
|
+
connected.add(neighborId);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
leafToAllServers.set(String(l.id()), connected);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const invalidServerToFirstLeaf = new Map<string, string>();
|
|
260
|
+
servers.forEach((s) => {
|
|
261
|
+
const sid = String(s.id());
|
|
262
|
+
if (!validServerIds.has(sid)) {
|
|
263
|
+
const connectedLeaves: string[] = [];
|
|
264
|
+
|
|
265
|
+
leafToAllServers.forEach((serversSet, leafId) => {
|
|
266
|
+
if (serversSet.has(sid)) {
|
|
267
|
+
connectedLeaves.push(leafId);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
connectedLeaves.sort((a, b) => {
|
|
272
|
+
const nodeA = leaf.find((l) => String(l.id()) === a);
|
|
273
|
+
const nodeB = leaf.find((l) => String(l.id()) === b);
|
|
274
|
+
return (nodeA?.data("label") || nodeA?.id() || a).localeCompare(
|
|
275
|
+
nodeB?.data("label") || nodeB?.id() || b,
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (connectedLeaves.length > 0) {
|
|
280
|
+
invalidServerToFirstLeaf.set(sid, connectedLeaves[0]!);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const leafToSuRoot = new Map<string, string>();
|
|
286
|
+
sortedLeafGroups.forEach(({ leafGroup, root }) => {
|
|
287
|
+
leafGroup.forEach((leafId) => leafToSuRoot.set(leafId, root));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const suGroups = new Map<string, any>();
|
|
291
|
+
|
|
292
|
+
sortedLeafGroups.forEach(({ leafGroup, root }) => {
|
|
293
|
+
const suGroup: any = {
|
|
294
|
+
leaves: [],
|
|
295
|
+
servers: [],
|
|
296
|
+
maxNodeSize: 0,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
let maxSize = 0;
|
|
300
|
+
leafGroup.forEach((leafId) => {
|
|
301
|
+
const leafNode = leaf.find((l) => String(l.id()) === leafId);
|
|
302
|
+
if (leafNode) {
|
|
303
|
+
suGroup.leaves.push(leafNode);
|
|
304
|
+
maxSize = Math.max(maxSize, leafNode.data("size") || 35);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
suGroup.maxNodeSize = maxSize;
|
|
309
|
+
suGroups.set(root, suGroup);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const assignedServerIds = new Set<string>();
|
|
313
|
+
sortedLeafGroups.forEach(({ leafGroup }) => {
|
|
314
|
+
leafGroup.forEach((leafId) => {
|
|
315
|
+
const validServers = leafToValidServers.get(leafId) || new Set();
|
|
316
|
+
validServers.forEach((sid) => {
|
|
317
|
+
if (!assignedServerIds.has(sid)) {
|
|
318
|
+
const serverNode = serverMap.get(sid);
|
|
319
|
+
if (serverNode) {
|
|
320
|
+
const root = leafToSuRoot.get(leafId);
|
|
321
|
+
if (root) {
|
|
322
|
+
const suGroup = suGroups.get(root);
|
|
323
|
+
if (suGroup) {
|
|
324
|
+
suGroup.servers.push(serverNode);
|
|
325
|
+
suGroup.maxNodeSize = Math.max(
|
|
326
|
+
suGroup.maxNodeSize,
|
|
327
|
+
serverNode.data("size") || 25,
|
|
328
|
+
);
|
|
329
|
+
assignedServerIds.add(sid);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
servers.forEach((s) => {
|
|
339
|
+
const sid = String(s.id());
|
|
340
|
+
if (!validServerIds.has(sid)) {
|
|
341
|
+
const firstLeaf = invalidServerToFirstLeaf.get(sid);
|
|
342
|
+
if (firstLeaf) {
|
|
343
|
+
const root = leafToSuRoot.get(firstLeaf);
|
|
344
|
+
if (root) {
|
|
345
|
+
const suGroup = suGroups.get(root);
|
|
346
|
+
if (suGroup && !assignedServerIds.has(sid)) {
|
|
347
|
+
suGroup.servers.push(s);
|
|
348
|
+
suGroup.maxNodeSize = Math.max(suGroup.maxNodeSize, s.data("size") || 25);
|
|
349
|
+
assignedServerIds.add(sid);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
suGroups.forEach((suGroup) => {
|
|
357
|
+
suGroup.servers.sort((a: any, b: any) => {
|
|
358
|
+
const isValidA = validServerIds.has(String(a.id()));
|
|
359
|
+
const isValidB = validServerIds.has(String(b.id()));
|
|
360
|
+
|
|
361
|
+
// 有效服务器在前,无效服务器在后
|
|
362
|
+
if (isValidA && !isValidB) return -1;
|
|
363
|
+
if (!isValidA && isValidB) return 1;
|
|
364
|
+
|
|
365
|
+
// 同类型的项目按自然序排序
|
|
366
|
+
const labelA = String(a.data("label") || a.id()).toLowerCase();
|
|
367
|
+
const labelB = String(b.data("label") || b.id()).toLowerCase();
|
|
368
|
+
return naturalSortCompare(labelA, labelB);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return suGroups;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface CyLayoutOptions {
|
|
376
|
+
eles: CollectionArgument;
|
|
377
|
+
devicesPerRow?: number;
|
|
378
|
+
horizontalSpacingFactor?: number;
|
|
379
|
+
spineToLeafSpacingFactor?: number;
|
|
380
|
+
suVerticalSpacingFactor?: number;
|
|
381
|
+
suSpacingFactor?: number;
|
|
382
|
+
spineCoverageFactor?: number;
|
|
383
|
+
scaleFactor?: number;
|
|
384
|
+
suScope?: any;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const defaultOptions = {
|
|
388
|
+
devicesPerRow: 8,
|
|
389
|
+
horizontalSpacingFactor: 2.0,
|
|
390
|
+
spineToLeafSpacingFactor: 8.0,
|
|
391
|
+
suVerticalSpacingFactor: 4.0,
|
|
392
|
+
suSpacingFactor: 2.0,
|
|
393
|
+
spineCoverageFactor: 0.8,
|
|
394
|
+
scaleFactor: 0.5,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
export function CyLayout(this: any, options: CyLayoutOptions) {
|
|
398
|
+
this.options = { ...defaultOptions, ...options };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
CyLayout.prototype.run = function () {
|
|
402
|
+
const {
|
|
403
|
+
scaleFactor = 0.5,
|
|
404
|
+
devicesPerRow = 8,
|
|
405
|
+
horizontalSpacingFactor = 2.0,
|
|
406
|
+
spineToLeafSpacingFactor = 8.0,
|
|
407
|
+
suVerticalSpacingFactor = 4.0,
|
|
408
|
+
suSpacingFactor = 2.0,
|
|
409
|
+
spineCoverageFactor = 0.8,
|
|
410
|
+
suScope,
|
|
411
|
+
} = this.options;
|
|
412
|
+
|
|
413
|
+
const eles = this.options.eles;
|
|
414
|
+
const nodes = eles.nodes();
|
|
415
|
+
const edges = eles.edges();
|
|
416
|
+
|
|
417
|
+
console.log("[CLOS 布局] 节点分类统计:");
|
|
418
|
+
console.log(" 节点总数:", nodes.length);
|
|
419
|
+
console.log(" 链路数:", edges.length);
|
|
420
|
+
|
|
421
|
+
const switchElements = nodes.filter((n: any) => {
|
|
422
|
+
const type = String(n.data("type") || "").toLowerCase();
|
|
423
|
+
return type === "switch";
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const serverElements = nodes.filter((n: any) => {
|
|
427
|
+
const type = String(n.data("type") || "").toLowerCase();
|
|
428
|
+
return type !== "switch";
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const { servers, leaf, spine, maxSpineSize } = classifyNodes(
|
|
432
|
+
nodes,
|
|
433
|
+
switchElements,
|
|
434
|
+
serverElements,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
console.log("[CLOS 布局] 节点分类:");
|
|
438
|
+
console.log(" SPINE:", spine.length);
|
|
439
|
+
console.log(" LEAF:", leaf.length);
|
|
440
|
+
console.log(" SERVER:", servers.length);
|
|
441
|
+
|
|
442
|
+
const sortedSpine = [...spine].sort((a, b) => {
|
|
443
|
+
return (a.data("label") || a.id()).localeCompare(b.data("label") || b.id());
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const suGroups = groupBySU(servers, leaf, eles.nodes(), suScope);
|
|
447
|
+
|
|
448
|
+
console.log("[CLOS 布局] SU 分组结果:");
|
|
449
|
+
console.log(" SU 组数:", suGroups.size);
|
|
450
|
+
let idx = 1;
|
|
451
|
+
suGroups.forEach((group, root) => {
|
|
452
|
+
console.log(
|
|
453
|
+
` SU${idx} (${root}): ${group.leaves.length} leaves, ${group.servers.length} servers`,
|
|
454
|
+
);
|
|
455
|
+
idx++;
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const suList = Array.from(suGroups.entries()).sort(
|
|
459
|
+
([, groupA], [, groupB]) => {
|
|
460
|
+
// 获取每个 SU 中最小的有效 server label
|
|
461
|
+
const validServersA = groupA.servers.filter((s) => {
|
|
462
|
+
if (!suScope) return true;
|
|
463
|
+
if (suScope.exclude?.(s)) return false;
|
|
464
|
+
if (suScope.include && !suScope.include(s)) return false;
|
|
465
|
+
return true;
|
|
466
|
+
});
|
|
467
|
+
const validServersB = groupB.servers.filter((s) => {
|
|
468
|
+
if (!suScope) return true;
|
|
469
|
+
if (suScope.exclude?.(s)) return false;
|
|
470
|
+
if (suScope.include && !suScope.include(s)) return false;
|
|
471
|
+
return true;
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const minLabelA = validServersA.reduce(
|
|
475
|
+
(min, s) => {
|
|
476
|
+
const label = String(s.data("label") || s.id()).toLowerCase();
|
|
477
|
+
return naturalSortCompare(label, min) < 0 ? label : min;
|
|
478
|
+
},
|
|
479
|
+
String(
|
|
480
|
+
validServersA[0]?.data("label") || validServersA[0]?.id() || "",
|
|
481
|
+
).toLowerCase(),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const minLabelB = validServersB.reduce(
|
|
485
|
+
(min, s) => {
|
|
486
|
+
const label = String(s.data("label") || s.id()).toLowerCase();
|
|
487
|
+
return naturalSortCompare(label, min) < 0 ? label : min;
|
|
488
|
+
},
|
|
489
|
+
String(
|
|
490
|
+
validServersB[0]?.data("label") || validServersB[0]?.id() || "",
|
|
491
|
+
).toLowerCase(),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
return naturalSortCompare(minLabelA, minLabelB);
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
const firstSu = suGroups.values().next().value;
|
|
499
|
+
const horizontalSpacing =
|
|
500
|
+
(firstSu?.maxNodeSize || 40) * horizontalSpacingFactor;
|
|
501
|
+
let maxSuWidth = 0;
|
|
502
|
+
suList.forEach(([, su]) => {
|
|
503
|
+
const suNodeCount = Math.max(
|
|
504
|
+
su.leaves.length,
|
|
505
|
+
Math.ceil(su.servers.length / devicesPerRow),
|
|
506
|
+
);
|
|
507
|
+
const width =
|
|
508
|
+
suNodeCount * su.maxNodeSize + (suNodeCount - 1) * horizontalSpacing;
|
|
509
|
+
maxSuWidth = Math.max(maxSuWidth, width);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const suSpacing = horizontalSpacing * suSpacingFactor;
|
|
513
|
+
const spineToLeafSpacing = maxSpineSize * spineToLeafSpacingFactor;
|
|
514
|
+
|
|
515
|
+
const positions: any = {};
|
|
516
|
+
const suPositions: Array<{ left: number; right: number }> = [];
|
|
517
|
+
|
|
518
|
+
let currentX = 0;
|
|
519
|
+
|
|
520
|
+
suList.forEach(([, su]) => {
|
|
521
|
+
const verticalSpacing = su.maxNodeSize * suVerticalSpacingFactor;
|
|
522
|
+
|
|
523
|
+
const suLeftX = currentX;
|
|
524
|
+
const leafContentWidth =
|
|
525
|
+
su.leaves.length * su.maxNodeSize +
|
|
526
|
+
(su.leaves.length - 1) * horizontalSpacing;
|
|
527
|
+
const serverContentWidth =
|
|
528
|
+
Math.min(su.servers.length, devicesPerRow) * su.maxNodeSize +
|
|
529
|
+
(Math.min(su.servers.length, devicesPerRow) - 1) * horizontalSpacing;
|
|
530
|
+
|
|
531
|
+
const contentLeft = suLeftX;
|
|
532
|
+
const contentRight =
|
|
533
|
+
suLeftX + Math.max(leafContentWidth, serverContentWidth);
|
|
534
|
+
const contentWidth = contentRight - contentLeft;
|
|
535
|
+
|
|
536
|
+
const leafY = spineToLeafSpacing;
|
|
537
|
+
const leafStartX =
|
|
538
|
+
contentLeft + (contentWidth - leafContentWidth) / 2 + su.maxNodeSize / 2;
|
|
539
|
+
su.leaves.forEach((l, i) => {
|
|
540
|
+
const leafX = leafStartX + i * (su.maxNodeSize + horizontalSpacing);
|
|
541
|
+
positions[l.id()] = { x: leafX * scaleFactor, y: leafY * scaleFactor };
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
su.servers.forEach((s, i) => {
|
|
545
|
+
const row = Math.floor(i / devicesPerRow);
|
|
546
|
+
const col = i % devicesPerRow;
|
|
547
|
+
const serverX =
|
|
548
|
+
contentLeft +
|
|
549
|
+
(contentWidth - serverContentWidth) / 2 +
|
|
550
|
+
col * (su.maxNodeSize + horizontalSpacing) +
|
|
551
|
+
su.maxNodeSize / 2;
|
|
552
|
+
const serverY =
|
|
553
|
+
spineToLeafSpacing +
|
|
554
|
+
su.maxNodeSize +
|
|
555
|
+
verticalSpacing +
|
|
556
|
+
row * (su.maxNodeSize + verticalSpacing);
|
|
557
|
+
positions[s.id()] = { x: serverX * scaleFactor, y: serverY * scaleFactor };
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
suPositions.push({ left: contentLeft, right: contentLeft + contentWidth });
|
|
561
|
+
currentX += contentWidth + suSpacing;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (sortedSpine.length > 0 && suPositions.length > 0) {
|
|
565
|
+
const firstSu = suPositions[0]!;
|
|
566
|
+
const lastSu = suPositions[suPositions.length - 1]!;
|
|
567
|
+
const totalSuWidth = lastSu.right - firstSu.left;
|
|
568
|
+
const spineAreaWidth = totalSuWidth * spineCoverageFactor;
|
|
569
|
+
const startX = firstSu.left + (totalSuWidth - spineAreaWidth) / 2;
|
|
570
|
+
|
|
571
|
+
if (sortedSpine.length > 1) {
|
|
572
|
+
const spacing = spineAreaWidth / (sortedSpine.length - 1);
|
|
573
|
+
sortedSpine.forEach((sp, i) => {
|
|
574
|
+
const spineX = startX + i * spacing;
|
|
575
|
+
positions[sp.id()] = { x: spineX * scaleFactor, y: 0 };
|
|
576
|
+
});
|
|
577
|
+
} else if (sortedSpine[0]) {
|
|
578
|
+
const spineX = startX + spineAreaWidth / 2;
|
|
579
|
+
positions[sortedSpine[0].id()] = { x: spineX * scaleFactor, y: 0 };
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
nodes.layoutPositions(this, this.options, (node: any) => {
|
|
584
|
+
return positions[node.id()] || { x: 0, y: 0 };
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
export function register(cytoscape: any) {
|
|
589
|
+
cytoscape("layout", "clos", CyLayout);
|
|
590
|
+
}
|