公司产品因为业务发展,出现了一个新的需求:需要去实现知识库的层级知识展示,展示效果通过树图来实现,具体的展示形式可见下图:

其中有几个需要注意点:

  1. 节点上的详情icon可以点击,点击展开关闭详情
  2. 节点后的伸缩icon在伸缩状态下需要显示当前节点的子节点个数

这个效果有点类似xmind的交互效果了,但是树的节点不论是样式还是点击事件都被高度定制了,在这种情况下基于配置的Echarts们就无用武之地了,我们只能利用更加底层的G6图表引擎去实现。

具体如何安装G6可以参见G6的文档,下面仅仅是选用文档中的第二种安装方式快速引入,写个demo出来验证可行。

首先我们需要完成G6的初始化等前置准备工作

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>树图</title>
<style>
::-webkit-scrollbar {
display: none;
} html, body {
background-color: #f0f2f5;
overflow: hidden;
margin: 0;
}
</style>
</head>
<body>
<div id="mountNode"></div>
<script src="https://gw.alipayobjects.com/os/antv/pkg/_antv.g6-3.1.0/build/g6.js"></script>
<script src="https://gw.alipayobjects.com/os/antv/pkg/_antv.hierarchy-0.5.0/build/hierarchy.js"></script>
<script src="https://gw.alipayobjects.com/os/antv/assets/lib/jquery-3.2.1.min.js"></script>
<script>
const CANVAS_WIDTH = window.innerWidth;
const CANVAS_HEIGHT = window.innerHeight; // 使用G6的TreeGraph
graph = new G6.TreeGraph({
container: "mountNode", width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
defaultNode: {
shape: "rect",
},
defaultEdge: {
shape: "cubic-horizontal",
style: {
stroke: "rgba(0,0,0,0.25)"
}
},
layout: (data) => {
return Hierarchy.compactBox(data, {
direction: "LR",
getId: function getId(d) {
return d.id;
},
getWidth: function getWidth() {
return 243;
},
getVGap: function getVGap() {
return 24;
},
getHGap: function getHGap() {
return 50;
}
});
}
}); function formatData(data) {
const recursiveTraverse = function recursiveTraverse(node, level) {
const targetNode = {
id: node.itemId + '',
level: level,
type: node.value,
name: node.name,
value: node.content,
collapsed: level > 0,
showDetail: false,
origin: node,
};
if (node.children) {
targetNode.children = [];
node.children.forEach(function (item) {
targetNode.children.push(recursiveTraverse(item, level + 1));
});
}
return targetNode;
};
return recursiveTraverse(data, 0);
} // 获取数据,渲染图表
$.getJSON('https://eliteapp.fanruan.com/certification/data.json', function (data) {
data = formatData(data);
graph.data(data);
graph.render();
});
</script>
</body>
</html>

之后我们便开始我们的自定义节点的设置,可以参考下自定义节点的文档

