d3 tree 实现双向动画树总结

news2024/11/25 22:50:01

使用d3.js 实现双向tree,并实现节点展开收起动画。

使用svg 绘制。

效果图

d3

 d3可与快速选择批量的节点。类似jquery一样可选择元素并更改其属性值。

选择节点并设置属性

import * as D3 from 'd3';
let svg = D3.select('.tree')
  .attr("width", 800)
  .attr("height", 600)
  .attr("class", "d3-tree")

 添加子节点

svg.append('g')

append函数返回了添加节点的引用,因此,后面继续链式调用.attr() 则为在g 元素上进行操作。

移除选中的节点

svg.selectAll('g').remove();

 selectAll 会将满足选择器条件的节点选中,后面链式调用remove() 方法,表示将这些选中的节点移除。

不像document.querySelectAll 一样返回一个数组,再遍历移除。d3 链式调用作用于其选中的集合。这也是我感觉比较好的地方。

d3.tree

是给树形结构的对象加上x,y,depth 属性。

默认状态下。

  • 根节点位置计算为(0,0)
  • 子节点垂直向下。
const treeLayout = D3.tree<TreeData>()
    .nodeSize([treeConfig.nodeHeight, treeConfig.nodeWidth]) // 设置tree节点的大小
    .separation((a, b) => {
      // 根据是否为同一父节点设置节点距离比例
      return a.parent === b.parent ? 1 : 2;
    });

它计算出来的是密集树,nodeSize 就是下面图的蓝色区域,不影响布局。黄点为最总计算出的坐标。

separation 方法用来控制节点间的间距。回调中判断相同父节点的间距为1(也就是没有距离),不同父节点间距离为两倍。

节点间没有间距
节点间没有间距
不同父节点间为1倍间距

 此时,d3 tree 的配置已经好了。(转为横向后面再说)

接着就需要将树形结构的对象传入,treeLayout,使其生成坐标。

普通的数据类型是不行了。必须要将数据源使用D3.hierarchy(data)进行包装。

D3.hierarchy 

比方我的源数据data是这样

D3.hierarchy(data) 转换后

 

可以看到所有数据都被包了一层,添加了一些属性,depth,height,parent,原数据都被装入data属性中。

之后使用这个包装过的数据,调用treeLayout 即可计算出树节点的位置信息。


const nodes = treeLayout(hierarchyData)

 

这样每个节点就有x,y坐标了


变为横向树

 实际上就是把x,y 互换位置。这样树图就变成横向了。

nodes.each(a => {
  [a.x, a.y] = [a.y, a.x]; // 旋转90度
});

each 方法也是d3自带的,用于遍历树的方法,否则自己遍历也可以。


 变为左右两侧树

其先将树右边的上半部分x值取反。

此时需要将左右侧树居中

居中计算方式为,y坐标点 - (children[0] + children.at(-1))/2

所以左侧树的数据务必要在数组的一端。如children:[{left},{left},{right},{right}]; 或children:[{right},{right},{left},{left}];

否则就会导致左右不对称了。

下面是分割树的代码:

/**将树分成左右两边,横坐标置反 */
export function separateTree(nodes: HierarchyPointNode<TreeData>) {
  const leftTree: HierarchyPointNode<TreeData>[] = [];
  const rightTree: HierarchyPointNode<TreeData>[] = [];
  nodes.children?.forEach(child => {
    if (child.data.align === 'left') leftTree.push(child);
    else rightTree.push(child);
  });
  // 左右树分开,并垂直居中
  const leftMiddleOffset = leftTree.length > 1 ? (leftTree[0].y + leftTree.at(-1)!.y) / 2 : leftTree[0]?.y || 0;
  leftTree.forEach(a => {
    a.descendants().forEach(b => {
      b.x = -b.x;
      b.y -= leftMiddleOffset;
    });
  });
  const rightMiddleOffset = rightTree.length > 1 ? (rightTree[0].y + rightTree.at(-1)!.y) / 2 : rightTree[0]?.y || 0;
  rightTree.forEach(a => {
    a.descendants().forEach(b => {
      b.y -= rightMiddleOffset; // 垂直居中
    });
  });
}

此时数据准备好就可以开始绘制节点了。

