使用d3js实现了一个组织架构树形图(拖拽,展开收起)

起因

最近公司想做一个组织架构的拓扑图,要有拖拽,展开收起节点,员工在一条直线上展开提高空间利用率的图形结构

技术了解

刚开始我了解了vue3-tree-org,和OrgChart这两个插件

  1. vue3-tree-org这个也可以实现一个拓扑图,他利用的还是html立面的标签去生成的树形结构,它也可以自定义,但是拖拽上面不是很灵活,而且加载节点多的话容易卡顿,还有个问题就是它无法把用户全部变成竖列,我尝试过把它变成竖列,但是过于麻烦。如果有简单需求不用太过于定制化可以选择这个插件
  2. 官网地址:Home | vue3-tree-org
  3. OrgChart这是一个国外的图形插件,它是利用svg这个来实现图形界面的。它里面可以做很多种图形,我想要的树形结构也在其中,定制化很强,可以满足你大部分需求。但是它的问题是国外插件需要收费,免费版的话很卡顿,因为它调用了国外服务器对节点进行缓存,所以会导致变慢,但是他的定制性真的很强,可以的话可以试试的

最终选定

前面两种因为他们自身的问题我不选择它,但是通过前面了解到,如果想做这种图形类的东西还是以svg或canvas生成来说性能要好些,后来我了解一下d3.js这个专门用来做图表的免费插件,通过了解它确实可以实现我们的需求,具体的细节都不说了,我把我得实现案例放到下面,给大家提供一个思路,对了我这里d3用的是v7版本的

d3js案例

