最近在学习X6的使用,参考官方提供的思维导图案例,新加了一些功能,记录一下如何实现一个简单的思维导图。
自定义节点和边
- 先自定义要用到的中心节点和子节点以及连接边的形状和样式,中心节点设置了左右各有一个图标可以进行两边节点的添加。
- tools中添加node-editor对象即可提供节点上文本编辑功能。
//中心节点
Graph.registerNode(
"topic",
{
inherit: "rect",
width: 100,
height: 40,
markup: [
// 指定了渲染节点/边时使用的 SVG/HTML 片段
{
tagName: "rect",
selector: "body",
},
{
tagName: "image",
selector: "img1",
},
{
tagName: "image",
selector: "img2",
},
{
tagName: "text",
selector: "label",
},
],
attrs: {
body: {
rx: 6,
ry: 6,
stroke: "#5178C7",
fill: "#5178C7",
strokeWidth: 1,
},
img1: {
ref: "body",
refX: "100%",
refY: "50%",
refY2: -8,
width: 18,
height: 18,
"xlink:href":
"https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*SYCuQ6HHs5cAAAAAAAAAAAAAARQnAQ",
event: "add:topic:right",
class: "topic-image",
},
img2: {
ref: "body",
refX: "0%",
refY: "50%",
refX2: "-18",
refY2: -8,
width: 18,
height: 18,
"xlink:href":
"https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*SYCuQ6HHs5cAAAAAAAAAAAAAARQnAQ",
event: "add:topic:left",
class: "topic-image",
},
label: {
fontSize: 14,
fill: "#ffffff",
},
},
tools: [
{
name: "node-editor",
args: {
attrs: {
backgroundColor: "#EFF4FF",
},
},
},
],
},
true
);
//子节点
Graph.registerNode(
"topic-child",
{
inherit: "rect",
width: 100,
height: 40,
markup: [
// 指定了渲染节点/边时使用的 SVG/HTML 片段
{
tagName: "rect",
selector: "body",
},
{
tagName: "image",
selector: "img",
},
{
tagName: "text",
selector: "label",
},
],
attrs: {
body: {
rx: 6,
ry: 6,
stroke: "#5F95FF",
fill: "#EFF4FF",
strokeWidth: 1,
},
img: {
ref: "body",
// refX: "100%",
refY: "50%",
refY2: -8,
width: 18,
height: 18,
"xlink:href":
"https://gw.alipayobjects.com/mdn/rms_43231b/afts/img/A*SYCuQ6HHs5cAAAAAAAAAAAAAARQnAQ",
event: "add:topic:right",
class: "topic-image",
},
label: {
fontSize: 14,
fill: "#262626",
},
},
tools: [
{
name: "node-editor",
args: {
attrs: {
backgroundColor: "#EFF4FF",
},
},
},
],
},
true
);
// 连接器
Graph.registerConnector(
"mindmap",
(sourcePoint, targetPoint, routerPoints, options) => {
const midX = sourcePoint.x + 10;
const midY = sourcePoint.y;
const ctrX = (targetPoint.x - midX) / 5 + midX;
const ctrY = targetPoint.y;
const pathData = `
M ${sourcePoint.x} ${sourcePoint.y}
L ${midX} ${midY}
Q ${ctrX} ${ctrY} ${targetPoint.x} ${targetPoint.y}
`;
return options.raw ? Path.parse(pathData) : pathData;
},
true
);
// 边
Graph.registerEdge(
"mindmap-edge",
{
inherit: "edge",
connector: {
name: "mindmap",
},
attrs: {
line: {
targetMarker: "",
stroke: "#A2B1C3",
strokeWidth: 2,
},
},
zIndex: 0,
},
true
);
初始化画布
graph.current = new Graph({
container: refContainer.current,
connecting: { //通过配置 connecting 可以实现丰富的连线交互。
connectionPoint: "anchor",
},
mousewheel: { //设置画布是否缩放
enabled: true,
},
panning: true, //设置画布是否可以平移
});
初始化数据,在画布上展示中心节点
- 利用@antv/hierarchy这个插件可以生成思维导图(Mind Map)类型的布局。
//初始节点数据
const data = {
id: "center",
type: "topic",
label: "中心主题",
width: 160,
height: 50,
children:[],
}
//初始化数据,生成思维导图(Mind Map)类型的布局。
const init = () => {
const result = Hierarchy.mindmap(data, {
direction: "H", //水平布局
getHeight(d) {
return d.height;
},
getWidth(d) {
return d.width;
},
getHGap() {
//获取节点水平间距的函数
return 40;
},
getVGap() {
//获取节点垂直间距的函数
return 20;
},
getSide: (e) => {
// 子节点在父节点的左边还是右边
return e.data.direction || "right";
},
});
// console.log("result", result);
const cells = [];
const traverse = (hierarchyItem) => {
if (hierarchyItem) {
const { data, children, x, y } = hierarchyItem;
// console.log("data", data);
const node = addOneNode({
...data,
x,
y,
});
cells.push(node);
if (children) {
children.forEach((item) => {
const { id, data } = item;
const edge = addOneEdge(hierarchyItem.id, id, data.direction);
cells.push(edge);
traverse(item);
});
}
}
};
traverse(result);
graph.current.resetCells(cells); //resetCells方法用于清空画布并添加用指定的节点/边。
graph.current.centerContent(); // 将画布中元素居中展示
};
点击添加图标新增子节点
- 添加的时候利用X6提供的addNode方法进行节点添加,利用addEdge方法进行节点的连接。
//自定义节点的时候给添加图标设置了event事件名称,这边进通过名称进行关联
graph.current.on("add:topic:left", (event) => {
addNewNode(event, "left");
});
graph.current.on("add:topic:right", (event) => {
addNewNode(event, "right");
});
//添加新节点
const addNewNode = (event, direction) => {
// console.log("addd", event.node);
event.e.stopPropagation(); //阻止事件冒泡防止添加的时候出现节点的拉伸功能
setResizingEnabled(false);
const cur_node = event.node;
const cur_node_pos = cur_node.position();
const { id } = cur_node;
const nodes = graph.current.getNodes(); //返回画布中所有节点和边的数量
const edges = graph.current.getEdges(); //返回画布中所有节点。
/**
* 因为要在原有的基础上添加新的节点,左右两边添加节点的位置不一样,目前想到的方式是获取所有的子节点,
* 判断是往哪个方向添加节点,先找到最远的那个子节点位置,然后在最远的那个下边添加新的节点。
*/
const childNodes = edges
.filter((edge) => edge.getSourceCellId() === id)
.map((edge) => edge.getTargetCell());
let p_x = -Infinity;
let p_y = -Infinity;
childNodes.forEach((node) => {
const { x, y } = node.position();
// console.log("x, y ", x, y);
if (direction === "left" && x < 0) {
p_x = Math.min(p_x, x);
p_y = Math.max(p_y, y);
} else if (direction === "right" && x > 0) {
p_x = Math.max(p_x, x);
p_y = Math.max(p_y, y);
}
});
// console.log("位置", p_x, p_y);
if (event.cell.isNode()) {
const newNodeId = `new-${Date.now()}`;
const init_x =
direction === "left" ? cur_node_pos.x - 150 : cur_node_pos.x + 150;
addOneNode({
id: newNodeId,
label: "输入文本",
children: [],
x: p_x === -Infinity ? init_x : p_x, // 设置新节点的位置
y: p_y === -Infinity ? cur_node_pos.y + 5 : p_y + 60,
type: "topic-child",
direction,
});
addOneEdge(event.cell, newNodeId, direction);
}
};
const addOneNode = (data) => {
const { type, direction } = data;
return graph.current.addNode({
...data,
shape: type === "topic-child" ? "topic-child" : "topic",
attrs:
type === "topic-child"
? {
img: {
refX: direction === "left" ? "0%" : "100%",
event:
direction === "left" ? "add:topic:left" : "add:topic:right",
refX2: direction === "left" ? -18 : 0,
},
}
: {},
});
};
// 添加新边
const addOneEdge = (source_id, target_id, direction) => {
return graph.current.addEdge({
shape: "mindmap-edge",
source: {
cell: source_id,
anchor: {
name: direction,
args: direction === "left" ? {} : { dx: -16 },
},
},
target: {
cell: target_id,
anchor: {
name: direction === "left" ? "right" : "left",
},
},
});
};
选中节点通过删除键进行删除
- 利用@antv/x6-plugin-keyboard这个插件来使用快捷键功能。
- 删除的时候需要获取到所有的子节点,通过X6提供的removeNode方法遍历删除该节点和所有子节点。
//绑定删除键的监听事件
graph.current.bindKey(["backspace", "delete"], () => {
const selectedNodes = graph.current
.getSelectedCells()
.filter((item) => item.isNode());
if (selectedNodes.length) {
const { id } = selectedNodes[0];
if (id !== "center") {
const allNode = getAllDescendants(id);
allNode.forEach((data) => {
graph.current.removeNode(data);
});
}
}
});
// 获取某个节点的所有子节点
const getAllDescendants = (nodeId) => {
const visited = new Set();
const descendants = new Set();
const traverse = (currentNodeId) => {
if (visited.has(currentNodeId)) {
return;
}
visited.add(currentNodeId);
const currentNode = graph.current.getCellById(currentNodeId);
if (!currentNode) {
return;
}
//getConnectedEdges方法获取与节点/边相连接的边。
const edges = graph.current.getConnectedEdges(currentNode,{});
edges.forEach((edge) => {
const target = edge.getTargetCell();
if (target && !descendants.has(target.id)) {
descendants.add(target.id);
traverse(target.id);
}
});
};
traverse(nodeId);
return Array.from(descendants);
};
实现节点可拉伸
- 利用@antv/x6-plugin-transform插件实现节点的拉伸功能。
graph.current.use(
new Transform({
resizing: resizingEnabled,
// rotating: true,
})
);
实现撤销和恢复功能
- 利用@antv/x6-plugin-history插件来实现撤销和恢复。
graph.current.use(
new History({
enabled: true,
})
);
//撤销某操作
const onUndo = () => {
graph.current.undo();
};
//恢复某操作
const onRedo = () => {
graph.current.redo();
};
实现数据的导入和导出功能
- 利用X6提供的toJSON来获取整个画布的数据,利用fromJSON方法按照指定的 JSON 数据渲染节点和边。
//导出
const onExport = () => {
const data = graph.current.toJSON();
console.log("data", data);
};
//导入
const onImport = () => {
graph.current.clearCells();
graph.current.fromJSON(test_data);
graph.current.centerContent();
};
总结
目前实现的就是上述这些功能,完整代码放在github上了,后续如果有新增加的功能和完善的地方也会更新上去,有需要参考的可以去这里看看。有任何写的不对或者有更好方式实现的地方欢迎大家提出来呦!
github地址如下:github.com/winterping/…