const getNodeConfig = function getNodeConfig(node) {
let config = {
basicColor: "#722ED1",
fontColor: "rgb(51, 51, 51)",
bgColor: "#ffffff"
};
// 请无视这种中文的判断,这里获取的数据为中文,就不做额外处理,直接拿来判断了
switch (node.type) {
case "标签": {
config = {
basicColor: 'rgba(61, 77, 102, 1)',
fontColor: "rgb(51, 51, 51)",
bgColor: "#ffffff"
};
break;
}
case "分类": {
config = {
basicColor: 'rgba(159, 230, 184, 1)',
fontColor: "rgb(51, 51, 51)",
bgColor: "#ffffff"
};
break;
}
case "业务问题":
config = {
basicColor: "rgba(45, 183, 245, 1)",
fontColor: "rgb(51, 51, 51)",
bgColor: "#ffffff"
};
break;
default:
break;
}
return config;
}; const nodeBasicMethod = {
createNodeBox: function createNodeBox(group, config, width, height, isRoot) {
// 最外面的大矩形,作为节点元素的容器
const container = group.addShape("rect", {
attrs: {
x: 0,
y: 0,
width: width,
height: height,
},
className: 'node-container',
});
if (!isRoot) {
// 不是跟节点,创建左边的小圆点
group.addShape("circle", {
attrs: {
x: 3,
y: height / 2,
r: 6,
fill: config.basicColor
},
className: 'node-left-circle',
});
}
// 节点标题的矩形
group.addShape("rect", {
attrs: {
x: 3,
y: 0,
width: width - 19,
height: height,
fill: config.bgColor,
radius: 2,
cursor: "pointer"
},
className: 'node-main-container',
}); // 节点标题左边的粗线
group.addShape("rect", {
attrs: {
x: 3,
y: 0,
width: 3,
height: height,
fill: config.basicColor,
},
className: 'node-left-line',
});
return container;
},
createDetailIcon: function createDetailIcon(group) {
// icon外面的矩形,用来计算icon的宽度
const iconRect = group.addShape("rect", {
attrs: {
fill: "#FFF",
radius: 2,
cursor: "pointer"
}
});
iconRect.attr({
x: 154,
y: 6,
width: 24,
height: 24
});
// 设置icon的图片
group.addShape("image", {
attrs: {
x: 154,
y: 6,
height: 24,
width: 24,
img: "https://eliteapp.fanruan.com/web-static/media/close.svg",
cursor: "pointer",
opacity: 1
},
className: "node-detail-icon"
});
// 放一个透明的矩形在 icon 区域上,方便监听点击
group.addShape("rect", {
attrs: {
x: 160,
y: 12,
width: 12,
height: 12,
fill: "#FFF",
cursor: "pointer",
opacity: 0
},
className: "node-detail-box",
});
return iconRect.getBBox().width;
},
createNodeName: (group, config) => {
group.addShape("text", {
attrs: {
// 根据 icon 的宽度计算出剩下的留给 name 的长度
text: "node title",
x: 18,
y: 18,
fontSize: 13,
fontWeight: 400,
textAlign: "left",
textBaseline: "middle",
fill: config.fontColor,
cursor: "pointer"
},
className: 'node-name-text',
});
},
createNodeDetail: function createNodeDetail(group, config) {
// 节点的类别说明,即 # 业务问题
group.addShape('text', {
attrs: {
text: '',
x: 18,
y: 45,
fontSize: 10,
lineHeight: 16,
textAlign: "left",
textBaseline: "middle",
fill: config.basicColor,
cursor: "pointer",
},
className: 'node-detail-info'
});
// 节点的详情
group.addShape("text", {
attrs: {
text: '',
x: 18,
y: 45,
fontSize: 11,
lineHeight: 16,
textAlign: "left",
textBaseline: "middle",
fill: 'rgb(51, 51, 51)',
cursor: "pointer",
},
className: "node-detail-text",
});
// 节点的 查看详情 按钮
group.addShape('text', {
attrs: {
text: '',
x: 18,
y: 61,
fontSize: 11,
lineHeight: 16,
textAlign: "left",
textBaseline: "middle",
fill: config.basicColor,
cursor: "pointer",
},
className: "node-detail-link",
});
// 节点的 反馈问题 按钮
group.addShape('text', {
attrs: {
text: '',
x: 99,
y: 61,
fontSize: 11,
lineHeight: 16,
textAlign: "left",
textBaseline: "middle",
fill: config.basicColor,
cursor: "pointer",
},
className: "node-detail-feedback",
});
},
createNodeMarker: function createNodeMarker(group, collapsed, x, y, childrenNum) {
// 伸缩按钮的圆形背景
group.addShape("circle", {
attrs: {
x: x,
y: y,
r: 13,
fill: "rgba(47, 84, 235, 0.05)",
opacity: 0,
zIndex: -2
},
className: "collapse-icon-bg"
});
// 伸缩按钮的 节点数量 文字
group.addShape("text", {
attrs: {
x: x,
y: y + (7 / 2),
text: collapsed ? childrenNum : '-',
textAlign: "center",
fontSize: 10,
lineHeight: 7,
stroke: "rgba(0,0,0,0.25)",
fill: "rgba(0,0,0,0)",
opacity: 1,
cursor: "pointer"
},
className: "collapse-icon-num"
});
// 伸缩按钮的圆形边框
group.addShape("circle", {
attrs: {
x: x,
y: y,
r: 7,
stroke: "rgba(0,0,0,0.25)",
fill: "rgba(0,0,0,0)",
opacity: 1,
cursor: "pointer"
},
className: "collapse-icon"
});
},
}; const TREE_NODE = "tree-node";
G6.registerNode(TREE_NODE, {
drawShape: function drawShape(cfg, group) {
// 获取节点的颜色配置
const config = getNodeConfig(cfg);
const isRoot = cfg.type === "标签";
// 最外面的大矩形
// 这里的宽度为写死的宽度,全部节点的宽度统一,高度为data在处理时赋予的高度
const container = nodeBasicMethod.createNodeBox(group, config, NODE_WIDTH, cfg.nodeHeight, isRoot);
// 创建节点详情展开关闭的icon
nodeBasicMethod.createDetailIcon(group);
// 创建节点标题
nodeBasicMethod.createNodeName(group, config);
// 创建节点详情
nodeBasicMethod.createNodeDetail(group, config); const childrenNum = (cfg.children || []).length;
if (childrenNum > 0) {
// 创建节点的伸缩icon
nodeBasicMethod.createNodeMarker(group, cfg.collapsed, 191, 18, childrenNum);
} return container;
},
}, "single-shape"); defaultNode: {
// 在G6的初始化中将节点改为使用自定义的节点
shape: TREE_NODE,
},

