先上效果图:
对应源码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="../d3.v5.js"></script> <script src="./data.js"></script> <style> svg{ display: block; margin: 100px auto; border: 3px solid #ccc; background: darkgray; } path.link{ stroke: #333; stroke-width: 1.5px; fill:transparent; } /* path.line{ fill: none; } */ .node circle{ fill:#fff; stroke:steelblue; stroke-width: 1.5px; } .grid-line { stroke: #fcfcfc; stroke-dasharray: 3 3; stroke-opacity: 0.5; } .x-axis .tick:nth-child(2) .grid-line { /*stroke- 4px;*/ stroke: none; } .y-axis .tick:last-child .grid-line { /* stroke: none; */ } </style> </head> <body> <script> var globalId = 1; function tree() { var _chart = {}; var _width = 1000, _height = 600, _margin = { left: 25, right: 15, top: 20, bottom: 20 }, _colors = d3.scaleOrdinal(d3.schemeCategory10), _svg, _nodes, _i = 0, _duration = 300, _bodyG, _root, _curMaxHeight, _tree, _nodeRectWidth = 100, hierarchyData = {}, textLineHeightCache = {}, textPadding = [10, 5, 10, 5], textLineHeight = 18; _chart.render = function() { if (!_svg) { _svg = d3.select("body").append("svg") .attr("width", _width) .attr("height", _height); } renderBody(_svg) } function renderBody(svg) { if (!_bodyG) { _bodyG = svg.append("g") .attr("class", "body") .attr("transform", function(d) { return 'translate(' + _margin.left + ',' + _margin.top +')' }) addScroll(); } _tree = d3.tree().nodeSize([120, 80]); for (let position in _nodes) { // 递归计算节点的偏移, 节点高度; 并缓存; initDistance(_nodes[position], calcTextLineght(_nodes[position].name, false)); // 获取树结构数据 hierarchyData[position] = d3.hierarchy(_nodes[position]) // 设置树的折叠; collapseChildren(hierarchyData[position], position > 'left' ? 1 : 2, position); // 渲染节点树 render(hierarchyData[position], _tree, position); } } // 折叠节点: function collapseChildren (treeNode, collapseDepth, direction) { var preOffset = 0; var walk = function(node, depth, direction) { node.direction = direction; node.offset = (node.offset || 0) + preOffset; if ( node.children ) { if (Array.isArray(node.children)) { if (node.depth >= depth) { node._children = node.children; node.children = null node.collapseAble = true; node._children.forEach(function(v) { walk(v, depth, direction) }) } else { node.children.forEach(function(v) { walk(v, depth, direction) }) } } else { if (node.depth >= depth) { node._children = node.children; node.children = null node.collapseAble = true; walk(node._children, depth, direction) } else { walk(node.children, depth, direction) } } } } walk(treeNode, collapseDepth, direction) } // 应用缩放 function addScroll() { var zoomHandler = d3.zoom().scaleExtent([0.2, 20]).on('zoom', function () { var transform = d3.event.transform; d3.select('svg g.body').attr('transform', transform) // .transition().duration(100) }) _svg.call(zoomHandler) } function render(root, tree, position) { // 计算树的布局 tree(root); // 将对称的两个树进行偏移, 让其居中, 对称 setNodePosition(root, position) renderNodes(root, position); renderLinks(root, position); } // 设置节点偏移, 计算节点高度 function initDistance(list, preOffSize) { preOffSize = preOffSize || 0; var iterator = [].forEach; iterator.call(list.children || list._children, item => { // item._children = item.children ? (item.children.length ? item.children : null) : null // item.children = null let offSize = 0 let height = calcTextLineght(item.name, !!(item._children || item.children)) // console.log('高度', height, item); offSize = height + preOffSize item.offSize = preOffSize item.height = height if (item.children || item._children) { initDistance(item, offSize) } }) } var canvas2dContext = document.createElement("canvas").getContext("2d"); function setNodePosition(root, position) { var offsetX = _width / 2, offsetY = _height / 2, oneWidth = 120, oneHeight = 80; // root.each() // console.log('root', root); root.each((node,index) => { var text = node.data.name; var textLineHeights = null; var height = null; if (textLineHeightCache[text]) { textLineHeights = textLineHeightCache[text] } else { textLineHeights = calcBreakWord(text, _nodeRectWidth - textPadding[1] - textPadding[3] - (node.collapseAble ? 16 + 10 : 0), 14, canvas2dContext) textLineHeightCache[text] = textLineHeights } // 如果是根节点, 将位置设置到中心 if (node.depth === 0) { height = textLineHeights.length * textLineHeight + textPadding[0] + textPadding[2] // 横向的上下图 node.x = offsetX; node.y = offsetY; node._width = _nodeRectWidth; node._height = height; } else { // console.log('计算高度', textLineHeights); height = textLineHeights.length * textLineHeight + textPadding[0] + textPadding[2] // cur = root if (position === 'left') { node.x += offsetX; node.y += offsetY; node._width = _nodeRectWidth; node._height = height; } else if (position === 'right') { node.x += offsetX; // node.y -= offsetY; node.y = -(node.y - offsetY) node._width = _nodeRectWidth; node._height = height; } else if (position === 'top') { // // node.x = -(node.x - gridX / 2); // console.log('Y周偏移', gridX / 2, node.x, offsetX, node.y, offsetY); // // node.y += offsetY * Math.cos(Math.PI / 2) // node.y = gridY / 2 - (node.x - gridX / 2); } else { node.x -= offsetX; node.y += offsetY; node._width = _nodeRectWidth; node._height = height; } } }) } // 计算文本高度; 并缓存起来 function calcTextLineght(text, collapseAble) { var textLineHeights = null; var height = null; if (textLineHeightCache[text]) { textLineHeights = textLineHeightCache[text] } else { textLineHeights = calcBreakWord(text, _nodeRectWidth - textPadding[1] - textPadding[3] - (collapseAble ? 16 + 10 : 0), 14, canvas2dContext) textLineHeightCache[text] = textLineHeights } height = textLineHeights.length * textLineHeight + textPadding[0] + textPadding[2] return height } function renderNodes(root, baseId) { var nodes = root.descendants(); // .slice(1) // console.log('重新计算坐标位置', nodes, root); var _offsetX = _width / 2, _offsetY = _height / 2, oneWidth = 120, oneHeight = 80; var nodeELements = _bodyG.selectAll("g.node._" + baseId) .data(nodes, function(d){ return d.id || (d.id = ++_i) }); var nodeEnter = nodeELements.enter().append("g") .attr("class", "node _" + baseId); nodeEnter.append("rect") .attr("x", function(d) { return -d._width / 2 }) .attr("y", function(d) { return 0 }) .attr("width", function(d) { return d._width }) .attr("height", function(d) { return d._height }) .attr("class", "node-background") .attr("fill", function(d) { return d.backgournd || '#fff' }) nodeEnter.append("circle") .attr("r", function(d) { return d.children || d._children ? 8 : 1e-8 }) .attr("class", "indicator-circle") .attr("stroke", "#333") .attr("fill", "#fff") .style("cursor", "pointer") .attr("cx", function(d) { return d._width / 2 - textPadding[1] - 8 }) .attr("cy", function(d) { return d._height / 2 }) .style("display", function(d) { return d.children || d._children ? "block" : "none" }); nodeEnter.append("text") .attr("class", "indicator-text") .attr("x", function(d) { return d._width / 2 - textPadding[1] - 8 }) .attr("y", function(d) { return d._height / 2 }) .attr("text-anchor", "middle") .attr("dominant-baseline", "middle") .style("cursor", "pointer") .text(function(d) { return d.children ? "-" : d._children ? "+" : "" }); nodeEnter.attr("transform", function(d) { if (d.parent) { var offsetY = d.parent.data.offSize || 0; if (baseId === 'right') { return 'translate(' + d.parent.x + ',' + (d.parent.y - offsetY) + ')' } else { return 'translate(' + d.parent.x + ',' + (d.parent.y + offsetY) + ')' } } else { var offsetY = d.data.offSize || 0; if (baseId === 'right') { return 'translate(' + d.x + ',' + (d.y - offsetY) + ')' } else { return 'translate(' + d.x + ',' + (d.y + offsetY) + ')' } } }) .on("click", function(d) { toggle(d); render(hierarchyData[d.direction], _tree, d.direction); }) // console.log('缓存的行高信息', textLineHeightCache, nodeEnter); nodeEnter.call(function (nodeGroup, i) { nodeGroup.selectAll("text.node-text") .data(function(d) { var cache = textLineHeightCache[d.data.name] var list = [] if (cache) { for (var i = 0; i < cache.length; i++) { list.push({ text: cache[i], _ d._width, _height: d._height }) } } return list }) .enter() .append("text") .attr("class", "node-text") .attr("x", function(d) { return -d._width / 2 + textPadding[3] }) .attr("y", function(d, i) { // var totalHeight = d._height - textPadding[0] - textPadding[3]; // var Total -d._height / 2 + return textPadding[0] + textLineHeight/2 + i * textLineHeight }) .text(function(d) { return d.text }) .attr("dominant-baseline", "middle") }) var nodeUpdate = nodeEnter.merge(nodeELements) // 从父节点伸展到节点位置 .attr("transform", function(d) { // if (d.parent) { // var offsetY = d.parent.data.offSize || 0; // if (baseId === 'right') { // return 'translate(' + d.parent.x + ',' + (d.parent.y - offsetY) + ')' // } else { // return 'translate(' + d.parent.x + ',' + (d.parent.y + offsetY) + ')' // } // } else { var offsetY = d.data.offSize || 0; if (baseId === 'right') { return 'translate(' + d.x + ',' + (d.y - offsetY) + ')' } else { return 'translate(' + d.x + ',' + (d.y + offsetY) + ')' } // } }) .transition().duration(_duration) .attr("transform", function(d) { var offsetY = d.data.offSize || 0; if (baseId === 'right') { return 'translate(' + d.x + ',' + (d.y - offsetY) + ')' } else { return 'translate(' + d.x + ',' + (d.y + offsetY) + ')' } }); nodeUpdate.select("text.indicator-text") .text(function(d) { return d.children ? "-" : d._children ? "+" : "" }); nodeUpdate.select("circle.indicator-circle") .attr("r", function(d) { return d.children || d._children ? 8 : 1e-8 }) .style("display", function(d) { return d.children || d._children ? "block" : "none" }) var nodeExit = nodeELements.exit() .transition().duration(_duration) .attr("transform", function(d) { // if (baseId === 'right') { // return 'translate(' + d.x + ',' + d.y + ')' // } else { // return 'translate(' + d.x + ',' + d.y + ')' // } // 收缩回父节点 if (d.parent) { var offsetY = d.parent.data.offSize || 0; if (baseId === 'right') { return 'translate(' + d.parent.x + ',' + (d.parent.y - offsetY) + ')' } else { return 'translate(' + d.parent.x + ',' + (d.parent.y + offsetY) + ')' } } else { var offsetY = d.data.offSize || 0; if (baseId === 'right') { return 'translate(' + d.x + ',' + (d.y - offsetY) + ')' } else { return 'translate(' + d.x + ',' + (d.y + offsetY) + ')' } } }) .call(function(node) { node.select("rect.node-text") .attr("r", 1e-6) // 0.000001 .remove(); node.select("text.indicator-text") .text(function(d) { return "" }) .remove(); node.select("circle.indicator-circle") .attr("r", function(d) { return 1e-8 }) .remove(); node.select("rect.node-background") .remove() node.selectAll("text.node-text") .remove() }) .remove(); // reCalcMaxHeight(nodes); // renderLabels(nodeEnter, nodeUpdate, nodeExit, root); } function toggle(d) { if(d.children) { d._children = d.children; d.children = null } else { d.children = d._children; d._children = null } } function renderLinks(root, baseId) { var nodes = root.descendants().slice(1); var links = _bodyG.selectAll("path.link._" + baseId) .data(nodes, function(d) { return d.id || (d.id = ++_i); }) links.enter() .insert("path") .merge(links) .attr("class", "link _" + baseId) .transition().duration(_duration) .attr("d", function(d) { return generateLinkPath(d, d.parent, baseId) }) links.exit().remove(); } function generateLinkPath(target, source, baseId) { // console.log('顶点位置', target, source); // (d.y + offsetY) // 直线链接 // var path = d3.path(); // if (baseId === 'left') { // path.moveTo(source.x, source.y + (source.data.offSize || 0) + (source._height || 0)); // path.lineTo(target.x, target.y + (target.data.offSize || 0)); // } else if (baseId === 'right') { // path.moveTo(source.x, source.y - (source.data.offSize || 0)); // path.lineTo(target.x, target.y - (target.data.offSize || 0) + (target._height || 0)); // } // return path.toString() // 曲线链接 // var path = d3.path(); // // // bezierCurveTo 三阶贝塞尔曲线: // // // 控制点 1 的 x坐标 // // // 控制点 1 的 y坐标 // // // 控制点 2 的 x坐标 // // // 控制点 2 的 y坐标 // // // 结束点 的 x坐标 // // // 结束点 的 y坐标 // // 折线链接方式 // var centerPointX = source.x - target.x; // var sourceY, targetY; // if (baseId === 'left') { // sourceY = source.y + (source.data.offSize || 0) + (source._height || 0); // targetY = target.y + (target.data.offSize || 0); // } else if (baseId === 'right') { // sourceY = source.y - (source.data.offSize || 0); // targetY = target.y - (target.data.offSize || 0) + (target._height || 0); // } // var centerPointY = sourceY - targetY; // path.moveTo(target.x, targetY); // path.bezierCurveTo( // target.x, // (targetY + sourceY) / 2, // source.x, // (targetY + sourceY) / 2, // source.x, // sourceY // ); // return path.toString() // 折线链接方式 var centerPointX = source.x - target.x; var sourceY, targetY; if (baseId === 'left') { sourceY = source.y + (source.data.offSize || 0) + (source._height || 0); targetY = target.y + (target.data.offSize || 0); } else if (baseId === 'right') { sourceY = source.y - (source.data.offSize || 0); targetY = target.y - (target.data.offSize || 0) + (target._height || 0); } var centerPointY = sourceY - targetY; var d = `M${target.x},${targetY}v${centerPointY / 3}h${centerPointX}v${centerPointY * 2 / 3}`; return d } _chart.nodes = function(node) { if(!arguments.length) return _nodes; _nodes = node; return _chart } _chart.valueAccessor = function(accessor) { if(!arguments.length) return _valueAccessor; _valueAccessor = accessor; return _chart } _chart.width = function(width) { if(!arguments.length) return _width; _width = width; return _chart } _chart.height = function(height) { if(!arguments.length) return _height; _height = height; return _chart } _chart.margins = function(margin) { if(!arguments.length) return _margin; _margin = margin; return _chart } _chart.colors = function(color) { if(!arguments.length) return _colors; _colors = color; return _chart } return _chart } function size(d) { return d.size } function count() { return 1 } function clip(d) { d.valueAccessor(chart.valueAccessor() == size ? count : size) } function randomData(base) { base = base || 10 return Math.random() * base } // 获取系统默认字体 function getSysFont(text) { var span = document.createElement('span'); var fontFamily = ''; span.innerHTML = text; span.style.display = 'none'; document.body.appendChild(span); fontFamily = getComputedStyle(span).fontFamily; span.parentNode.removeChild(span); return fontFamily; } // 计算文本宽度 function calcTextWidth(text, font, canvas2dContext) { if (canvas2dContext) { canvas2dContext.font = font; return canvas2dContext.measureText(text).width } else { var canvas = document.createElement("canvas"); var context = canvas.getContext("2d"); context.font = font; var metrics = context.measureText(text); canvas = null; return metrics.width } } // 文本断行 function calcBreakWord (text, limitWidth, fontSize, canvas2dContext) { var defaultSysFont = getSysFont(text), textWidth = calcTextWidth(text, fontSize + 'px ' + defaultSysFont, canvas2dContext), // 计算字符串总宽度; res = {length: 0}, len = text.length, i = 0, char = '', lastCharUnit = '', lastIndex; if (textWidth > limitWidth) { while(i < len) { char += text[i]; if (calcTextWidth(char, fontSize + 'px ' + defaultSysFont, canvas2dContext) > limitWidth) { lastIndex = i; lastCharUnit = text[i]; res[res.length] = char.slice(0, -1); res.length += 1; char = lastCharUnit; } i++; } // 添加末尾 if (lastIndex < len) { res[res.length] = text.slice(lastIndex); res.length += 1; } } else { res[res.length] = text; res.length += 1; } return res; } var chart = tree(); chart.nodes(data).valueAccessor(size).render() </script> </body> </html>
var data = { left: { name: 'Boss', bgColor: '#F6AA65', id: 1, children: [ { name: 'left-1', bgColor: '#D0E1F1', total: 10, children: [ { name: 'left-1-1' }, { name: 'left-1-2' }, { name: 'left-1-3' }, { name: 'left-1-4' }, { name: 'left-1-5', children: [ { name: 'left-1-5-1 来一个我们不一样' } ] }, { name: 'left-1-6' }, { name: 'left-1-7' }, { name: 'left-1-8' }, { name: 'left-1-9' }, { name: 'left-1-10' }, ] }, { name: 'left-2', bgColor: '#C3CFEF', total: 22, children: [ { name: 'left-2-1 文字来长一点断个行, 计算好叠加的坐标', children: [ { name: 'left-2-1-1 再来一个; 其中之一而已, 要在一个分支上的不断叠加', children: [ {name: 'left-2-1-1-1'}, {name: 'left-2-1-1-2'} ] }, { name: 'left-2-1-2' } ] }, { name: 'left-2-2' }, { name: 'left-2-3' }, { name: 'left-2-4' }, { name: 'left-2-5' }, { name: 'left-2-6' }, { name: 'left-2-7' }, { name: 'left-2-8' }, { name: 'left-2-9' }, { name: 'left-2-10' }, { name: 'left-2-11' }, { name: 'left-2-12' } ] }, { name: 'left-3', bgColor: '#D7D7D7', total: 0, children: [] } ] }, right: { name: 'Boss', bgColor: '#F6AA65', id: 1, children: [ { name: 'right-1', bgColor: '#BFCDE3', total: 63, children: [ { name: 'right-1-1' }, { name: 'right-1-2' }, { name: 'right-1-3' }, { name: 'right-1-4' }, { name: 'right-1-5' }, { name: 'right-1-6' }, { name: 'right-1-7' } ] }, { name: 'right-2', bgColor: '#B9D4F3', total: 18326, children: [ { name: 'right-2-1' }, { name: 'right-2-2' }, { name: 'right-2-3' }, { name: 'right-2-4' }, { name: 'right-2-5' } ] }, { name: 'right-3', bgColor: '#D7D7D7', total: 0, children: [] } ] } }
实现上比较粗糙, 但也应该能够应对一般的业务场景了. 像天眼查企业图谱的树形结构, 对展开事件变换处理下即可。
实现上关键点在于
d3.tree().nodeSize([120, 80]) 这一行代码, d3.tree() 会提供我们一个树形布局算法, 支持 [nodeSize | size]两种方式
nodeSize: 为每一个节点设定固定大小的布局
size: 为整个树设定固定大小的布局
separation 额外支持设置相邻节点之间的间隔
设想一下:
在一块画布上, 针对未知层级未知数量的节点进行布局的方案, 如果是需要固定树大小, 那么就需要找出最边界的节点(上 | 下 | 左 | 右),
上下(深度)确定每个节点所占的高度, 左右(跨度)确定每个节点所占宽度;
而如果是固定节点大小, 通过计算当前(父)节点的最大子节点数量和子节点数量, 则可以确定(父)节点的宽度和横向位置, 高度累加;
d3 帮我们完成了从: 树结构数据 -> 包含坐标信息的数据 的一个运算;
得到了坐标信息, 再对坐标进行一个平移, 以达到居中;
如果要把节点内容所占宽高也算进去, 则还需要计算出节点的偏移距离(因为上层节点的位置是会影响的), 在具体绘制时把节点坐标和偏移都进行计算, 这样便达到了最终的布局效果;
以下是 d3.tree() 的实现;
// Node-link tree diagram using the Reingold-Tilford "tidy" algorithm function tree() { var separation = defaultSeparation$1, dx = 1, dy = 1, nodeSize = null; function tree(root) { var t = treeRoot(root); // Compute the layout using Buchheim et al.’s algorithm. t.eachAfter(firstWalk), t.parent.m = -t.z; t.eachBefore(secondWalk); // If a fixed node size is specified, scale x and y. if (nodeSize) root.eachBefore(sizeNode); // If a fixed tree size is specified, scale x and y based on the extent. // Compute the left-most, right-most, and depth-most nodes for extents. else { var left = root, right = root, bottom = root; root.eachBefore(function(node) { if (node.x < left.x) left = node; if (node.x > right.x) right = node; if (node.depth > bottom.depth) bottom = node; }); var s = left === right ? 1 : separation(left, right) / 2, tx = s - left.x, kx = dx / (right.x + s + tx), ky = dy / (bottom.depth || 1); root.eachBefore(function(node) { node.x = (node.x + tx) * kx; node.y = node.depth * ky; }); } return root; } // Computes a preliminary x-coordinate for v. Before that, FIRST WALK is // applied recursively to the children of v, as well as the function // APPORTION. After spacing out the children by calling EXECUTE SHIFTS, the // node v is placed to the midpoint of its outermost children. function firstWalk(v) { var children = v.children, siblings = v.parent.children, w = v.i ? siblings[v.i - 1] : null; if (children) { executeShifts(v); var midpoint = (children[0].z + children[children.length - 1].z) / 2; if (w) { v.z = w.z + separation(v._, w._); v.m = v.z - midpoint; } else { v.z = midpoint; } } else if (w) { v.z = w.z + separation(v._, w._); } v.parent.A = apportion(v, w, v.parent.A || siblings[0]); } // Computes all real x-coordinates by summing up the modifiers recursively. function secondWalk(v) { v._.x = v.z + v.parent.m; v.m += v.parent.m; } // The core of the algorithm. Here, a new subtree is combined with the // previous subtrees. Threads are used to traverse the inside and outside // contours of the left and right subtree up to the highest common level. The // vertices used for the traversals are vi+, vi-, vo-, and vo+, where the // superscript o means outside and i means inside, the subscript - means left // subtree and + means right subtree. For summing up the modifiers along the // contour, we use respective variables si+, si-, so-, and so+. Whenever two // nodes of the inside contours conflict, we compute the left one of the // greatest uncommon ancestors using the function ANCESTOR and call MOVE // SUBTREE to shift the subtree and prepare the shifts of smaller subtrees. // Finally, we add a new thread (if necessary). function apportion(v, w, ancestor) { if (w) { var vip = v, vop = v, vim = w, vom = vip.parent.children[0], sip = vip.m, sop = vop.m, sim = vim.m, som = vom.m, shift; while (vim = nextRight(vim), vip = nextLeft(vip), vim && vip) { vom = nextLeft(vom); vop = nextRight(vop); vop.a = v; shift = vim.z + sim - vip.z - sip + separation(vim._, vip._); if (shift > 0) { moveSubtree(nextAncestor(vim, v, ancestor), v, shift); sip += shift; sop += shift; } sim += vim.m; sip += vip.m; som += vom.m; sop += vop.m; } if (vim && !nextRight(vop)) { vop.t = vim; vop.m += sim - sop; } if (vip && !nextLeft(vom)) { vom.t = vip; vom.m += sip - som; ancestor = v; } } return ancestor; } function sizeNode(node) { node.x *= dx; node.y = node.depth * dy; } tree.separation = function(x) { return arguments.length ? (separation = x, tree) : separation; }; tree.size = function(x) { return arguments.length ? (nodeSize = false, dx = +x[0], dy = +x[1], tree) : (nodeSize ? null : [dx, dy]); }; tree.nodeSize = function(x) { return arguments.length ? (nodeSize = true, dx = +x[0], dy = +x[1], tree) : (nodeSize ? [dx, dy] : null); }; return tree; }