绘制节点

 const allNodesGroup = this.#$nodeGroup
      .selectAll('.' + treeConfig.className.nodeGroup)
      .data(this.#nodes.descendants(), (d: any) => keyGen(d.data, this.key))
      .join(
        enter => enter ,
        update => update,
        exit => exit 
        },
      )
      .attr('class','.' + treeConfig.className.nodeGroup );

selectAll 函数将返回满足指定选择器的集合。

data 入参为nodes.descendants(), descendants 意为“后代”的意思。第二个参数理解为唯一id。

注:回调参数d 的类型 hierarchy对象类型,因此用d.data访问源数据。

其实就是将nodes 的树形结构展平为一维数组结构了。

这里使用data 方法,就可以在selectAll 找不到的时候,生成节点。data的第二个参数为唯一键。这个唯一键用于标识下面join函数中新节点:enter,更新的节点:update,移除的节点:exit。

重新组织语言。。。

首先根据selectAll函数得到节点(当然第一次为空),与data函数中的节点做diff比较。有点像vue这样的虚拟dom的感觉,比较后,d3就能判断哪些节点是新的,哪些节点是被移除的了,我们只需要改变数据源,之后再在entry,exit回调中对这些动作的节点做操作即可。(这里不说更新是因为我没用到,也不知道什么时候会进入update回调)

join函数的返回为enter 回调的返回。

因此我在enter回调中新增节点。比如

enter => {
  const g = enter.append('g');
  const rootNodes = g.filter(node => node.depth === 0);
  const parentNodes = g.filter(node => node.data.nodeType === 'parent');
  const moreNodes = g.filter(node => node.data.nodeType === 'more');
  const leafNodes = g.filter(node => !node.data.nodeType);
  const lineTextNodes = g.filter(node => Boolean(node.data.lineText));
  addRootNode.call(this, rootNodes);
  addParentNode.call(this, parentNodes);
  addMoreNode.call(this, moreNodes);
  addLeafNode.call(this, leafNodes);
  addLineText.call(this, lineTextNodes);
  return g;
},

这里我将每个节点都包一层g标签,创建节点的过程都抽出成函数了。

用添加叶子节点的函数举例子:

export function addLeafNode(this: Tree, d3Selection: NodeSelection) {
  const fObj = filterNotVisibleNode
    .call(this, d3Selection)
    .append('foreignObject')
    .attr('width', leafNodeWidth)
    .attr('height', leafNodeHeight)
    .attr('class', 'leaf-node-wrapper')
    .attr('transform', d => {
      return `translate(-${d.x < 0 ? leafNodeWidth : 0},-${leafNodeHeight / 2})`;
    })
    .on('click', (e, d) => {
      this.dispatchEvent('leafClick', d.data, d);
    });
  fObj
    .append('xhtml:div')
    .attr('class', 'leaf-node')
    .attr('title', d => d.data.name)
    .style('text-align', d => d.x < 0 && 'right') // 左侧树,右对齐
    .append('xhtml:span')
    .attr('class', 'node-text')
    .text(d => d.data.name);
  return fObj;
}

绘制节点由于借用html DOM的文字排版能力。这里创建了ForeignObject元素,用于在svg中插入html元素。再借助transform 微调节点到正确位置。

绘制连接线

