d3树形图、企业图谱雏形(展开、收起、放大、缩小、重置功能)

2,031 阅读6分钟

前言

网上关于d3的教程比较少,在此通过一个树形图的示例让大家加深对d3的理解;并可以此为基础开发企业图谱这种业务功能

一、实现效果

SDGIF_Rusult_1.gif

二、d3 API

1. d3.hierarchy

层级布局,为每个节点指定深度等属性

var root = d3.hierarchy(data);

返回的root会多些层级信息,如depth,height,parent等属性,其中data属性指向源数据

2. d3.tree

创建一个树状图生成器

var tree = d3.tree()(root)

对指定的 root hierarchy 进行布局,为节点添加x,y坐标

3. tree.nodeSize

设置节点尺寸

tree.nodeSize([30,100])

当指定了nodeSize时,根节点的初始位置总是返回(0,0)

4. tree.separation

设置两个相邻的节点之间的距离

tree.separation((a, b) => {
  return (a.parent == b.parent ? 1 : 2) / a.depth;
});

返回的数字越大,间距越大。间距也与上面设置的nodeSize有关

5. node.descendants

返回后代节点数组,第一个节点为自身,然后依次为所有子节点的拓扑排序

root.descendants()

6. node.links

root.links()

7. d3.zoom

创建一个缩放交互

var zoom = d3.zoom()

8. zoom.scaleExtent

设置可缩放系数大小

zoom.scaleExtent([0.8,2])

9. zoom.on

监听缩放事件

zoom.on("zoom", () => {
  //监听到缩放事件后,执行的回调函数
  svg.select("g").attr("transform", d3.event.transform);
});

10. zoom.translateBy

对指定的选中元素进行平移变换,用于初始化平移时位置

//如果svg初始化时translate不是从(0,0)开始的,缩放时也要设置初始位置 
svg.call(zoom).call(zoom.translateBy,200,100)

11. zoom.scaleBy

对选中的元素进行缩放(在当前缩放基础上叠加缩放)

zoom.scaleBy(svg,1.1)

二、坐标系

1. svg坐标系

以屏幕左上角为原点(0,0),水平方向为X轴,垂直方向为Y轴

222.jpg

2. 水平树状图坐标系

d3.tree()生成的x,y坐标默认是生成垂直方向的树,如果要画水平方向的树,需要把坐标反过来

333.jpg

三、画图

1. 数据

示例数据

  var dataset = {
    name: "中国",
    children: [
      {
        name: "浙江",
        children: [
          {
            name: "杭州",
          },
          {
            name: "宁波",
          },
          {
            name: "温州",
          },
          {
            name: "绍兴",
          },
        ],
      },
    ],
  };

2. 初始化

添加dom元素

 <div style="height: 100vh" id="tree"></div>
  1. 通过d3选择器获取到id=tree节点,并添加svg画布;

  2. svg画布下添加g节点,并通过translate属性改变原点坐标

  3. 创建并调用d3缩放器,使图能够缩放、平移

  /* g:容器,root:d3转换dataset后的数据,zoomHandler:缩放器 */
  var g = null,
    root,
    zoomHandler,
    svg,
    width = document.body.clientWidth,
    height = document.body.clientHeight;

  //过渡时长
  var duration = 750;

  /* 初始化 */
  function init() {
    svg = d3
      .select("#tree")
      .append("svg")
      .attr("width", width)
      .attr("height", height)
      .attr("font-size", "16px");

    g = svg
      .append("g")
      .attr(
        "transform",
        `translate(${width / 2 - 200},${height / 2}) scale(1)`
      );

    //缩放器
    zoomHandler = d3
      .zoom()
      .scaleExtent([0.8, 2]) // 缩放范围
      .on("zoom", () => {
        svg.select("g").attr("transform", d3.event.transform);
      });

    //调用缩放器
    svg
      .call(zoomHandler)
      .call(zoomHandler.translateBy, width / 2 - 200, height / 2) //初始化平移位置
      .on("dblclick.zoom", null); //禁用双击缩放事件

    dealData();
  }

3. 处理数据

  1. 将原数据通过d3转为层级数据

  2. 通过遍历,为所有节点添加 _children 属性(用来展开、缩放)

  3. 通过遍历,为所有节点添加唯一 id(添加、删除节点时,d3能够找到对应节点)

  /* 处理数据 */
  function dealData() {
    //转换成层级数据
    root = d3.hierarchy(dataset);
    /* 添加_children属性并绑定唯一标识用于实现点击收缩及展开功能 */
    root.descendants().forEach((d) => {
      d._children = d.children;
      d.id = uuid();
    });

    update(root);
  }

