1126 lines
35 KiB
TypeScript
1126 lines
35 KiB
TypeScript
import type { Edge, Node, NodeConfig } from "@antv/x6";
|
||
import { Graph } from "@antv/x6";
|
||
import { v4 as uuidv4 } from "uuid";
|
||
|
||
// 层级类型定义
|
||
export type NodeLevel = "level2" | "level3" | "level4";
|
||
|
||
// 目标灌库数据的类型定义
|
||
interface ImportDataItem {
|
||
userName: string; // 对应 level2 节点的 label
|
||
tables: Array<{
|
||
tableName: string; // 对应 level3 节点的 label
|
||
columnName: string; // 对应 level4 节点的 label
|
||
}>;
|
||
}
|
||
|
||
// 子节点配置接口
|
||
interface ChildNodeConfig {
|
||
id: string;
|
||
label: string;
|
||
level: NodeLevel;
|
||
x?: number;
|
||
businessId?: string;
|
||
}
|
||
|
||
// 根节点配置接口
|
||
interface RootNodeConfig {
|
||
id: string;
|
||
label: string;
|
||
businessId?: string;
|
||
x?: number;
|
||
}
|
||
|
||
// 普通节点配置(内部使用)
|
||
interface SimpleNodeConfig {
|
||
id: string;
|
||
x: number;
|
||
y: number;
|
||
label: string;
|
||
level?: NodeLevel;
|
||
businessId?: string;
|
||
}
|
||
|
||
export class X6Graph {
|
||
private graph: Graph | null = null;
|
||
private containerId: string;
|
||
private menuElement: HTMLDivElement | null = null; // 右键菜单
|
||
private readonly nodePadding = 20;
|
||
// 节点样式(原生rect节点,确保可见)
|
||
private readonly nodeStyle = {
|
||
width: 100,
|
||
height: 40,
|
||
fill: "#0a4a5c", // 深色背景(示例中的深青色)
|
||
stroke: "#0a4a5c", // 边框与背景同色,视觉上隐藏边框
|
||
strokeWidth: 2,
|
||
radius: 4,
|
||
label: {
|
||
fill: "#93CDD5", // 白色文字
|
||
fontSize: 14,
|
||
fontWeight: "bold", // 可选:文字加粗
|
||
},
|
||
};
|
||
|
||
// 固定尺寸
|
||
private readonly nodeSize = {
|
||
width: 200,
|
||
height: 40,
|
||
};
|
||
|
||
// 层级X坐标映射
|
||
private readonly levelXMap = new Map<NodeLevel, number>([
|
||
["level2", 300],
|
||
["level3", 550],
|
||
["level4", 1000],
|
||
]);
|
||
|
||
// 层级节点数量记录
|
||
private readonly levelCountMap = new Map<NodeLevel, number>([
|
||
["level2", 0],
|
||
["level3", 0],
|
||
["level4", 0],
|
||
]);
|
||
|
||
constructor(containerId: string) {
|
||
this.containerId = containerId;
|
||
this.createMenuElement(); // 初始化右键菜单
|
||
}
|
||
|
||
private createMenuElement() {
|
||
this.menuElement = document.getElementById("ant-x6-menu4");
|
||
if (this.menuElement) {
|
||
this.menuElement.remove();
|
||
}
|
||
this.menuElement = document.createElement("div");
|
||
this.menuElement.id = "ant-x6-menu4";
|
||
this.menuElement.style.cssText = `
|
||
position: absolute;
|
||
width: 120px;
|
||
background: white;
|
||
border: 1px solid #ccc;
|
||
border-radius: 4px;
|
||
padding: 5px 0;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
z-index: 1000;
|
||
display: none;
|
||
font-family: sans-serif;
|
||
`;
|
||
|
||
// 3. 配置
|
||
let pzItem = document.getElementById("ant-x6-menu");
|
||
if (pzItem) {
|
||
pzItem.remove();
|
||
}
|
||
pzItem = document.createElement("div");
|
||
pzItem.id = "ant-x6-menu";
|
||
pzItem.style.cssText = `
|
||
padding: 6px 12px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
border-bottom: 1px solid #f0f0f0; /* 分隔线,区分两个选项 */
|
||
`;
|
||
pzItem.textContent = "配置";
|
||
pzItem.addEventListener("click", () => {
|
||
const nodeId = this.menuElement?.getAttribute("data-node-id");
|
||
if (nodeId) {
|
||
const graph = this.getGraph();
|
||
const targetNode = graph.getCellById(nodeId) as Node;
|
||
const nodeData = targetNode.getData();
|
||
const nodeLevel = nodeData.level;
|
||
|
||
// 2. 高亮样式(保持原有)
|
||
targetNode.attr({
|
||
body: {
|
||
stroke: "rgb(12,129,123)",
|
||
strokeWidth: 2,
|
||
strokeDasharray: "5, 5",
|
||
},
|
||
});
|
||
|
||
// ======================== 新增:获取所有父级信息 ========================
|
||
// 1. 定义追溯父级的方法
|
||
const getAllParents = (
|
||
targetNodeId: string,
|
||
): Array<{ id: string; label: string; level?: NodeLevel }> => {
|
||
const parents: Array<{
|
||
id: string;
|
||
label: string;
|
||
level?: NodeLevel;
|
||
}> = [];
|
||
let currentNodeId = targetNodeId;
|
||
|
||
// 循环追溯:直到找不到父节点(根节点)
|
||
while (true) {
|
||
// 找到指向当前节点的连线(来源即父节点)
|
||
const parentEdge = graph
|
||
.getEdges()
|
||
.find((edge) => edge.getTargetCellId() === currentNodeId);
|
||
if (!parentEdge) break; // 无父节点,终止循环
|
||
|
||
// 获取父节点ID和父节点实例
|
||
const parentNodeId = parentEdge.getSourceCellId();
|
||
const parentNode = graph.getCellById(parentNodeId) as Node;
|
||
if (!parentNode) break;
|
||
|
||
// 收集父节点信息(ID、标签、层级)
|
||
parents.push({
|
||
id: parentNodeId,
|
||
label: parentNode.attr("label/text") || "", // 节点显示的文字
|
||
level: parentNode.getData().level, // 父节点的层级
|
||
});
|
||
|
||
// 下一轮追溯:以父节点为当前节点,找它的父节点
|
||
currentNodeId = parentNodeId;
|
||
}
|
||
|
||
return parents; // 返回所有父级(顺序:直接父级 → 祖父级 → ... → 根节点父级)
|
||
};
|
||
|
||
// 2. 执行追溯,获取所有父级
|
||
const allParentNodes = getAllParents(nodeId);
|
||
// 反转数组,让顺序变为“根节点父级 → 祖父级 → 直接父级”
|
||
let allParentsOrdered = allParentNodes.reverse();
|
||
|
||
// 关键:添加当前节点信息(含label),拼接成“父级+当前节点”的完整链
|
||
const currentNodeInfo = {
|
||
id: nodeId,
|
||
label: targetNode.attr("label/text") || "", // 从当前节点获取label
|
||
level: nodeLevel,
|
||
};
|
||
allParentsOrdered = [...allParentsOrdered, currentNodeInfo];
|
||
|
||
console.log("目标节点(父级+自身)完整链:", allParentsOrdered);
|
||
// ========================================================================
|
||
|
||
// 3. 事件总线传递:携带完整链信息
|
||
EventBusTool.getEventBus().publish("antvX6Event", {
|
||
key: "configNode",
|
||
id: nodeId,
|
||
label: targetNode.attr("label/text") || "",
|
||
level: nodeLevel,
|
||
allParents: allParentsOrdered, // 包含父级+当前节点(均有label)
|
||
});
|
||
}
|
||
this.hideMenu();
|
||
});
|
||
// 配置菜单悬停效果
|
||
pzItem.addEventListener("mouseenter", () => {
|
||
pzItem.style.background = "#f0f0f0";
|
||
});
|
||
pzItem.addEventListener("mouseleave", () => {
|
||
pzItem.style.background = "white";
|
||
});
|
||
|
||
// 1. 配置同步字段:新增“配置”菜单项
|
||
let configItem = document.getElementById("ant-x6-menu-1");
|
||
if (configItem) {
|
||
configItem.remove();
|
||
}
|
||
configItem = document.createElement("div");
|
||
configItem.id = "ant-x6-menu-1";
|
||
configItem.style.cssText = `
|
||
padding: 6px 12px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
border-bottom: 1px solid #f0f0f0; /* 分隔线,区分两个选项 */
|
||
`;
|
||
configItem.textContent = "配置同步字段";
|
||
configItem.addEventListener("click", () => {
|
||
const nodeId = this.menuElement?.getAttribute("data-node-id");
|
||
if (nodeId) {
|
||
const graph = this.getGraph();
|
||
const targetNode = graph.getCellById(nodeId) as Node;
|
||
const nodeData = targetNode.getData();
|
||
const nodeLevel = nodeData.level;
|
||
|
||
// 2. 高亮样式(保持原有)
|
||
targetNode.attr({
|
||
body: {
|
||
stroke: "rgb(12,129,123)",
|
||
strokeWidth: 2,
|
||
strokeDasharray: "5, 5",
|
||
},
|
||
});
|
||
|
||
// ======================== 新增:获取所有父级信息 ========================
|
||
// 1. 定义追溯父级的方法
|
||
const getAllParents = (
|
||
targetNodeId: string,
|
||
): Array<{ id: string; label: string; level?: NodeLevel }> => {
|
||
const parents: Array<{
|
||
id: string;
|
||
label: string;
|
||
level?: NodeLevel;
|
||
}> = [];
|
||
let currentNodeId = targetNodeId;
|
||
|
||
// 循环追溯:直到找不到父节点(根节点)
|
||
while (true) {
|
||
// 找到指向当前节点的连线(来源即父节点)
|
||
const parentEdge = graph
|
||
.getEdges()
|
||
.find((edge) => edge.getTargetCellId() === currentNodeId);
|
||
if (!parentEdge) break; // 无父节点,终止循环
|
||
|
||
// 获取父节点ID和父节点实例
|
||
const parentNodeId = parentEdge.getSourceCellId();
|
||
const parentNode = graph.getCellById(parentNodeId) as Node;
|
||
if (!parentNode) break;
|
||
|
||
// 收集父节点信息(ID、标签、层级)
|
||
parents.push({
|
||
id: parentNodeId,
|
||
label: parentNode.attr("label/text") || "", // 节点显示的文字
|
||
level: parentNode.getData().level, // 父节点的层级
|
||
});
|
||
|
||
// 下一轮追溯:以父节点为当前节点,找它的父节点
|
||
currentNodeId = parentNodeId;
|
||
}
|
||
|
||
return parents; // 返回所有父级(顺序:直接父级 → 祖父级 → ... → 根节点父级)
|
||
};
|
||
|
||
// 2. 执行追溯,获取所有父级
|
||
const allParentNodes = getAllParents(nodeId);
|
||
// 反转数组,让顺序变为“根节点父级 → 祖父级 → 直接父级”
|
||
let allParentsOrdered = allParentNodes.reverse();
|
||
|
||
// 关键:添加当前节点信息(含label),拼接成“父级+当前节点”的完整链
|
||
const currentNodeInfo = {
|
||
id: nodeId,
|
||
label: targetNode.attr("label/text") || "", // 从当前节点获取label
|
||
level: nodeLevel,
|
||
};
|
||
allParentsOrdered = [...allParentsOrdered, currentNodeInfo];
|
||
|
||
console.log("目标节点(父级+自身)完整链:", allParentsOrdered);
|
||
// ========================================================================
|
||
|
||
// 3. 事件总线传递:携带完整链信息
|
||
EventBusTool.getEventBus().publish("antvX6Event", {
|
||
key: "configNode",
|
||
id: nodeId,
|
||
label: targetNode.attr("label/text") || "",
|
||
level: nodeLevel,
|
||
allParents: allParentsOrdered, // 包含父级+当前节点(均有label)
|
||
isTbsl: false,
|
||
});
|
||
}
|
||
this.hideMenu();
|
||
});
|
||
// 配置项悬停效果
|
||
configItem.addEventListener("mouseenter", () => {
|
||
configItem.style.background = "#f0f0f0";
|
||
});
|
||
configItem.addEventListener("mouseleave", () => {
|
||
configItem.style.background = "white";
|
||
});
|
||
|
||
// 2. 配置同步数量
|
||
let syncNumItem = document.getElementById("ant-x6-menu-2");
|
||
if (syncNumItem) {
|
||
syncNumItem.remove();
|
||
}
|
||
syncNumItem = document.createElement("div");
|
||
syncNumItem.id = "ant-x6-menu-2";
|
||
syncNumItem.style.cssText = `
|
||
padding: 6px 12px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
border-bottom: 1px solid #f0f0f0; /* 分隔线,区分两个选项 */
|
||
`;
|
||
syncNumItem.textContent = "配置同步数量";
|
||
syncNumItem.addEventListener("click", () => {
|
||
const nodeId = this.menuElement?.getAttribute("data-node-id");
|
||
if (nodeId) {
|
||
const graph = this.getGraph();
|
||
const targetNode = graph.getCellById(nodeId) as Node;
|
||
const nodeLevel = targetNode.getData().level;
|
||
|
||
// TODO:配置同步数量逻辑
|
||
|
||
// 3. 事件总线传递:携带完整链信息
|
||
EventBusTool.getEventBus().publish("antvX6Event", {
|
||
key: "configNode",
|
||
id: nodeId,
|
||
label: targetNode.attr("label/text") || "",
|
||
level: nodeLevel,
|
||
isTbsl: true,
|
||
});
|
||
}
|
||
this.hideMenu();
|
||
});
|
||
// 配置同步数量菜单悬停效果
|
||
syncNumItem.addEventListener("mouseenter", () => {
|
||
syncNumItem.style.background = "#f0f0f0";
|
||
});
|
||
syncNumItem.addEventListener("mouseleave", () => {
|
||
syncNumItem.style.background = "white";
|
||
});
|
||
|
||
// 3. 原有“删除”菜单项(保持不变,调整顺序在配置项下方)
|
||
let deleteItem = document.getElementById("ant-x6-menu-3");
|
||
if (deleteItem) {
|
||
deleteItem.remove();
|
||
}
|
||
deleteItem = document.createElement("div");
|
||
deleteItem.id = "ant-x6-menu-3";
|
||
deleteItem.style.cssText = `
|
||
padding: 6px 12px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
`;
|
||
deleteItem.textContent = "删除";
|
||
deleteItem.addEventListener("click", () => {
|
||
const nodeId = this.menuElement?.getAttribute("data-node-id");
|
||
if (nodeId) {
|
||
// 获取图形实例和目标节点(和配置项逻辑一致)
|
||
const graph = this.getGraph();
|
||
const targetNode = graph.getCellById(nodeId) as Node;
|
||
// 从节点数据中提取level
|
||
const nodeLevel = targetNode.getData().level;
|
||
this.removeNode(nodeId);
|
||
EventBusTool.getEventBus().publish("antvX6Event", {
|
||
key: "removeNode",
|
||
id: nodeId,
|
||
level: nodeLevel, // 新增:传递level信息
|
||
});
|
||
}
|
||
this.hideMenu();
|
||
});
|
||
// 删除项悬停效果
|
||
deleteItem.addEventListener("mouseenter", () => {
|
||
deleteItem.style.background = "#f0f0f0";
|
||
});
|
||
deleteItem.addEventListener("mouseleave", () => {
|
||
deleteItem.style.background = "white";
|
||
});
|
||
|
||
// 3. 将两个菜单项添加到菜单容器(配置项在上,删除项在下)
|
||
this.menuElement.appendChild(pzItem);
|
||
this.menuElement.appendChild(configItem);
|
||
this.menuElement.appendChild(syncNumItem);
|
||
this.menuElement.appendChild(deleteItem);
|
||
document.body.appendChild(this.menuElement);
|
||
}
|
||
|
||
/**
|
||
* 显示右键菜单
|
||
*/
|
||
private showMenu(nodeId: string, x: number, y: number) {
|
||
if (!this.menuElement) return;
|
||
this.menuElement.setAttribute("data-node-id", nodeId);
|
||
this.menuElement.style.left = `${x}px`;
|
||
this.menuElement.style.top = `${y}px`;
|
||
this.menuElement.style.display = "block";
|
||
}
|
||
|
||
/**
|
||
* 隐藏右键菜单
|
||
*/
|
||
private hideMenu() {
|
||
if (this.menuElement) {
|
||
this.menuElement.style.display = "none";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化画布(使用原生rect节点)
|
||
*/
|
||
init(options: Partial<{ grid: boolean; zoom: boolean }> = {}): this {
|
||
const container = document.getElementById(this.containerId);
|
||
if (!container) {
|
||
throw new Error(`画布容器 "${this.containerId}" 不存在`);
|
||
}
|
||
|
||
// 初始化画布
|
||
this.graph = new Graph({
|
||
container,
|
||
width: container.clientWidth,
|
||
height: container.clientHeight,
|
||
grid: options.grid ?? { size: 10, visible: false, type: "dot" },
|
||
mousewheel: options.zoom ?? {
|
||
enabled: true,
|
||
modifiers: "ctrl",
|
||
minScale: 0.5,
|
||
maxScale: 2,
|
||
},
|
||
panning: { enabled: true, modifier: "shift" },
|
||
snapline: true,
|
||
// 关键:完善选择配置,确保单击和框选生效
|
||
selecting: {
|
||
enabled: true, // 开启选择交互
|
||
},
|
||
});
|
||
|
||
// 注册“直接曲线虚线”自定义边
|
||
Graph.registerEdge(
|
||
"direct-curved-dashed-edge",
|
||
{
|
||
inherit: "edge",
|
||
// 连接器:使用 smooth(平滑曲线,无额外拐点)
|
||
connector: { name: "smooth" },
|
||
attrs: {
|
||
line: {
|
||
targetMarker: "", // 移除目标标记(可选)
|
||
stroke: "#A2B1C3", // 线条颜色
|
||
strokeWidth: 2, // 线条宽度
|
||
strokeDasharray: "5, 5", // 虚线:线段5px + 间隔5px
|
||
},
|
||
},
|
||
zIndex: 0, // 层级(避免遮挡节点)
|
||
},
|
||
true,
|
||
);
|
||
|
||
// 绑定右键菜单事件
|
||
this.bindRightClickEvent();
|
||
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 绑定右键点击事件
|
||
*/
|
||
private bindRightClickEvent() {
|
||
const graph = this.getGraph();
|
||
|
||
// 节点右键点击
|
||
graph.on("node:contextmenu", (args) => {
|
||
args.e.preventDefault(); // 阻止浏览器默认右键菜单
|
||
const { cell, e } = args;
|
||
const node = cell as Node;
|
||
|
||
const nodeData = node.getData(); // 获取节点数据
|
||
|
||
console.log(nodeData);
|
||
|
||
// 关键判断:如果节点无 level(根节点),直接返回不显示菜单
|
||
if (!nodeData.level) {
|
||
return;
|
||
}
|
||
|
||
if (nodeData.level === "level2") {
|
||
const item2 = document.getElementById("ant-x6-menu");
|
||
item2.style.display = "block";
|
||
const item = document.getElementById("ant-x6-menu-1");
|
||
item.style.display = "none";
|
||
const item1 = document.getElementById("ant-x6-menu-2");
|
||
item1.style.display = "none";
|
||
} else if (nodeData.level === "level3") {
|
||
const item = document.getElementById("ant-x6-menu-1");
|
||
item.style.display = "block";
|
||
const item1 = document.getElementById("ant-x6-menu-2");
|
||
item1.style.display = "block";
|
||
const item2 = document.getElementById("ant-x6-menu");
|
||
item2.style.display = "none";
|
||
} else if (nodeData.level === "level4") {
|
||
const item = document.getElementById("ant-x6-menu-1");
|
||
item.style.display = "none";
|
||
const item1 = document.getElementById("ant-x6-menu-2");
|
||
item1.style.display = "none";
|
||
const item2 = document.getElementById("ant-x6-menu");
|
||
item2.style.display = "none";
|
||
}
|
||
// 显示菜单(位置为点击坐标)
|
||
this.showMenu(node.id, e.clientX, e.clientY);
|
||
});
|
||
|
||
// 画布空白处右键点击(隐藏菜单)
|
||
graph.on("blank:contextmenu", (args) => {
|
||
args.e.preventDefault();
|
||
this.hideMenu();
|
||
});
|
||
|
||
// 点击其他区域隐藏菜单
|
||
document.addEventListener("click", () => {
|
||
this.hideMenu();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 计算文字宽度(用于自适应节点宽度)
|
||
*/
|
||
private calculateTextWidth(text: string, fontSize = 14): number {
|
||
// 创建临时元素计算文字宽度
|
||
const tempSpan = document.createElement("span");
|
||
tempSpan.style.cssText = `
|
||
position: absolute;
|
||
visibility: hidden;
|
||
white-space: nowrap;
|
||
font-size: ${fontSize}px;
|
||
font-weight: bold;
|
||
font-family: sans-serif;
|
||
`;
|
||
tempSpan.textContent = text;
|
||
document.body.appendChild(tempSpan);
|
||
const width = tempSpan.offsetWidth;
|
||
document.body.removeChild(tempSpan);
|
||
return width;
|
||
}
|
||
|
||
/**
|
||
* 添加根节点(原生rect节点)
|
||
*/
|
||
addRootNode(config: RootNodeConfig): Node {
|
||
const graph = this.getGraph();
|
||
const containerHeight = graph.container.clientHeight;
|
||
const centerY = (containerHeight - this.nodeStyle.height) / 2;
|
||
const rootX = config.x ?? 50;
|
||
|
||
console.log(config.label);
|
||
|
||
return this.addNode({
|
||
id: config.id,
|
||
x: rootX,
|
||
y: centerY,
|
||
label: config.label,
|
||
businessId: config.businessId,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 添加普通节点
|
||
*/
|
||
private addNode(config: SimpleNodeConfig): Node {
|
||
const graph = this.getGraph();
|
||
// 计算根节点自适应宽度
|
||
const rootWidth =
|
||
this.calculateTextWidth(config.label, this.nodeStyle.label.fontSize) +
|
||
this.nodePadding * 2;
|
||
const node = graph.addNode({
|
||
shape: "rect",
|
||
id: config.id,
|
||
x: config.x,
|
||
y: config.y,
|
||
width: rootWidth,
|
||
height: this.nodeSize.height,
|
||
label: config.label,
|
||
attrs: {
|
||
// 矩形主体的样式(背景、边框、圆角等)
|
||
body: {
|
||
fill: this.nodeStyle.fill, // 深青色背景
|
||
stroke: this.nodeStyle.stroke, // 边框与背景同色(视觉隐藏)
|
||
strokeWidth: 1,
|
||
rx: 20, // 圆角
|
||
ry: 20,
|
||
},
|
||
// 文字的样式(颜色、位置、字号等)
|
||
label: {
|
||
text: config.label,
|
||
fill: "#ffffff", // 白色文字
|
||
fontSize: 14,
|
||
fontWeight: "bold",
|
||
textAnchor: "middle", // 文字水平居中
|
||
verticalAnchor: "middle", // 文字垂直居中
|
||
},
|
||
},
|
||
data: {
|
||
businessId: config.businessId,
|
||
level: config.level,
|
||
},
|
||
});
|
||
|
||
if (!graph.isNode(node)) {
|
||
throw new Error(`创建节点 "${config.id}" 失败`);
|
||
}
|
||
return node as Node;
|
||
}
|
||
|
||
/**
|
||
* 添加子节点(按层级动态分配位置)
|
||
*/
|
||
addChildNode(
|
||
parentId: string,
|
||
config: ChildNodeConfig,
|
||
skipRearrange = false,
|
||
): { node: Node; edge: Edge } {
|
||
const graph = this.getGraph();
|
||
|
||
// 校验父节点
|
||
const parentCell = graph.getCellById(parentId);
|
||
if (!parentCell || !graph.isNode(parentCell)) {
|
||
throw new Error(`父节点 "${parentId}" 不存在或无效`);
|
||
}
|
||
|
||
// 确定X位置
|
||
const levelX = this.levelXMap.get(config.level) ?? 300;
|
||
const nodeX = config.x ?? levelX;
|
||
|
||
// 更新层级节点数量
|
||
const currentCount = this.levelCountMap.get(config.level) || 0;
|
||
const totalNodesAfterAdd = currentCount + 1;
|
||
this.levelCountMap.set(config.level, totalNodesAfterAdd);
|
||
|
||
// 创建子节点(临时位置,后续重排)
|
||
const childNode = this.addNode({
|
||
...config,
|
||
x: nodeX,
|
||
y: 0, // 临时Y值
|
||
});
|
||
|
||
// 创建连线
|
||
const edge = graph.addEdge({
|
||
shape: "direct-curved-dashed-edge",
|
||
source: {
|
||
cell: parentId,
|
||
anchor: { name: "right" },
|
||
},
|
||
// 目标节点(子节点)的锚点:左侧
|
||
target: {
|
||
cell: childNode.id,
|
||
anchor: { name: "left" },
|
||
},
|
||
style: {
|
||
stroke: "#888",
|
||
strokeWidth: 1.2,
|
||
targetMarker: { name: "block", size: 8 },
|
||
},
|
||
});
|
||
|
||
if (!graph.isEdge(edge)) {
|
||
throw new Error(`创建连线(${parentId}→${childNode.id})失败`);
|
||
}
|
||
|
||
// // 重排同层级节点
|
||
// this.rearrangeLevelNodes(config.level);
|
||
// // 新增:如果添加的是第三层级,同步重排第二层级
|
||
// if (config.level === "level3") {
|
||
// this.rearrangeLevelNodes("level2");
|
||
// }
|
||
|
||
if (!skipRearrange) {
|
||
this.rearrangeLevelNodes(config.level);
|
||
// 新增:如果添加的是第三层级,同步重排第二层级
|
||
if (config.level === "level3") {
|
||
this.rearrangeLevelNodes("level2");
|
||
}
|
||
// 如果新增level4,要重排level3(父层级),父节点位置可能变化
|
||
if (config.level === "level4") {
|
||
this.rearrangeLevelNodes("level3");
|
||
}
|
||
}
|
||
return { node: childNode, edge: edge as Edge };
|
||
}
|
||
|
||
/**
|
||
* 重排指定层级的节点(垂直分布,第三层级跟随父节点,第二层级间距动态适配子节点数)
|
||
*/
|
||
private rearrangeLevelNodes(level: NodeLevel): void {
|
||
const graph = this.getGraph();
|
||
const containerHeight = graph.container.clientHeight;
|
||
const centerY = (containerHeight - this.nodeStyle.height) / 2;
|
||
|
||
const levelNodes = graph
|
||
.getNodes()
|
||
.filter((node) => node.getData().level === level)
|
||
.sort((a, b) => {
|
||
const labelA = a.attr("label/text") || "";
|
||
const labelB = b.attr("label/text") || "";
|
||
return labelA.localeCompare(labelB, undefined, { numeric: true });
|
||
});
|
||
const totalNodes = levelNodes.length;
|
||
if (totalNodes === 0) return;
|
||
|
||
if (level === "level2") {
|
||
const level2ChildCount = new Map<string, number>();
|
||
levelNodes.forEach((node) => {
|
||
const childIds = this.getChildNodeIds(node.id);
|
||
const level3ChildCount = childIds.filter((childId) => {
|
||
const childNode = graph.getCellById(childId) as Node;
|
||
return childNode?.getData().level === "level3";
|
||
}).length;
|
||
level2ChildCount.set(node.id, level3ChildCount);
|
||
});
|
||
|
||
const baseGap = 100;
|
||
const factor = 30;
|
||
let currentY = centerY;
|
||
const nodePositions = new Map<string, number>();
|
||
|
||
const firstNode = levelNodes[0];
|
||
nodePositions.set(firstNode.id, currentY);
|
||
|
||
for (let i = 1; i < totalNodes; i++) {
|
||
const prevNode = levelNodes[i - 1];
|
||
const prevChildCount = level2ChildCount.get(prevNode.id) || 0;
|
||
const dynamicGap = baseGap + prevChildCount * factor;
|
||
currentY += this.nodeStyle.height + dynamicGap;
|
||
nodePositions.set(levelNodes[i].id, currentY);
|
||
}
|
||
|
||
// ✅修复括号优先级
|
||
const firstY = nodePositions.get(levelNodes[0].id) ?? 0;
|
||
const lastNodeY = nodePositions.get(levelNodes[totalNodes - 1].id) ?? 0;
|
||
const totalHeight = lastNodeY - firstY + this.nodeStyle.height;
|
||
const offset = centerY - firstY - totalHeight / 2;
|
||
|
||
levelNodes.forEach((node) => {
|
||
const nodeY = (nodePositions.get(node.id) || 0) + offset;
|
||
const nodeX = this.levelXMap.get("level2") || 300;
|
||
node.setPosition(nodeX, nodeY);
|
||
});
|
||
} else if (level === "level3") {
|
||
const parentGroups = new Map<string, Node[]>();
|
||
levelNodes.forEach((node) => {
|
||
const parentEdge = graph
|
||
.getEdges()
|
||
.find((edge) => edge.getTargetCellId() === node.id);
|
||
if (parentEdge) {
|
||
const parentId = parentEdge.getSourceCellId();
|
||
parentGroups.set(parentId, [
|
||
...(parentGroups.get(parentId) || []),
|
||
node,
|
||
]);
|
||
}
|
||
});
|
||
|
||
parentGroups.forEach((children, parentId) => {
|
||
const parentNode = graph.getCellById(parentId) as Node;
|
||
if (!parentNode) return;
|
||
|
||
const parentY = parentNode.getPosition().y;
|
||
const childCount = children.length;
|
||
const verticalGap = 20;
|
||
|
||
const totalChildHeight =
|
||
this.nodeStyle.height * childCount + verticalGap * (childCount - 1);
|
||
const startOffset = -totalChildHeight / 2 + this.nodeStyle.height / 2;
|
||
|
||
children.forEach((child, index) => {
|
||
const childY =
|
||
parentY +
|
||
startOffset +
|
||
(this.nodeStyle.height + verticalGap) * index;
|
||
const childX = this.levelXMap.get("level3") || 550;
|
||
child.setPosition(childX, childY);
|
||
});
|
||
});
|
||
} else if (level === "level4") {
|
||
const levelNodes = this.graph.getCells().filter((cell) => {
|
||
return cell.isNode() && cell.getData()?.level === level;
|
||
}) as Node[];
|
||
if (!levelNodes.length) return;
|
||
|
||
const allEdges = this.graph.getEdges();
|
||
//按照父节点Y排序,保证顺序和左侧level3从上到下保持一致
|
||
levelNodes.sort((a, b) => {
|
||
let ay = 0,
|
||
by = 0;
|
||
for (const e of allEdges) {
|
||
if (e.getTargetCellId() === a.id) {
|
||
const p = this.graph.getCellById(e.getSourceCellId()) as Node;
|
||
ay = p.getPosition().y;
|
||
}
|
||
if (e.getTargetCellId() === b.id) {
|
||
const p = this.graph.getCellById(e.getSourceCellId()) as Node;
|
||
by = p.getPosition().y;
|
||
}
|
||
}
|
||
return ay - by;
|
||
});
|
||
|
||
const nodeH = this.nodeStyle.height;
|
||
const verticalGap = 20;
|
||
const totalHeight =
|
||
levelNodes.length * nodeH + (levelNodes.length - 1) * verticalGap;
|
||
|
||
//参考level3整体的中心点
|
||
const level3Nodes = this.graph
|
||
.getCells()
|
||
.filter((c) => c.isNode() && c.getData()?.level === "level3") as Node[];
|
||
let refCenterY = 300;
|
||
if (level3Nodes.length > 0) {
|
||
const topY = Math.min(...level3Nodes.map((n) => n.getPosition().y));
|
||
const botY =
|
||
Math.max(...level3Nodes.map((n) => n.getPosition().y)) + nodeH;
|
||
refCenterY = (topY + botY) / 2;
|
||
}
|
||
|
||
const startY = refCenterY - totalHeight / 2;
|
||
const level4X = this.levelXMap.get("level4") || 800;
|
||
|
||
let currentY = startY;
|
||
levelNodes.forEach((node) => {
|
||
node.setPosition(level4X, currentY);
|
||
currentY += nodeH + verticalGap;
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除节点(递归删除子节点并重新排列)
|
||
*/
|
||
removeNode(nodeId: string): void {
|
||
const graph = this.getGraph();
|
||
const cell = graph.getCellById(nodeId);
|
||
if (!cell || !graph.isNode(cell)) return;
|
||
|
||
// if (targetNode.getData().tbslFlag) {
|
||
// // 1、拿到被删节点的直接父节点
|
||
// const inEdges = graph.getIncomingEdges(graph.getCellById(nodeId));
|
||
// inEdges[0].setData({ ...inEdges[0].getData(), syncCount: null });
|
||
|
||
// inEdges[0].setAttrs({
|
||
// label: {
|
||
// text: 22222,
|
||
// },
|
||
// });
|
||
// }
|
||
|
||
if (cell.data.tbslFlag) {
|
||
graph.getEdges().forEach((edge) => {
|
||
const sourceNode = edge.getSourceNode();
|
||
const targetNode = edge.getTargetNode();
|
||
if (targetNode.id == cell.id) {
|
||
sourceNode.setData({ syncCount: null });
|
||
}
|
||
});
|
||
|
||
const inEdges = graph.getIncomingEdges(cell);
|
||
inEdges[0].setData({ ...inEdges[0].getData(), syncCount: null });
|
||
|
||
inEdges[0].setAttrs({
|
||
label: {
|
||
text: 22222,
|
||
},
|
||
});
|
||
}
|
||
|
||
const node = cell as Node;
|
||
const nodeLevel = node.getData().level as NodeLevel;
|
||
|
||
// 递归删除子节点
|
||
this.getChildNodeIds(nodeId).forEach((childId) => {
|
||
this.removeNode(childId);
|
||
});
|
||
|
||
// 删除当前节点
|
||
node.remove();
|
||
|
||
// 重置数量并重新排列
|
||
if (nodeLevel) {
|
||
this.resetLevelCount(nodeLevel);
|
||
this.rearrangeLevelNodes(nodeLevel);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 灌库方法:将目标数据按层级创建到画布(根节点→level2→level3→level4)
|
||
* @param importData - 目标灌库数据(格式如 [{userName: "...", tables: [...]}])
|
||
* @param rootConfig - 根节点配置(需提前确定根节点的 id 和 label,如 sourceOwner)
|
||
*/
|
||
importData(importData: ImportDataItem[], rootId): void {
|
||
// 3. 遍历灌库数据,创建 level2→level3→level4 层级
|
||
importData.forEach((item) => {
|
||
// 3.1 创建 level2 节点(userName 作为 label,父节点是根节点)
|
||
const { node: level2Node } = this.addChildNode(
|
||
rootId,
|
||
{
|
||
id: uuidv4(),
|
||
label: item.userName,
|
||
level: "level2",
|
||
},
|
||
true,
|
||
);
|
||
|
||
// 3.2 遍历当前 level2 的 tables,创建 level3 和 level4 节点
|
||
item.tables.forEach((table) => {
|
||
// 3.2.1 创建 level3 节点(tableName 作为 label,父节点是当前 level2)
|
||
const { node: level3Node } = this.addChildNode(
|
||
level2Node.id,
|
||
{
|
||
id: uuidv4(),
|
||
label: table.tableName,
|
||
level: "level3",
|
||
},
|
||
true,
|
||
);
|
||
|
||
// 3.2.2 创建 level4 节点(columnName 作为 label,父节点是当前 level3)
|
||
this.addChildNode(
|
||
level3Node.id,
|
||
{
|
||
id: uuidv4(),
|
||
label: table.columnName,
|
||
level: "level4",
|
||
},
|
||
true,
|
||
);
|
||
|
||
if (table.syncCount) {
|
||
level3Node.data.syncCount = table.syncCount;
|
||
console.log(level3Node, "3333");
|
||
this.addChildNode(level3Node.id, {
|
||
id: uuidv4(),
|
||
label: table.syncCount.toString(),
|
||
level: "level4",
|
||
});
|
||
}
|
||
}, true);
|
||
});
|
||
|
||
// // 全部节点循环创建完成后,统一执行全套重排
|
||
this.rearrangeLevelNodes("level2");
|
||
this.rearrangeLevelNodes("level3");
|
||
this.rearrangeLevelNodes("level4");
|
||
|
||
this.graph.zoomToFit({
|
||
padding: 40, // 四周留白,节点不会贴边框
|
||
maxZoom: 1, // 最大缩放比例,不要放太大;数据多时会缩小
|
||
minZoom: 0.3,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取目标表格数据(按 level2→level3→level4 层级组装,含子级完整性校验)
|
||
* @param taskId - 传入的任务ID
|
||
* @param name - 传入的targetOwner字段值
|
||
* @returns 符合格式的目标表格数组 / 错误信息
|
||
*/
|
||
getTargetTableData(
|
||
taskId: string,
|
||
name: string,
|
||
): { data?: TargetTableItem[]; error?: string } {
|
||
const graph = this.getGraph();
|
||
const result: TargetTableItem[] = [];
|
||
|
||
// 1. 找到所有 level2 节点(sourceOwner 来源于 level2 的 label)
|
||
const level2Nodes = graph
|
||
.getNodes()
|
||
.filter((node) => node.getData().level === "level2");
|
||
if (level2Nodes.length === 0) {
|
||
return { error: "未找到任何 level2 节点,无法组装数据" };
|
||
}
|
||
|
||
// 2. 遍历每个 level2 节点,校验子级并组装数据
|
||
for (const level2Node of level2Nodes) {
|
||
const sourceOwner = level2Node.attr("label/text") || "";
|
||
if (!sourceOwner) {
|
||
return {
|
||
error: `存在未设置 label 的 level2 节点(ID: ${level2Node.id})`,
|
||
};
|
||
}
|
||
|
||
// 2.1 校验 level2 是否有子级(必须包含 level3)
|
||
const level2ChildIds = this.getChildNodeIds(level2Node.id);
|
||
const level3Nodes = level2ChildIds
|
||
.map((id) => graph.getCellById(id) as Node)
|
||
.filter((node) => node?.getData().level === "level3");
|
||
|
||
if (level3Nodes.length === 0) {
|
||
return {
|
||
error: `level2 节点「${sourceOwner}」下无 level3 子节点,请完善配置`,
|
||
};
|
||
}
|
||
|
||
// 2.2 遍历每个 level3 节点,校验子级并组装数据
|
||
for (const level3Node of level3Nodes) {
|
||
const tableName = level3Node.attr("label/text") || "";
|
||
if (!tableName) {
|
||
return {
|
||
error: `存在未设置 label 的 level3 节点(父级:${sourceOwner})`,
|
||
};
|
||
}
|
||
|
||
const syncCount = level3Node.getData().syncCount || "";
|
||
|
||
// 2.2.1 校验 level3 是否有子级(必须包含 level4)
|
||
const level3ChildIds = this.getChildNodeIds(level3Node.id);
|
||
const level4Nodes = level3ChildIds
|
||
.map((id) => graph.getCellById(id) as Node)
|
||
.filter((node) => node?.getData().level === "level4");
|
||
|
||
if (level4Nodes.length === 0) {
|
||
return {
|
||
error: `level3 节点「${tableName}」下无 level4 子节点,请完善配置`,
|
||
};
|
||
}
|
||
|
||
// 2.2.2 遍历每个 level4 节点(每个level3仅取第一个有效level4,避免重复)
|
||
const validLevel4Node = level4Nodes[0];
|
||
const columnName = validLevel4Node.attr("label/text") || "";
|
||
if (!columnName) {
|
||
return {
|
||
error: `存在未设置 label 的 level4 节点(父级:${tableName})`,
|
||
};
|
||
}
|
||
|
||
// 2.2.3 组装单条数据
|
||
result.push({
|
||
taskId,
|
||
sourceOwner,
|
||
targetOwner: name, // 传入的name字段作为targetOwner
|
||
tableName,
|
||
columnName,
|
||
syncCount,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 3. 数据组装完成,返回结果
|
||
return result.length > 0 ? result : null;
|
||
}
|
||
|
||
/**
|
||
* 重置层级节点数量
|
||
*/
|
||
private resetLevelCount(level: NodeLevel): void {
|
||
const graph = this.getGraph();
|
||
const actualCount = graph.getNodes().filter((node) => {
|
||
return node.getData().level === level;
|
||
}).length;
|
||
this.levelCountMap.set(level, actualCount);
|
||
}
|
||
|
||
/**
|
||
* 获取子节点ID列表
|
||
*/
|
||
private getChildNodeIds(parentId: string): string[] {
|
||
return this.getGraph()
|
||
.getEdges()
|
||
.filter((edge) => edge.getSourceCellId() === parentId)
|
||
.map((edge) => edge.getTargetCellId());
|
||
}
|
||
|
||
/**
|
||
* 适配视图
|
||
*/
|
||
zoomToFitView(): void {
|
||
this.getGraph().zoomToFit({ padding: 50 });
|
||
}
|
||
|
||
/**
|
||
* 清空画布
|
||
*/
|
||
clear(): void {
|
||
this.getGraph().clearCells();
|
||
this.levelCountMap.set("level2", 0);
|
||
this.levelCountMap.set("level3", 0);
|
||
this.levelCountMap.set("level4", 0);
|
||
}
|
||
|
||
/**
|
||
* 销毁画布
|
||
*/
|
||
destroy(): void {
|
||
this.graph?.destroy();
|
||
this.graph = null;
|
||
// 移除右键菜单
|
||
if (this.menuElement && this.menuElement.parentElement) {
|
||
this.menuElement.parentElement.removeChild(this.menuElement);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取画布实例
|
||
*/
|
||
private getGraph(): Graph {
|
||
if (!this.graph) {
|
||
throw new Error("请先调用 init() 初始化画布");
|
||
}
|
||
return this.graph;
|
||
}
|
||
}
|