this.#$linkGroup
  .selectAll('.' + treeConfig.className.linkGroup)
  .data(this.#nodes.links(), (d: any) => keyGen(d.target.data, this.key)) 
...

和绘制节点类似,不同的是这里data中传入了nodes.links() 。

这个方法将nodes的树形结构展平并将每个节点变为{ source, target }对象的形式。比如source 为root节点,target为连接的节点。

用descendents()也可以,因为每个节点都可通过parent的方式访问父节点信息,不同的是,要额外处理根节点的情况,因为parent == null。

使用links() 函数更方便。

既然知道sourceNode ,targetNode 的位置信息,就可以使用svg 的path标签绘制连接线了。

连接线的转折点我选择定在开始结束位置的1/2处。

折叠和展开节点

为节点添加点击事件。我把可展开的节点记做“parent”。

节点元素.on('click', (e, d) => {

});

上面提到,我这里标的回调参数d,其类型为hierarchy类型。因此对节点乃至树的信息具有完整的访问权限。

折叠节点就是将原d.children中的内容保存到自定义的一个属性中比如d._children。这样下次渲染树的时候就不会渲染其子节点了。同时,这些节点也被join函数识别成为exit的节点。

因此需要在node和link 的exit 的回调中,将这些节点移除。

exit => {
    exit.remove();
}

折叠展开动画

动画的实现方式均遵循FLIP(First, Last, Invert, Play)动画技术,即在位置发生改变后,在改变后的那一时刻,通过transform 移动到原来的位置,再通过transition播放动画,将元素从刚才的位置移动到现在的位置。这里不过多讨论此技术。

因此基于这种动画模式来说,在元素位置变动后,知道这个元素之前的位置,就十分重要了。

首先看收起节点

收起节点动画

在收起的节点中保存父节点的位置信息。

在join的exit回调用,通过动画更新节点的位置。

由于节点的位置在收起后有可能发生改变,因此,就需要在树节点位置更新后再获取父节点的位置。这里就在exit回调用获取父节点位置。

其次,考虑节点不只有一级的,点一级节点后,2级,3级,4级...所有下面的节点都要移动到点击的这个节点上。

exit => {
  // 节点移除,收起动画
  exit
    .filter((d: any) => !d.transition)
    .transition()
    .duration(treeConfig.animationDuration)
    .attr('opacity', 0)
    .attr('transform', (d: any) => {
      const position = parentPositionStore.getPosition(d, d.exitToParent).join(',');
      return `translate(${position})`;
    })
    .remove();
},

根据这个思路,在点击一个节点时,将其所有子节点的移动目标设置为当前节点。

如下

toggleNode(d: any) {
  if (d.depth !== 0) {
    if (d.children && !d._children) {
      // 需要收起
      eachChildren(d.children, (child: any) => {
        child.exitToParent = keyGen(d.data, this.key); // 表明
      });
      d._children = d.children;
      delete d.children;
    } else if (d._children) {
      // 展开
     ...
    }
  }
}

 eachChildren 是我封装的,用于遍历树节点的方法。

可以看到,在每个子节点上都标记了需要收起到的节点的id (exitToParent)

回到上面exit回调的代码,

position 的位置从当前位置向上找,直到找到exitToParent记录的id的节点。

实现方式为while 循环,d.parent 一层层向上找。

因此,考虑到节点深且多的画,每个节点都走这个逻辑就可能会有性能问题。

为解决这个问题,我用上面代码中使用的parentPositionStore做了Map缓存。

展开节点动画

展开节点相对收起来说更顺利一些,步骤为

  1. 在所有后代节点中记录点击节点的位置信息。
  2. 在join函数的entry回调中移动到记录的位置信息。
  3. 删除记录的位置信息。
toggleNode(d: any) {
  if (d.depth !== 0) {
    if (d.children && !d._children) {
      // 需要收起
      //...
    } else if (d._children) {
      // 展开
      d.lineStartPosition = [d.x, d.y];
      d.children = d._children;
      eachChildren(d.children, (child: any) => {
        child.nodeStartPosition = [d.x, d.y]; // 动画开始位置
        child.lineStartPosition = [d.x, d.y]; // 动画开始位置
      });
      delete d._children;
    }
  }
}

这里也记录了连接线节点的动画位置信息。

对于节点来说,在刚才的nodes.join() 的entry回调中,没有设置节点的位置。而是重新选中所有需要动画的节点,根据FLIP动画规则,通过transform移动到节点动画开始位置。

再将所有节点通过动画移动到实际的位置。

allNodesGroup
  .filter((d: any) => d.nodeStartPosition)
  .attr('opacity', 0)
  .attr('transform', (d: any) => {
    const transform = `translate(${d.nodeStartPosition[0]}, ${d.nodeStartPosition[1]})`;
    delete d.nodeStartPosition;
    return transform;
  });
// 节点展开动画
allNodesGroup
  .transition()
  .duration(treeConfig.animationDuration)
  .attr('opacity', 1)
  .attr('transform', d => `translate(${d.x},${d.y})`);
}

同时用opacity 做0-1的透明度渐变。