此时,我们便可得到如图的示例:

但是这里的跟节点的连线位置是四分五裂的,我们的交互图是统一到右侧中间伸缩icon右侧和节点左侧中间的,所以接下来我们需要对节点连线的控制点进行适配,节点的连接控制点可以参见G6的文档

defaultNode: {
shape: TREE_NODE,
// 全局设置节点的锚点控制点,分别在左侧中间和右侧中间
anchorPoints: [[0, 0.5], [1, 0.5]]
},

此时的树形图便如下:

到这里之后,节点的效果图已经出来了,但是节点的详情交互还未实现,接下来开始实现详情的交互。

节点的交互主要为展开关闭节点详情、展开伸缩子树。

展开关闭节点详情由用户点击下拉icon触发,所以我们就需要监听节点的点击事件再具体一点就是监听节点icon的点击事件。

// 由于节点的文本不会换行,根据节点的宽度切分节点详情文本到数组中,然后进行换行
const fittingStringLine = function fittingStringLine(str, maxWidth, fontSize) {
str = str.replace(/\n/gi, '');
const fontWidth = fontSize * 1.3; //字号+边距 const actualLen = Math.floor(maxWidth / fontWidth);
let width = strLen(str) * fontWidth;
let lineStr = [];
while (width > 0) {
const substr = str.substring(0, actualLen);
lineStr.push(substr); str = str.substring(actualLen);
width = strLen(str) * fontWidth;
}
return lineStr;
}; const strLen = function strLen(str) {
let len = 0;
if(!str) {
return len;
} for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 0 && str.charCodeAt(i) < 128) {
len++;
} else {
len += 2;
}
}
return len;
}; const nodeBasicMethod = { afterDraw: function afterDraw(cfg, group) {
// 伸缩icon的背景色交互
const collapseIcon = group.findByClassName("collapse-icon");
if (collapseIcon) {
const bg = group.findByClassName("collapse-icon-bg");
// 监听事件
collapseIcon.on("mouseenter", function () {
bg.attr("opacity", 1);
graph.get("canvas").draw();
});
collapseIcon.on("mouseleave", function () {
bg.attr("opacity", 0);
graph.get("canvas").draw();
});
} // 下拉展示与隐藏节点详情
const nodeDetailBox = group.findByClassName("node-detail-box");
nodeDetailBox.on("click", function () {
nodeBasicMethod.handleDetail(cfg, group);
});
},
handleDetail: function handleDetail(cfg, group) {
const circle = group.findByClassName('node-left-circle');
const mainContainer = group.findByClassName('node-main-container');
const nodeLeftLine = group.findByClassName('node-left-line');
const rightCircleBg = group.findByClassName('collapse-icon-bg');
const rightCircleIconNum = group.findByClassName('collapse-icon-num');
const rightCircleIcon = group.findByClassName('collapse-icon'); const nodeDetailText = group.findByClassName('node-detail-text');
const nodeDetailInfo = group.findByClassName('node-detail-info');
const nodeDetailLink = group.findByClassName('node-detail-link');
const nodeDetailFeedback = group.findByClassName('node-detail-feedback'); // 查找节点在树上的下方节点
const node = graph.findById(cfg.id);
const nodes = graph.findAll('node', item => {
const model = item.getModel();
return model.level === node.getModel().level;
});
const leftNodes = nodes.slice(nodes.indexOf(node) + 1); let nodeHeight;
if (cfg.showDetail) {
// 详情已经展开,开始关闭详情
nodeHeight = NODE_HEIGHT; // 关闭详情
nodeDetailText.attr('text', '');
nodeDetailInfo.attr('text', '');
nodeDetailLink.attr('text', '');
nodeDetailFeedback.attr('text', ''); // 下方节点上移
leftNodes.forEach((leftNode) => {
leftNode.getModel().y = leftNode.getBBox().y - 80;
graph.updateItem(leftNode, {
y: leftNode.getBBox().y - cfg.nodeHeight + NODE_HEIGHT,
});
}); cfg.showDetail = false;
} else {
// 详情未展开,开始展开详情 // 展示详情
const detailText = fittingStringLine(cfg.value, 198, 12);
nodeDetailText.attr('text', detailText.join('\n'));
nodeDetailText.attr('y', 45 + 16 + (detailText.length) * 8); nodeDetailInfo.attr('text', `# ${cfg.type}`);
nodeDetailLink.attr('text', '查看详情');
nodeDetailLink.attr('y', 45 + 16 + (detailText.length) * 16 + 16);
nodeDetailFeedback.attr('text', '反馈问题');
nodeDetailFeedback.attr('y', 45 + 16 + (detailText.length) * 16 + 16); nodeHeight = 45 + 16 + (detailText.length + 1) * 16 + 16; // 下方的节点下移
leftNodes.forEach((leftNode) => {
leftNode.getModel().y = leftNode.getBBox().y + 80;
graph.updateItem(leftNode, {
y: leftNode.getBBox().y + nodeHeight - cfg.nodeHeight,
});
}); cfg.showDetail = true;
}
cfg.nodeHeight = nodeHeight; // 调节节点元素高度
circle.attr('y', nodeHeight / 2);
mainContainer.attr('height', nodeHeight);
nodeLeftLine.attr('height', nodeHeight);
if (rightCircleBg && rightCircleIconNum && rightCircleIcon) {
rightCircleBg.attr('y', nodeHeight / 2);
// 计算伸缩icon的位置,G6在这里有个坑,canvas模式下的文本位置会产生偏差
rightCircleIconNum.attr('y', nodeHeight / 2 + 5 + (nodeHeight - NODE_HEIGHT) * 0.1);
rightCircleIcon.attr('y', nodeHeight / 2);
} // 更新当前节点的高度
graph.updateItem(node, Object.assign(cfg, {
style: {
height: nodeHeight,
},
}));
graph.get('canvas').draw();
},
};
G6.registerNode(TREE_NODE, {
drawShape: function drawShape(cfg, group) {},
// 设置监听
afterDraw: nodeBasicMethod.afterDraw,
}, "single-shape");

