公司產品因為業務發展,出現了一個新的需求:需要去實現知識庫的層級知識展示,展示效果通過樹圖來實現,具體的展示形式可見下圖:
其中有幾個需要注意點:
- 節點上的詳情icon可以點擊,點擊展開關閉詳情
- 節點后的伸縮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