若节点没有nodeStartPosition 属性,则节点位置(transform)不变,则不会有动画效果。

这里补充一下,所有节点我都是在root节点(0,0) 位置生成,通过transform移动到实际位置的。

展开收起连接线

和展开收起节点方式大体类似。

添加查看更多节点

鉴于产品需求,当叶子节点超过5个时,其余叶子节点收起并展示为“查看更多”节点。

这个效果和展开收起节点的方案基本一致

  1. children.slice 多余叶子节点。
  2. 添加“查看更多”节点。
  3. “查看更多”节点的depth,heigth,parent属性要保持与当前兄弟叶子节点相同。
  4. 将节点保存到一个临时属性中。
  5. 点击展开后,从临时属性中恢复节点信息到children,
  6. 移除“查看更多”节点

多余节点保存进临时变量的操作,我在通过D3.hierarchy(data)转换数据之后就进行了。

点击查看更多节点代码:

/** 点击查看更多 */
showMore(d: HierarchyPointNode<TreeData>) {
  const { parent } = d;
  if (!parent) return;
  // 去除"查看更多"节点
  parent.children = parent.children?.slice(0, -1);
  (d as any).transition = 'fadeOut'; // 节点动画临时变为 渐出
  eachChildren((d as any).moreData, (child: any) => {
    child.nodeStartPosition = [d.x, d.y]; //新节点从父节点移出
    child.lineStartPosition = [parent.x, parent.y, d.x, d.y]; // 曲线开始与结束位置
  });
  parent.children?.push(...(d as any).moreData);
  delete (d as any).moreData;
}

这里需要注意的是展开更多的动画了。

我希望效果和节点展开是不一样的,展开的节点不能从它的父节点飞出来吧。

所以我做了特殊判断,加了transition属性用于区分。同时曲线动画开始结束位置不再从其父节点展开了,所以lineStartPosition 传递了4个值。分别是[startx,starty,endx,endy](曲线两端坐标)

同时“展开更多”此节点会进入join的exit回调,这个节点的移除动画也要做相应调整。

这里不过多赘述。

高亮节点

实现如下效果

主要关注的逻辑如下:

  • 命中叶子节点,需要将整条链路都高亮。
  • 命中节点父节点已被收起时要主动展开。
  • 命中的叶子节点在“查看更多”中时,也需要主动展开。
/**
 * 高亮
 * @param id
 */
highlightNode(ids: string[] | string = []) {
  if (typeof ids === 'string') ids = [ids];
  if (!ids.length) {
    this.resetHighlight();
    this.renderTree();
    return;
  }

  const highlightHelper = new HighlightHelper({
    highlightKeyName: 'highlight',
    highlightRule: node => ids.includes(keyGen(node.data, this.key)) && node.data.nodeType !== 'more',
  });
  this.#highlightMode = highlightHelper.setHighlightFlag(this.#nodes); // 增加高亮标记

  const expandStack = highlightHelper.getExpandStack();
  // 从根节点开始一步步展开
  let n: any;
  while ((n = expandStack.pop())) {
    if (n._children) this.toggleNode(n); // 展开节点
    if (n.moreData) this.showMore(n); // 展开“查看更多”
  }
  this.updateTreeNodePosition();
  this.renderTree();
}

由于从叶子节点往根节点依次展开会有问题,所以我先记录了需要展开的顺序,并从根节点往叶子节点展开;

其中HighlightHelper主要为给节点增加高亮的class类,代码略多,就抽离出去了。

HighlightHelper.prototype.getExpandStack 得到需要展开的节点数组。

缩放与拖拽

直接使用d3.zoom 的封装。

这里要注意的是,我在svg标签上使用d3.zoom,发现鼠标点击事件被停止冒泡了。

其次关于调用d3.zoom.scaleBy/translateBy 等等方法,第一个参数务必传当时监听的对象。

比如

this.#$svg.call(this.#$zoom as any);
this.#$zoom.scaleBy(this.#$svg.transition().duration(200) as any, num);

 否则zoom不会保存调用这种方式后的位置信息。一旦拖拽,滚动,就会从上次的位置开始。

大数据优化

根据前端表格虚拟滚动的经验,数图也可以将可视区外的节点隐藏。