4. 绘制

  1. 通过d3.tree为层级数据添加空间坐标

  2. 通过nodeSize,separation分别设置节点大小和节点间距

  3. 获取页面节点并与数据中的节点进行比较

  4. 如果有需要添加的节点,通过 .enter().append('g')添加

  5. 如果有需要删除的节点,通过 .exit().remove()删除

  6. 对线条的处理方式与节点一致

  7. 遍历所以节点,通过添加x0,y0属性用于记录上一次的位置

  /* 绘制 */
  function update(source) {
    /* 每次更新需重新进行布局,为了调整展开收缩时节点之间的间距 */
    d3
      .tree()
      .nodeSize([30, 100])
      .separation((a, b) => {
        //调整节点之间的间隙
        let result =
          a.parent === b.parent && !a.children && !b.children ? 1 : 2;
        if (result > 1) {
          let length = 0;
          length = a.children ? length + a.children.length : length;
          length = b.children ? length + b.children.length : length;
          result = length / 2 + 0.5;
        }
        return result;
      })(root);
    /* 获取页面节点并与数据中的节点进行比较 */
    const node = g
      .selectAll("g.gNode")
      .data(root.descendants(), (d) => d.id);

    /* 需要增加的节点 */
    const nodeEnter = node
      .enter()
      .append("g")
      .attr("id", (d) => `g${d.id}`)
      .attr("class", "gNode")
      .attr(
        "transform",
        () => `translate(${source.y0 || 0},${source.x0 || 0})`
      )
      .attr("fill-opacity", 0)
      .attr("stroke-opacity", 0)
      .on("click", (d) => {
        clickNode(d);
      });

    //在节点中增加元素(圆圈和描述文字)
    nodeEnter.each((d) => {
      drawText(d.id);
      //有子节点
      if (d._children) {
        drawCircle(d.id);
      }
    });

    /* 去除多余的节点 */
    node
      .exit()
      .transition()
      .duration(duration)
      .remove()
      .attr("transform", () => `translate(${source.y},${source.x})`)
      .attr("fill-opacity", 0)
      .attr("stroke-opacity", 0);

    /* 更新节点 */
    node
      .merge(nodeEnter)
      .transition()
      .duration(duration)
      .attr("transform", (d) => `translate(${d.y - 16},${d.x})`)
      .attr("fill-opacity", 1)
      .attr("stroke-opacity", 1);

    /* 获取页面线条并与数据中的线条进行比较 */
    const link = g
      .selectAll("path.gNode")
      .data(root.links(), (d) => d.target.id);

    /* insert是在g标签前面插入,防止连接线挡住G节点内容 */
    const linkEnter = link
      .enter()
      .insert("path", "g")
      .attr("class", "gNode")
      .attr("d", () => {
        const o = { x: source.x0 || 0, y: source.y0 || 0 };
        return diagonal({ source: o, target: o });
      })
      .attr("fill", "none")
      .attr("stroke-width", 1)
      .attr("stroke", "#dddddd");

    /* 去除多余的线条 */
    link
      .exit()
      .transition()
      .duration(duration)
      .remove()
      .attr("d", () => {
        const o = { x: source.x, y: source.y };
        return diagonal({ source: o, target: o });
      });

    /* 更新线条 */
    link
      .merge(linkEnter)
      .transition()
      .duration(duration)
      .attr("d", diagonal);

    /* 前序遍历处理数据,增加x0,y0用于记录同一节点上一次位置 */
    root.eachBefore((d) => {
      d.x0 = d.x;
      d.y0 = d.y;
    });

    console.log(root);
  }

5. 节点点击事件

  1. 通过children赋值为空,删除子节点;通过 _children赋值给children,添加子节点
  //点击节点
  function clickNode(d) {
    //无子节点
    if (!d.children && !d._children) return;
    d.children = d.children ? null : d._children;
    //改变圆圈内竖线属性,使其呈现出来
    d3.select(`#g${d.id} .node-circle .node-circle-vertical`)
      .transition()
      .duration(duration)
      .attr("stroke-width", d.children ? 0 : 1);
    update(d);
  }

6. 放大、缩小、重置

  1. 通过zoom.scaleBy对图形进行放大缩小

  2. 通过删除svg属性,并重绘达到重置的效果

  //放大
  function onMagnifySVG() {
    zoomHandler.scaleBy(svg, 1.1);
  }

  //缩小
  function onShrinkSVG() {
    zoomHandler.scaleBy(svg, 0.9);
  }

  //重置
  function onResetSVG() {
    d3.select("svg").remove();
    init();
  }

