vue2、d3绘制树形图,叶子节点是矩形的

31 阅读2分钟

树形图,节点是矩形。 文字长度过长,省略号展示,点击有 选中状态

93b4fbdcea7c55b3c27318a78a80ff59.png

<template>
  <el-card style="margin-top: 10px;">

    <div class="tree-container">
      <ul class="controls">
        <li @click="onMagnifySVG">放大</li>
        <li @click="onShrinkSVG">缩小</li>
        <li @click="onResetSVG">重置</li>
      </ul>
      <div ref="tree" class="tree-view"></div>
    </div>
  </el-card>
</template>

<script>
import * as d3 from "d3";


export default {
  name: "D3TreeView",
  props: {
    productInfo: {
      type: Object,
      required: true
    }
  },
  data() {
    return {
      dataset: {
        name: "中国",
        children: [
          {
            name: "浙江",
            children: [
              { name: "杭州" },
              { name: "宁波" },
              { name: "温州" },
              { name: "绍兴" }
            ]
          },
          {
            name: "广西",
            children: [
              {
                name: "桂林",
                children: [
                  { name: "秀峰区" },
                  { name: "叠彩区" },
                  { name: "象山区" },
                  { name: "七星区" }
                ]
              },
              { name: "南宁" },
              { name: "柳州" },
              { name: "防城港" }
            ]
          },
          {
            name: "黑龙321231江",
            children: [
              { name: "哈尔滨" },
              { name: "齐齐哈尔" },
              { name: "牡丹江" },
              { name: "大庆" }
            ]
          },
          {
            name: "新疆",
            children: [
              { name: "乌鲁木齐" },
              { name: "克拉玛依" },
              { name: "吐鲁番" },
              { name: "哈密" }
            ]
          }
        ]
      },
      svg: null,
      g: null,
      root: null,
      zoomHandler: null,
      width: 0,
      height: 0,
      duration: 750,
      rectWidth: 100,
      rectHeight: 50
    };
  },
  
  mounted() {
    this.$nextTick(() => {
      this.initTree();
    })
  },
  methods: {
    // 初始化树形图
    initTree() {

      const winH = window.innerHeight - 64 - 100
      this.width = this.$refs.tree.clientWidth;
      this.height = this.$refs.tree.clientHeight || winH;


      // 创建 SVG 容器
      this.svg = d3
        .select(this.$refs.tree)
        .append("svg")
        .attr("width", this.width)
        .attr("height", this.height);

      // 添加 g 容器,用于保存树节点
      this.g = this.svg
        .append("g")
        .attr("transform", `translate(${this.width / 2 - 200},${this.height / 2}) scale(1)`);

      // .attr("transform", `translate(${this.width / 2}, 40)`);

      // 设置缩放功能
      this.zoomHandler = d3
        .zoom()
        .scaleExtent([0.5, 5])
        .on("zoom", (event) => {
          // 使用 event 传递事件对象
          this.g.attr("transform", event.transform);
        });

      // 应用缩放功能
      this.svg
        .call(this.zoomHandler)
        // .call(this.zoomHandler.translateBy, this.width / 2, 40)
        .call(this.zoomHandler.translateBy, this.width / 2 - 200, this.height / 2)
        .on("dblclick.zoom", null); // 禁用双击缩放

        this.processData();
    },

    // 处理数据并生成节点层级
    processData() {
      this.root = d3.hierarchy(this.dataset);
      this.root.descendants().forEach((d) => {
        d._children = d.children; // 折叠所有子节点
        d.id = this.generateUUID(); // 绑定唯一 ID
      });

      this.updateTree(this.root);
    },

    // 更新树形结构
    updateTree(source) {
      const treeLayout = d3
        .tree()
        .nodeSize([100, 200])
        .separation((a, b) => (a.parent === b.parent && !a.children && !b.children ? 1 : 2))(this.root);

      // 处理节点
      const nodes = this.g
        .selectAll("g.node")
        .data(this.root.descendants(), (d) => d.id);

      // 新增节点
      const nodeEnter = nodes
        .enter()
        .append("g")
        .attr("id", d => `g${d.id}`) // 手动为每个 g 节点添加 id
        .attr("class", "node")
        .attr("transform", () => `translate(${source.y0 || 0},${source.x0 || 0})`)
        .attr("opacity", 0)
        .on("click", (d) => this.toggleChildren(d));

      nodeEnter.each((d) => {
        this.drawRect(d)
        this.drawText(d);
      });

      // 移除旧节点
      nodes
        .exit()
        .transition()
        .duration(this.duration)
        .attr("transform", () => `translate(${source.y},${source.x})`)
        .remove()
        .attr("opacity", 0);

      // 更新节点位置
      nodes
        .merge(nodeEnter)
        .transition()
        .duration(this.duration)
        .attr("transform", (d) => `translate(${d.y - 16},${d.x})`)
        .attr("opacity", 1);

      // 处理连接线
      const links = this.g
        .selectAll("path.link")
        .data(this.root.links(), (d) => d.target.id);

      // 新增连接线
      const linkEnter = links
        .enter()
        .insert("path", "g")
        .attr("class", "link")
        .attr("d", () => {
          const o = { x: source.x0 || 0, y: source.y0 || 0 };
          return this.diagonal({ source: o, target: o });
        })
        .attr("fill", "none")
        .attr("stroke-width", 1)
        .attr("stroke", "#dddddd");

      // 移除旧连接线
      links
        .exit()
        .transition()
        .duration(this.duration)
        .remove()
        .attr("d", () => {
          const o = { x: source.x, y: source.y };
          return this.diagonal({ source: o, target: o });
        });

      // 更新连接线位置
      links.merge(linkEnter).transition().duration(this.duration).attr("d", this.diagonal);

      // 记录当前位置
      this.root.each((d) => {
        d.x0 = d.x;
        d.y0 = d.y;
      });
    },

    // 生成连接线的路径
    diagonal({ source, target }) {
      return `M ${source.y} ${source.x}
              L ${(source.y + target.y) / 2} ${source.x},
              L ${(source.y + target.y) / 2} ${target.x},
              ${target.y} ${target.x}`;
    },

    // 生成 UUID
    generateUUID() {
      return `${this.s4()}${this.s4()}-${this.s4()}-${this.s4()}-${this.s4()}-${this.s4()}${this.s4()}${this.s4()}`;
    },

    s4() {
      return Math.floor((1 + Math.random()) * 0x10000)
        .toString(16)
        .substring(1);
    },

    // 切换子节点的展开状态
    toggleChildren(d) {
      if (!d.children && !d._children) return;
      d.children = d.children ? null : d._children;
      this.updateTree(d);
    },

    drawRect(d) {
      const { rectWidth, rectHeight } = this
      const rect = d3.select(`#g${d.id}`)
        .append("rect")
        
        .attr("x", -rectWidth / 2 -10)
        .attr("y", -rectHeight / 2)
        .attr("width", rectWidth)
        .attr("height", rectHeight)
        .style("cursor", "pointer")
        .style("fill", "#eff3fd")
        .style("stroke", "#FFF")
        .style("stroke-width", "1px")
        .style("box-shadow", "0 5px 12.6px 0 rgba(114, 128, 160, 0.13)")
        .style("rx", "5px") // 添加圆角
        .style("ry", "5px") // 添加圆角
        .on("click", function () {
          // 移除其他节点的 active 样式
          d3.selectAll("rect").classed("active", false);
          
          // 为当前节点添加 active 样式
          d3.select(this).classed("active", true);
        });

        // 添加或移除 active 样式
        rect.classed("active", d.active);
    },

    // 绘制节点文字
    drawText(d) {
      const { rectWidth } = this

      const padding = 15; // 留白宽度
      const charWidth = 14; // 中文字符宽度
      const maxChars = Math.floor((rectWidth - padding) / charWidth); // 最大可展示字符数

      let displayName = d.data.name;
      if (d.data.name.length > maxChars) {
        // 截取文本并添加省略号
        displayName = `${d.data.name.substring(0, maxChars)}...`;
      }

      d3.select(`#g${d.id}`)
        .append("text")
        .attr("x", -rectWidth / 2 )
        .attr("y", 5)
        .attr("fill", "#858ea8;")
        .style("cursor", "pointer")
        .text(displayName)
        .append("title")
        .text(`${d.data.name}`);
    },

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

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

    // 重置
    onResetSVG() {
      this.svg.remove();
      this.initTree();
    }
  }
};
</script>


<style scoped>
.tree-container {
  width: 100%;
  height: 100%;
  border: 1px solid #dcdcdc;
  position: relative;
}
.tree-view {
  width: 100%;
  height: 100%;
}
.main-title {
  font-size: 16px;
  color: #333;
  font-weight: 600;
  margin-bottom: 20px;
}
.controls {
  list-style: none;
  padding: 0;
  margin: 0;
  position: absolute;
  top: 20px;
  left: 20px;
  background: rgba(255, 255, 255, 0.8);
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.controls li {
  padding: 5px 10px;
  cursor: pointer;
  border-bottom: 1px solid #ddd;
}
.controls li:last-child {
  border-bottom: none;
}
.controls li:hover {
  background: #f5f5f5;
}
</style>


<style>
rect.active {
  stroke: #3388fc !important;
  stroke-width: 2px !important;
}
</style>