方案为:

  1. 拖动或缩放后。
  2. remove所有节点和连接线。
  3. entry 节点的时候,计算每个节点的x,y坐标,把不在可视区中的数据过滤。
  4. 重绘树图。

应该有更好的优化方案。在不删除所有节点的情况下。

d3.zoom.on('end'),会在zoom动作结束后触发。

但是这个该死的zoom又停止冒泡了点击事件。之前在节点上on('click' 的这些事件都失效了,改成on('mousedown' 才行。这个还未弄清楚有没有更好的方式。

 

源码

d3/src/Tree · JA+/html-test - 码云 - 开源中国 (gitee.com)

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/383459.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

线程池中shutdown()和shutdownNow()方法的区别

线程池中shutdown()和shutdownNow()方法的区别 一般情况下&#xff0c;当我们频繁的使用线程的时候&#xff0c;为了节约资源快速响应需求&#xff0c;我们都会考虑使用线程池&#xff0c;线程池使用完毕都会想着关闭&#xff0c;关闭的时候一般情况下会用到shutdown和shutdow…

UVa 817 According to Bartjens 数字表达式 DFS ID 迭代加深搜 逆波兰表达式

题目链接&#xff1a;According to Bartjens 题目描述&#xff1a; 给定一个由数字和一个组成的字符串&#xff0c;你需要在数字之间添加,−,∗,-,*,−,∗三种符号&#xff0c;在保证表达式合法的情况下&#xff08;同时形成的新的数字不能有前导零&#xff09;&#xff0c;使表…

java工具jconsole/jstat学习

参考视频【java】jvm指令与工具jstat/jstack/jmap/jconsole/jps/visualVM_哔哩哔哩_bilibili 一、jps 我们再windows和linux都可以看到哪些java进程。 有小伙伴又会问了 这个类是java的 那其他的这么多进程18096 /8685 这些是啥啊 其实也是java进程&#xff0c;只不过是其他程…

23.3.1调研

上一篇调研之后&#xff0c;还是没有思路&#xff0c;继续调研文献。 文章目录WEAKLY SUPERVISED EXPLAINABLE PHRASALREASONING WITH NEURAL FUZZY LOGIC模型结构ASK ME ANYTHING: A SIMPLE STRATEGY FOR PROMPTING LANGUAGE MODELSHumanly Certifying Superhuman Classifiers…

即时通讯和实时通讯的区别

即时通讯&#xff08;IM&#xff09;和实时通讯是一套网络通讯系统&#xff0c;其本质都是对信息进行转发。最大的不同点是对信息传递的时间规定。二者的区别可以从以下几个方面&#xff1a;一、场景常见的即时通讯 场景包括文字聊天、语音消息发送、文件传输、音视频播放等。通…

【RocketMQ】消息的刷盘机制

刷盘策略 CommitLog的asyncPutMessage方法中可以看到在写入消息之后&#xff0c;调用了submitFlushRequest方法执行刷盘策略&#xff1a; public class CommitLog {public CompletableFuture<PutMessageResult> asyncPutMessage(final MessageExtBrokerInner msg) {// …

docker(一)简介、环境搭建

文章目录前言一、docker简介1.什么是docker2. 什么是容器3.传统的虚拟化技术和容器之间的差别4.容器是如何运行的二、docker环境部署及测试1.环境部署&#xff1a;2.通过镜像运行容器3.拉取镜像前言 一、docker简介 1.什么是docker Docker是一个开源的应用容器引擎&#xff0…

TDEngine集群监控组件安装配置(Telegra+Grafana方案)

Tdengine的监控指标包括以下几个方面&#xff1a; 系统指标&#xff1a;CPU使用率、内存使用率、磁盘空间、网络流量等。数据库指标&#xff1a;连接数、查询数、写入数、读取数等。SQL指标&#xff1a;执行时间、执行计划、索引使用情况等。集群指标&#xff1a;节点状态、数…

生命周期:Vue,微信小程序

目录 一、vue2生命周期&#xff08;钩子函数&#xff09; 二、vue3生命周期&#xff08;钩子函数&#xff09; 三、vue-router3钩子函数&#xff08;与vue2匹配&#xff09; 1、全局钩子&#xff08;全局守卫&#xff09; 2、路由内钩子&#xff08;路由独享的守卫&#x…

Python3-字典

Python3 字典 字典是另一种可变容器模型&#xff0c;且可存储任意类型对象。 字典的每个键值 key>value 对用冒号 : 分割&#xff0c;每个对之间用逗号(,)分割&#xff0c;整个字典包括在花括号 {} 中 ,格式如下所示&#xff1a; d {key1 : value1, key2 : value2, key3…

HTTP头注入

HTTP头注入HTTP Header概述HTTP报文类型HTTP请求方法HTTP Header内容HTTP Header注入HTTP Header注入实例User-Agent注入XFF注入HTTP Header概述 HTTP报文类型 请求报文&#xff1a;由客户端发送给服务器的消息&#xff0c;其组成包括请求行&#xff0c;请求头和请求体。响应…

区块链安全正当时|《Hyperledger Fabric2.0架构安全报告》解读

2021年&#xff0c;《中华人民共和国国民经济和社会发展第十四个五年规划和2035年远景目标纲要》将区块链纳入数字产业&#xff0c;对其发展作出了重要部署。同年6月中央网信办和工信部联合印发了《关于加快推动区块链技术应用和产业发展的指导意见》&#xff0c;提出加快区块链…

Babel-好文

ES6的很多语法在浏览器甚至node环境中无法执行&#xff0c;babel就是一个广泛使用的转码器&#xff0c;可以将ES6代码转化成ES5代码定义一个文件夹&#xff0c;cmd进入到其中&#xff0c;运行npm install -g babel-cli安装&#xff0c;然后可以通过babel --version查看是否安装…

odoo15 登录界面的标题自定义

odoo15 登录界面的标题自定义 原代码中查询:<title>Odoo<title> <html> <head><meta http-equiv="content-type" content="text/html; charset=utf-8" /><title>Odoo</title><link rel="shortcut icon…

Jvm调优实战笔记

一、基础命令jps 查看所有java进程jinfo 进程号 查看该线程相关信息3、jstat 统计信息&#xff08;数据跟踪信息&#xff09;jstat -gc 进程号 查看该线程在内存中每一块占用的大小jstat -gc 进程号 时间&#xff08;毫秒&#xff09; 更新频率4、jstack 跟踪线程jstack 进程号…

Ethercat系列(10)用QT实现SOEM主站

首先将SOEM编译成静态Lib库可以参考前面的博文(83条消息) VS2017下编译SOEM(Simle Open EtherCAT Master)_soem vs_CoderIsArt的博客-CSDN博客make_libsoem_lib.bat "C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Auxiliary\Build" x86用QT创建…

【C++】多态虚表

多态——多种形态多态的分类&#xff1a;1.静态多态&#xff1a;函数重载和运算符重载&#xff08;复用函数名&#xff09;2.动态多态&#xff1a;派生类和虚函数实现运行时多态静态多态和动态多态的区别&#xff1a;静态多态的函数地址早绑定——编译阶段确定函数地址动态多态…

页面访问升级出错怎么解决

相信大家在访问网站的时候时常会遇到页面访问界面升级&#xff0c;暂时不可能进行访问操作&#xff0c;可能遇到这种情况很多小伙伴们都不知道怎么版&#xff0c;其实互联网网页在正常使用过程中是不会出现这种问题的。那么如果遇到页面访问界面升级怎么办?页面访问界面升级通…

TryHackMe-Binex

Binex 枚举计算机并获取交互式 shell。利用 SUID 位文件&#xff0c;使用 GNU 调试器利用缓冲区溢出并通过 PATH 操作获得根访问权限。 端口扫描 循例 nmap SMB枚举 题目给了提示&#xff1a;Hint 1: RID range 1000-1003 Hint 2: The longest username has the unsecure pa…

面试中问你查看日志的命令,可不能只说tail,cat,more

首选&#xff0c;如何查看日志&#xff1a; 很多初级测试人员&#xff0c;在进行执行测试用例这个步骤时&#xff0c;发现bug&#xff0c;不能更加的准确去定位bug&#xff0c;在这样的情况下就可以打开Linux服务器&#xff0c;敲命令查看操作进行中的实时日志&#xff0c;当系…