- Go backend (server/)
- Frontend (web/, server/static/)
- Database and deployment files
- Scripts and docs
Co-Authored-By: 狸花猫/Claude-Qwen3.6-Plus 🐾
370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
/**
|
||
* @fileOverview Combo force layout
|
||
* @author shiwu.wyy@antfin.com
|
||
*/
|
||
import { FORCE_LAYOUT_TYPE_MAP } from './constants';
|
||
import { Base } from './base';
|
||
import { isArray, isNumber, isFunction, traverseTreeUp, isObject, getLayoutBBox, } from '../util';
|
||
import { ConcentricLayout, GridLayout, GForceLayout, MDSLayout, } from '.';
|
||
/**
|
||
* combined two layouts (inner and outer) for graph with combos
|
||
*/
|
||
export class ComboCombinedLayout extends Base {
|
||
constructor(options) {
|
||
super();
|
||
/** 布局中心 */
|
||
this.center = [0, 0];
|
||
/** 内部计算参数 */
|
||
this.nodes = [];
|
||
this.edges = [];
|
||
this.combos = [];
|
||
this.comboEdges = [];
|
||
/** Combo 内部的 padding */
|
||
this.comboPadding = 10;
|
||
this.comboTrees = [];
|
||
this.updateCfg(options);
|
||
}
|
||
getDefaultCfg() {
|
||
return {};
|
||
}
|
||
/**
|
||
* 执行布局
|
||
*/
|
||
execute() {
|
||
const self = this;
|
||
const nodes = self.nodes;
|
||
const center = self.center;
|
||
if (!nodes || nodes.length === 0) {
|
||
if (self.onLayoutEnd)
|
||
self.onLayoutEnd();
|
||
return;
|
||
}
|
||
if (nodes.length === 1) {
|
||
nodes[0].x = center[0];
|
||
nodes[0].y = center[1];
|
||
if (self.onLayoutEnd)
|
||
self.onLayoutEnd();
|
||
return;
|
||
}
|
||
self.initVals();
|
||
// layout
|
||
self.run();
|
||
if (self.onLayoutEnd)
|
||
self.onLayoutEnd();
|
||
}
|
||
run() {
|
||
var _a;
|
||
const self = this;
|
||
const { nodes, edges, combos, comboEdges, center } = self;
|
||
const nodeMap = {};
|
||
nodes.forEach((node) => {
|
||
nodeMap[node.id] = node;
|
||
});
|
||
const comboMap = {};
|
||
combos.forEach((combo) => {
|
||
comboMap[combo.id] = combo;
|
||
});
|
||
const innerGraphs = self.getInnerGraphs(nodeMap);
|
||
// 每个 innerGraph 作为一个节点,带有大小,参与 force 计算
|
||
const outerNodeIds = [];
|
||
const outerNodes = [];
|
||
const nodeAncestorIdMap = {};
|
||
let allHaveNoPosition = true;
|
||
this.comboTrees.forEach((cTree) => {
|
||
const innerNode = innerGraphs[cTree.id];
|
||
if (!innerNode) {
|
||
return;
|
||
}
|
||
// 代表 combo 的节点
|
||
const oNode = Object.assign(Object.assign({}, cTree), { x: innerNode.x || comboMap[cTree.id].x, y: innerNode.y || comboMap[cTree.id].y, fx: innerNode.fx || comboMap[cTree.id].fx, fy: innerNode.fy || comboMap[cTree.id].fy, mass: innerNode.mass || comboMap[cTree.id].mass, size: innerNode.size });
|
||
outerNodes.push(oNode);
|
||
if (!isNaN(oNode.x) &&
|
||
oNode.x !== 0 &&
|
||
!isNaN(oNode.y) &&
|
||
oNode.y !== 0) {
|
||
allHaveNoPosition = false;
|
||
}
|
||
else {
|
||
oNode.x = Math.random() * 100;
|
||
oNode.y = Math.random() * 100;
|
||
}
|
||
outerNodeIds.push(cTree.id);
|
||
traverseTreeUp(cTree, (child) => {
|
||
if (child.id !== cTree.id)
|
||
nodeAncestorIdMap[child.id] = cTree.id;
|
||
return true;
|
||
});
|
||
});
|
||
nodes.forEach((node) => {
|
||
if (node.comboId && comboMap[node.comboId])
|
||
return;
|
||
// 代表节点的节点
|
||
const oNode = Object.assign({}, node);
|
||
outerNodes.push(oNode);
|
||
if (!isNaN(oNode.x) &&
|
||
oNode.x !== 0 &&
|
||
!isNaN(oNode.y) &&
|
||
oNode.y !== 0) {
|
||
allHaveNoPosition = false;
|
||
}
|
||
else {
|
||
oNode.x = Math.random() * 100;
|
||
oNode.y = Math.random() * 100;
|
||
}
|
||
outerNodeIds.push(node.id);
|
||
});
|
||
const outerEdges = [];
|
||
edges.concat(comboEdges).forEach((edge) => {
|
||
const sourceAncestorId = nodeAncestorIdMap[edge.source] || edge.source;
|
||
const targetAncestorId = nodeAncestorIdMap[edge.target] || edge.target;
|
||
// 若两个点的祖先都在力导图的节点中,且是不同的节点,创建一条链接两个祖先的边到力导图的边中
|
||
if (sourceAncestorId !== targetAncestorId &&
|
||
outerNodeIds.includes(sourceAncestorId) &&
|
||
outerNodeIds.includes(targetAncestorId)) {
|
||
outerEdges.push({
|
||
source: sourceAncestorId,
|
||
target: targetAncestorId,
|
||
});
|
||
}
|
||
});
|
||
// 若有需要最外层的 combo 或节点,则对最外层执行力导向
|
||
if (outerNodes === null || outerNodes === void 0 ? void 0 : outerNodes.length) {
|
||
if (outerNodes.length === 1) {
|
||
outerNodes[0].x = center[0];
|
||
outerNodes[0].y = center[1];
|
||
}
|
||
else {
|
||
const outerData = {
|
||
nodes: outerNodes,
|
||
edges: outerEdges,
|
||
};
|
||
// 需要使用一个同步的布局
|
||
// @ts-ignore
|
||
const outerLayout = this.outerLayout ||
|
||
new GForceLayout({
|
||
gravity: 1,
|
||
factor: 4,
|
||
linkDistance: (edge, source, target) => {
|
||
var _a, _b;
|
||
const nodeSize = ((((_a = source.size) === null || _a === void 0 ? void 0 : _a[0]) || 30) + (((_b = target.size) === null || _b === void 0 ? void 0 : _b[0]) || 30)) / 2;
|
||
return Math.min(nodeSize * 1.5, 700);
|
||
},
|
||
});
|
||
const outerLayoutType = (_a = outerLayout.getType) === null || _a === void 0 ? void 0 : _a.call(outerLayout);
|
||
outerLayout.updateCfg({
|
||
center,
|
||
kg: 5,
|
||
preventOverlap: true,
|
||
animate: false,
|
||
});
|
||
// 若所有 outerNodes 都没有位置,且 outerLayout 是力导家族的布局,则先执行 preset mds 或 grid
|
||
if (allHaveNoPosition && FORCE_LAYOUT_TYPE_MAP[outerLayoutType]) {
|
||
const outerLayoutPreset = outerNodes.length < 100 ? new MDSLayout() : new GridLayout();
|
||
outerLayoutPreset.layout(outerData);
|
||
}
|
||
outerLayout.layout(outerData);
|
||
}
|
||
// 根据外部布局结果,平移 innerGraphs 中的节点(第一层)
|
||
outerNodes.forEach((outerNode) => {
|
||
const innerGraph = innerGraphs[outerNode.id];
|
||
if (!innerGraph) {
|
||
const node = nodeMap[outerNode.id];
|
||
if (node) {
|
||
node.x = outerNode.x;
|
||
node.y = outerNode.y;
|
||
}
|
||
return;
|
||
}
|
||
innerGraph.visited = true;
|
||
innerGraph.x = outerNode.x;
|
||
innerGraph.y = outerNode.y;
|
||
innerGraph.nodes.forEach((node) => {
|
||
node.x += outerNode.x;
|
||
node.y += outerNode.y;
|
||
});
|
||
});
|
||
}
|
||
// 至上而下遍历树处理下面各层节点位置
|
||
const innerGraphIds = Object.keys(innerGraphs);
|
||
for (let i = innerGraphIds.length - 1; i >= 0; i--) {
|
||
const id = innerGraphIds[i];
|
||
const innerGraph = innerGraphs[id];
|
||
if (!innerGraph)
|
||
continue;
|
||
innerGraph.nodes.forEach((node) => {
|
||
if (!innerGraph.visited) {
|
||
node.x += innerGraph.x || 0;
|
||
node.y += innerGraph.y || 0;
|
||
}
|
||
if (nodeMap[node.id]) {
|
||
nodeMap[node.id].x = node.x;
|
||
nodeMap[node.id].y = node.y;
|
||
}
|
||
});
|
||
if (comboMap[id]) {
|
||
comboMap[id].x = innerGraph.x;
|
||
comboMap[id].y = innerGraph.y;
|
||
}
|
||
}
|
||
return { nodes, edges, combos, comboEdges };
|
||
}
|
||
getInnerGraphs(nodeMap) {
|
||
const self = this;
|
||
const { comboTrees, nodeSize, edges, comboPadding, spacing } = self;
|
||
const innerGraphs = {};
|
||
// @ts-ignore
|
||
const innerGraphLayout = this.innerLayout ||
|
||
new ConcentricLayout({ type: 'concentric', sortBy: 'id' });
|
||
innerGraphLayout.center = [0, 0];
|
||
innerGraphLayout.preventOverlap = true;
|
||
innerGraphLayout.nodeSpacing = spacing;
|
||
(comboTrees || []).forEach((ctree) => {
|
||
traverseTreeUp(ctree, (treeNode) => {
|
||
var _a;
|
||
// @ts-ignore
|
||
let padding = (comboPadding === null || comboPadding === void 0 ? void 0 : comboPadding(treeNode)) || 10; // 返回的最大值
|
||
if (isArray(padding))
|
||
padding = Math.max(...padding);
|
||
if (!((_a = treeNode.children) === null || _a === void 0 ? void 0 : _a.length)) {
|
||
// 空 combo
|
||
if (treeNode.itemType === 'combo') {
|
||
const treeNodeSize = padding
|
||
? [padding * 2, padding * 2]
|
||
: [30, 30];
|
||
innerGraphs[treeNode.id] = {
|
||
id: treeNode.id,
|
||
nodes: [],
|
||
size: treeNodeSize,
|
||
};
|
||
}
|
||
}
|
||
else {
|
||
// 非空 combo
|
||
const innerGraphNodes = treeNode.children.map((child) => {
|
||
if (child.itemType === 'combo')
|
||
return innerGraphs[child.id];
|
||
const oriNode = nodeMap[child.id] || {};
|
||
return Object.assign(Object.assign({}, oriNode), child);
|
||
});
|
||
const innerGraphNodeIds = innerGraphNodes.map((node) => node.id);
|
||
const innerGraphData = {
|
||
nodes: innerGraphNodes,
|
||
edges: edges.filter((edge) => innerGraphNodeIds.includes(edge.source) &&
|
||
innerGraphNodeIds.includes(edge.target)),
|
||
};
|
||
let minNodeSize = Infinity;
|
||
innerGraphNodes.forEach((node) => {
|
||
var _a;
|
||
// @ts-ignore
|
||
if (!node.size)
|
||
node.size = ((_a = innerGraphs[node.id]) === null || _a === void 0 ? void 0 : _a.size) ||
|
||
(nodeSize === null || nodeSize === void 0 ? void 0 : nodeSize(node)) || [30, 30];
|
||
if (isNumber(node.size))
|
||
node.size = [node.size, node.size];
|
||
if (minNodeSize > node.size[0])
|
||
minNodeSize = node.size[0];
|
||
if (minNodeSize > node.size[1])
|
||
minNodeSize = node.size[1];
|
||
});
|
||
// 根据节点数量、spacing,调整布局参数
|
||
innerGraphLayout.layout(innerGraphData);
|
||
const { minX, minY, maxX, maxY } = getLayoutBBox(innerGraphNodes);
|
||
// move the innerGraph to [0, 0],for later controled by parent layout
|
||
const center = { x: (maxX + minX) / 2, y: (maxY + minY) / 2 };
|
||
innerGraphData.nodes.forEach((node) => {
|
||
node.x -= center.x;
|
||
node.y -= center.y;
|
||
});
|
||
const innerGraphWidth = Math.max(maxX - minX, minNodeSize) + padding * 2;
|
||
const innerGraphHeight = Math.max(maxY - minY, minNodeSize) + padding * 2;
|
||
innerGraphs[treeNode.id] = {
|
||
id: treeNode.id,
|
||
nodes: innerGraphNodes,
|
||
size: [innerGraphWidth, innerGraphHeight],
|
||
};
|
||
}
|
||
return true;
|
||
});
|
||
});
|
||
return innerGraphs;
|
||
}
|
||
initVals() {
|
||
const self = this;
|
||
const nodeSize = self.nodeSize;
|
||
const spacing = self.spacing;
|
||
let nodeSizeFunc;
|
||
let spacingFunc;
|
||
// nodeSpacing to function
|
||
if (isNumber(spacing)) {
|
||
spacingFunc = () => spacing;
|
||
}
|
||
else if (isFunction(spacing)) {
|
||
spacingFunc = spacing;
|
||
}
|
||
else {
|
||
spacingFunc = () => 0;
|
||
}
|
||
this.spacing = spacingFunc;
|
||
// nodeSize to function
|
||
if (!nodeSize) {
|
||
nodeSizeFunc = (d) => {
|
||
const spacing = spacingFunc(d);
|
||
if (d.size) {
|
||
if (isArray(d.size)) {
|
||
const res = d.size[0] > d.size[1] ? d.size[0] : d.size[1];
|
||
return (res + spacing) / 2;
|
||
}
|
||
if (isObject(d.size)) {
|
||
const res = d.size.width > d.size.height ? d.size.width : d.size.height;
|
||
return (res + spacing) / 2;
|
||
}
|
||
return (d.size + spacing) / 2;
|
||
}
|
||
return 10 + spacing / 2;
|
||
};
|
||
}
|
||
else if (isFunction(nodeSize)) {
|
||
nodeSizeFunc = (d) => {
|
||
const size = nodeSize(d);
|
||
const spacing = spacingFunc(d);
|
||
if (isArray(d.size)) {
|
||
const res = d.size[0] > d.size[1] ? d.size[0] : d.size[1];
|
||
return (res + spacing) / 2;
|
||
}
|
||
return ((size || 10) + spacing) / 2;
|
||
};
|
||
}
|
||
else if (isArray(nodeSize)) {
|
||
const larger = nodeSize[0] > nodeSize[1] ? nodeSize[0] : nodeSize[1];
|
||
const radius = larger / 2;
|
||
nodeSizeFunc = (d) => radius + spacingFunc(d) / 2;
|
||
}
|
||
else {
|
||
// number type
|
||
const radius = nodeSize / 2;
|
||
nodeSizeFunc = (d) => radius + spacingFunc(d) / 2;
|
||
}
|
||
this.nodeSize = nodeSizeFunc;
|
||
// comboPadding to function
|
||
const comboPadding = self.comboPadding;
|
||
let comboPaddingFunc;
|
||
if (isNumber(comboPadding)) {
|
||
comboPaddingFunc = () => comboPadding;
|
||
}
|
||
else if (isArray(comboPadding)) {
|
||
comboPaddingFunc = () => Math.max.apply(null, comboPadding);
|
||
}
|
||
else if (isFunction(comboPadding)) {
|
||
comboPaddingFunc = comboPadding;
|
||
}
|
||
else {
|
||
// null type
|
||
comboPaddingFunc = () => 0;
|
||
}
|
||
this.comboPadding = comboPaddingFunc;
|
||
}
|
||
getType() {
|
||
return 'comboCombined';
|
||
}
|
||
}
|
||
//# sourceMappingURL=comboCombined.js.map
|