使用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>
相关推荐
南屿im6 分钟前
发布订阅模式和观察者模式傻傻分不清?一文搞懂两大设计模式
前端·javascript
I_have_a_lemon6 分钟前
前端、产品、设计师神器推荐——Onlook
前端·cursor
前端小巷子7 分钟前
深入解析CSRF攻击
前端·安全·面试
JustHappy8 分钟前
SPA?MPA?有啥关系?有啥区别?聊一聊页面形态 or 路由模式
前端·javascript·架构
每天开心8 分钟前
🧙‍♂️闭包应用场景之--防抖和节流
前端·javascript·面试
hxmmm13 分钟前
webpack多入口打包文件
前端
CAD老兵15 分钟前
前端组件库的多主题实现原理与实战指南
前端
归于尽17 分钟前
Generator?从 yield 卡壳,到终于搞懂协程那点事
前端·javascript
FogLetter17 分钟前
React组件开发进阶:本地存储与自定义Hooks的艺术
前端·javascript·react.js
支撑前端荣耀21 分钟前
五、测试用例的组织和编写
前端