7. 完整代码

<!DOCTYPE html>
<html>
  <head>
    <title>testD3_chp15_1.html</title>

    <script type="text/javascript" src="http://d3js.org/d3.v5.min.js"></script>

    <meta name="keywords" content="keyword1,keyword2,keyword3" />
    <meta name="description" content="this is my page" />
    <meta name="content-type" content="text/html; charset=GBK" />

    <style>
      body {
        margin: 0;
      }
    </style>

    <!--<link rel="stylesheet" type="text/css" href="./styles.css">-->
  </head>

  <body>
    <div style="height: 100vh" id="tree"></div>
    <ul style="position: absolute; top: 0; left: 0; cursor: pointer">
      <li onclick="onMagnifySVG()">放大</li>
      <li onclick="onShrinkSVG()">缩小</li>
      <li onclick="onResetSVG()">重置</li>
    </ul>

    <script>
      //数据
      var dataset = {
        name: "中国",
        children: [
          {
            name: "浙江",
            children: [
              {
                name: "杭州",
              },
              {
                name: "宁波",
              },
              {
                name: "温州",
              },
              {
                name: "绍兴",
              },
            ],
          },
          {
            name: "广西",
            children: [
              {
                name: "桂林",
                children: [
                  {
                    name: "秀峰区",
                  },
                  {
                    name: "叠彩区",
                  },
                  {
                    name: "象山区",
                  },
                  {
                    name: "七星区",
                  },
                ],
              },
              {
                name: "南宁",
              },
              {
                name: "柳州",
              },
              {
                name: "防城港",
              },
            ],
          },
          {
            name: "黑龙江",
            children: [
              {
                name: "哈尔滨",
              },
              {
                name: "齐齐哈尔",
              },
              {
                name: "牡丹江",
              },
              {
                name: "大庆",
              },
            ],
          },
          {
            name: "新疆",
            children: [
              {
                name: "乌鲁木齐",
              },
              {
                name: "克拉玛依",
              },
              {
                name: "吐鲁番",
              },
              {
                name: "哈密",
              },
            ],
          },
        ],
      };

      //随机数,用于绑定id
      function uuid() {
        function s4() {
          return Math.floor((1 + Math.random()) * 0x10000)
            .toString(16)
            .substring(1);
        }

        return (
          s4() +
          s4() +
          "-" +
          s4() +
          "-" +
          s4() +
          "-" +
          s4() +
          "-" +
          s4() +
          s4() +
          s4()
        );
      }

      /* g:容器,root:d3转换dataset后的数据,zoomHandler:缩放器 */
      var g = null,
        root,
        zoomHandler,
        svg,
        width = document.body.clientWidth,
        height = document.body.clientHeight;

      //过渡时长
      var duration = 750;

      /* 初始化 */
      function init() {
        svg = d3
          .select("#tree")
          .append("svg")
          .attr("width", width)
          .attr("height", height)
          .attr("font-size", "16px");

        g = svg
          .append("g")
          .attr(
            "transform",
            `translate(${width / 2 - 200},${height / 2}) scale(1)`
          );

        //缩放器
        zoomHandler = d3
          .zoom()
          .scaleExtent([0.8, 2]) // 缩放范围
          .on("zoom", () => {
            svg.select("g").attr("transform", d3.event.transform);
          });

        //调用缩放器
        svg
          .call(zoomHandler)
          .call(zoomHandler.translateBy, width / 2 - 200, height / 2) //初始化平移位置
          .on("dblclick.zoom", null); //禁用双击缩放事件

        dealData();
      }

      /* 处理数据 */
      function dealData() {
        //转换成层级数据
        root = d3.hierarchy(dataset);
        /* 添加_children属性并绑定唯一标识用于实现点击收缩及展开功能 */
        root.descendants().forEach((d) => {
          d._children = d.children;
          d.id = uuid();
        });

        update(root);
      }

      /* 绘制 */
      function update(source) {
        /* 每次更新需重新进行布局,为了调整展开收缩时节点之间的间距 */
        d3
          .tree()
          .nodeSize([30, 100])
          .separation((a, b) => {
            //调整节点之间的间隙
            let result =
              a.parent === b.parent && !a.children && !b.children ? 1 : 2;
            if (result > 1) {
              let length = 0;
              length = a.children ? length + a.children.length : length;
              length = b.children ? length + b.children.length : length;
              result = length / 2 + 0.5;
            }
            return result;
          })(root);
        /* 获取页面节点并与数据中的节点进行比较 */
        const node = g
          .selectAll("g.gNode")
          .data(root.descendants(), (d) => d.id);

        /* 需要增加的节点 */
        const nodeEnter = node
          .enter()
          .append("g")
          .attr("id", (d) => `g${d.id}`)
          .attr("class", "gNode")
          .attr(
            "transform",
            () => `translate(${source.y0 || 0},${source.x0 || 0})`
          )
          .attr("fill-opacity", 0)
          .attr("stroke-opacity", 0)
          .on("click", (d) => {
            clickNode(d);
          });

        //在节点中增加元素(圆圈和描述文字)
        nodeEnter.each((d) => {
          drawText(d.id);
          //有子节点
          if (d._children) {
            drawCircle(d.id);
          }
        });

        /* 去除多余的节点 */
        node
          .exit()
          .transition()
          .duration(duration)
          .remove()
          .attr("transform", () => `translate(${source.y},${source.x})`)
          .attr("fill-opacity", 0)
          .attr("stroke-opacity", 0);

        /* 更新节点 */
        node
          .merge(nodeEnter)
          .transition()
          .duration(duration)
          .attr("transform", (d) => `translate(${d.y - 16},${d.x})`)
          .attr("fill-opacity", 1)
          .attr("stroke-opacity", 1);

        /* 获取页面线条并与数据中的线条进行比较 */
        const link = g
          .selectAll("path.gNode")
          .data(root.links(), (d) => d.target.id);

        /* insert是在g标签前面插入,防止连接线挡住G节点内容 */
        const linkEnter = link
          .enter()
          .insert("path", "g")
          .attr("class", "gNode")
          .attr("d", () => {
            const o = { x: source.x0 || 0, y: source.y0 || 0 };
            return diagonal({ source: o, target: o });
          })
          .attr("fill", "none")
          .attr("stroke-width", 1)
          .attr("stroke", "#dddddd");

        /* 去除多余的线条 */
        link
          .exit()
          .transition()
          .duration(duration)
          .remove()
          .attr("d", () => {
            const o = { x: source.x, y: source.y };
            return diagonal({ source: o, target: o });
          });

        /* 更新线条 */
        link
          .merge(linkEnter)
          .transition()
          .duration(duration)
          .attr("d", diagonal);

        /* 前序遍历处理数据,增加x0,y0用于记录同一节点上一次位置 */
        root.eachBefore((d) => {
          d.x0 = d.x;
          d.y0 = d.y;
        });

        console.log(root);
      }

      //点击节点
      function clickNode(d) {
        //无子节点
        if (!d.children && !d._children) return;
        d.children = d.children ? null : d._children;
        //改变圆圈内竖线属性,使其呈现出来
        d3.select(`#g${d.id} .node-circle .node-circle-vertical`)
          .transition()
          .duration(duration)
          .attr("stroke-width", d.children ? 0 : 1);
        update(d);
      }

      //画文本
      function drawText(id) {
        d3.select(`#g${id}`)
          .append("text")
          .attr("x", (d) => (d.children ? -(8 + d.data.name.length * 16) : 8))
          .attr("y", 5)
          .text((d) => d.data.name);
      }

      //画圆圈
      function drawCircle(id) {
        let gMark = d3
          .select(`#g${id}`)
          .append("g")
          .attr("class", "node-circle")
          .attr("stroke", "red")
          .attr("stroke-width", 1);

        //画圆
        gMark
          .append("circle")
          .attr("fill", "none")
          .attr("r", 6)
          .attr("fill", "#ffffff");

        const padding = 4;

        //横线
        gMark.append("path").attr("d", `m -${padding} 0 l ${2 * padding} 0`);

        //竖线
        gMark
          .append("path")
          .attr("d", `m 0 -${padding} l 0 ${2 * padding}`)
          .attr("stroke-width", 0)
          .attr("class", "node-circle-vertical");
      }

      //画连接线
      function diagonal({ source, target }) {
        let s = source,
          d = target;
        return `M ${s.y} ${s.x}
                  L ${(s.y + d.y) / 2} ${s.x},
                  L ${(s.y + d.y) / 2} ${d.x},
                  ${d.y} ${d.x}`;
      }

      //放大
      function onMagnifySVG() {
        zoomHandler.scaleBy(svg, 1.1);
      }

      //缩小
      function onShrinkSVG() {
        zoomHandler.scaleBy(svg, 0.9);
      }

      //重置
      function onResetSVG() {
        d3.select("svg").remove();
        init();
      }

      init();
    </script>
  </body>
</html>

四、结语

如果本篇比较受欢迎的话,后期再更一篇,实现企业图谱大致功能;

你的点赞,是我创作的动力;码字不易,点赞支持!!!