此时,展开关闭详情的交互就已经实现了,如图:

对于伸缩的交互,G6提供的树图自带了专用的伸缩Behavior,可以直接拿过来进行定制使用。

graph = new G6.TreeGraph({
container: "mountNode", width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
defaultNode: {},
defaultEdge: {},
modes: {
default: [{
type: "collapse-expand",
// 判断是否开始伸缩
shouldBegin: function shouldBegin(e) {
console.log('shouldBegin', e.target.get("className") === "collapse-icon");
// 点击 node 禁止展开收缩,只有在点击到的是伸缩icon的时候才允许伸缩
return e.target.get("className") === "collapse-icon";
},
// 伸缩状态发生改变
onChange: function onChange(item, collapsed) {
const icon = item.get("group").findByClassName("collapse-icon-num");
icon.attr("text", collapsed ? item.getModel().children.length : '-'); // 关闭全部的详情
const detailNodeList = graph.findAll('node', node => {
return node.getModel().showDetail;
});
detailNodeList.forEach(detailNode => {
const group = detailNode.get('group');
const cfg = detailNode.getModel(); nodeBasicMethod.handleDetail(cfg, group);
});
},
}]
},
layout: (data) => {}
});

我们的树图便可以正常的伸缩啦,如图:

完整代码可暂时从这儿下载:https://files.cnblogs.com/files/tingyugetc/g6-tree.zip

最新文章

  1. SAP CRM 性能小技巧
  2. echarts学习总结
  3. Redis教程(十三):管线详解
  4. situations where MyISAM will be faster than InnoDB
  5. sql server 2000通过机器名可以连,通过ip连不上的问题
  6. android显示当前时间
  7. Zepto源码解读
  8. apache的FileUtils方法大全
  9. PHP自定义弹出消息类,用于弹出提示信息并返回
  10. PCB Layout 中的高频电路布线技巧
  11. css修改浏览器默认的滚动条样式
  12. sping框架纯注解配置
  13. 微信小程序UI组件--Lin UI
  14. str、tuple、dict之间的相互转换
  15. 如何隐藏Excel中单元格公式且其他单元格可修改
  16. [转]Python依赖打包发布详细
  17. [No0000EE]主要的宏观经济指标查询
  18. Django 配置
  19. QEMU 模拟运行 VxWorks 6.6
  20. 商城项目(ssm+dubbo+nginx+mysql统合项目)总结(4)

热门文章

  1. Codeforces Round #610 (Div. 2) A-E简要题解
  2. 部署项目到jetty
  3. 在UTF-8页面中引入编码为GBK的JavaScript文件乱码问题了
  4. 网络https工作原理
  5. 题解【CJOJ1371】[IOI2002]任务安排
  6. LED Magic Light - How Does The LED Light Change Color?
  7. 哪款C语言编译器(IDE)适合初学者?
  8. HTML5学习(1)简介
  9. IntelliJ IDEA之如何设置JVM运行参数
  10. ThinkPhp5 中Volist标签的用法