js 复制代码
```<script setup lang="ts">
import * as d3 from 'd3';

const data = [
  { id: 1, label: '落魄山', name: 'Eve', pid: '', type: 0 },
  { id: 2, label: '祖师堂', name: 'Cain', pid: 1, type: 0 },
  { id: 3, label: '青萍剑宗', name: 'Seth', pid: 1, type: 0 },
  { id: 18, label: '龙象剑宗', name: 'Seth', pid: 1, type: 0 },
  { id: 30, label: '北京', name: 'Seth', pid: 1, type: 0 },
  { id: 31, label: '北京2', name: 'Seth', pid: 1, type: 0 },

  { id: 21, label: '武汉电商', name: 'Seth', pid: 18, type: 0 },
  { id: 22, label: '武汉电商', name: 'Seth', pid: 18, type: 0 },
  { id: 19, label: '广州', name: 'Seth', pid: 1, type: 0 },
  { id: 20, label: '广州', name: 'Seth', pid: 19, type: 0 },
  { id: 4, label: '生产部', name: 'Enos', pid: 3, type: 0 },
  { id: 5, label: '标签部', name: 'Noam', pid: 3, type: 0 },
  { id: 6, label: '综合部', name: 'Abel', pid: 3, type: 0 },
  { id: 7, label: '电商部', name: 'Awan', pid: 3, type: 0 },
  { id: 8, label: '电商一部', name: 'Enoch', pid: 7, type: 0 },
  { id: 9, label: '电商二部', name: 'Azura', pid: 7, type: 0 },
  { id: 10, label: '技术部', name: 'Tech', pid: 2, type: 0 },
  { id: 11, label: '销售部', name: 'Sales', pid: 2, type: 0 },
  { id: 12, label: '人力资源部', name: 'HR', pid: 2, type: 0 },
  { id: 40, label: '人力资源部', name: 'HR', pid: 2, type: 0 },
  { id: 41, label: '人力资源部', name: 'HR', pid: 2, type: 0 },
  { id: 42, label: '人力资源部', name: 'HR', pid: 41, type: 1 },
  {
    id: 13,
    label: '前端开发组',
    location: '上海',
    name: 'Dev1',
    pid: 10,
    type: 1,
    userName: '陈十一',
  },
  {
    id: 14,
    label: '后端开发组',
    location: '上海',
    name: 'Dev2',
    pid: 10,
    type: 1,
    userName: '陈十一',
  },
  {
    id: 15,
    label: '测试组',
    location: '上海',
    name: 'QA',
    pid: 10,
    type: 1,
    userName: '陈十一',
  },
  { id: 16, location: '上海', pid: 11, type: 1, userName: '陈十一' },
  { id: 17, location: '张家港', pid: 11, type: 1, userName: '北海北' },
  { id: 24, location: '上海', pid: 11, type: 1, userName: '陈十一' },
  { id: 25, location: '张家港', pid: 11, type: 1, userName: '北海北' },
  { id: 26, location: '上海', pid: 11, type: 1, userName: '陈十一' },
  { id: 27, location: '张家港', pid: 11, type: 1, userName: '北海北' },
  { id: 26, location: '上海', pid: 11, type: 1, userName: '陈十一' },
  { id: 27, location: '张家港', pid: 11, type: 1, userName: '北海北' },
];
// 初始化变量
let group, root, svg, treeData, treeLayout, zoom;
const margin = { bottom: 50, left: 120, right: 120, top: 50 };
// const width = 1400 - margin.left - margin.right;
// const height = 800 - margin.top - margin.bottom;
let height, width;
const rectX = 200;
const rectY = 80;

const isDragging = shallowRef(false);
const clickFlag = shallowRef(true);
const dragNode = shallowRef(null);
const isVertical = shallowRef(true); // true  水平y轴为纵深  false 垂直x为纵深
const depth = shallowRef(0); // 二级组织数量
const dragStartPoint = ref({ x: 0, y: 0 });
const dragOffset = ref({ x: 0, y: 0 });
// 创建节点
const color = d3.scaleOrdinal(d3.schemeCategory10);
onMounted(() => {
  resize();
  window.addEventListener('resize', resize);
  // treeInit();
});
function resize() {
  const treeSvg = document.querySelector('#svg');
  width = treeSvg.clientWidth - margin.left - margin.right;
  height = treeSvg.clientHeight - margin.top - margin.bottom;
  d3.select('#svg>svg').remove();
  treeInit();
}
function treeInit() {
  // 颜色比例尺
  const color = d3.scaleOrdinal(d3.schemeCategory10);
  const dataSet = d3
    .stratify(data)
    .id((d) => {
      return d.id;
    })
    .parentId((d) => {
      return d.pid;
    })(data);
  // 开始创建

  // 创建根节点
  root = d3.hierarchy(dataSet);
  root.x0 = isVertical.value ? width / 2 : height / 2;
  root.y0 = 0;

  // 展开第一层节点
  // root.children.forEach(collapse);
  // 创建SVG容器
  svg = d3
    .select('#svg')
    .append('svg')
    .attr('width', width + margin.left + margin.right)
    .attr('height', height + margin.top + margin.bottom);

  group = svg
    .append('g')
    .attr('transform', `translate(${margin.left},${margin.top})`);
  // 添加缩放行为
  zoom = d3
    .zoom()
    .scaleExtent([0.5, 5]) // 缩放范围限制
    .on('zoom', zoomHandler);

  // 应用缩放到svg容器
  svg.call(zoom);
  // 创建树布局
  treeLayout = d3.tree();

  treeLayout(root).each((d) => {
    if (d.depth === 0) {
      depth.value = d.children.length;
    }
  });
  treeLayout.separation((a, b) => {
    const baseSpacing = 0.8;
    const depthFactor = Math.max(0, 2 - a.depth * 0.2);
    return a.parent === b.parent ? baseSpacing * depthFactor : 1.5;
  });
  // 更新树图
  update(root, true);
}

// 新增缩放处理函数
function zoomHandler(event) {
  group.attr('transform', event.transform);
}

// 切换树形结构得方向
function changeTreeDirection() {
  isVertical.value = !isVertical.value;
  d3.select('#svg>svg').remove();
  treeLayout(root);
  treeInit();
}

// 适配树形结构
function adaptTree() {
  const currentTransform = d3.zoomIdentity
    .translate(
      isVertical.value
        ? width * 0.12 * depth.value
        : width * 0.02 * depth.value,
      isVertical.value
        ? height * 0.02 * depth.value
        : height * (depth.value * 0.12)
    ) // 减少初始偏移量
    .scale(
      isVertical.value ? 0.14 * (8 - depth.value) : 0.12 * (8 - depth.value)
    ); // 这里得缩放比例也得计算
  // 添加平滑过渡

  svg.call(zoom.transform, currentTransform);
}
// 更新树图

function update(source, flag = false) {
  treeLayout.nodeSize([isVertical.value ? 180 : 90, 80]);

  // 在节点更新前保存当前缩放状态
  let currentTransform;
  if (flag) {
    currentTransform = d3.zoomIdentity
      .translate(
        isVertical.value
          ? width * 0.12 * depth.value
          : width * 0.01 * depth.value,
        isVertical.value
          ? height * 0.01 * depth.value
          : height * (depth.value * 0.12)
      ) // 减少初始偏移量
      .scale(
        isVertical.value ? 0.14 * (8 - depth.value) : 0.12 * (8 - depth.value)
      ); // 这里得缩放比例也得计算
  }
  // 应用树布局
  const tree = treeLayout(root);

  // 获取所有节点
  const nodes = tree.descendants();

  // 获取所有连接
  const links = tree.links();
  // 处理节点位置
  nodes.forEach((d) => {
    if (d.data.data.type === 0) {
      if (isVertical.value) {
        d.y = d.depth * 310;
        // 添加横向间距计算
        if (d.parent) {
          const siblings = d.parent.children;
          const index = siblings.indexOf(d);
          d.x += (index - siblings.length / 2) * 12; // 添加30px的横向偏移
        }
      } else {
        d.y = d.depth * 540;
        // 添加横向间距计算
        if (d.parent) {
          const findItem = data.find((item) => item.id === d.data.data.id);
          const siblings = d.parent.children;
          const index = siblings.indexOf(d);
          d.x +=
            (index - siblings.length / 2) * (findItem.type === 1 ? 60 : 10); // 添加30px的横向偏移
        }
      }
    }
  });
  // 把员工变成水平显示
  changeUserDirection(nodes, source);

  // 添加连接
  const link = group.selectAll('.link').data(links, (d) => d.target.data.id);

  // 退出过渡
  link.exit().transition().duration(100).attr('d', directionLine()).remove();

  // 进入过渡
  const linkEnter = link
    .enter()
    .insert('path', '.node') // 在节点前插入连接线
    .attr('class', 'link')

    .attr('d', directionLine())
    .attr('fill', 'none')
    .attr('stroke', '#95a5a6')
    .attr('stroke-width', 2);

  // 更新过渡
  link
    .merge(linkEnter)
    .transition()
    .duration(300)
    .ease(d3.easeQuadInOut)
    .attr('d', directionLine());

  // 添加节点组
  const node = group.selectAll('.node').data(nodes, (d) => d.data.id);
  // 退出过渡
  const nodeExit = node
    .exit()
    .transition()
    .duration(300)
    .attr('transform', (d) => directionNode(source))
    .remove();

  // 节点进入
  const nodeEnter = node
    .enter()
    .append('g')
    .attr('class', 'node')
    .attr('transform', (d) => {
      return directionNode(source);
    })
    .call((g) => {
      g.filter((d) => d.depth > 0)
        .filter(function () {
          return !d3.select(this).select('.expand-icon').node();
        })
        .call(
          d3.drag().on('start', startDrag).on('drag', drag).on('end', endDrag)
        );
    });
  // 添加矩形
  const depRect = nodeEnter.filter((node) => {
    const findItem = data.find((item) => item.id === node.data.data.id);
    return findItem && findItem.type === 0;
  });

  addRect(depRect, rectX, rectY);
  const userRect = nodeEnter.filter((node) => {
    const findItem = data.find((item) => item.id === node.data.data.id);
    return findItem && findItem.type === 1;
  });

  addRect(userRect, 100, 140);
  rectStyle(depRect, -58, -24);
  rectStyle(userRect, -45, -24);

  // 节点更新
  const nodeUpdate = node
    .merge(nodeEnter)
    .transition()
    .duration(300)
    .ease(d3.easeQuadInOut) // 添加缓动函数
    .attr('transform', (d) => {
      return directionNode(d);
    });
  // 展开和收起icon
  expandIcon(nodeEnter, nodeUpdate);
  // 在节点更新后恢复缩放状态
  if (flag) {
    svg.call(zoom.transform, currentTransform);
  }
}

// 切换员工的展示方向
function changeUserDirection(nodes) {
  nodes.forEach((node, idx) => {
    if (node?.children) {
      const userType = node.children.every((item) => {
        return item.data.data.type === 1;
      });
      if (userType) {
        const parentX = isVertical.value ? node.y : node.x;

        const parentY = isVertical.value ? node.x : node.y;
        node.children.forEach((ite, index) => {
          ite.x = isVertical.value ? parentY + 50 : parentX - 0;
          ite.y = isVertical.value
            ? parentX + 180 * (index + 1) + 150
            : parentY + 180 * (index + 1) + 150;
        });
      }
    }
  });
}

// 添加矩形
function addRect(nodeEnter: any, width: number, height: number) {
  nodeEnter
    .append('rect')
    .attr('width', width)
    .attr('height', height)
    .attr('x', isVertical.value ? -60 : -60)
    .attr('y', -30)
    .attr('rx', 8)
    .attr('ry', 8)
    .attr('fill', (d) => (d.children || d._children ? '#3498db' : '#e74c3c'));
}

// 展开和收起得icon
function expandIcon(nodeEnter, nodeUpdate) {
  // 添加展开/折叠图标
  nodeEnter
    .filter((d) => {
      return d.children || d._children;
    })
    .append('g')
    .attr('class', 'expand-icon')
    .attr(
      'transform',
      isVertical.value ? 'translate(50,54)' : 'translate(150,10)'
    ) // 调整图标位置
    .call((g) => {
      g.append('circle')
        .attr('r', 12)
        .attr('fill', '#fff')
        .attr('stroke', '#666')
        .attr('cursor', 'pointer');

      g.append('path')
        .attr('d', (d) => (d.children ? 'M-6,0 H6' : 'M0,-6 V6 M-6,0 H6')) // 展开状态显示横线,收起显示十字
        .attr('stroke', '#666')
        .attr('stroke-width', 2)
        .attr('transform', 'translate(0,2)');
    })
    .on('click', click);
  // 动态更新展开图标
  nodeUpdate.each(function (d) {
    const g = d3.select(this);
    const hasChildren = d.children || d._children;

    // 添加或移除图标
    if (hasChildren) {
      if (g.select('.expand-icon').empty()) {
        const icon = g
          .append('g')
          .attr('class', 'expand-icon')
          .attr(
            'transform',
            isVertical.value ? 'translate(50,54)' : 'translate(150,10)'
          ) // 调整图标位置)
          .on('click', click);

        icon
          .append('circle')
          .attr('r', 12)
          .attr('fill', '#fff')
          .attr('stroke', '#666')
          .attr('cursor', 'pointer');

        icon
          .append('path')
          .attr('d', (d) => (d.children ? 'M-6,0 H6' : 'M0,-6 V6 M-6,0 H6')) // 展开状态显示横线,收起显示十字
          .attr('stroke', '#666')
          .attr('stroke-width', 2)
          .attr('transform', 'translate(0,2)');
      }
    } else {
      g.select('.expand-icon').remove();
    }
  });

  // 更新图标
  nodeUpdate
    .select('.expand-icon path')
    .attr('d', (d) => (d.children ? 'M-6,0 H6' : 'M0,-6 V6 M-6,0 H6'))
    .transition()
    .duration(200)
    .attr('transform', (d) => `translate(0,0)`);

  // 移除没有子元素的图标
  nodeUpdate
    .select('.expand-icon')
    .filter((d) => !d.children && !d._children)
    .remove();
}

// 开始拖拽
function startDrag(e, d) {
  // 添加事件传播阻止
  e.sourceEvent.stopPropagation();
  e.sourceEvent.stopImmediatePropagation();

  // 重置拖拽状态
  isDragging.value = false;
  dragNode.value = d;
  // 获取相对于group容器的坐标
  const [startX, startY] = d3.pointer(e.sourceEvent, group.node());

  dragStartPoint.value = { x: startX, y: startY };
  dragOffset.value = { x: 0, y: 0 };
  // 标记当前节点为拖拽状态
  d3.select(this).classed('dragging', true);

  // 标记连接线为拖拽状态
  group
    .selectAll('.link')
    .filter((link) => link.source === d || link.target === d)
    .classed('dragging-link', true);
}
// 拖拽中
function drag(e, d) {
  // if (!isDragging.value) return;
  const [currentX, currentY] = d3.pointer(e.sourceEvent, group.node());
  const dx = currentX - dragStartPoint.value.x;
  const dy = currentY - dragStartPoint.value.y;
  const distance = Math.hypot(dx, dy);
  if (distance > 5) {
    isDragging.value = true;
    dragOffset.value = { x: dx, y: dy };
    const safeCoord = (coord: number) => coord || 0;
    // 计算偏移量
    dragOffset.value = {
      x: safeCoord(currentX) - safeCoord(dragStartPoint.value.x) - 52,
      y: safeCoord(currentY) - safeCoord(dragStartPoint.value.y) - 18,
    }; // 移动的距离要进行计算

    // 移动当前节点
    d3.select(this).attr(
      'transform',
      `translate(${
        safeCoord(dragStartPoint.value.x) + safeCoord(dragOffset.value.x)
      }, 
     ${safeCoord(dragStartPoint.value.y) + safeCoord(dragOffset.value.y)})`
    );

    // 移动所有连接线
    updateLinks(d);
  }
}

// 拖拽结束
function endDrag(e, d) {
  if (!isDragging.value) {
    // 如果没有实际拖拽,触发点击事件
    return;
  }

  isDragging.value = false;
  // 移除拖拽状态
  d3.select(this).classed('dragging', false);

  // 移除连接线拖拽状态
  group.selectAll('.dragging-link').classed('dragging-link', false);
  // 查找最近的节点作为父节点
  const targetNode = findNearestNode(e.sourceEvent);
  if (targetNode && isValidDrop(d, targetNode)) {
    // 从原父节点移除
    if (d.parent) {
      const index = d.parent.children.indexOf(d);
      if (index > -1) {
        d.parent.children.splice(index, 1);
        if (d.parent.children.length === 0) {
          delete d.parent.children;
          delete d.parent.data.children;
        }
      }
    }
    // 添加到新父节点
    if (!targetNode.children) targetNode.children = [];

    d.data.data.pid = targetNode.data.data.id;
    if (targetNode._children) {
      targetNode._children.push(d);
      targetNode.children = null;
    } else {
      targetNode.children.push(d); // 目前是拖拽后y的值没有变化 这个问题要解决
    }

    d.parent = targetNode;
  }

  // 重新计算布局
  treeLayout(root);
  update(d.parent);
}

// 部门 员工样式
function rectStyle(node, x: number, y: number) {
  const img =
    'https://p9-flow-imagex-sign.byteimg.com/ocean-cloud-tos/image_skill/e589127d-d495-4599-970c-d89b7ab3bb41_1751595979774603863~tplv-a9rns2rl98-web-thumb-watermark-v2.jpeg?rk3s=b14c611d&x-expires=1783131980&x-signature=1fNRgbX8doj9Sm9yY8kdPr6Gd30%3D';

  node
    .append('g') // 使用分组包裹图片和边框
    .attr('transform', `translate(${x},${y})`) // 调整位置使其居中
    .append('image')
    .attr('xlink:href', (d) => img) // 从数据中获取图片路径
    .attr('width', 70)
    .attr('height', 70)
    .attr('x', 0)
    .attr('y', 0)
    .attr('clip-path', 'circle(35px at 35px 35px)') // 圆形裁剪
    .attr('stroke', '#fff') // 边框颜色
    .attr('stroke-width', 2); // 边框宽度

  // 添加公司名称
  node
    .append('text')
    .attr('dx', '3em')
    .attr('dy', '.3em')
    .attr('text-anchor', 'middle')
    .attr('font-size', 16)
    .attr('font-family', 'Microsoft YaHei')
    .attr('fill', 'white')
    .text((d) => d.data.data.label);
  // 添加公司人数
  node
    .append('text')
    .attr('dx', '7em')
    .attr('dy', '.3em')
    .attr('text-anchor', 'middle')
    .attr('font-size', 16)
    .attr('font-family', 'Microsoft YaHei')
    .attr('fill', 'white')
    .text((d) => '1/100');
  // 添加公司或者部门负责人
  node
    .append('text')
    .attr('dx', '3em')
    .attr('dy', '2.3em')
    .attr('text-anchor', 'middle')
    .attr('font-size', 16)
    .attr('font-family', 'Microsoft YaHei')
    .attr('fill', 'white')
    .text((d) => d.data.data.name);

  // 用户名称
  node
    .append('text')
    .attr('x', -10)
    .attr('dy', '4.6em')
    .attr('text-anchor', 'middle')
    .attr('font-size', 16)
    .attr('font-family', 'Microsoft YaHei')
    .attr('fill', 'white')
    .text((d) => d.data.data.userName);

  // 用户工作地点
  node
    .append('text')
    .attr('x', -10)
    .attr('dy', '6em')
    .attr('text-anchor', 'middle')
    .attr('font-size', 16)
    .attr('font-family', 'Microsoft YaHei')
    .attr('fill', 'white')
    .text((d) => d.data.data.location);
}

// 水平或垂直的连线
function directionLine() {
  return isVertical.value
    ? d3
        .linkVertical()
        .x((d) => {
          const item = data.find((ite) => ite.id === d.data.data.id);
          return item.type === 0 ? d.x + 50 : d.x;
        })
        .y((d) => {
          return d.y;
        })
    : d3
        .linkHorizontal()
        .x((d) => {
          return d.y + 90;
        })
        .y((d) => {
          return d.x + 10;
        });
}

// 修改水平或者垂直时节点的位置
function directionNode(d) {
  const isChildrenX = d.children ? d.y - 20 : d.y + 60;
  let x: number, y: number;

  if (d.data.data.type === 0) {
    y = isVertical.value ? d.y - 40 : d.y;
    x = isVertical.value ? d.y : isChildrenX;
    return isVertical.value
      ? `translate(${d.x},${y})`
      : `translate(${y},${d.x})`;
  } else {
    x = isVertical.value ? d.y : isChildrenX;
    y = isVertical.value ? d.y - 40 : d.y;
    return isVertical.value
      ? `translate(${d.x},${y})`
      : `translate(${x},${d.x - 20})`;
  }
}
// 更新连接线位置 - 修复版本
function updateLinks(d) {
  const getValidCoord = (value) => (isNaN(value) ? 0 : value);
  // 更新从父节点到当前节点的连接线
  group
    .selectAll('.link')
    .filter((link) => link.target === d)
    .attr('d', directionLine());

  // 更新从当前节点到子节点的连接线
  if (d.children) {
    d.children.forEach((child) => {
      group
        .selectAll('.link')
        .filter((link) => link.source === d && link.target === child)
        .attr('d', directionLine());
    });
  }
}
// 新增辅助函数:查找最近的节点
function findNearestNode(event) {
  const [x, y] = d3.pointer(event, group.node());
  let minDist = Infinity;
  let nearestNode = null;

  group.selectAll('.node').each(function (d) {
    const bbox = this.getBBox();
    // 根据布局方向调整坐标计算
    const centerX = isVertical.value
      ? d.x + bbox.x + bbox.width / 2 // 垂直布局时x轴是层级坐标
      : d.y + bbox.x + bbox.width / 2; // 水平布局时y轴是层级坐标

    const centerY = isVertical.value
      ? d.y + bbox.y + bbox.height / 2 // 垂直布局时y轴是兄弟节点坐标
      : d.x + bbox.y + bbox.height / 2; // 水平布局时x轴是兄弟节点坐标

    const dist = Math.hypot(centerX - x, centerY - y);

    if (dist < 160 && dist < minDist) {
      // 80px为有效吸附距离
      minDist = dist;
      nearestNode = d;
    }
  });

  return nearestNode;
}

// 新增辅助函数:验证拖拽有效性
function isValidDrop(draggedNode, targetNode) {
  // 不能挂载到自己或子节点
  if (draggedNode === targetNode || draggedNode.depth <= targetNode.depth)
    return false;
  let parent = targetNode;
  while (parent) {
    if (parent === draggedNode) return false;
    parent = parent.parent;
  }
  return true;
}

// 节点点击事件
function click(event, d) {
  clickFlag.value = false;
  if (d.children) {
    d._children = d.children;
    d.children = null;
  } else {
    d.children = d._children;
    d._children = null;
    // 展开后重新计算布局
    treeLayout(root);
  }
  // 局部更新树图
  update(d);
}

// 全部展开
function expandAll() {
  root.descendants().forEach((d) => {
    if (d._children) {
      d.children = d._children;
      d._children = null;
    }
  });
  update(root);
}

// 全部收起
function collapseAll() {
  root.descendants().forEach((d) => {
    if (d.children && d.depth > 0) {
      d._children = d.children;
      d.children = null;
    }
  });
  update(root);
}

// 重置视图
function resetView() {
  root.descendants().forEach((d) => {
    if (d.depth === 0 && d.children) {
      // 根节点保持展开
    } else if (d.depth === 1) {
      // 第一层节点展开
      if (d._children) {
        d.children = d._children;
        d._children = null;
      }
    } else if (
      d.depth > 1 && // 更深层节点收起
      d.children
    ) {
      d._children = d.children;
      d.children = null;
    }
  });
  update(root);
}

// 折叠节点
function collapse(d) {
  if (d.children) {
    d._children = d.children;
    d._children.forEach(collapse);
    d.children = null;
  }
}
</script>

<template>
  <div class="my-2 flex gap-x-3">
    <div class="w-25 border-blue h-10 cursor-pointer rounded-full border text-center leading-10"
      @click="changeTreeDirection">
      切换
    </div>
    <div class="w-25 border-blue h-10 cursor-pointer rounded-full border text-center leading-10"
      @click="adaptTree">
      适配
    </div>
  </div>
  <div id="svg"></div>
</template>

<style>
#svg {
  height: 65vh;
  margin: 0 auto;
  overflow: hidden;
  border: 1px solid #ccc;
}

.expand-icon {
  pointer-events: none;
  cursor: pointer;
  transition: transform 0.3s;

  /* 防止图标遮挡点击 */
}

.expand-icon circle {
  /* 恢复圆圈点击 */
  z-index: 2;
  pointer-events: all;

  /* 确保图标在连线上方 */
}

.expand-icon {
  z-index: 3;
  cursor: pointer;
  transition: transform 0.3s, opacity 0.3s;
  transform-origin: center;
}

.node {
  /* transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1); */
  z-index: 2;

  /* 添加节点整体过渡 */
}

.link {
  /* transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1); */
  z-index: 1;

  /* 连线过渡 */
}
</style>
相关推荐
前端大卫15 分钟前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘31 分钟前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare32 分钟前
浅浅看一下设计模式
前端
Lee川35 分钟前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix1 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人1 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl1 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人1 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼1 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端